Simulation physique de centaines de milliers de particules sur Unity + DOTS

À un moment donnĂ©, en me promenant dans les Ă©tendues du World Wide Web sur l'un des sites, j'ai trouvĂ© un Ă©lĂ©ment JS interactif - une image composĂ©e de particules se sĂ©parant Ă  l'approche du curseur de la souris. Il y avait un dĂ©sir de programmer une simulation d'un tel comportement sur Unity et de vĂ©rifier quelles performances peuvent ĂȘtre retirĂ©es du moteur. Je partage avec vous les solutions techniques et mes observations reçues lors du processus de mise en Ɠuvre.

image

Le résultat est le suivant:

Animation


Pour y parvenir, vous devez résoudre les problÚmes suivants:

  • dĂ©crire la physique des particules;
  • assurer la vitesse optimale de calcul de position sur chaque trame;
  • choisissez un moyen de dessiner rapidement un grand nombre de particules sur l'Ă©cran.

La physique


Pour décrire le comportement souhaité des particules, nous devons formuler seulement trois principes: le désir de réduire la distance jusqu'au point de départ, le désir de «s'envoler» du curseur de la souris et l'amortissement du mouvement. Nous n'avons pas besoin d'interactions physiques et de formules exactes, nous avons seulement besoin de principes généraux, se déplaçant selon lesquels la particule se comportera de maniÚre délibérée. Par souci de simplicité, la masse est exclue de toutes les formules - nous convenons de la considérer comme unique.

Réduire la distance jusqu'à la position de départ


Pour que la particule s'efforce de revenir à sa position d'origine, la loi de Hooke nous conviendra : la force dirigée vers la position initiale sera linéairement proportionnelle à la distance qui la sépare. La particule s'est envolée deux fois plus loin de la position initiale - deux fois plus forte, elle «tire» en arriÚre, tout est simple.

La distance du curseur


Pour ĂȘtre intĂ©ressant, les particules doivent en quelque sorte interagir avec le curseur, se permettre de «pousser» depuis la position de dĂ©part. J'ai pris la gravitĂ© avec le signe opposĂ© comme base de cette interaction : les particules seront repoussĂ©es par une force inversement proportionnelle au carrĂ© de la distance entre la position du curseur de la souris et la position actuelle de la particule et dirigĂ©es le long du vecteur du curseur Ă  la particule.F=−C/r2 , oĂčC est une certaine constante qui rĂ©gule l'interaction.

Atténuation


Si nous nous limitons aux deux formules prĂ©cĂ©dentes, la particule aprĂšs avoir rĂ©glĂ© l'amplitude initiale oscillera pour toujours, car il n'y a nulle part oĂč perdre de l'Ă©nergie dans le cadre d'un tel modĂšle. Nous simulerons l'attĂ©nuation comme la force de la rĂ©sistance visqueuse du milieu, qui, selon la loi de Stokes , est linĂ©airement proportionnelle Ă  la vitesse des particules et dirigĂ©e dans la direction opposĂ©e au mouvement. Nous sommes satisfaits des restrictions imposĂ©es Ă  l'applicabilitĂ© de cette formule, car dans ce cas, nous ne nous intĂ©ressons pas Ă  la prĂ©cision absolue de l'interaction physique, mais uniquement au principe.

RĂ©sultat


En conséquence, nous obtenons la formule de la force agissant sur la particule à un moment arbitraire dans le temps:

F→s=Ca∗(x→−x→0)−Cr||x→−x→r||2∗x→−x→r||x→−x→r||−Cd∗v→,


OĂč x,v- position actuelle et vitesse des particules,x0 est la position initiale,xr - position du curseur,Ca,Cr,Cd sont respectivement les coefficients d'attraction, de rĂ©pulsion et d'attĂ©nuation.

Une autre animation


Comportement de programmation


Étant donnĂ© que dans notre modĂšle, les particules sont indĂ©pendantes les unes des autres et que leur comportement est dĂ©terminĂ© uniquement par leurs caractĂ©ristiques et leurs constantes communes, le calcul de l'Ă©tat mis Ă  jour est idĂ©alement adaptĂ© Ă  la parallĂ©lisation. Compte tenu de mon intĂ©rĂȘt de longue date pour Unity DOTS , j'ai dĂ©cidĂ© d'appliquer cette technologie pour rĂ©soudre le problĂšme formulĂ©.

DOTS est un développement relativement récent du moteur Unity, une pile technologique axée sur l'écriture de code multithread haute performance. Sur Habré, il y a une traduction d'un article introductif dans DOTS. Grùce à l'utilisation de l' ECS , du modÚle architectural de Jobs System et du compilateur Burst, DOTS permet d'écrire du code multithread rapide, tout en réduisant la probabilité d'un tir dans le pied.

L'essence de l'écriture d'un programme dans l' ECS est de séparer le code en composants ( composants ) qui décrivent l'état, systÚmes ( systÚmes ) qui décrivent le comportement et l'interaction des composants, et entités - objets qui contiennent un ensemble de composants.

Composants


Nous aurons besoin des composants suivants:

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

Avec les composants, tout est assez simple, allez aux systĂšmes:

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

Vous pouvez contrÎler la séquence des systÚmes à l'aide d'attributs

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

Le plus grand systÚme qui décrit le comportement sera le systÚme de mise à jour de l'accélération, agissant conformément à la formule dérivée :

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

Rendre


MalgrĂ© le nombre potentiellement Ă©norme de particules Ă  afficher, vous pouvez utiliser pour tous le mĂȘme maillage carrĂ© de deux polygones, ainsi que le mĂȘme matĂ©riau, ce qui permettrait de les rendre tous en un seul appel, sinon pour un seul " mais ": toutes les particules, en gĂ©nĂ©ral, ont des couleurs diffĂ©rentes, sinon l'image rĂ©sultante sera trĂšs ennuyeuse.

Le shader Unity standard «Sprites / Default» peut utiliser l' instance de GPU pour optimiser le rendu des objets avec des sprites et des couleurs inĂ©gales, en les combinant en un seul appel de dessin, mais dans ce cas, un lien vers la texture et la couleur de chaque objet spĂ©cifique doit ĂȘtre dĂ©fini Ă  partir d'un script, auquel de ECS, nous n'avons pas accĂšs.

La solution peut ĂȘtre la mĂ©thode Graphics.DrawMeshInstanced, qui vous permet de dessiner plusieurs fois un maillage en un seul appel avec des paramĂštres de matĂ©riau diffĂ©rents, en utilisant la mĂȘme instance de GPU. Cela ressemble Ă  ceci:

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

Pour rendre un groupe d'objets à l'aide de cette méthode, vous devez collecter un tableau de matrices de transition et les propriétés des matériaux qui sont censées varier. Le systÚme de rendu, comme vous pouvez le voir, est exécuté aprÚs le TrsMatrixCalculationSystem - un systÚme auxiliaire qui calcule la matrice de transition pour chacune des particules, ce qui semble trÚs 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 de 46 000 particules


Performance


Il est temps de parler de la raison pour laquelle (en plus, bien sûr, de la satisfaction esthétique). Dans Unity, pour le moment, il est possible de choisir entre deux scripts de backend (implémentations CLR ): le bon vieux Mono et une solution plus moderne des développeurs Unity - IL2CPP .

Comparez les performances de génération de ces deux implémentations d'exécution:
Le nombre de particules sur l'écranFréquence d'images moyenne, MonoFréquence d'images moyenne, IL2CPP
50 000128255
100 00066130
200 0003157
Spécifications du PC:
QHD 2560x1440
Intel Core i5-9600K
16 Go de RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8,0 Go


160 000 particules

Il est à noter que, dans cette expérience de vide sphérique, IL2CPP surpasse Mono environ deux fois.

Selon le profileur, littéralement tout le temps passé sur l'image est consacré au systÚme de rendu, les calculs des autres systÚmes sont pratiquement «gratuits»:

Unity Editor


Build

Il est à noter que la plupart du temps est consacré au processus de conversion des couleurs de Color en Vector4 et d'ajout à la liste. (). Nous pouvons facilement nous débarrasser de la premiÚre - nous reporterons la transformation au moment de la génération des particules, car la couleur ne change pas dans le processus:

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

Ce changement nous a permis de nous débarrasser complÚtement de l'opération de conversion coûteuse: le

nombre d'images par seconde pour 200 000 particules aprÚs qu'il soit passé à 61.

Il est possible d'optimiser le remplissage des listes, par exemple, pour stocker tous les tableaux de couleurs, en les générant une fois, mais cette solution ne me semble donc pas élégante, donc Je serai heureux de suggestions constructives dans les commentaires.

Conclusion


ECS en général et Unity DOTS en particulier sont d'excellents outils pour des scénarios et des classes de tùches spécifiques. Ils remplissent leur rÎle dans le traitement efficace d'une énorme quantité de données et vous permettent de créer des simulations qui, en leur absence, auraient demandé beaucoup plus d'efforts. Cependant, ne considérez pas DOTS comme une «solution miracle» et jetez des pierres aux développeurs qui adhÚrent aux concepts Unity traditionnels dans les nouveaux projets - DOTS n'est pas pour tout le monde et pas pour chaque tùche.

PS


Tout le monde peut se familiariser avec le projet dans mon référentiel github , mais comment installer la build comme fond d'écran sur Wallpaper Engine : lien .

All Articles