查看原文
其他

使用Unity的ECS和Job System实现流体模拟效果

Unity Unity官方平台 2022-05-07

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官方中文论坛

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存