Physical simulation of hundreds of thousands of particles on Unity + DOTS

At one point, while wandering around the expanses of the World Wide Web on one of the sites, I discovered an interactive JS element - a picture made up of particles flying apart as the mouse cursor approached. There was a desire to program a simulation of such behavior on Unity and check what performance can be squeezed out of the engine. I am sharing with you technical solutions and my observations received during the implementation process.

image

The result is as follows:

Animation


In order to achieve it, you need to solve the following problems:

  • describe particle physics;
  • ensure the optimal speed of position calculation on each frame;
  • choose a way to quickly draw a large number of particles on the screen.

Physics


To describe the desired particle behavior, we need to formulate only three principles: the desire to reduce the distance to the starting point, the desire to "fly away" from the mouse cursor and the damping of the movement. We do not need exact physical interactions and formulas, we only need general principles, moving in accordance with which the particle will behave in a deliberate manner. For simplicity, mass is excluded from all formulas - we agree to consider it to be single.

Reducing the distance to the starting position


In order for the particle to strive to return to its original position, Hooke's law will suit us : the force directed towards the initial position will be linearly proportional to the distance to it. The particle flew away two times farther from the initial position - two times stronger it “pulls” backward, everything is simple.

The distance from the cursor


To be interesting, the particles must somehow interact with the cursor, allow themselves to "push" from the starting position. I took gravity with the opposite sign as the basis for this interaction : particles will be repelled by a force inversely proportional to the square of the distance between the position of the mouse cursor and the current position of the particle and directed along the vector from the cursor to the particle.F=-C/r2where C- a certain constant that regulates the interaction.

Attenuation


If we restrict ourselves to the two previous formulas, the particle after setting the initial amplitude will oscillate forever, because there is nowhere to lose energy in the framework of such a model. We will simulate the attenuation as the force of the viscous resistance of the medium, which, according to the Stokes law , is linearly proportional to the particle velocity and directed in the opposite direction to the motion. We are satisfied with the restrictions imposed on the applicability of this formula, since in this case we are not interested in the absolute accuracy of the physical interaction, but only on the principle.

Result


As a result, we obtain the formula of the force acting on the particle at an arbitrary moment in time:

Fs=Ca(x-x0)-Cr||x-xr||2x-xr||x-xr||-Cdv,


Where x,v- current position and particle velocity, x0- starting position xr- cursor position, Ca,Cr,Cd- coefficients of attraction, repulsion and attenuation, respectively.

Another animation


Programming behavior


Since in our model the particles are independent of each other, and their behavior is determined solely by their characteristics and common constants, the calculation of the updated state is ideally suited for parallelization. Taking into account my long-standing interest in Unity DOTS , I decided to apply this technology to solve the formulated problem.

DOTS is a relatively recent development of the Unity engine, a technology stack focused on writing high-performance multi-threaded code. On Habré there is a translation of an introductory article in DOTS. Thanks to the use of the ECS , Jobs System architectural pattern and the Burst compiler, DOTS makes it possible to write fast multi-threaded code, while reducing the likelihood of a shot in the foot.

The essence of writing a program within the ECS is to separate the code into components ( Components ) that describe the state, systems ( Systems ) that describe the behavior and interaction of components, and Entities - objects that contain a set of components.

Components


We will need the following components:

//    ( )
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;
}

With components, everything is quite simple, go to the systems:

//   
[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();
    }
}

You can control the sequence of systems using attributes

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

The largest system that describes the behavior will be the acceleration update system, acting in accordance with the derived formula :

[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();
    }
}

Render


Despite the potentially huge number of particles that need to be displayed, for all you can use the same square mesh of two polygons, as well as the same material, which would make it possible to render them all in one draw call, if not for one “ but ”: all particles, in general, have different colors, otherwise the resulting picture will be very boring.

The standard Unity shader “Sprites / Default” can use GPU Instancing to optimize the rendering of objects with unequal sprites and colors, combining them into one draw call, but in this case, a link to the texture and color for each specific object must be set from a script, to which from ECS we do not have access.

The solution may be the Graphics.DrawMeshInstanced method, which allows you to draw one mesh several times in one draw call with different material parameters, using the same GPU Instancing. It looks like this:

[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();
    }
}

In order to render a group of objects using this method, you need to collect an array of transition matrices and those material properties that are supposed to be varied. The rendering system, as you can see, runs after the TrsMatrixCalculationSystem - an auxiliary system that calculates the transition matrix for each of the particles, which looks very simple:

[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();
    }
}

Animation of 46,000 particles


Performance


It's time to talk about why (besides, of course, aesthetic satisfaction) this was all up to. In Unity, at the moment, there is an opportunity to choose from two Scripting Backend ( CLR implementations ): the good old Mono and a more modern solution from Unity developers - IL2CPP .

Compare the build performance for these two runtime implementations:
The number of particles on the screenAverage frame rate, MonoAverage frame rate, IL2CPP
50,000128255
100,00066130
200,0003157
PC specifications:
QHD 2560x1440
Intel Core i5-9600K
16GB RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8.0 GB


160,000 particles

It is noticeable that, in this spherical vacuum experiment, IL2CPP outperforms Mono about twice.

According to the profiler, literally all the time spent on the frame is spent on the rendering system, the calculations of the other systems are practically “free”:

Unity Editor


Build

It is noticeable that most of the time is spent on the process of converting color from Color to Vector4 and adding to the list.Add (). We can easily get rid of the first one - we will postpone the transformation at the moment of particle generation, since the color does not change in the process:

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

This change allowed us to completely get rid of the expensive conversion operation: The

number of frames per second for 200,000 particles after it increased to 61.

It is possible to optimize the filling of lists, for example, to store all arrays of colors, generating them once, but this solution does not seem elegant to me, therefore I will be glad to constructive suggestions in the comments.

Conclusion


ECS in general and Unity DOTS in particular are great tools for specific scenarios and task classes. They perform their role in efficiently processing a huge amount of data and allow you to create simulations, which in their absence would have taken much more effort. However, do not consider DOTS as a “silver bullet” and throw stones at developers who adhere to traditional Unity concepts in new projects - DOTS is not for everyone and not for every task.

PS


Everyone can get acquainted with the project in my github repository , but how to install the build as a desktop wallpaper in Wallpaper Engine : link .

All Articles