Handbuch zur Komprimierung von Skelettanimationen


Dieser Artikel bietet einen kurzen Überblick über die Implementierung eines einfachen Animationskomprimierungsschemas und einiger verwandter Konzepte. Ich bin kein Experte in dieser Angelegenheit, aber es gibt nur sehr wenige Informationen zu diesem Thema und es ist ziemlich fragmentiert. Wenn Sie ausführlichere Artikel zu diesem Thema lesen möchten, empfehle ich Ihnen, auf die folgenden Links zuzugreifen:


Bevor wir beginnen, lohnt es sich, eine kurze Einführung in die Skelettanimation und einige ihrer Grundkonzepte zu geben.

Grundlagen der Animation und Komprimierung


Skelettanimation ist ein ziemlich einfaches Thema, wenn Sie das Enthäuten vergessen. Wir haben ein Konzept eines Skeletts, das Transformationen der Knochen eines Charakters enthält. Diese Knochentransformationen werden in einem hierarchischen Format gespeichert. Tatsächlich werden sie als Delta zwischen ihrer globalen Position und der Position des Elternteils gespeichert. Die Terminologie hier ist verwirrend, da in der Spiel-Engine lokal oft als Modell- / Charakterraum bezeichnet wird und global der Weltraum ist. In der Animationsterminologie wird lokal als der Raum des Elternteils des Knochens bezeichnet, und global ist entweder der Raum des Charakters oder der Weltraum, je nachdem, ob sich der Wurzelknochen bewegt. Aber machen wir uns darüber keine Sorgen. Wichtig ist, dass Knochentransformationen relativ zu ihren Eltern lokal gespeichert werden. Dies hat viele Vorteile, insbesondere beim Mischen (Mischen):Wenn die Vermischung der beiden Positionen global wäre, würden sie in der Position linear interpoliert, was zu einer Zunahme und Abnahme der Knochen und einer Verformung des Charakters führen würde.Wenn Sie Deltas verwenden, wird das Mischen von einem Unterschied zum anderen durchgeführt. Wenn also die Delta-Transformation für einen Knochen zwischen zwei Posen gleich ist, bleibt die Länge des Knochens konstant. Ich denke, dass es am einfachsten (aber nicht ganz genau) ist, dies so zu verstehen: Die Verwendung von Deltas führt zu einer „sphärischen“ Bewegung der Knochenpositionen während des Mischens, und das Mischen globaler Transformationen führt zu einer linearen Bewegung der Knochenpositionen.

Die Skelettanimation ist nur eine geordnete Liste von Keyframes mit einer (normalerweise) konstanten Framerate. Der Schlüsselrahmen ist die Skeletthaltung. Wenn wir eine Pose zwischen Keyframes erhalten möchten, probieren wir beide Keyframes aus und mischen zwischen ihnen, wobei der Bruchteil der Zeit zwischen ihnen als Gewicht der Mischung verwendet wird. Das Bild unten zeigt eine Animation mit 30 Bildern pro Sekunde. Die Animation hat insgesamt 5 Frames und wir müssen die Pose 0,52 s nach dem Start bekommen. Daher müssen wir die Pose in Bild 1 und die Pose in Bild 2 abtasten und dann mit einem Mischgewicht von ca. 57% zwischen ihnen mischen.


Ein Beispiel für eine Animation mit 5 Bildern und eine Anforderung für eine Pose zu einem Zwischenbildzeitpunkt

Wenn Sie die obigen Informationen haben und der Ansicht sind, dass der Speicher für uns kein Problem darstellt, ist das sequentielle Speichern der Pose der ideale Weg, um die Animation zu speichern, wie unten gezeigt:


Einfache Speicherung von Animationsdaten

Warum ist das perfekt? Das Abtasten eines beliebigen Keyframes erfolgt über eine einfache Memcpy-Operation. Das Abtasten einer Zwischenpose erfordert zwei Memcpy-Operationen und eine Mischoperation. Aus Sicht des Caches kopieren wir mit memcpy zwei Datenblöcke der Reihe nach, dh nach dem Kopieren des ersten Frames hat einer der Caches bereits einen zweiten Frame. Sie können sagen: Warten Sie, wenn wir mischen, müssen wir alle Knochen mischen. Was ist, wenn die meisten von ihnen nicht zwischen Frames wechseln? Wäre es nicht besser, Knochen als Datensätze zu speichern und nur geänderte Transformationen zu mischen? Wenn dies implementiert ist, können beim Lesen einzelner Datensätze möglicherweise etwas mehr Cache-Fehler auftreten, und dann müssen Sie nachverfolgen, welche Konvertierungen Sie mischen müssen, und so weiter ... Das Mischen scheint eine Menge Arbeit zu seinIm Wesentlichen ist es jedoch die Anwendung eines Befehls auf zwei Speicherblöcke, die sich bereits im Cache befinden. Darüber hinaus ist der Mischcode relativ einfach, oft nur ein Satz von SIMD-Befehlen ohne Verzweigung, und ein moderner Prozessor verarbeitet sie in wenigen Augenblicken.

Das Problem bei diesem Ansatz besteht darin, dass extrem viel Speicher benötigt wird, insbesondere bei Spielen, bei denen die folgenden Bedingungen für 95% der Daten zutreffen.

  • Die Knochen haben eine konstante Länge
    • Die Charaktere in den meisten Spielen dehnen die Knochen nicht, daher sind innerhalb derselben Animation die Aufzeichnungen der Transformationen konstant.
  • Normalerweise skalieren wir die Knochen nicht.
    • Skalierung wird in Spielanimationen selten verwendet. Es wird ziemlich aktiv in Filmen und VFX verwendet, aber sehr wenig in Spielen. Selbst wenn verwendet, wird normalerweise die gleiche Skala verwendet.
    • Tatsächlich habe ich in den meisten Animationen, die ich zur Laufzeit erstellt habe, diese Tatsache ausgenutzt und die gesamte Knochentransformation in 8 Float-Variablen beibehalten: 4 zum Drehen des Quaternions, 3 zum Bewegen und 1 zum Skalieren. Dies reduziert die Größe der Pose zur Laufzeit erheblich und erhöht die Produktivität beim Mischen und Kopieren.

Vor diesem Hintergrund können Sie anhand des Originaldatenformats feststellen, wie ineffizient der Speicherplatz ist. Wir duplizieren die Verschiebungs- und Skalenwerte jedes Knochens, auch wenn sie sich nicht ändern. Und die Situation gerät schnell außer Kontrolle. Normalerweise erstellen Animatoren Animationen mit einer Frequenz von 30 fps, und in Spielen auf AAA-Ebene hat ein Charakter normalerweise etwa 100 Knochen. Basierend auf dieser Informationsmenge und einem Format von 8 Float benötigen wir ungefähr 3 KB pro Pose und 94 KB pro Sekunde Animation. Werte häufen sich schnell und auf einigen Plattformen kann der gesamte Speicher leicht verstopfen.

Sprechen wir also über die Komprimierung. Beim Versuch, Daten zu komprimieren, müssen verschiedene Aspekte berücksichtigt werden:

  • Kompressionsrate
    • Wie viel haben wir geschafft, die Menge des belegten Speichers zu reduzieren
  • Qualität
    • Wie viele Informationen haben wir aus den Quelldaten verloren?
  • Kompressionsrate
    • .

Ich mache mir hauptsächlich Sorgen um Qualität und Geschwindigkeit und weniger um das Gedächtnis. Außerdem arbeite ich mit Spielanimationen und kann die Tatsache ausnutzen, dass wir zur Reduzierung der Speicherbelastung keine Verschiebung und Skalierung in den Daten verwenden müssen. Aufgrund dessen können wir eine Qualitätsminderung vermeiden, die durch eine Verringerung der Anzahl von Frames und anderen Lösungen mit Verlusten verursacht wird.

Es ist auch äußerst wichtig zu beachten, dass Sie den Effekt der Animationskomprimierung auf die Leistung nicht unterschätzen sollten: In einem meiner vorherigen Projekte verringerte sich die Abtastrate um etwa 35%, und es gab auch einige Qualitätsprobleme.

Wenn wir mit der Komprimierung von Animationsdaten beginnen, müssen zwei wichtige Bereiche berücksichtigt werden:

  • Wie schnell können wir einzelne Informationselemente in einem Schlüsselrahmen (Quaternionen, Float usw.) komprimieren?
  • Wie können wir die Reihenfolge der Schlüsselbilder komprimieren, um redundante Informationen zu entfernen?

Datendiskretisierung


Fast der gesamte Abschnitt kann auf ein Prinzip reduziert werden: Daten diskretisieren.

Die Diskretisierung ist eine schwierige Art zu sagen, dass wir einen Wert aus einem kontinuierlichen Intervall in einen diskreten Satz von Werten konvertieren möchten.

Diskretisierungs-Float


Wenn es darum geht, Gleitkommawerte abzutasten, bemühen wir uns, diesen Gleitkommawert als Ganzzahl mit weniger Bits darzustellen. Der Trick besteht darin, dass eine Ganzzahl möglicherweise nicht tatsächlich eine Quellennummer darstellt, sondern einen Wert in einem diskreten Intervall, der einem kontinuierlichen Intervall zugeordnet ist. Normalerweise wird ein sehr einfacher Ansatz verwendet. Um einen Wert abzutasten, benötigen wir zunächst ein Intervall für den ursprünglichen Wert. Nachdem wir dieses Intervall erhalten haben, normalisieren wir den Anfangswert für dieses Intervall. Dann wird dieser normalisierte Wert mit dem maximal möglichen Wert für die gewünschte gegebene Ausgangsgröße in Bit multipliziert. Das heißt, für 16 Bits multiplizieren wir den Wert mit 65535. Dann wird der resultierende Wert auf die nächste ganze Zahl gerundet und gespeichert. Dies ist im Bild deutlich zu sehen:


Ein Beispiel für das Abtasten eines 32-Bit-Floats in eine vorzeichenlose 16-Bit-Ganzzahl

Um den ursprünglichen Wert wieder zu erhalten, führen wir die Operationen einfach in umgekehrter Reihenfolge aus. Es ist wichtig anzumerken, dass wir irgendwo das Anfangsintervall des Wertes aufzeichnen müssen; Andernfalls können wir den abgetasteten Wert nicht dekodieren. Die Anzahl der Bits im abgetasteten Wert bestimmt die Schrittgröße im normalisierten Intervall und damit die Schrittgröße im ursprünglichen Intervall: Der decodierte Wert ist ein Vielfaches dieser Schrittgröße, wodurch wir den maximalen Fehler, der aufgrund des Abtastprozesses auftritt, leicht berechnen können, sodass wir die Anzahl der Bits bestimmen können für unsere Bewerbung erforderlich.

Ich werde keine Beispiele für den Quellcode geben, da es eine ziemlich bequeme und einfache Bibliothek für die Durchführung grundlegender Stichprobenoperationen gibt, die eine gute Quelle zu diesem Thema darstellt: https://github.com/r-lyeh-archived/quant (würde ich sagen dass Sie nicht die Quaternion-Diskretisierungsfunktion verwenden sollten, aber dazu später mehr).

Quaternion-Komprimierung


Die Quaternion-Komprimierung ist ein gut untersuchtes Thema, daher werde ich nicht wiederholen, was andere Leute besser erklärt haben. Hier ist ein Link zu einem Snapshot-Komprimierungsbeitrag, der die beste Beschreibung zu diesem Thema enthält: https://gafferongames.com/post/snapshot_compression/ .

Ich habe jedoch etwas zu diesem Thema zu sagen. Die Bitsquid-Posts, die sich mit Quaternionskomprimierung befassen, schlagen vor, die Quaternion auf 32 Bit zu komprimieren, wobei ungefähr 10 Datenbits für jede Quaternionskomponente verwendet werden. Dies ist genau das, was Quant tut, da es auf Bitsquid-Posts basiert. Meiner Meinung nach ist eine solche Kompression zu groß und hat in meinen Tests starkes Schütteln verursacht. Vielleicht haben die Autoren weniger tiefe Hierarchien des Charakters verwendet, aber wenn Sie mehr als 15 Quaternionen aus meinen Animationsbeispielen multiplizieren, stellt sich der kombinierte Fehler als ziemlich schwerwiegend heraus. Meiner Meinung nach beträgt das absolute Minimum an Genauigkeit 48 Bit pro Quaternion.

Downsizing aufgrund von Sampling


Bevor wir uns mit den verschiedenen Komprimierungsmethoden und der Anordnung der Datensätze befassen, wollen wir uns ansehen, welche Art der Komprimierung wir erhalten, wenn wir einfach die Diskretisierung in der ursprünglichen Schaltung anwenden. Wir werden das gleiche Beispiel wie zuvor verwenden (ein Skelett von 100 Knochen). Wenn Sie also 48 Bit (3 x 16 Bit) pro Quaternion verwenden, 48 Bit (3 × 16) zum Verschieben und 16 Bit zum Skalieren, dann insgesamt zur Konvertierung Wir brauchen 14 Bytes anstelle von 32 Bytes. Dies sind 43,75% der Originalgröße. Das heißt, für 1 Sekunde Animation mit einer Frequenz von 30 fps haben wir das Volumen von ca. 94 KB auf ca. 41 KB reduziert.

Dies ist überhaupt nicht schlecht, die Diskretisierung ist relativ kostengünstig, sodass die Auspackzeit nicht zu stark beeinträchtigt wird. Wir haben einen guten Ausgangspunkt für den Start gefunden, und in einigen Fällen reicht dies sogar aus, um Animationen innerhalb des Ressourcenbudgets zu implementieren und eine hervorragende Qualität und Leistung sicherzustellen.

Komprimierung aufzeichnen


Hier wird alles sehr kompliziert, insbesondere wenn Entwickler anfangen, Techniken wie das Reduzieren des Schlüsselrahmens, die Kurvenanpassung usw. auszuprobieren. Auch in dieser Phase beginnen wir wirklich, die Qualität der Animationen zu verringern.

Bei fast allen derartigen Entscheidungen wird davon ausgegangen, dass die Eigenschaften jedes Knochens (Rotation, Verschiebung und Skalierung) als separate Aufzeichnung gespeichert werden. Daher können wir die Schaltung umdrehen, wie ich es zuvor gezeigt habe:


Speichern von Knochendaten als Datensätze

Hier speichern wir einfach alle Datensätze nacheinander, können aber auch alle Datensätze von Rotationen, Verschiebungen und Skalen gruppieren. Die Grundidee ist, dass wir vom Speichern von Daten aus jeder Pose zum Speichern von Datensätzen übergehen.

Nachdem wir dies getan haben, können wir andere Möglichkeiten verwenden, um den belegten Speicher weiter zu reduzieren. Der erste besteht darin, Frames zu löschen. Hinweis: Dies erfordert kein Datensatzformat und diese Methode kann im vorherigen Schema angewendet werden. Diese Methode funktioniert, führt jedoch zum Verlust kleiner Bewegungen in der Animation, da wir die meisten Daten verwerfen. Diese Technik wurde auf der PS3 aktiv eingesetzt, und manchmal mussten wir uns auf wahnsinnig niedrige Abtastfrequenzen bücken, zum Beispiel bis zu 7 Bilder pro Sekunde (normalerweise für nicht sehr wichtige Animationen). Ich habe immer noch schlechte Erinnerungen daran, als Animationsprogrammierer sehe ich deutlich die verlorenen Details und die Ausdruckskraft, aber wenn Sie aus der Sicht des Systemprogrammierers schauen, können wir sagen, dass die Animation "fast" dieselbe ist, weil die Bewegung im Allgemeinen bestehen bleibt, aber gleichzeitig wir Sparen Sie viel Speicher.

Lassen wir diesen Ansatz weg (meiner Meinung nach ist er zu destruktiv) und betrachten andere mögliche Optionen. Ein anderer beliebter Ansatz besteht darin, eine Kurve für jeden Datensatz zu erstellen und eine Reduzierung der Keyframes auf der Kurve durchzuführen, d. H. Entfernen doppelter Keyframes. Aus Sicht der Spielanimationen werden bei diesem Ansatz die Bewegungs- und Skalenaufzeichnungen perfekt komprimiert und manchmal auf einen Keyframe reduziert. Diese Lösung ist zerstörungsfrei, erfordert jedoch das Entpacken, da wir jedes Mal, wenn wir die Transformation erhalten müssen, die Kurve berechnen müssen, da wir nicht mehr nur zu den Daten im Speicher gehen können. Die Situation kann ein wenig verbessert werden, wenn Sie Animationen nur in eine Richtung berechnen.und speichern Sie den Status des Samplers jeder Animation für jeden Knochen (d. h. woher die Berechnung der Kurve stammt), aber Sie müssen dafür mit einer Erhöhung des Speichers und einer signifikanten Erhöhung der Codekomplexität bezahlen. In modernen Animationssystemen spielen wir Animationen oft nicht von Anfang bis Ende ab. Oft machen sie zu bestimmten Zeitversätzen Übergänge zu neuen Animationen, beispielsweise durch synchronisiertes Mischen oder Phasenanpassung. Oft probieren wir einzelne, aber nicht aufeinanderfolgende Posen aus, um Dinge wie das Mischen von Zielen / Betrachten eines Objekts zu implementieren, und oft werden Animationen in umgekehrter Reihenfolge abgespielt. Daher empfehle ich nicht, eine solche Lösung zu verwenden, da sich der Aufwand aufgrund der Komplexität und potenzieller Fehler einfach nicht lohnt.

Es gibt auch das Konzept, nicht nur identische Schlüssel in Kurven zu löschen, sondern auch einen Schwellenwert anzugeben, bei dem ähnliche Schlüssel gelöscht werden. Dies führt dazu, dass die Animation stärker verblasst, ähnlich wie beim Löschen von Frames, da das Endergebnis hinsichtlich der Daten dasselbe ist. Es werden häufig Animationskomprimierungsschemata verwendet, bei denen für jeden Datensatz Komprimierungsparameter festgelegt werden und Animatoren ständig mit diesen Werten gequält werden, um gleichzeitig die Qualität zu erhalten und die Größe zu reduzieren. Dies ist ein schmerzhafter und stressiger Arbeitsablauf, der jedoch erforderlich ist, wenn Sie mit dem begrenzten Speicher älterer Konsolengenerationen arbeiten. Glücklicherweise haben wir heute ein großes Speicherbudget und brauchen keine so schrecklichen Dinge.

All diese Aspekte werden in den Beiträgen von Riot / BitSquid und Nicholas offenbart (siehe Links am Anfang meines Artikels). Ich werde nicht im Detail darüber sprechen. Stattdessen werde ich darüber sprechen, was ich über das Komprimieren der Datensätze beschlossen habe ...

Ich ... habe beschlossen, die Datensätze nicht zu komprimieren.

Bevor Sie mit dem Winken beginnen, lassen Sie mich erklären ...

Wenn ich die Daten in den Datensätzen speichere, speichere ich die Rotationsdaten für alle Frames. Wenn es um Bewegung und Skalierung geht, verfolge ich, ob Bewegung und Skalierung während der Komprimierung statisch sind, und wenn ja, speichere ich nur einen Wert pro Datensatz. Das heißt, wenn sich der Datensatz entlang X bewegt, aber nicht entlang Y und Z, speichere ich alle Werte für das Verschieben des Datensatzes entlang X, aber nur einen Wert für das Verschieben des Datensatzes entlang Y und Z.

Diese Situation tritt bei den meisten Knochen in etwa 95% unserer Animationen auf, sodass wir am Ende den belegten Speicher erheblich reduzieren können, ohne an Qualität zu verlieren. Dies erfordert Arbeit unter dem Gesichtspunkt der Inhaltserstellung (DCC): Wir möchten nicht, dass die Knochen im Workflow für die Animationserstellung leichte Bewegungen und Zooms aufweisen, aber ein solcher Vorteil ist die zusätzlichen Kosten wert.

In unserem Animationsbeispiel gibt es nur zwei Datensätze mit Verschiebung und keine Datensätze mit Skalierung. Dann verringert sich das Datenvolumen für 1 Sekunde der Animation von 41 KB auf 18,6 KB (dh bis zu 20% des Volumens der Originaldaten). Die Situation wird noch besser, wenn die Dauer der Animation zunimmt, wir Ressourcen nur für das Aufzeichnen von Kurven und dynamischen Bewegungen verwenden und die Kosten für statische Aufzeichnungen konstant bleiben, was bei langen Animationen mehr spart. Und wir müssen keinen Qualitätsverlust durch Stichproben feststellen.

In Anbetracht all dieser Informationen sieht mein endgültiges Datenschema folgendermaßen aus:


Ein Beispiel für ein komprimiertes Animationsdatenschema (3 Bilder pro Datensatz)

Außerdem speichere ich den Versatz im Datenblock, um die Daten jedes Knochens zu starten. Dies ist notwendig, da wir manchmal Daten für nur einen Knochen abtasten müssen, ohne die gesamte Pose zu lesen. Dies bietet uns eine schnelle Möglichkeit, direkt auf Datensatzdaten zuzugreifen.

Zusätzlich zu den in einem Speicherblock gespeicherten Animationsdaten habe ich auch Komprimierungsoptionen für jeden Datensatz:


Beispiel für Komprimierungsparameter für Datensätze aus meiner Kruger-Engine

Diese Parameter speichern alle Daten, die ich zum Dekodieren der abgetasteten Werte jedes Datensatzes benötige. Sie überwachen auch die Statik von Datensätzen, sodass ich weiß, wie ich mit komprimierten Daten umgehen soll, wenn ich beim Sampling auf einen statischen Datensatz stoße.

Sie können auch feststellen, dass die Diskretisierung für jeden Datensatz individuell ist: Während der Komprimierung verfolge ich die Minimal- und Maximalwerte jedes Merkmals (z. B. entlang des X) jedes Datensatzes, um sicherzustellen, dass die Daten innerhalb des minimalen / maximalen Intervalls diskretisiert werden und die maximale Genauigkeit erhalten bleibt. Ich denke nicht, dass es im Allgemeinen möglich ist, globale Abtastintervalle zu erstellen, ohne Ihre Daten zu zerstören (wenn die Werte außerhalb des Intervalls liegen) und ohne signifikante Fehler zu machen.

Wie dem auch sei, hier ist eine kurze Zusammenfassung meiner dummen Versuche, die Animationskomprimierung zu implementieren: Am Ende verwende ich fast die Komprimierung.

All Articles