GPU gebunden. So übertragen Sie alles auf die Grafikkarte und ein bisschen mehr. Animationen

Es war einmal ein großes Ereignis, als eine Multitexturing-Einheit oder Hardware Transformation & Lighting (T & L) auf der GPU erschien. Das Festlegen einer Pipeline mit festen Funktionen war magischer Schamanismus. Und diejenigen, die wussten, wie man die erweiterten Funktionen bestimmter Chips über die D3D9-API-Hacks aktiviert und nutzt, gaben an, Zen gelernt zu haben. Aber die Zeit verging, Shader erschienen. Zunächst stark eingeschränkt in Funktionalität und Länge. Weiter, immer mehr Funktionen, mehr Anweisungen, mehr Geschwindigkeit. Compute (CUDA, OpenCL, DirectCompute) erschien und der Umfang der Grafikkartenkapazitäten begann sich rasch zu erweitern.

In dieser Reihe von (hoffentlich) Artikeln werde ich versuchen zu erklären und zu zeigen, wie „ungewöhnlich“ Sie die Funktionen der modernen GPU bei der Entwicklung von Spielen zusätzlich zu Grafikeffekten anwenden können. Der erste Teil ist dem Animationssystem gewidmet. Alles, was beschrieben wird, basiert auf praktischen Erfahrungen, implementiert und funktioniert in realen Spielprojekten.

Oooo, wieder die Animation. Darüber hundertmal schon geschrieben und beschrieben. Was ist so kompliziert? Wir packen die Knochenmatrix in den Puffer / die Textur und verwenden sie zum Enthäuten im Vertex-Shader. Dies wurde bereits in GPU Gems 3 (Kapitel 2. Animiertes Crowd-Rendering) beschrieben . Und implementiert in der jüngsten Unite Tech Presentation . Ist es anders möglich?

Technodemka von Unity


Viel Hype, aber ist es wirklich cool? Auf dem Hub gibt es einen Artikel, der ausführlich beschreibt, wie Skelettanimationen erstellt werden und in dieser Techno-Demo funktionieren. Parallelarbeit ist alles gut, wir betrachten sie nicht. Aber wir müssen herausfinden, was und wie es in Bezug auf das Rendern gibt.

In einer groß angelegten Schlacht kämpfen zwei Armeen, die jeweils aus einem Einheitentyp bestehen. Skelette links, Ritter rechts. Abwechslung ist so lala. Jede Einheit besteht aus 3 LODs (jeweils ~ 300, ~ 1000, ~ 4000 Scheitelpunkte), und nur 2 Knochen beeinflussen den Scheitelpunkt. Das Animationssystem besteht nur aus 7 Animationen für jeden Einheitentyp (ich erinnere mich, dass es bereits 2 davon gibt). Animationen fügen sich nicht ein, sondern wechseln diskret von einfachem Code, der in job'ax ausgeführt wird, was in der Präsentation hervorgehoben wird. Es gibt keine Zustandsmaschine. Wenn wir zwei Arten von Maschen haben, können Sie diese ganze Menge in zwei Instanzen zeichnen. Die Skelettanimation basiert, wie ich bereits schrieb, auf der 2009 beschriebenen Technologie.
Innovativ? Hmm ... ein Durchbruch? Ähm ... Geeignet für moderne Spiele? Nun, vielleicht rühmt sich das Verhältnis von FPS zur Anzahl der Einheiten.

Die Hauptnachteile dieses Ansatzes (Vormatrix in Texturen):

  1. Bildrate abhängig. Wollte doppelt so viele Animationsbilder - geben Sie doppelt so viel Speicher.
  2. Fehlende Mischanimationen. Sie können sie natürlich erstellen, aber im Skin-Shader bildet sich aus der Mischlogik ein komplexes Durcheinander.
  3. Fehlende Bindung an die Unity Animator-Zustandsmaschine. Ein praktisches Werkzeug zum Anpassen des Verhaltens des Charakters, das mit jedem Skinning-System verbunden werden kann. In unserem Fall wird jedoch aufgrund von Punkt 2 alles sehr schwierig (stellen Sie sich vor, wie Sie den verschachtelten BlendTree mischen).

GPAS


GPU-basiertes Animationssystem. Der Name ist gerade aufgetaucht.
Das neue Animationssystem hatte mehrere Anforderungen:

  1. Arbeiten Sie schnell (gut, verständlich). Sie müssen Zehntausende verschiedener Einheiten animieren.
  2. Seien Sie ein vollständiges (oder fast) Analogon des Unity-Animationssystems. Wenn dort die Animation so aussieht, sollte sie im neuen System genauso aussehen. Möglichkeit zum Umschalten zwischen integrierten CPU- und GPU-Systemen. Dies ist häufig zum Debuggen erforderlich. Wenn Animationen „fehlerhaft“ sind, können Sie durch Umschalten auf den klassischen Animator verstehen: Dies sind die Pannen des neuen Systems oder der Zustandsmaschine / Animation selbst.
  3. Alle Animationen können in Unity Animator angepasst werden. Ein praktisches, getestetes und vor allem gebrauchsfertiges Tool. Wir werden woanders Fahrräder bauen.

Überdenken wir die Vorbereitung und das Backen von Animationen. Wir werden keine Matrizen verwenden. Moderne Grafikkarten funktionieren gut mit Schleifen, unterstützen nativ zusätzlich zu float nativ, sodass wir mit Keyframes wie auf einer CPU arbeiten werden.

Schauen wir uns ein Beispiel für eine Animation im Animations-Viewer an:



Es ist ersichtlich, dass die Keyframes für Position, Skalierung und Drehung separat eingestellt sind. Für einige Bones benötigen Sie viele, für einige nur wenige, und für die Bones, die nicht separat animiert werden, werden nur der erste und der letzte Keyframe festgelegt.

Position - Vektor3, Quaternion - Vektor4, Skala - Vektor3. Die Keyframe-Struktur hat eines gemeinsam (zur Vereinfachung), daher benötigen wir 4 float, um zu einem der oben genannten Typen zu passen. Wir benötigen auch InTangent und OutTangent für die korrekte Interpolation zwischen Keyframes entsprechend der Krümmung. Oh ja, und die normalisierte Zeit vergisst nicht:

struct KeyFrame { float4 v; float4 inTan, outTan; float time; }; 

Verwenden Sie AnimationUtility.GetEditorCurve (), um alle Keyframes abzurufen.
Außerdem müssen wir uns die Namen der Knochen merken, da die Knochen der Animation in den Knochen des Skeletts (und möglicherweise nicht übereinstimmen) in der Phase der Vorbereitung der GPU-Daten neu zugeordnet werden müssen.

Wenn wir lineare Puffer mit Arrays von Keyframes füllen, werden wir uns an die Offsets in ihnen erinnern, um diejenigen zu finden, die sich auf die Animation beziehen, die wir benötigen.

Jetzt interessant. GPU-Skelettanimation.

Wir bereiten einen großen Puffer ("Anzahl der animierten Skelette" X "Anzahl der Knochen im Skelett" X "empirischer Koeffizient der maximalen Anzahl von Animationsmischungen") vor. Darin speichern wir die Position, Rotation und Skalierung des Knochens zum Zeitpunkt der Animation. Führen Sie für alle geplanten animierten Bones in diesem Frame den Compute Shader aus. Jeder Thread animiert seinen Knochen.

Jeder Keyframe, unabhängig von seiner Größe (Übersetzen, Drehen, Skalieren), wird auf genau dieselbe Weise interpoliert (Suche durch lineare Suche, verzeihen Sie mir, Knuth):

 void InterpolateKeyFrame(inout float4 rv, int startIdx, int endIdx, float t) { for (int i = startIdx; i < endIdx; ++i) { KeyFrame k0 = keyFrames[i + 0]; KeyFrame k1 = keyFrames[i + 1]; float lerpFactor = (t - k0.time) / (k1.time - k0.time); if (lerpFactor < 0 || lerpFactor > 1) continue; rv = CurveInterpoate(k0, k1, lerpFactor); break; } } 

Die Kurve ist eine kubische Bezier-Kurve, daher lautet die Interpolationsfunktion wie folgt:

 float4 CurveInterpoate(KeyFrame v0, KeyFrame v1, float t) { float dt = v1.time - v0.time; float4 m0 = v0.outTan * dt; float4 m1 = v1.inTan * dt; float t2 = t * t; float t3 = t2 * t; float a = 2 * t3 - 3 * t2 + 1; float b = t3 - 2 * t2 + t; float c = t3 - t2; float d = -2 * t3 + 3 * t2; float4 rv = a * v0.v + b * m0 + c * m1 + d * v1.v; return rv; } 

Die lokale Haltung (TRS) des Knochens wurde berechnet. Als Nächstes mischen wir mit einem separaten Compute-Shader alle erforderlichen Animationen für diesen Bone. Zu diesem Zweck haben wir einen Puffer mit Animationsindizes und Gewichten jeder Animation in der endgültigen Mischung. Wir erhalten diese Informationen von der Zustandsmaschine. Die Situation von BlendTree in BlendTree wird wie folgt gelöst. Zum Beispiel gibt es einen Baum:



BlendTree Walk hat ein Gewicht von 0,35, Run - 0,65. Dementsprechend sollte die endgültige Position der Knochen durch 4 Animationen bestimmt werden: Walk1, Walk2, Run1 und Run2. Ihre Gewichte haben Werte (0,35 · 0,92, 0,35 · 0,08, 0,65 · 0,92, 0,65 · 0,08) = (0,322, 0,028, 0,598, 0,052). Es sollte beachtet werden, dass die Summe der Gewichte immer gleich eins sein sollte, oder es werden magische Fehler bereitgestellt.

Das "Herz" der Mischfunktion:

 float bw = animDef.blendWeight; BoneXForm boneToBlend = animatedBones[srcBoneIndex]; float4 q = boneToBlend.quat; float3 t = boneToBlend.translate; float3 s = boneToBlend.scale; if (dot(resultBone.quat, q) < 0) q = -q; resultBone.translate += t * bw; resultBone.quat += q * bw; resultBone.scale += s * bw; 

Jetzt können Sie in eine Transformationsmatrix übersetzen. Hör auf Über die Hierarchie der Knochen völlig vergessen.
Basierend auf den Daten aus dem Skelett konstruieren wir ein Array von Indizes, wobei die Zelle mit dem Knochenindex den Index ihres Elternteils enthält. Schreiben Sie in root -1.

Ein Beispiel:



 float4x4 animMat = IdentityMatrix(); float4x4 mat = initialPoses[boneId]; while (boneId >= 0) { BoneXForm b = blendedBones[boneId]; float4x4 xform = MakeTransformMatrix(b.translate, b.quat, b.scale); animMat = mul(animMat, xform); boneId = bonesHierarchyIndices[boneId]; } mat = mul(mat, animMat); resultSkeletons[id] = mat; 

Hier im Prinzip alle Hauptpunkte des Renderns und Mischens von Animationen.

GPSM


GPU Powered State Machine (Sie haben es richtig erraten). Das oben beschriebene Animationssystem würde perfekt mit der Unity Animation State Machine funktionieren, aber dann wären alle Bemühungen nutzlos. Mit der Möglichkeit, Zehntausende (wenn nicht Hunderttausende) Animationen pro Frame zu berechnen, zieht UnityAnimator nicht Tausende von gleichzeitig funktionierenden Zustandsmaschinen heraus. Hmm ...
Was ist eine Zustandsmaschine in Unity? Dies ist ein geschlossenes System von Zuständen und Übergängen, das durch einfache numerische Eigenschaften gesteuert wird. Jede Zustandsmaschine arbeitet unabhängig voneinander und für denselben Satz von Eingabedaten. Warte eine Minute. Dies ist eine ideale Aufgabe für die GPU und Compute Shader!

Backphase

Zunächst müssen wir alle Zustandsmaschinendaten sammeln und in einer GPU-freundlichen Struktur platzieren. Und das: Zustände (Zustände), Übergänge (Übergänge) und Parameter (Parameter).
Alle diese Daten werden in linearen Puffern abgelegt und durch Indizes adressiert.
Jeder Rechenthread berücksichtigt seine Zustandsmaschine. AnimatorController bietet eine Schnittstelle zu allen erforderlichen internen Zustandsmaschinenstrukturen.

Die Hauptstrukturen der Zustandsmaschine:

 struct State { float speed; int firstTransition; int numTransitions; int animDefId; }; struct Transition { float exitTime; float duration; int sourceStateId; int targetStateId; int firstCondition; int endCondition; uint properties; }; struct StateData { int id; float timeInState; float animationLoop; }; struct TransitionData { int id; float timeInTransition; }; struct CurrentState { StateData srcState, dstState; TransitionData transition; }; struct AnimationDef { uint animId; int nextAnimInTree; int parameterIdx; float lengthInSec; uint numBones; uint loop; }; struct ParameterDef { float2 line0ab, line1ab; int runtimeParamId; int nextParameterId; }; struct Condition { int checkMode; int runtimeParamIndex; float referenceValue; }; 

  • Der Status enthält die Geschwindigkeit, mit der der Status abgespielt wird, und die Indizes der Bedingungen für den Übergang zu anderen gemäß der Statusmaschine.
  • Der Übergang enthält Zustandsindizes „von“ und „bis“. Übergangszeit, Austrittszeit und eine Verknüpfung zu einer Reihe von Bedingungen für den Eintritt in diesen Zustand.
  • CurrentState ist ein Laufzeitdatenblock mit Daten zum aktuellen Status der Zustandsmaschine.
  • AnimationDef enthält eine Beschreibung der Animation mit Links zu anderen von BlendTree verwandten Animationen.
  • ParameterDef ist eine Beschreibung des Parameters, der das Verhalten der Zustandsautomaten steuert. Line0ab und Line1ab sind die Koeffizienten der Liniengleichung, um das Gewicht der Animation anhand des Werts des Parameters zu bestimmen. Von hier aus:


  • Bedingung - Angabe der Bedingung zum Vergleichen des Laufzeitwerts des Parameters und des Referenzwerts.

Laufzeitphase

Der Hauptzyklus jeder Zustandsmaschine kann mit dem folgenden Algorithmus angezeigt werden:



Es gibt 4 Arten von Parametern im Unity-Animator: float, int, bool und trigger (das ist bool). Wir werden sie alle als Float präsentieren. Beim Einrichten der Bedingungen kann einer von sechs Vergleichstypen ausgewählt werden. Wenn == gleich. IfNot == NotEqual. Wir werden also nur 4 verwenden. Der Operatorindex wird an das Feld checkMode der Bedingungsstruktur übergeben.

 for (int i = t.firstCondition; i < t.endCondition; ++i) { Condition c = allConditions[i]; float paramValue = runtimeParameters[c.runtimeParamIndex]; switch (c.checkMode) { case 3: if (paramValue < c.referenceValue) return false; case 4: if (paramValue > c.referenceValue) return false; case 6: if (abs(paramValue - c.referenceValue) > 0.001f) return false; case 7: if (abs(paramValue - c.referenceValue) < 0.001f) return false; } } return true; 

Um den Übergang zu starten, müssen alle Bedingungen erfüllt sein. Die seltsamen Fallbezeichnungen sind nur (int) AnimatorConditionMode. Die Unterbrechungslogik ist eine schwierige Logik zum Unterbrechen und Zurücksetzen von Übergängen.

Nachdem wir den Status der Zustandsmaschine aktualisiert und die Zeitstempel im Delta-t-Frame gescrollt haben, ist es Zeit, Daten darüber vorzubereiten, welche Animationen in diesem Frame gelesen werden sollen, und die entsprechenden Gewichte. Dieser Schritt wird übersprungen, wenn sich das Gerätemodell nicht im Rahmen befindet (Frustum gekeult). Warum sollten wir Animationen von dem betrachten, was nicht sichtbar ist? Wir gehen den Quellstatus des Mischbaums und den Zielstatus des Mischbaums durch, fügen alle Animationen daraus hinzu und berechnen die Gewichte anhand der normalisierten Übergangszeit von der Quelle zum Ziel (Zeit für den Übergang). Mit vorbereiteten Daten kommt GPAS ins Spiel und zählt Animationen für jede animierte Entität im Spiel.

Die Gerätesteuerungsparameter stammen aus der Gerätesteuerungslogik. Sie müssen beispielsweise das Laufen aktivieren, den Parameter CharSpeed ​​festlegen und eine korrekt konfigurierte Zustandsmaschine mischt Übergangsanimationen von „Gehen“ zu „Laufen“ reibungslos.

Natürlich hat die vollständige Analogie zu Unity Animator nicht funktioniert. Interne Arbeitsprinzipien mussten, sofern sie nicht in der Dokumentation beschrieben sind, umgekehrt und analog gemacht werden. Einige Funktionen wurden noch nicht abgeschlossen (möglicherweise nicht). Beispielsweise unterstützt BlendType in BlendTree nur 1D. Andere Typen herzustellen ist im Prinzip nicht schwierig, gerade jetzt ist es nicht notwendig. Es gibt keine Animationsereignisse, da ein Rücklesen mit der GPU erforderlich ist und das „richtige“ Zurücklesen mehrere Frames zurückliegt, was nicht immer akzeptabel ist. Es ist aber auch möglich.

Rendern


Das Rendern von Einheiten erfolgt durch Instanziieren. Gemäß SV_InstanceID erhalten wir im Vertex-Shader die Matrix aller Bones, die den Vertex beeinflussen, und transformieren sie. Absolut nichts Ungewöhnliches:

 float4 ApplySkin(float3 v, uint vertexID, uint instanceID) { BoneInfoPacked bip = boneInfos[vertexID]; BoneInfo bi = UnpackBoneInfo(bip); SkeletonInstance skelInst = skeletonInstances[instanceID]; int bonesOffset = skelInst.boneOffset; float4x4 animMat = 0; for (int i = 0; i < 4; ++i) { float bw = bi.boneWeights[i]; if (bw > 0) { uint boneId = bi.boneIDs[i]; float4x4 boneMat = boneMatrices[boneId + bonesOffset]; animMat += boneMat * bw; } } float4 rv = float4(v, 1); rv = mul(rv, animMat); return rv; } 

Zusammenfassung


Funktioniert diese Farm schnell? Offensichtlich langsamer als das Abtasten der Textur mit Matrizen, aber dennoch kann ich einige Zahlen anzeigen (GTX 970).

Hier sind 50.000 Zustandsautomaten:



Hier sind 280.000 animierte Knochen:



Das Entwerfen und Debuggen all dessen ist ein echtes Problem. Eine Reihe von Puffern und Offsets. Eine Reihe von Komponenten und ihre Wechselwirkungen. Es gab Zeiten, in denen Hände fielen, als Sie sich mehrere Tage lang wegen eines Problems den Kopf schlugen, aber Sie können das Problem nicht finden. Es ist besonders "schön", wenn bei den Testdaten alles so funktioniert, wie es sollte, aber in einer echten "Kampf" -Situation gibt es keinen Animationsfehler. Diskrepanzen zwischen dem Betrieb von Unity-Zustandsautomaten und ihren eigenen sind ebenfalls nicht sofort sichtbar. Wenn Sie sich entscheiden, ein Analogon für sich selbst zu erstellen, beneide ich Sie im Allgemeinen nicht. Eigentlich ist die ganze Entwicklung für die GPU so, warum sich beschweren.

PS Ich möchte einen Stein in den Garten der Unite TechDemo-Entwickler werfen. Sie haben eine große Anzahl identischer Modelle von Ruinen und Brücken auf der Bühne und sie haben ihr Rendering in keiner Weise optimiert. Vielmehr versuchten sie es mit „statisch“. Erst jetzt können Sie in 16-Bit-Indizes nicht viel Geometrie überfüllen (dreimal haha, 2017), und es ist nichts zusammengekommen, da die Modelle stark polygonal sind. Ich habe "Instanz aktivieren" für alle Shader aktiviert und "Statisch" deaktiviert. Es gab keinen spürbaren Schub, aber verdammt noch mal, du machst eine Techno-Demo und kämpfst um jeden FPS. Das kannst du nicht machen.

War
 *** Summary *** Draw calls: 2553 Dispatch calls: 0 API calls: 8378 Index/vertex bind calls: 2992 Constant bind calls: 648 Sampler bind calls: 395 Resource bind calls: 805 Shader set calls: 682 Blend set calls: 230 Depth/stencil set calls: 92 Rasterization set calls: 238 Resource update calls: 1017 Output set calls: 74 API:Draw/Dispatch call ratio: 3.28163 298 Textures - 1041.01 MB (1039.95 MB over 32x32), 42 RTs - 306.94 MB. Avg. tex dimension: 1811.77x1810.21 (2016.63x2038.98 over 32x32) 216 Buffers - 180.11 MB total 17.54 MB IBs 159.81 MB VBs. 1528.06 MB - Grand total GPU buffer + texture load. *** Draw Statistics *** Total calls: 2553, instanced: 2, indirect: 2 Instance counts: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: >=15: ******************************************************************************************************************************** (2) 


Ist geworden
 *** Summary *** Draw calls: 1474 Dispatch calls: 0 API calls: 11106 Index/vertex bind calls: 3647 Constant bind calls: 1039 Sampler bind calls: 348 Resource bind calls: 718 Shader set calls: 686 Blend set calls: 230 Depth/stencil set calls: 110 Rasterization set calls: 258 Resource update calls: 1904 Output set calls: 74 API:Draw/Dispatch call ratio: 7.5346 298 Textures - 1041.01 MB (1039.95 MB over 32x32), 42 RTs - 306.94 MB. Avg. tex dimension: 1811.77x1810.21 (2016.63x2038.98 over 32x32) 427 Buffers - 93.30 MB total 9.81 MB IBs 80.51 MB VBs. 1441.25 MB - Grand total GPU buffer + texture load. *** Draw Statistics *** Total calls: 1474, instanced: 391, indirect: 2 Instance counts: 1: 2: ******************************************************************************************************************************** (104) 3: ************************************************* (40) 4: ********************** (18) 5: ****************************** (25) 6: ********************************************************************************************* (76) 7: *********************************** (29) 8: ************************************************** (41) 9: ********* (8) 10: ************** (12) 11: 12: ****** (5) 13: ******* (6) 14: ** (2) >=15: ****************************** (25) 


PPS Zu allen Zeiten waren Spiele hauptsächlich CPU-gebunden, d. H. Die CPU konnte mit der GPU nicht mithalten. Zu viel Logik und Physik. Wenn wir einen Teil der Spielelogik von der CPU auf die GPU übertragen, entladen wir die erste und die zweite, d. H. machen die Situation der GPU gebunden wahrscheinlicher. Daher der Titel des Artikels.

Source: https://habr.com/ru/post/de468549/


All Articles