在Unity + DOTS上对数十万个粒子进行物理模拟

有一次,当我在其中一个站点上浏览万维网的广阔空间时,我发现了一个交互式的JS元素-由粒子组成的图片,随着鼠标指针的接近,粒子飞散开来。希望在Unity上对这种行为进行仿真编程,并检查可以从引擎中挤出哪些性能。我将与您分享技术解决方案以及在实施过程中收到的意见。

图片

结果如下:

动画


为了实现它,您需要解决以下问题:

  • 描述粒子物理学;
  • 确保每帧的位置计算的最佳速度;
  • 选择一种在屏幕上快速绘制大量粒子的方法。

物理


为了描述所需的粒子行为,我们只需要制定三个原则:减少到起点的距离的愿望,从鼠标光标“飞走”的愿望以及运动的阻尼。我们不需要精确的物理相互作用和公式,我们只需要通用原理,粒子将根据这些原理故意移动。为简单起见,所有公式均不包括质量-我们同意将其视为单个。

缩短到起始位置的距离


为了使粒子努力返回其原始位置,胡克定律适合我们:指向初始位置的力将与到该位置的距离成线性比例。粒子飞离初始位置的距离增加了两倍-向后“拉动”强度增加了两倍,一切都很简单。

距光标的距离


有趣的是,粒子必须以某种方式与光标交互,允许它们从起始位置“推动”。我以具有相反符号的重力作为这种交互作用基础:粒子将受到与鼠标光标的位置与粒子当前位置之间的距离的平方成反比的力的排斥,并沿着矢量从光标指向粒子。F=C/r2哪里 C-调节交互作用的某个常数。

衰减


如果将我们限制在前面的两个公式中,则设置初始振幅后的粒子将永远振荡,因为在这种模型的框架中没有地方会损失能量。我们将衰减模拟为介质的粘性阻力,根据斯托克斯定律该阻力与粒子速度成线性比例,并指向与运动相反的方向。我们对施加在该公式上的限制感到满意,因为在这种情况下,我们对物理相互作用的绝对准确性不感兴趣,而仅对原理感兴趣。

结果


结果,我们得出了在任意时间作用在粒子上的力的公式:

Fs=Ca(xx0)Cr||xxr||2xxr||xxr||Cdv,


哪里 x,v-当前位置和粒子速度, x0- 起始位置 xr-光标位置, Ca,Cr,Cd-分别具有吸引力,排斥力和衰减系数。

另一个动画


编程行为


由于在我们的模型中,粒子彼此独立,并且它们的行为仅由其特性和公共常数决定,因此更新状态的计算非常适合并行化。考虑到我对Unity DOTS的长期兴趣,我决定应用此技术来解决所提出的问题。

DOTS是Unity引擎的相对较新的开发,Unity引擎是一种专注于编写高性能多线程代码的技术堆栈。在哈布雷(Habré)上,翻译了 DOTS 中的介绍性文章。由于使用了ECS,Jobs System 架构模式和Burst编译器,DOTS使得编写快速多线程代码成为可能,同时减少了踩脚的可能性。

ECS框架内编写程序的本质是将代码分为描述状态的组件(Components),描述组件的行为和交互的系统(Systems)和Entities-包含一组组件的对象。

组件


我们将需要以下组件:

//    ( )
public struct AttractorPosData : IComponentData
{
   public float2 Value;
}

//  
public struct VelocityData : IComponentData
{
   public float2 Value;
} 

//  
public struct AccelerationData : IComponentData
{
   public float2 Value;
}

//   
public struct ParticleData : IComponentData 
{
    public Color color;
    public Matrix4x4 matrix;
}

使用组件,一切都非常简单,请转到系统:

//   
[UpdateBefore(typeof(MoveSystem))]
public class VelocityUpdateSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var time = Time.DeltaTime;
        Entities.ForEach((ref VelocityData velocity, 
                in AccelerationData acceleration) =>
            {
                velocity.Value += time * acceleration.Value;
            })
            .ScheduleParallel();
    }
}

//   
public class MoveSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var time = Time.DeltaTime;
        Entities.ForEach((ref Translation t, in VelocityData velocity) =>
            {
                t.Value.xy += time * velocity.Value;
            })
            .ScheduleParallel();
    }
}

您可以使用属性控制系统的顺序

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

描述行为的最大系统将是加速度更新系统,该系统按照派生的公式进行操作

[UpdateBefore(typeof(VelocityUpdateSystem))]
public class AccelerationSystem : SystemBase
{
    protected override void OnUpdate()
    {
        float2 repulsorPos = Globals.Repulsor.Position;

        //   
        var attractionPower = Globals.SettingsHolder.SettingsModel.Attraction;
        //  
        var repulsionPower = Globals.SettingsHolder.SettingsModel.Repulsion;
        //  
        var dampingPower = Globals.SettingsHolder.SettingsModel.Damping;
        Entities.ForEach((
                ref AccelerationData acceleration, 
                in AttractorPosData attractorPos, 
                in Translation t,
                in VelocityData velocity) =>
            {
                //  
                var attraction = (attractorPos.Value - t.Value.xy) * attractionPower;

                var distSqr = math.distancesq(repulsorPos, t.Value.xy);
                //  
                var repulsion = -math.normalizesafe(repulsorPos - t.Value.xy) 
                    / distSqr * repulsionPower;

                //  
                var damping = -velocity.Value * dampingPower;

                acceleration.Value = attraction + repulsion + damping;
            })
            .ScheduleParallel();
    }
}

渲染


尽管需要显示潜在的大量粒子,但是对于所有对象,您都可以使用两个多边形的相同正方形网格以及相同的材质,这将使它们可以在一次绘制调用中全部呈现,如果不是为一个“但是”:通常,所有粒子都有不同的颜色,否则生成的图片将非常无聊。

标准的Unity着色器“ Sprites / Default”可以使用GPU实例化优化具有不同精灵和颜色的对象的渲染,将它们组合成一个绘制调用,但是在这种情况下,必须从脚本设置指向每个特定对象的纹理和颜色的链接,没有来自ECS的访问权限。

解决方案可能是Graphics.DrawMeshInstanced方法,您可以使用相同的GPU实例化在一次具有不同材质参数的绘制调用中多次绘制一个网格。看起来像这样:

[UpdateAfter(typeof(TrsMatrixCalculationSystem))]
public class SpriteSheetRenderer : SystemBase
{
    // DrawMeshInstanced     1023    
    private const int BatchCount = 1023;
    //   
    private readonly List<Matrix4x4> _matrixList = new List<Matrix4x4>(BatchCount);
    //  
    private readonly List<Vector4> _colorList = new List<Vector4>(BatchCount);

    protected override void OnUpdate()
    {
        var materialPropertyBlock = new MaterialPropertyBlock();
        var quadMesh = Globals.Quad;
        var material = Globals.ParticleMaterial;

        //        
        //   
        var shaderPropertyId = Shader.PropertyToID("_Color");

        var entityQuery = GetEntityQuery(typeof(ParticleData));
        //   ,   ParticleData
        var animationData = 
                   entityQuery.ToComponentDataArray<ParticleData>(Allocator.TempJob);
        var layer = LayerMask.NameToLayer("Particles");

        for (int meshCount = 0; 
              meshCount < animationData.Length; 
              meshCount += BatchCount)
        {
            var batchSize = math.min(BatchCount, animationData.Length - meshCount);
            _matrixList.Clear();
            _colorList.Clear();

            for (var i = meshCount; i < meshCount + batchSize; i++)
            {
                _matrixList.Add(animationData[i].matrix);
                _colorList.Add(animationData[i].color);
            }

            materialPropertyBlock.SetVectorArray(shaderPropertyId, _colorList);
            Graphics.DrawMeshInstanced(
                quadMesh,
                0,
                material,
                _matrixList,
                materialPropertyBlock,
                ShadowCastingMode.Off, false, layer);
        }

        animationData.Dispose();
    }
}

为了使用此方法渲染一组对象,您需要收集一个转换矩阵数组和那些应该被改变的材料属性。如您所见,渲染系统在TrsMatrixCalculationSystem之后运行-TrsMatrixCalculationSystem是一个辅助系统,它为每个粒子计算过渡矩阵,这看起来非常简单:

[UpdateAfter(typeof(MoveSystem))]
public class TrsMatrixCalculationSystem : SystemBase
{
    protected override void OnUpdate()
    {
        //   scale   
        var scale = Globals.SettingsHolder.SettingsModel.ParticlesScale;
        Entities.ForEach((ref ParticleData data, in Translation translation) =>
            {
                data.matrix = Matrix4x4.TRS(translation.Value, Quaternion.identity,
                    new Vector3(scale, scale, scale));
            })
            .ScheduleParallel();
    }
}

46,000个粒子的动画


性能


现在该谈论为什么(当然,除了美学上的满足)这一切都可以解决。目前,在Unity中,有机会从以下两个脚本后端CLR实现)中进行选择:老式的Mono和Unity开发人员提供的更现代的解决方案-IL2CPP

比较这两个运行时实现的构建性能:
屏幕上的粒子数平均帧频,单声道平均帧率,IL2CPP
50,000128255
100,00066130
200,0003157
PC规格:
QHD 2560x1440
Intel Core i5-9600K
16GB RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8.0 GB


160,000个颗粒

值得注意的是,在此实验中,球形球形IL2CPP在真空中的性能比Mono高出大约两倍。

根据探查器,实际上花在框架上的所有时间都花在了渲染系统上,其他系统的计算实际上是“免费的”:

Unity Editor


Build

值得注意的是,大部分时间都花在了将颜色从Color转换Vector4并添加到列表的过程中。 ()我们可以轻松摆脱第一个-我们将在粒子生成时推迟转换,因为在此过程中颜色不会改变:

// 
public struct ParticleData : IComponentData
{
    public Color color;
    public Matrix4x4 matrix;
}
// 
public struct ParticleData : IComponentData
{
    public Vector4 color;
    public Matrix4x4 matrix;
}

这种变化使我们完全摆脱了昂贵的转换操作:将

200,000个粒子的每秒帧数增加到61之后。

可以优化列表的填充,例如存储所有颜色数组,一次生成一次,但是这种解决方案在我看来并不好我很高兴在评论中提出建设性的建议。

结论


总体而言,ECS尤其是Unity DOTS是针对特定场景和任务类别的出色工具。它们在有效处理大量数据中发挥了作用,并允许您创建模拟,而如果没有这些模拟,则将花费更多的精力。但是,您不应将DOTS视为“银弹”,而要向在新项目中坚持传统Unity概念的开发人员扔石头-DOTS并不适合每个人,也不适合每个任务。

聚苯乙烯


每个人都可以在我的github存储库中熟悉该项目,但是如何在墙纸引擎中将构建作为桌面墙纸安装链接

All Articles