Notifications
Article
使用Unity的ECS和Job System实现流体模拟效果
Published 9 months ago
1.1 K
1
Unity中实体组件系统ECS,Job System和Burst编译器,这些新工具的特点是让性能得到极高的提升。本文将由法国的开发工程师Léonardo Montes为我们分享如何将项目移植到ECS和Job system。
当我在巴黎独立游戏工作室Atomic Raccoon工作时,我希望了解如何在运行时模拟多个角色像流体一样互相交互。
Unity中实体组件系统ECS,Job System和Burst编译器,这些新工具的特点是拥有极快的速度,而且性能提升效果高达100倍。于是我向团队建议使用这些新工具来实现流体模拟,这将允许我们通过制作小型物理引擎的原型来熟悉新的编码方式。
我首先研究可以尝试实现的物理模拟效果,然后找到了软粒子流体力学SPH算法。本文我将介绍如何在Unity实现SPH算法,以及如何将项目移植到ECS和Job system。

学习准备

本文提供项目的源代码下载:
https://github.com/leonardo-montes/Unity-ECS-Job-System-SPH
进行开发前,我仔细观察了Unity的Boid演示项目,该示例展示了很多条鱼相交互的效果。下面是Unite大会中关于ECS和Job System的演讲。

单线程实现

我使用“老方法”实现了SPH算法,也就是使用Monobehaviours。
首先,我调用InitSPH() 来创建粒子的游戏对象,并初始化它们的属性,并给位置和引用设置了正确参数,以便为模拟使用,这将让我得到一组粘性较大的粒子。
然后,我在每一帧调用ComputeDensityPressure()、ComputeForces()和Integrate(),它们会处理粒子与粒子间的碰撞。
我添加了二个方法:ComputeColliders()和ApplyPosition()。ComputeColliders()可以处理粒子与标记为“SPHCollider”的墙体游戏对象之间的碰撞,ApplyPosition()方法用于将粒子游戏对象的位置设为和粒子位置相同。
void Start() { InitSPH(); } void Update() { ComputeDensityPressure(); ComputeForces(); Integrate(); ComputeColliders(); ApplyPosition(); }
这样就完成了,效果如下图所示。

使用ECS和Job System实现

下面我们将使用ECS和Job System来实现流体模拟。
现在我们要改变思考方式,我们需要使用多个脚本,而不是只用一个脚本来初始化并更新粒子及其游戏对象。
初始化
首先,我将创建了一个SPHManager.cs脚本,它将处理新粒子和墙体的初始化过程。
void Start() { //导入 manager = World.Active.GetOrCreateManager<EntityManager>(); // 设置 AddColliders(); AddParticles(amount); }
在单线程版本中,我可以将游戏对象定义为碰撞体,然后运行碰撞求解算法。但在这里,我需要将粒子转为由系统进行处理实体。
我将粒子加入到NativeArray中,然后使用一个游戏对象预制件来实例化实体,该预制件将用作每个实体使用的所有组件的模版。
基于被定义为碰撞体的游戏对象,循环处理了所有实体来设置数值。
void AddColliders() { // 找到所有碰撞体 GameObject[] colliders = GameObject.FindGameObjectsWithTag("SPHCollider"); // 将它们转换为实体 NativeArray<Entity> entities = new NativeArray<Entity>(colliders.Length, Allocator.Temp); manager.Instantiate(sphColliderPrefab, entities); // 设置数据 for (int i = 0; i < colliders.Length; i++) { manager.SetComponentData(entities[i], new SPHCollider { position = colliders[i].transform.position, right = colliders[i].transform.right, up = colliders[i].transform.up, scale = new float2(colliders[i].transform.localScale.x / 2f, colliders[i].transform.localScale.y / 2f) }); } // 完成 entities.Dispose(); }
sphColliderPrefab由二个组件构成:GameObjectEntity 组件和SPHCollider组件。
自定义组件保存了执行墙体碰撞后所需的数据,代码和单线程版本相同,只不过我使用了新的数学库和float3类型,而不是Vector3类型。ComponentDataWrapper部分允许组件在检视窗口添加给游戏对象。
using Unity.Entities; using Unity.Mathematics; [System.Serializable] public struct SPHCollider : IComponentData { public float3 position; public float3 right; public float3 up; public float2 scale; } public class SPHColliderComponent : ComponentDataWrapper<SPHCollider> { }
现在对粒子做同样的处理,我循环处理了所有粒子来设置它们的位置。
void AddParticles(int _amount) { NativeArray<Entity> entities = new NativeArray<Entity>(_amount, Allocator.Temp); manager.Instantiate(sphParticlePrefab, entities); for (int i = 0; i < _amount; i++) { manager.SetComponentData(entities[i], new Position { Value = new float3(i % 16 + UnityEngine.Random.Range(-0.1f, 0.1f), 2 + (i / 16 / 16) * 1.1f, (i / 16) % 16) + UnityEngine.Random.Range(-0.1f, 0.1f) }); } entities.Dispose(); }
另一方面,sphParticlePrefab略有些复杂。
现在介绍它的组件:
  • PositionComponent组件让我们可以查看实体的位置,以便进行渲染。
  • SPHVelocityComponent组件用于保存粒子的速度。
  • SPHParticleComponent组件用于保存粒子的属性。
  • MeshInstanceRendererComponent组件的功能等价于MeshFilter和MeshRenderer,它允许实体被Unity渲染。
类似Position组件,我们只能创建该组件来访问float3数值
using Unity.Entities; using Unity.Mathematics; [System.Serializable] public struct SPHVelocity : IComponentData { public float3 Value; } public class SPHVelocityComponent : ComponentDataWrapper<SPHVelocity> { }
但是SPHParticleComponent组件不是一个简单的组件,它是一个共享组件。
using Unity.Entities; [System.Serializable] public struct SPHParticle : ISharedComponentData { public float radius; public float smoothingRadius; public float smoothingRadiusSq; public float mass; public float restDensity; public float viscosity; public float gravityMult; public float drag; } public class SPHParticleComponent : SharedComponentDataWrapper<SPHParticle> { }
使用共享组件可以让我们访问实体块,这些实体块拥有相同的共享组件属性。我们不会给每个粒子一个参数id,而是要设置共享组件参数,来让二个流体得到不同的属性。
完成了初始化部分,现在我们处理系统和作业。

系统和作业

我们从获取实体开始。
ComponentGroup SPHCharacterGroup; ComponentGroup SPHColliderGroup; protected override void OnCreateManager() { SPHCharacterGroup = GetComponentGroup(ComponentType.ReadOnly(typeof(SPHParticle)), typeof(Position), typeof(SPHVelocity)); SPHColliderGroup = GetComponentGroup(ComponentType.ReadOnly(typeof(SPHCollider))); }
在Unity中ECS文档中: ComponentGroup会基于组件提取出独立的实体数组。所以我将获取组件,但是我并不打算写入这些组件,于是我将部分组件标记为ReadOnly 。
让我们创建更新方法,该方法会在每一帧运行。该方法的代码很长,因此我会随着教程内容逐渐补充代码,下面是开始部分。
protected override JobHandle OnUpdate(JobHandle inputDeps) { return inputDeps; }
首先获取独占式共享组件,uniqueTypes是SPHParticle的列表。然后把SPHCollider组件放入ComponentDataArray组件的数组中,此后循环处理所有独占式流体粒子集。
EntityManager.GetAllUniqueSharedComponentData(uniqueTypes); ComponentDataArray<SPHCollider> colliders = SPHColliderGroup.GetComponentDataArray<SPHCollider>(); int colliderCount = colliders.Length; for (int typeIndex = 1; typeIndex < uniqueTypes.Count; typeIndex++) { }
现在让我们缓存数据。我获取了设置流体属性,组件和将要迭代的数值particlesPosition,particlesVelocity和particlesForces等。
我将它们放入NativeArrays中,把分配器设为TempJob,它会持续一个作业。你可能会想,既然我们已经有了Position ComponentDataArray,为什么还要创建Position NativeArray?
这是因为我们不会在每帧多次设置transform.position,我们只在开始时获取该属性,修改数据,然后把数据设置给组件。
// 添加新粒子,或处理之前的粒子块 PreviousParticle nextParticles = new PreviousParticle { hashMap = hashMap, particlesPosition = particlesPosition, particlesVelocity = particlesVelocity, particlesForces = particlesForces, particlesPressure = particlesPressure, particlesDensity = particlesDensity, particleIndices = particleIndices, cellOffsetTable = cellOffsetTableNative, copyColliders = copyColliders }; if (cacheIndex > previousParticles.Count - 1) { previousParticles.Add(nextParticles); } else { previousParticles[cacheIndex].hashMap.Dispose(); previousParticles[cacheIndex].particlesPosition.Dispose(); previousParticles[cacheIndex].particlesVelocity.Dispose(); previousParticles[cacheIndex].particlesForces.Dispose(); previousParticles[cacheIndex].particlesPressure.Dispose(); previousParticles[cacheIndex].particlesDensity.Dispose(); previousParticles[cacheIndex].particleIndices.Dispose(); previousParticles[cacheIndex].cellOffsetTable.Dispose(); previousParticles[cacheIndex].copyColliders.Dispose(); } previousParticles[cacheIndex] = nextParticles;
现在已经可以看到该脚本和单线程工作流程的不同之处。我们要在此使用HashMap来改进性能。
首先,我要把之前创建的所有NativeArray放到一个结构中,然后存入列表。这样在拥有多个独占式共享组件时,我们就可以处理NativeArray的旧集合。
// 添加新粒子,或处理之前的粒子块 PreviousParticle nextParticles = new PreviousParticle { hashMap = hashMap, particlesPosition = particlesPosition, particlesVelocity = particlesVelocity, particlesForces = particlesForces, particlesPressure = particlesPressure, particlesDensity = particlesDensity, particleIndices = particleIndices, cellOffsetTable = cellOffsetTableNative, copyColliders = copyColliders }; if (cacheIndex > previousParticles.Count - 1) { previousParticles.Add(nextParticles); } else { previousParticles[cacheIndex].hashMap.Dispose(); previousParticles[cacheIndex].particlesPosition.Dispose(); previousParticles[cacheIndex].particlesVelocity.Dispose(); previousParticles[cacheIndex].particlesForces.Dispose(); previousParticles[cacheIndex].particlesPressure.Dispose(); previousParticles[cacheIndex].particlesDensity.Dispose(); previousParticles[cacheIndex].particleIndices.Dispose(); previousParticles[cacheIndex].cellOffsetTable.Dispose(); previousParticles[cacheIndex].copyColliders.Dispose(); } previousParticles[cacheIndex] = nextParticles;
现在让我们使用作业来填充NativeArrays。我使用组件数值填充了particlesPosition和particlesVelocity ,这些是要调度的第一批作业。
为此,我用ParticlesPositionJob创建作业并设置数值,然后对作业进行调度,设置粒子数量,批大小和作业依赖的JobHandle。
我们也使用了MemsetNativeArray,用来初始化默认数值。
// 复制组件数据到本地数组 CopyComponentData<Position> particlesPositionJob = new CopyComponentData<Position> { Source = positions, Results = particlesPosition }; JobHandle particlesPositionJobHandle = particlesPositionJob.Schedule(particleCount, 64, inputDeps); CopyComponentData<SPHVelocity> particlesVelocityJob = new CopyComponentData<SPHVelocity> { Source = velocities, Results = particlesVelocity }; JobHandle particlesVelocityJobHandle = particlesVelocityJob.Schedule(particleCount, 64, inputDeps); CopyComponentData<SPHCollider> copyCollidersJob = new CopyComponentData<SPHCollider> { Source = colliders, Results = copyColliders }; JobHandle copyCollidersJobHandle = copyCollidersJob.Schedule(colliderCount, 64, inputDeps); MemsetNativeArray<float> particlesPressureJob = new MemsetNativeArray<float> { Source = particlesPressure, Value = 0.0f }; JobHandle particlesPressureJobHandle = particlesPressureJob.Schedule(particleCount, 64, inputDeps); MemsetNativeArray<float> particlesDensityJob = new MemsetNativeArray<float> { Source = particlesDensity, Value = 0.0f }; JobHandle particlesDensityJobHandle = particlesDensityJob.Schedule(particleCount, 64, inputDeps); MemsetNativeArray<int> particleIndicesJob = new MemsetNativeArray<int> { Source = particleIndices, Value = 0 }; JobHandle particleIndicesJobHandle = particleIndicesJob.Schedule(particleCount, 64, inputDeps); MemsetNativeArray<float3> particlesForcesJob = new MemsetNativeArray<float3> { Source = particlesForces, Value = new float3(0, 0, 0) }; JobHandle particlesForcesJobHandle = particlesForcesJob.Schedule(particleCount, 64, inputDeps);
现在来调度更重要的作业,我们首先进行优化部分。
我调度了一个作业来把粒子位置存入HashMap中。你可以注意到,这次作业不依赖于inputDeps,而是依赖particlesPositionJobHandle。
这意味着作业会等待ParticlesPositionJob 完成后才开始运行。我们需要这样处理,否则脚本会在作业被填入数据时访问未初始化数据。
CombineDependencies可以把多个JobHandles 合并成一个,然后我们就能根据之前的多个作业来执行作业。
// 将位置写入hashMap HashPositions hashPositionsJob = new HashPositions { positions = particlesPosition, hashMap = hashMap.ToConcurrent(), cellRadius = settings.radius }; JobHandle hashPositionsJobHandle = hashPositionsJob.Schedule(particleCount, 64, particlesPositionJobHandle); JobHandle mergedPositionIndicesJobHandle = JobHandle.CombineDependencies(hashPositionsJobHandle, particleIndicesJobHandle); MergeParticles mergeParticlesJob = new MergeParticles { particleIndices = particleIndices }; JobHandle mergeParticlesJobHandle = mergeParticlesJob.Schedule(hashMap, 64, mergedPositionIndicesJobHandle); JobHandle mergedMergedParticlesDensityPressure = JobHandle.CombineDependencies(mergeParticlesJobHandle, particlesPressureJobHandle, particlesDensityJobHandle);
HashPositions和MergeParticles都是来自Boid示例的作业。我大幅修改了MergeParticles ,使它符合项目的需要。
MergeParticles 属于IJobNativeMultiHashMapMergedSharedKeyIndices 作业,和HashMaps相关,该作业的目的在于给每个粒子提供粒子所在的hashMap 存储桶的id。
我可以调度需要的作业来解决粒子之间的碰撞。方法很简单,我只是设置了作业数据并进行调度,它们都依赖于之前的作业。
// 计算密度压力 ComputeDensityPressure computeDensityPressureJob = new ComputeDensityPressure { particlesPosition = particlesPosition, densities = particlesDensity, pressures = particlesPressure, hashMap = hashMap, cellOffsetTable = cellOffsetTableNative, settings = settings }; JobHandle computeDensityPressureJobHandle = computeDensityPressureJob.Schedule(particleCount, 64, mergedMergedParticlesDensityPressure); // 合并 JobHandle mergeComputeDensityPressureVelocityForces = JobHandle.CombineDependencies(computeDensityPressureJobHandle, particlesForcesJobHandle, particlesVelocityJobHandle); // 计算作用力 ComputeForces computeForcesJob = new ComputeForces { particlesPosition = particlesPosition, particlesVelocity = particlesVelocity, particlesForces = particlesForces, particlesPressure = particlesPressure, particlesDensity = particlesDensity, cellOffsetTable = cellOffsetTableNative, hashMap = hashMap, settings = settings }; JobHandle computeForcesJobHandle = computeForcesJob.Schedule(particleCount, 64, mergeComputeDensityPressureVelocityForces); // 集成 Integrate integrateJob = new Integrate { particlesPosition = particlesPosition, particlesVelocity = particlesVelocity, particlesDensity = particlesDensity, particlesForces = particlesForces }; JobHandle integrateJobHandle = integrateJob.Schedule(particleCount, 64, computeForcesJobHandle);
我通过解决墙体碰撞并将粒子位置应用于组件位置来完成作业调度,然后循环会继续进行。别忘了退出循环后,在返回inputDeps前添加uniqueTypes.Clear()。
JobHandle mergedIntegrateCollider = JobHandle.CombineDependencies(integrateJobHandle, copyCollidersJobHandle); // 计算碰撞体 ComputeColliders computeCollidersJob = new ComputeColliders { particlesPosition = particlesPosition, particlesVelocity = particlesVelocity, copyColliders = copyColliders, settings = settings }; JobHandle computeCollidersJobHandle = computeCollidersJob.Schedule(particleCount, 64, mergedIntegrateCollider); // 应用位置 ApplyPositions applyPositionsJob = new ApplyPositions { particlesPosition = particlesPosition, particlesVelocity = particlesVelocity, positions = positions, velocities = velocities }; JobHandle applyPositionsJobHandle = applyPositionsJob.Schedule(particleCount, 64, computeCollidersJobHandle); inputDeps = applyPositionsJobHandle;
作业的结构和单线程工作流程中调用的方法差不多。当我们调度作业时,Unity会为每个粒子调用Execute(index)方法。该方法类似计算着色器,但有几个地方需要注意。
记得在开始时添加[BurstCompile],它会让作业的运行速度提高到原来的10倍。我对仅用于读取的数值标记了[ReadOnly],我们也可以在需要时使用[WriteOnly]标记。
[BurstCompile] private struct ComputeDensityPressure : IJobParallelFor { [ReadOnly] public NativeMultiHashMap<int, int> hashMap; [ReadOnly] public NativeArray<int> cellOffsetTable; [ReadOnly] public NativeArray<Position> particlesPosition; [ReadOnly] public SPHParticle settings; public NativeArray<float> densities; public NativeArray<float> pressures; const float PI = 3.14159274F; const float GAS_CONST = 2000.0f; public void Execute(int index) { // 缓存 int particleCount = particlesPosition.Length; float3 position = particlesPosition[index].Value; float density = 0.0f; int i, hash, j; int3 gridOffset; int3 gridPosition = GridHash.Quantize(position, settings.radius); bool found; // 找到相邻粒子 for (int oi = 0; oi < 27; oi++) { i = oi * 3; gridOffset = new int3(cellOffsetTable[i], cellOffsetTable[i + 1], cellOffsetTable[i + 2]); hash = GridHash.Hash(gridPosition + gridOffset); NativeMultiHashMapIterator<int> iterator; found = hashMap.TryGetFirstValue(hash, out j, out iterator); while (found) { // 找到相邻粒子后获取密度 float3 rij = particlesPosition[j].Value - position; float r2 = math.lengthsq(rij); if (r2 < settings.smoothingRadiusSq) { density += settings.mass * (315.0f / (64.0f * PI * math.pow(settings.smoothingRadius, 9.0f))) * math.pow(settings.smoothingRadiusSq - r2, 3.0f); } // 下一个相邻粒子 found = hashMap.TryGetNextValue(out j, ref iterator); } } // 应用密度,计算或应用压力 densities[index] = density; pressures[index] = GAS_CONST * (density - settings.restDensity); } }
最后,我们需要添加OnStopRunning()方法来去掉创建后未处理的NativeArrays例如在退出场景的时候。
protected override void OnStopRunning() { for (int i = 0; i < previousParticles.Count; i++) { previousParticles[i].hashMap.Dispose(); previousParticles[i].particlesPosition.Dispose(); previousParticles[i].particlesVelocity.Dispose(); previousParticles[i].particlesForces.Dispose(); previousParticles[i].particlesPressure.Dispose(); previousParticles[i].particlesDensity.Dispose(); previousParticles[i].particleIndices.Dispose(); previousParticles[i].cellOffsetTable.Dispose(); previousParticles[i].copyColliders.Dispose(); } previousParticles.Clear(); }
这样就完成了,所有内容都正常工作,我们可以观察到更好的运行效果。

进一步优化

下面解释一下如何比单线程版本更好地优化它。我们不是循环所有粒子来找到碰撞粒子,而是仅检查粒子附近的26个相邻粒子以及当前粒子即可。
我们在所有检测碰撞粒子的地方都使用了这个方法

基准测试

下面的视频展示了三个不同版本的效果:单线程,使用哈希的ECS/Job system/Burst编译器版本,不使用哈希的ECS/Job system/Burst编译器版本。
我们可以看到,ECS版本的速度更快。使用哈希也能显著提升性能。
整个项目过程中,最难处理的部分是内存优化,包括如何处理NativeArrays和Components等。
Burst Compiler在优化方面发挥了很大作用。它的优化能力有“开箱即用”的效果,我们只要在作业结构前添加[BurstCompile],脚本就会以原来速度的十倍运行。

小结

使用Unity的ECS和Job System实现流体模拟效果这个项目的实现过程就为大家介绍完毕了,你可以下载项目源代码,进一步的进行研究。
希望大家熟练的掌握实体组件系统ECS,Job System和Burst编译器的运用,让你的项目性能得到提升。
更多Unity教程,尽在Unity官方中文论坛(UnityChina.cn)。
Unity China
710
Comments
lin
8 months ago
ttt
0