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

发布于 2019-03-03  125 次阅读


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。

[转载]使用Unity的ECS和Job System实现流体模拟效果插图

学习准备

本文提供项目的源代码下载:
https://github.com/leonardo-montes/Unity-ECS-Job-System-SPH

进行开发前,我仔细观察了Unity的Boid演示项目,该示例展示了很多条鱼相交互的效果。下面是Unite大会中关于ECS和Job System的演讲。

[转载]使用Unity的ECS和Job System实现流体模拟效果插图1

单线程实现

我使用“老方法”实现了SPH算法,也就是使用Monobehaviours。

首先,我调用InitSPH() 来创建粒子的游戏对象,并初始化它们的属性,并给位置和引用设置了正确参数,以便为模拟使用,这将让我得到一组粘性较大的粒子。

然后,我在每一帧调用ComputeDensityPressure()、ComputeForces()和Integrate(),它们会处理粒子与粒子间的碰撞。

我添加了二个方法:ComputeColliders()和ApplyPosition()。ComputeColliders()可以处理粒子与标记为“SPHCollider”的墙体游戏对象之间的碰撞,ApplyPosition()方法用于将粒子游戏对象的位置设为和粒子位置相同。

void Start()
{
   InitSPH();
}

void Update()
{
   ComputeDensityPressure();
   ComputeForces();
   Integrate();
   ComputeColliders();
   ApplyPosition();
}

这样就完成了,效果如下图所示。
enter description here

使用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组件。

enter description here
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略有些复杂。

enter description here

现在介绍它的组件:

  1. PositionComponent组件让我们可以查看实体的位置,以便进行渲染。
  2. SPHVelocityComponent组件用于保存粒子的速度。
  3. SPHParticleComponent组件用于保存粒子的属性。
  4. 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,而是要设置共享组件参数,来让二个流体得到不同的属性。

完成了初始化部分,现在我们处理系统和作业。

enter description here
被实例化和渲染的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();
}

这样就完成了,所有内容都正常工作,我们可以观察到更好的运行效果。

enter description here

进一步优化

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

enter description here

基准测试

下面的视频展示了三个不同版本的效果:单线程,使用哈希的ECS/Job system/Burst编译器版本,不使用哈希的ECS/Job system/Burst编译器版本。

我们可以看到,ECS版本的速度更快。使用哈希也能显著提升性能。

观看基准测试结果

整个项目过程中,最难处理的部分是内存优化,包括如何处理NativeArrays和Components等。

Burst Compiler在优化方面发挥了很大作用。它的优化能力有“开箱即用”的效果,我们只要在作业结构前添加[BurstCompile],脚本就会以原来速度的十倍运行。

小结

使用Unity的ECS和Job System实现流体模拟效果这个项目的实现过程就为大家介绍完毕了,你可以下载项目源代码,进一步的进行研究。

[转载]使用Unity的ECS和Job System实现流体模拟效果插图1

版权所有:unity中文官方社区 原文地址:使用Unity的ECS和Job System实现流体模拟效果