Physikalische Simulation von Hunderttausenden von Partikeln auf Unity + DOTS

Als ich auf einer der Websites durch die Weiten des World Wide Web wanderte, entdeckte ich einmal ein interaktives JS-Element - ein Bild aus Partikeln, die auseinanderflogen, als sich der Mauszeiger näherte. Es bestand der Wunsch, eine Simulation eines solchen Verhaltens auf Unity zu programmieren und zu überprüfen, welche Leistung aus dem Motor herausgedrückt werden kann. Ich teile Ihnen technische Lösungen und meine Beobachtungen mit, die ich während des Implementierungsprozesses erhalten habe.

Bild

Das Ergebnis ist wie folgt:

Animation


Um dies zu erreichen, müssen Sie die folgenden Probleme lösen:

  • Teilchenphysik beschreiben;
  • Gewährleistung der optimalen Geschwindigkeit der Positionsberechnung für jeden Frame;
  • Wählen Sie einen Weg, um schnell eine große Anzahl von Partikeln auf den Bildschirm zu zeichnen.

Physik


Um das gewünschte Partikelverhalten zu beschreiben, müssen wir nur drei Prinzipien formulieren: den Wunsch, den Abstand zum Startpunkt zu verringern, den Wunsch, vom Mauszeiger wegzufliegen und die Dämpfung der Bewegung. Wir brauchen keine exakten physikalischen Wechselwirkungen und Formeln, wir brauchen nur allgemeine Prinzipien, die sich so bewegen, dass sich das Teilchen absichtlich verhält. Der Einfachheit halber wird Masse von allen Formeln ausgeschlossen - wir stimmen zu, sie als einzeln zu betrachten.

Abstand zur Ausgangsposition verringern


Damit das Teilchen danach strebt, in seine ursprüngliche Position zurückzukehren, ist das Hookesche Gesetz für uns geeignet : Die auf die Ausgangsposition gerichtete Kraft ist linear proportional zum Abstand zu ihr. Das Teilchen flog zweimal weiter von der Ausgangsposition weg - zweimal stärker „zieht“ es nach hinten, alles ist einfach.

Der Abstand zum Cursor


Um interessant zu sein, müssen die Partikel irgendwie mit dem Cursor interagieren und sich erlauben, von der Startposition aus zu "schieben". Ich habe die Schwerkraft mit dem entgegengesetzten Vorzeichen als Grundlage für diese Wechselwirkung genommen : Partikel werden durch eine Kraft abgestoßen, die umgekehrt proportional zum Quadrat des Abstands zwischen der Position des Mauscursors und der aktuellen Position des Partikels ist und entlang des Vektors vom Cursor zum Partikel gerichtet ist.F=C/r2wo C- eine bestimmte Konstante, die die Interaktion reguliert.

Dämpfung


Wenn wir uns auf die beiden vorherigen Formeln beschränken, schwingt das Teilchen nach dem Einstellen der Anfangsamplitude für immer, da im Rahmen eines solchen Modells nirgendwo Energie verloren gehen kann. Wir werden die Dämpfung als die Kraft des viskosen Widerstands des Mediums simulieren, der nach dem Stokes-Gesetz linear proportional zur Teilchengeschwindigkeit ist und in die entgegengesetzte Richtung zur Bewegung gerichtet ist. Wir sind mit den Einschränkungen der Anwendbarkeit dieser Formel zufrieden, da wir in diesem Fall nicht an der absoluten Genauigkeit der physikalischen Wechselwirkung interessiert sind, sondern nur am Prinzip.

Ergebnis


Als Ergebnis erhalten wir die Formel der Kraft, die zu einem beliebigen Zeitpunkt auf das Teilchen wirkt:

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


Wo x,v- aktuelle Position und Partikelgeschwindigkeit, x0- Startposition xr- Cursorposition, Ca,Cr,Cd- Anziehungs-, Abstoßungs- und Dämpfungskoeffizienten.

Eine weitere Animation


Programmierverhalten


Da in unserem Modell die Partikel unabhängig voneinander sind und ihr Verhalten ausschließlich durch ihre Eigenschaften und gemeinsamen Konstanten bestimmt wird, ist die Berechnung des aktualisierten Zustands ideal für die Parallelisierung geeignet. Angesichts meines langjährigen Interesses an Unity DOTS habe ich mich entschlossen, diese Technologie anzuwenden, um das formulierte Problem zu lösen.

DOTS ist eine relativ junge Entwicklung der Unity-Engine, einem Technologie-Stack, der sich auf das Schreiben von Hochleistungs-Multithread-Code konzentriert. Auf Habré gibt es eine Übersetzung eines Einführungsartikels in DOTS. Dank der Verwendung des ECS , des Architekturmusters von Jobs System und des Burst-Compilers ermöglicht DOTS das Schreiben von schnellem Multithread-Code und verringert gleichzeitig die Wahrscheinlichkeit eines Fußschusses.

Das Wesentliche beim Schreiben eines Programms innerhalb des ECS besteht darin, den Code in Komponenten ( Komponenten ) zu unterteilen, die den Status beschreiben, Systeme ( Systeme ), die das Verhalten und die Interaktion von Komponenten beschreiben, und Entitäten - Objekte, die eine Reihe von Komponenten enthalten.

Komponenten


Wir benötigen folgende Komponenten:

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

Mit Komponenten ist alles ganz einfach, gehen Sie zu den Systemen:

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

Sie können die Reihenfolge der Systeme mithilfe von Attributen steuern

[UpdateBeforeAttribute]
[UpdateAfterAttribute]

Das größte System, das das Verhalten beschreibt, ist das Beschleunigungsaktualisierungssystem, das gemäß der abgeleiteten Formel handelt :

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

Machen


Trotz der potenziell großen Anzahl von Partikeln, die angezeigt werden müssen, können Sie für alle das gleiche quadratische Netz aus zwei Polygonen sowie dasselbe Material verwenden, wodurch es möglich wäre, sie alle in einem Draw-Aufruf zu rendern, wenn nicht für einen. “ aber ”: Alle Partikel haben im Allgemeinen unterschiedliche Farben, sonst wird das resultierende Bild sehr langweilig.

Der Standard-Unity-Shader „Sprites / Default“ kann mithilfe der GPU-Instanzierung das Rendern von Objekten mit ungleichen Sprites und Farben optimieren und zu einem Zeichenaufruf kombinieren. In diesem Fall muss jedoch über ein Skript eine Verknüpfung zur Textur und Farbe für jedes bestimmte Objekt festgelegt werden Von ECS haben wir keinen Zugriff.

Die Lösung kann die Graphics.DrawMeshInstanced- Methode seinHiermit können Sie ein Netz mehrmals in einem Zeichnungsaufruf mit unterschiedlichen Materialparametern mit derselben GPU-Instanz zeichnen. Es sieht aus wie das:

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

Um eine Gruppe von Objekten mit dieser Methode zu rendern, müssen Sie ein Array von Übergangsmatrizen und die Materialeigenschaften erfassen, die variiert werden sollen. Wie Sie sehen, läuft das Rendering-System nach dem TrsMatrixCalculationSystem - einem Hilfssystem, das die Übergangsmatrix für jedes der Partikel berechnet, was sehr einfach aussieht:

[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 von 46.000 Partikeln


Performance


Es ist Zeit darüber zu sprechen, warum (abgesehen natürlich von der ästhetischen Zufriedenheit) dies alles so war. In Unity besteht derzeit die Möglichkeit, zwischen zwei Scripting Backend ( CLR- Implementierungen ) zu wählen : dem guten alten Mono und einer moderneren Lösung von Unity-Entwicklern - IL2CPP .

Vergleichen Sie die Build-Leistung für diese beiden Laufzeitimplementierungen:
Die Anzahl der Partikel auf dem BildschirmDurchschnittliche Bildrate, MonoDurchschnittliche Bildrate, IL2CPP
50.000128255
100.00066130
200.0003157
PC-Spezifikationen:
QHD 2560
x 1440 Intel Core i5-9600K
16 GB RAM
MSI GeForce RTX 2080 SUPER VENTUS XS OC 8,0 GB


160.000 Partikel

Es ist bemerkenswert, dass IL2CPP in diesem sphärischen Vakuumexperiment Mono etwa zweimal übertrifft.

Laut dem Profiler wird buchstäblich die gesamte Zeit, die für den Frame aufgewendet wird, für das Rendering-System aufgewendet. Die Berechnungen der anderen Systeme sind praktisch „kostenlos“:

Unity Editor


Build

Es fällt auf, dass die meiste Zeit für die Konvertierung von Farbe von Farbe in Vektor4 und das Hinzufügen zur Liste aufgewendet wird (). Wir können den ersten leicht loswerden - wir werden die Transformation im Moment der Partikelerzeugung verschieben, da sich die Farbe dabei nicht ändert:

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

Diese Änderung ermöglichte es uns, den teuren Konvertierungsvorgang vollständig zu vermeiden: Die

Anzahl der Bilder pro Sekunde für 200.000 Partikel stieg auf 61.

Es ist möglich, das Füllen von Listen zu optimieren, um beispielsweise alle Farbarrays zu speichern und sie einmal zu generieren, aber diese Lösung erscheint mir daher nicht elegant Ich freue mich über konstruktive Vorschläge in den Kommentaren.

Fazit


ECS im Allgemeinen und Unity DOTS im Besonderen sind hervorragende Tools für bestimmte Szenarien und Aufgabenklassen. Sie erfüllen ihre Aufgabe bei der effizienten Verarbeitung einer großen Datenmenge und ermöglichen Ihnen die Erstellung von Simulationen, die ohne sie viel mehr Aufwand gekostet hätten. Betrachten Sie DOTS jedoch nicht als „Silberkugel“ und werfen Sie Steine ​​auf Entwickler, die in neuen Projekten an traditionellen Unity-Konzepten festhalten - DOTS ist nicht für jeden und nicht für jede Aufgabe geeignet.

PS


Jeder kann sich mit dem Projekt in meinem Github-Repository vertraut machen , aber wie man den Build als Desktop-Hintergrund in Wallpaper Engine installiert : Link .

All Articles