使用Unity的ECS和Job System实现流体模拟效果
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();
}
这样就完成了,效果如下图所示。
250个粒子
使用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,而是要设置共享组件参数,来让二个流体得到不同的属性。
完成了初始化部分,现在我们处理系统和作业。
被实例化和渲染的2500个粒子
系统和作业
我们从获取实体开始。
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,我们只在开始时获取该属性,修改数据,然后把数据设置给组件。
// 获取当前实体块设置
SPHParticle settings = uniqueTypes[typeIndex];
SPHCharacterGroup.SetFilter(settings);
// 缓存数据
ComponentDataArray<Position> positions = SPHCharacterGroup.GetComponentDataArray<Position>();
ComponentDataArray<SPHVelocity> velocities = SPHCharacterGroup.GetComponentDataArray<SPHVelocity>();
int cacheIndex = typeIndex - 1;
int particleCount = positions.Length;
NativeMultiHashMap<int, int> hashMap = new NativeMultiHashMap<int, int>(particleCount, Allocator.TempJob);
NativeArray<Position> particlesPosition = new NativeArray<Position>(particleCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<SPHVelocity> particlesVelocity = new NativeArray<SPHVelocity>(particleCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<float3> particlesForces = new NativeArray<float3>(particleCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<float> particlesPressure = new NativeArray<float>(particleCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<float> particlesDensity = new NativeArray<float>(particleCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<int> particleIndices = new NativeArray<int>(particleCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
NativeArray<int> cellOffsetTableNative = new NativeArray<int>(cellOffsetTable, Allocator.TempJob);
NativeArray<SPHCollider> copyColliders = new NativeArray<SPHCollider>(colliderCount, Allocator.TempJob, NativeArrayOptions.UninitializedMemory);
现在已经可以看到该脚本和单线程工作流程的不同之处。我们要在此使用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();
}
这样就完成了,所有内容都正常工作,我们可以观察到更好的运行效果。
2500个粒子
进一步优化
下面解释一下如何比单线程版本更好地优化它。我们不是循环所有粒子来找到碰撞粒子,而是仅检查粒子附近的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趣味小知识活动获奖提醒
Unity趣味小知识活动目前还有3位02200059、心海★星晴、Mrs.Chicken soup.Chen朋友尚未提供信息,请在3月1日14点前,在获奖信息登记文章中进行留言,否则会过期无效~
Obstacle Tower挑战赛火热进行中
Unity举办的Obstacle Tower挑战赛现在正式开启,此次比赛将为富有挑战的全新任务训练出最佳性能的代理。了解详情,请点击此处。
Unite Shanghai 2019
5月10日-12日上海,Unite大会强势回归。技术门票正在热销中,购票即获指定Asset Store资源商店精品21款资源的5折优惠券。
购票请访问:Unite2019.csdn.net
点击“阅读原文”访问Unity官方中文论坛