Simulação física de centenas de milhares de partículas no Unity + DOTS

A certa altura, enquanto vagava pelas extensões da World Wide Web em um dos sites, descobri um elemento JS interativo - uma imagem composta de partículas se separando quando o cursor do mouse se aproximava. Havia o desejo de programar uma simulação desse comportamento no Unity e verificar qual desempenho pode ser extraído do mecanismo. Estou compartilhando com você soluções técnicas e minhas observações recebidas durante o processo de implementação.

imagem

O resultado é o seguinte:

Animação


Para alcançá-lo, você precisa resolver os seguintes problemas:

  • descrever a física de partículas;
  • garantir a velocidade ideal do cálculo da posição em cada quadro;
  • escolha uma maneira de desenhar rapidamente um grande número de partículas na tela.

Física


Para descrever o comportamento desejado das partículas, precisamos formular apenas três princípios: o desejo de reduzir a distância até o ponto de partida, o desejo de "fugir" do cursor do mouse e o amortecimento do movimento. Não precisamos de interações e fórmulas físicas exatas, precisamos apenas de princípios gerais, movendo-nos de acordo com os quais a partícula se comportará de maneira deliberada. Por simplicidade, a massa é excluída de todas as fórmulas - concordamos em considerá-la única.

Reduzindo a distância para a posição inicial


Para que a partícula se esforce para retornar à sua posição original, a lei de Hooke é adequada para nós : a força direcionada para a posição inicial será linearmente proporcional à distância a ela. A partícula voou duas vezes mais longe da posição inicial - duas vezes mais forte que "puxa" para trás, tudo é simples.

A distância do cursor


Para ser interessante, as partículas devem de alguma forma interagir com o cursor, permitir-se "empurrar" a partir da posição inicial. Tomei a gravidade com o sinal oposto como base para essa interação : as partículas serão repelidas por uma força inversamente proporcional ao quadrado da distância entre a posição do cursor do mouse e a posição atual da partícula e direcionadas ao longo do vetor do cursor para a partícula.F=C/r2Onde C- uma certa constante que regula a interação.

Atenuação


Se nos restringirmos às duas fórmulas anteriores, a partícula após definir a amplitude inicial oscilará para sempre, porque não há onde perder energia na estrutura de um modelo desse tipo. Simularemos a atenuação como a força da resistência viscosa do meio, que, de acordo com a lei de Stokes , é linearmente proporcional à velocidade da partícula e direcionada na direção oposta ao movimento. Estamos satisfeitos com as restrições impostas à aplicabilidade dessa fórmula, pois, neste caso, não estamos interessados ​​na precisão absoluta da interação física, mas apenas no princípio.

Resultado


Como resultado, obtemos a fórmula da força que atua sobre a partícula em um momento arbitrário no tempo:

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


Onde x,v- posição atual e velocidade das partículas, x0- posicão inicial xr- posição do cursor, Ca,Cr,Cd- coeficientes de atração, repulsão e atenuação, respectivamente.

Outra animação


Comportamento de programação


Como em nosso modelo as partículas são independentes umas das outras e seu comportamento é determinado apenas por suas características e constantes comuns, o cálculo do estado atualizado é ideal para paralelização. Levando em conta meu interesse de longa data no Unity DOTS , decidi aplicar essa tecnologia para resolver o problema formulado.

DOTS é um desenvolvimento relativamente recente do mecanismo Unity, uma pilha de tecnologia focada na gravação de código multithread de alto desempenho. Em Habré, há uma tradução de um artigo introdutório no DOTS. Graças ao uso do ECS , do padrão de arquitetura Jobs System e do compilador Burst, o DOTS possibilita a criação de códigos rápidos de vários segmentos, reduzindo a probabilidade de um tiro no pé.

A essência de escrever um programa no ECS é separar o código em componentes ( Componentes ) que descrevem o estado, sistemas ( Sistemas ) que descrevem o comportamento e a interação de componentes e Entidades - objetos que contêm um conjunto de componentes.

Componentes


Vamos precisar dos seguintes 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;
}

Com componentes, tudo é bem simples, vá para os 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();
    }
}

Você pode controlar a sequência de sistemas usando atributos

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

O maior sistema que descreve o comportamento será o sistema de atualização de aceleração, agindo de acordo com a 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();
    }
}

Render


Apesar do número potencialmente grande de partículas que precisam ser exibidas, você pode usar a mesma malha quadrada de dois polígonos e o mesmo material, o que tornaria possível renderizá-las todas em uma única chamada, se não for uma " mas ”: todas as partículas, em geral, têm cores diferentes; caso contrário, a imagem resultante será muito chata.

O shader padrão do Unity, “Sprites / Padrão”, pode usar o GPU Instancing para otimizar a renderização de objetos com cores e sprites desiguais, combinando-os em uma chamada de desenho, mas, neste caso, um link para a textura e a cor de cada objeto específico deve ser definido a partir de um script. da ECS não temos acesso.

A solução pode ser o método Graphics.DrawMeshInstanced, que permite desenhar uma malha várias vezes em uma chamada de desenho com diferentes parâmetros de material, usando a mesma instância de GPU. Se parece com isso:

[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 renderizar um grupo de objetos usando esse método, você precisa coletar uma matriz de matrizes de transição e as propriedades do material que devem variar. O sistema de renderização, como você pode ver, é executado após o TrsMatrixCalculationSystem - um sistema auxiliar que calcula a matriz de transição para cada uma das partículas, o que parece muito simples:

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

Animação de 46.000 partículas


atuação


Chegou a hora de falar sobre o motivo (além, é claro, da satisfação estética). No Unity, no momento, há uma oportunidade de escolher entre dois Scripting Backend (implementações de CLR ): o bom e velho Mono e uma solução mais moderna dos desenvolvedores do Unity - IL2CPP .

Compare o desempenho da compilação para essas duas implementações de tempo de execução:
O número de partículas na telaTaxa média de quadros, MonoTaxa média de quadros, IL2CPP
50.000128255
100.00066.130
200.0003157
Especificações do PC:
QHD 2560x1440
Intel Core i5-9600K
16GB RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8,0 GB


160.000 partículas

É perceptível que, neste experimento de vácuo esférico, o IL2CPP supera o Mono duas vezes.

De acordo com o criador de perfil, literalmente todo o tempo gasto no quadro é gasto no sistema de renderização, os cálculos dos outros sistemas são praticamente "gratuitos": Compilação do

Unity Editor É perceptível que a maior parte do tempo é gasta no processo de conversão de cores de Color para Vector4 e adição à lista.Add ()




. Podemos facilmente nos livrar da primeira - adiaremos a transformação no momento da geração de partículas, pois a cor não muda no processo:

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

Essa alteração nos permitiu livrar-se completamente da operação dispendiosa de conversão: o

número de quadros por segundo para 200.000 partículas após aumentar para 61.

É possível otimizar o preenchimento de listas, por exemplo, para armazenar todas as matrizes de cores, gerando-as uma vez, mas essa solução não me parece elegante, portanto Ficarei feliz em sugestões construtivas nos comentários.

Conclusão


O ECS em geral e o Unity DOTS em particular são ótimas ferramentas para cenários e classes de tarefas específicos. Eles cumprem seu papel no processamento eficiente de uma enorme quantidade de dados e permitem criar simulações, o que, na sua ausência, exigiria muito mais esforço. No entanto, você não deve considerar o DOTS como uma “bala de prata” e atirar pedras nos desenvolvedores que aderem aos conceitos tradicionais do Unity em novos projetos - o DOTS não é para todos e nem para todas as tarefas.

PS


Todos podem se familiarizar com o projeto no meu repositório do github , mas como instalar a compilação como papel de parede da área de trabalho no Wallpaper Engine : link .

All Articles