Simulación física de cientos de miles de partículas en Unity + DOTS

En un momento, mientras deambulaba por las extensiones de la World Wide Web en uno de los sitios, encontré un elemento JS interactivo: una imagen compuesta de partículas que se separan al acercarse el cursor del mouse. Hubo un deseo de programar una simulación de tal comportamiento en Unity y verificar qué rendimiento se puede extraer del motor. Estoy compartiendo con ustedes soluciones técnicas y mis observaciones recibidas durante el proceso de implementación.

imagen

El resultado es el siguiente:

Animación


Para lograrlo, debe resolver los siguientes problemas:

  • describir la física de partículas;
  • asegurar la velocidad óptima de cálculo de posición en cada cuadro;
  • elija una forma de dibujar rápidamente una gran cantidad de partículas en la pantalla.

Física


Para describir el comportamiento deseado de las partículas, necesitamos formular solo tres principios: el deseo de reducir la distancia al punto de partida, el deseo de "volar" lejos del cursor del mouse y la amortiguación del movimiento. No necesitamos fórmulas e interacciones físicas exactas, solo necesitamos principios generales, que se muevan de acuerdo con los cuales la partícula se comportará de manera deliberada. Por simplicidad, la masa se excluye de todas las fórmulas: acordamos considerar que es única.

Reducir la distancia a la posición inicial.


Para que la partícula se esfuerce por volver a su posición original, la ley de Hooke es adecuada para nosotros : la fuerza dirigida hacia la posición inicial será linealmente proporcional a la distancia a ella. La partícula voló dos veces más lejos de la posición inicial, dos veces más fuerte que "tira" hacia atrás, todo es simple.

La distancia desde el cursor


Para ser interesante, las partículas deben de alguna manera interactuar con el cursor, permitirse "empujar" desde la posición inicial. Tomé la gravedad con el signo opuesto como base para esta interacción : las partículas serán repelidas por una fuerza inversamente proporcional al cuadrado de la distancia entre la posición del cursor del mouse y la posición actual de la partícula y dirigida a lo largo del vector desde el cursor hasta la partícula.F=C/r2dónde C- cierta constante que regula la interacción.

Atenuación


Si nos limitamos a las dos fórmulas anteriores, la partícula después de establecer la amplitud inicial oscilará para siempre, porque no hay ningún lugar para perder energía en el marco de dicho modelo. Simularemos la atenuación como la fuerza de la resistencia viscosa del medio, que, según la ley de Stokes , es linealmente proporcional a la velocidad de la partícula y se dirige en la dirección opuesta al movimiento. Estamos satisfechos con las restricciones impuestas a la aplicabilidad de esta fórmula, ya que en este caso no estamos interesados ​​en la precisión absoluta de la interacción física, sino solo en el principio.

Resultado


Como resultado, obtenemos la fórmula de la fuerza que actúa sobre la partícula en un momento arbitrario en el tiempo:

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


Dónde x,v- posición actual y velocidad de partícula, x0- posición inicial xr- posición del cursor, Ca,Cr,Cd- coeficientes de atracción, repulsión y atenuación, respectivamente.

Otra animación


Comportamiento de programación


Dado que en nuestro modelo las partículas son independientes entre sí, y su comportamiento está determinado únicamente por sus características y constantes comunes, el cálculo del estado actualizado es ideal para la paralelización. Teniendo en cuenta mi antiguo interés en Unity DOTS , decidí aplicar esta tecnología para resolver el problema formulado.

DOTS es un desarrollo relativamente reciente del motor Unity, una pila de tecnología centrada en escribir código multiproceso de alto rendimiento. En Habré hay una traducción de un artículo introductorio en DOTS. Gracias al uso del ECS , el patrón arquitectónico del sistema de trabajos y el compilador Burst, DOTS hace posible escribir código rápido de subprocesos múltiples, al tiempo que reduce la probabilidad de un disparo en el pie.

La esencia de escribir un programa dentro del ECS es separar el código en componentes ( Componentes ) que describen el estado, los sistemas ( Sistemas ) que describen el comportamiento y la interacción de los componentes y las Entidades , objetos que contienen un conjunto de componentes.

Componentes


Necesitaremos los siguientes componentes:

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

Con los componentes, todo es bastante simple, vaya a los sistemas:

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

Puedes controlar la secuencia de sistemas usando atributos

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

El sistema más grande que describe el comportamiento será el sistema de actualización de aceleración, que actúa de acuerdo con la fórmula derivada :

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

Hacer


A pesar de la cantidad potencialmente enorme de partículas que deben mostrarse, para todos puede usar la misma malla cuadrada de dos polígonos, así como el mismo material, lo que permitiría renderizarlos a todos en una sola llamada de dibujo, de no ser por uno " pero ": todas las partículas, en general, tienen colores diferentes, de lo contrario la imagen resultante será muy aburrida.

El sombreador de Unity estándar “Sprites / Default” puede usar GPU Instancing para optimizar la representación de objetos con sprites y colores desiguales, combinándolos en una llamada de dibujo, pero en este caso, un enlace a la textura y el color para cada objeto específico debe establecerse desde un script, al cual desde ECS no tenemos acceso.

La solución puede ser el método Graphics.DrawMeshInstanced, que le permite dibujar una malla varias veces en una llamada de dibujo con diferentes parámetros de material, utilizando la misma Instancia de GPU. Se parece a esto:

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

Para representar un grupo de objetos con este método, debe recopilar una matriz de matrices de transición y las propiedades de los materiales que se supone que varían. El sistema de renderizado, como puede ver, se ejecuta después del TrsMatrixCalculationSystem , un sistema auxiliar que calcula la matriz de transición para cada una de las partículas, que se ve muy 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();
    }
}

Animación de 46,000 partículas.


Actuación


Es hora de hablar sobre por qué (además, por supuesto, la satisfacción estética), todo esto dependía. En Unity, en este momento, existe la oportunidad de elegir entre dos Scripting Backend (implementaciones de CLR ): el viejo Mono y una solución más moderna de los desarrolladores de Unity: IL2CPP .

Compare el rendimiento de compilación para estas dos implementaciones de tiempo de ejecución:
El número de partículas en la pantalla.Velocidad de fotogramas promedio, monoVelocidad de fotogramas promedio, IL2CPP
50,000128255
100,00066130
200,0003157
Especificaciones de la PC:
QHD 2560x1440
Intel Core i5-9600K
16GB RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8.0 GB


160,000 partículas

Es notable que, en este experimento de vacío esférico, IL2CPP supera a Mono aproximadamente dos veces.

Según el generador de perfiles, literalmente todo el tiempo dedicado al marco se gasta en el sistema de renderizado, los cálculos de los otros sistemas son prácticamente "gratuitos":

Unity Editor


Build

Es notable que la mayor parte del tiempo se dedica al proceso de convertir el color de Color a Vector4 y agregarlo a la lista. (). Podemos deshacernos fácilmente de la primera: pospondremos la transformación en el momento de la generación de partículas, ya que el color no cambia en el proceso:

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

Este cambio nos permitió deshacernos por completo de la costosa operación de conversión: el

número de fotogramas por segundo para 200,000 partículas después de que aumentó a 61.

Es posible optimizar el llenado de listas, por ejemplo, almacenar todas las matrices de colores, generándolos una vez, pero esta solución no me parece elegante, por lo tanto Estaré encantado de sugerencias constructivas en los comentarios.

Conclusión


ECS en general y Unity DOTS en particular son excelentes herramientas para escenarios específicos y clases de tareas. Cumplen su función en el procesamiento eficiente de una gran cantidad de datos y le permiten crear simulaciones, que en su ausencia habrían requerido mucho más esfuerzo. Sin embargo, no considere DOTS como una "bala de plata" y arroje piedras a los desarrolladores que se adhieren a los conceptos tradicionales de Unity en nuevos proyectos: DOTS no es para todos y no para todas las tareas.

PD


Todos pueden familiarizarse con el proyecto en mi repositorio de github , pero cómo instalar la compilación como fondo de escritorio en Wallpaper Engine : enlace .

All Articles