Unity Runtime 28.02.2022, 11:38 Uhr

Komprimierte Atlanten in der Unity Runtime

Wie man Texturatlanten zur Laufzeit ein bisschen, aber nicht zu sehr komprimiert
(Quelle: Autor)
In diesem Artikel geht es um die Erstellung komprimierter Atlanten zur Laufzeit. Zunächst nehmen wir Atlanten im Allgemeinen unter die Lupe, wofür sie verwendet werden und welche Einschränkungen für die ursprünglichen Texturen gelten. Dann werden wir uns der einfachsten Technik zuwenden, um einen Atlas zur Laufzeit zu kompilieren, und das Ergebnis aus technischer Sicht bewerten. Anschließend geht es um die Experimente, die wir zur Laufzeitkomprimierung gemacht haben. Zum Schluss werden wir uns ansehen, was die verschiedenen Bildkomprimierungstechniken gemeinsam haben und wir werden über unseren alternativen Ansatz sprechen, bei dem Sie sich überhaupt nicht um übermäßige Pixel-Laufzeitkomprimierung kümmern müssen.

Das Projekt

Bei Warface: GO handelt es sich um einen teambasierten Action-Shooter, dessen Kernstück 4v4-PvP-Kämpfe sind. Die Spieler haben Zugang zu Hunderten von austauschbaren Ausrüstungsgegenständen (Bild 1). Jeder Charakter besteht aus einer Sammlung von acht Skin-Meshes, die in Unity nicht direkt zusammengefügt werden können. Jedes Mesh hat zwei unterschiedliche Texturen: diffuse und normale. Darüber hinaus hat jeder Charakter zwei austauschbare Waffen und mindestens einen zusätzlichen Renderer.
So sehen die Charaktere und ihre Ausrüstung vor und nach dem Wechsel aus (Bild 1)
Quelle: Autor
Infolgedessen wird jeder Charakter im Spiel mit mindestens 18 Zeichenaufrufen gerendert, von denen neun für das Hauptbild und neun für Schatten-Maps verwendet werden. Insgesamt kommen wir so auf 144 Zeichenaufrufe - und das nur für die Charaktere!

Was Atlanten sind und warum wir sie brauchen

Da wir das iPhone 6 unterstützen und zu Beginn des Entwicklungsprozesses sogar auf das 5s abzielten, war es für uns wichtig, viele Zeichenaufrufe loszuwerden. Bei unseren Projekten hängt die Performance auf schwachen Geräten von der CPU ab, die die Zeichenaufrufe in die Befehlswarteschlange stellt, und nicht von der GPU, die sie dann ausführt.
Um die Anzahl der Zeichenaufrufe zu reduzieren, integrieren wir die Geometrie der Ausrüstungselemente, aus denen unsere Figur besteht, manuell in ein einziges Mesh. Damit dies sinnvoll ist, müssen Sie nicht nur die Geometrie, sondern auch die Texturen integrieren, damit Sie ein einziges Material mit einem einzigen Satz von Texturen verwenden können.
Hier kommen Atlanten ins Spiel: Ohne sie müssten wir selbst nach der Integration der Geometrie immer noch Ausrüstungselemente mit separaten Zeichenaufrufen zeichnen und zwischen den Texturen hin- und herschalten. Atlanten werden bei der Produktion von statischen Inhalten oft von Künstlern manuell erstellt, aber wir wollen dies zur Laufzeit erreichen, da unsere Charaktere von den Spielern selbst dynamisch aus vorgegebenen Elementen geformt werden.
Natürlich gibt es auch alternative Ansätze. Zum Beispiel können Sie hier über die Verwendung von Textur-Arrays lesen, die es Ihnen ermöglichen, mehrere Texturen der gleichen Art zu einem Material hinzuzufügen.
Wenn Sie zum ersten Mal mit Atlanten arbeiten, sollten Sie die Anforderungen und Einschränkungen bedenken, die an die Originaltexturen gestellt wurden:
  • Wir sind gezwungen, die Originaltexturen in einem komprimierten Format an das Gerät des Benutzers zu liefern, da sie sonst zu viel Platz im Speicher des Endgeräts beanspruchen würden.
  • Wenn ein Material zwei Textur-Slots hat, die durch denselben Satz von UV-Koordinaten adressiert werden, müssen wir sicherstellen, dass die Proportionen der entsprechenden Texturen gleich sind - andernfalls kann es passieren, dass sich einer der Atlanten nicht korrekt zusammensetzt und/oder dem zweiten entspricht, und die Originaltexturen im selben Atlas können sich beim Hoch- oder Herunterskalieren in ihrer Qualität unterscheiden.
Es ist auch wichtig, Color Bleeding zu berücksichtigen. Jedes Mal, wenn wir einen Atlas zusammenstellen, müssen wir damit kämpfen. In Bild 2 sehen Sie ein Beispiel mit aktivierter und deaktivierter bilinearer Filterung.
Bild mit aktivierter und deaktivierter Filterung (Bild 2)
Quelle: Autor
Wie Sie sehen können, beginnen bei aktivierter Filterung die Texturgrenzen innerhalb des Atlas zu verschwimmen, und die Farben innerhalb der Texturen werden überblendet, da die bilineare Filterung angrenzende Pixel an der Grenze zweier Texturen interpoliert. Sie können dies leicht beheben, indem Sie einen Rand innerhalb der Textur für die UV-Hülle hinzufügen. Dieser sollte sich nicht in der Nähe der Texturgrenzen befinden (Bild 3).
Die Textur sollte nicht bis zum Rand gegen (rechts) (Bild 3)
Quelle: Autor

Naive Texturatlas-Implementierung

Nachdem wir uns nun mit den Originaltexturen befasst haben, wollen wir versuchen, sie zu verschmelzen und den einfachsten Weg dafür zu finden:
  • Nehmen Sie ein Texturpaket.
  • Erstellen Sie ein Layout dieser Texturen innerhalb des Atlasses. Wir können auch die Methode Texture2D.GenerateAtlas verwenden.
  • Erstellen Sie eine ARGB32 RenderTextur.
  • Fügen Sie die Texturen entsprechend dem vorbereiteten Layout zu einem Atlas zusammen.
  • Korrigieren Sie die UV-Koordinaten der kombinierten Geometrie.
  • Erzeugen Sie eine zusammengesetzte Figur.
Das Ergebnis ist visuell identisch mit dem einer separaten Gruppe von Meshes und Texturen, aber aus technischer Sicht wird die Figur zu einem einzigen Mesh, das mit einem einzigen Zeichenaufruf gezeichnet wird.
Ich habe eine Testszene mit und ohne Kombinieren ausgeführt, um zu sehen, wie sich dies auf die Leistung auswirkt, und habe die folgenden Ergebnisse erhalten (Bild 4).
Die Ergebnisse mit und ohne Kombination der Meshes und Texturen (Bild 4)
Quelle: Autor
Die Anzahl der sichtbaren Meshes wurde um ein Vielfaches reduziert, und die Anzahl der Batches wurde fast halbiert. Auch die Zeit, die der Render-Thread benötigt, wurde reduziert.
Die generierte ARGB32-Rendertextur verbraucht eine beträchtliche Menge an Speicher. Sie können natürlich die Auflösung verringern, um weniger Speicherplatz zu verbrauchen, aber Sie werden dabei Bilddetails verlieren. Diese Textur kann jedoch beliebige Proportionen und Größen haben, wird breit unterstützt und funktioniert überall.
Es ist zu beachten, dass sich nicht alle Texturen mit dieser Methode zu einem Atlas zusammenfügen lassen. Probleme können entstehen, wenn man versucht, Rohtexturen mit farbkodierten Daten zusammenzuführen. Die Neuinterpretation der Farbe wird mit Sicherheit dazu führen, dass die Daten nicht mehr zurückdekodiert werden können. Aber dieselbe Farbumdeutung ermöglicht es, Quelltexturen beliebigen Formats in einen Atlas einzubauen. Das heißt, Sie können gemischte Texturen zu einem Atlas hinzufügen.
Nichtsdestotrotz überwiegt das Problem des von einem solchen Atlas verwendeten Speicherplatzes und der Beziehung zwischen diesem Platz und der Auflösung alles andere. Da ein solches Ergebnis für uns nicht sehr vorteilhaft ist, begannen wir, alternative Möglichkeiten in Betracht zu ziehen. Der erste offensichtliche Ansatz war, die Laufzeitkomprimierung zu versuchen.

Komprimierung zur Laufzeit

Zunächst fanden wir eine Bibliothek namens Unity.PVRTC auf GitHub und spielten ein wenig damit herum. Die Bibliothek funktionierte auf Anhieb, war aber sehr langsam. Aus dem Quellcode war sofort ersichtlich, dass sie wirklich unfertig war. Wir mussten viel umschreiben, sogar mit Burst und Unity Jobs. Als Ergebnis konnten wir die Komprimierungszeit für eine einzelne 2K-Textur auf dem iPhone 6 von 4s auf 220ms senken.
Ironischerweise war das immer noch nicht genug. Die Produzenten waren unzufrieden, da wir durch die Verwendung von ARGB32-Atlanten und die Laufzeitkomprimierung die Gesamtstartzeit der Mission um mehrere Sekunden erhöhten, was sich negativ auf die Nutzererfahrung (UX) auswirkte. Darüber hinaus planten wir die Unterstützung von Player Backfill, wenn ein neuer Spieler einer laufenden Spielsitzung beitritt. Um den verlorenen Charakter durch einen neuen zu ersetzen, musste die gleiche Komprimierung in der Mitte der Spielsitzung auf jedem Benutzergerät durchgeführt werden.
Unter anderem verfügte die Bibliothek über eine relativ schwache Heuristik für die Auswahl von Referenzfarben (zum Beispiel frontal), was zu einer geringen Kompressionsqualität führte. Entscheidend war auch, dass wir die Texturen in einem komprimierten Format an die Geräte der Spieler schickten und anschließend einen ARGB32-Atlas daraus erstellten, der dann zur Laufzeit die Komprimierung durchlief. Infolgedessen wurden die Originaltexturen zweimal komprimiert, was die Fehler und Artefakte noch verschlimmerte (Bild 5).
Stärkere Kompression führt zu mehr Artefakten (Bild 5)
Quelle: Autor
Nachdem wir mit dieser Bibliothek experimentiert hatten, suchten wir weiter nach Möglichkeiten, einen Standardatlas zu erstellen, und dachten schließlich: Wie wäre es, wenn wir die Kompressionsalgorithmen von der anderen Seite her untersuchen? Wir hatten die Idee, die Feinheiten verschiedener Kompressionsalgorithmen wie ASTC, PVRTC, ETC und BC (DXT) genauer unter die Lupe zu nehmen. Wir hofften, Hinweise darauf zu finden, wie sich die Laufzeitkomprimierung effizienter umsetzen lässt.

Verschiedene Komprimierungsalgorithmen

Alle oben genannten Formate, ASTC, PVRTC, ETC und BC (DXT), sind Formate, die mit Pixelblöcken oder Paketen arbeiten. Jeder Block wird in einen oder zwei 64-Bit-Werte (long/int64) kodiert, und alle Speicherblöcke sind linear und zeilenweise für alle Formate außer PVRTC, das die Z-Ordnung (Morton-Kurve) verwendet. MIPs in allen Formaten (einschließlich PVRTC) sind ebenfalls linear von der größten zur kleinsten Textur sortiert.
Was ein Pixelblock ist, sehen wir uns am Beispiel von DXT1/BC1 an (Bild 6).
Was ist ein Pixelblock? (Bild 6)
Quelle: Autor
Das Bild wird in 44 gleiche Quadrate segmentiert, dann werden aus diesen 16 Pixeln zwei Referenzfarben ausgewählt und in je 16 Bit kodiert. Zusätzlich zu diesen beiden Referenzfarben wird eine Indexmatrix gebildet, die es ermöglicht, alle 16 Pixel mit einer gewissen Annäherung daraus zu erhalten.
Wie bereits erwähnt, können diese Blöcke entweder linear oder in der Z-Reihenfolge wie folgt gefunden werden (Bild 7).
Lineare und Z-Reihenfolge (Bild 7)
Quelle: Autor
Der Unterschied (und wahrscheinlich ein Vorteil) von PVRTC in diesem Fall ist, dass die Verwendung der Z-Reihenfolge die Lokalität der Datenregion im Prozessor-Cache erhöht, was zu mehr Cache-Hits als Cache-Misses führt, wenn mit einem Bildbereich gearbeitet wird, der normalerweise zweidimensional und nicht eindimensional ist. Das heißt, es gibt weit weniger Szenarien, in denen eine Reihe von Pixeln/Blöcken benötigt wird als ein rechteckiger Abschnitt derselben Daten.
Mit diesen Informationen versuchten wir, einen Atlas aus den Blöcken zu erstellen, indem wir sie einfach im Speicher neu anordneten. Der Blockcharakter dieser Daten sowie ihre Unabhängigkeit von anderen Blöcken halfen uns dabei: Zum Ausgleich konnten diese Blöcke als reguläre Longs (oder Paare von Longs) gelesen werden.

Unsere PVRTC-Atlas-Implementierung

Damit das Ganze funktioniert, mussten wir noch ein paar Anforderungen an die anfänglichen Texturen stellen:
  • Erstens müssen die Texturen quadratisch sein und eine Größe mit einer Potenz von 2 haben, da unsere Layout-Technik ziemlich kompliziert ist und Unity kein Level-MIP konstruiert, wenn die Textur nicht eine Potenz von 2 ist.
  • Zweitens müssen alle Quelltexturen mit den gleichen Einstellungen importiert werden. Dies liegt daran, dass das Verbinden von Blöcken im Atlas auf diese Weise nur möglich ist, wenn die eingehenden Daten einheitlich sind.
  • Drittens unterstützen wir nur die ASTC-Blockgrößen 4x4 und 8x8. Unser Algorithmus zur Anordnung der Texturen im Atlas war in diesem Fall entscheidend. Das grundlegende Problem war jedoch die mangelnde Bereitschaft, sich mit allen Arten von Grenzen zu befassen. Denn bei der Verwendung von ASTC 10×10 können Zweierpotenzen von Texturen nicht vollständig durch die Blockgröße geteilt werden. Das hat zur Folge, dass ASTC-Blöcke am Rand der Textur verweilen und nur teilweise mit relevanten Daten gefüllt werden. Es ist unklar, was man mit ihnen machen soll. Im Idealfall hätten die Texturen übermäßig komprimiert werden müssen, was wir zu vermeiden suchten.
  • Aktivieren Sie schließlich die Kontrollkästchen Read/Write Enabled im Importer aller Quelltexturen, damit wir auf die Pixel auf der CPU-Seite zugreifen können.
Werfen wir einen Blick auf die Erstellung eines solchen Atlasses anhand eines Pseudocodes als Beispiel.
Wir haben eine Funktion, die einen Satz anfänglicher Texturen, ein Format und ein Layout als Eingabe erhält. Innerhalb der Funktion erstellen wir eine Texture2D in der gewünschten Größe und dem gewünschten Format mit MIP-Unterstützung:
public static Texture2D GenerateAtlas(Texture2D[] sources, 
                                      TextureFormat format,
                                      Layout layout)
{
  var atlas = new Texture2D(4096, 4096, format, mipChain: true,
                            linear: false);
Ich möchte anmerken, dass hier speziell Texture2D erstellt wird und nicht RenderTexture, wie in der groben Implementierung.
Wir verwenden dann die allgemeine Funktion GetRawTextureData, um auf den Speicherbereich zuzugreifen, der die Pixel dieser Textur enthält, wobei wir long als Datentyp verwenden:
NativeArray<long> atlasData = atlas.GetRawTextureData<long>(); 
Nun können Sie diesem Array Blöcke hinzufügen. Wir schauen durch alle unsere Quelltexturen und erhalten Referenzen zu den entsprechenden Block-Arrays:
for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex) 
{
  var source = sources[srcIndex];
  NativeArray<long> sourceData = source.GetRawTextureData<long>();
Wir berechnen Offsets und kopieren Blöcke mit den Originaltexturen in das Array der Blöcke in unserem Atlas:
Rect sourceRect = layout.GetRect(srcIndex); 
for (int mip = 0; mip < source.mipmapCount; ++mip)
{
  MemoryRect memRect = GetMemoryRect(format, 4096, 4096, sourceRect,
                                     source.width, source.height, mip);
  CopyMemoryData(sourceData, atlasData, format, memRect);
}
Hier gibt Rect die Position einer separaten Textur im Atlas an. MemoryRect ist das Objekt, das für die Berechnung aller Offsets, Größen, Einzüge und Schritte zuständig ist.
Bei einem linearen Block-Layout könnte die Funktion zum Beispiel so aussehen:
public static void CopyMemoryDataLinear(NativeArray<long> source, 
                                        NativeArray<long> destination,
                                        MemoryRect memRect)
{
  for (int y = 0; y < memRect.blocksY; ++y)
  for (int x = 0; x < memRect.blocksX; ++x)
  {
    int srcOffset = memRect.GetSliceOffsetSrc(x, y);
    int dstOffset = memRect.GetSliceOffsetDst(x, y);
    Ziel[dstOffset] = Quelle[srcOffset];
  }
}
Führen Sie abschließend die Methode Apply aus, mit der die heruntergeladenen Daten auf die Grafik-API angewendet werden:
atlas.Apply(); 
Der vollständige Code:
public static Texture2D GenerateAtlas(Texture2D[] sources, TextureFormat format, Layout layout) 
    {
        var atlas = new Texture2D(4096, 4096, format, mipChain: true, linear: false);
        NativeArray<long> atlasData = atlas.GetRawTextureData<long>();

        for (int srcIndex = 0; srcIndex < sources.Length; ++srcIndex)
        {
            var source = sources[srcIndex];
            NativeArray<long> sourceData = source.GetRawTextureData<long>();

            Rect sourceRect = layout.GetRect(srcIndex);

            for (int mip = 0; mip < source.mipmapCount; ++mip)
            {
                MemoryRect memRect = GetMemoryRect(format, 4096, 4096, sourceRect, source.width, source.height, mip);
                CopyMemoryData(sourceData, atlasData, format, memRect);
            }
        }

        atlas.Apply();
        atlas zurückgeben;
    }

    public static void CopyMemoryDataLinear(NativeArray<long> source, NativeArray<long> destination, MemoryRect memRect)
    {
        for (int y = 0; y < memRect.blocksY; ++y)
        for (int x = 0; x < memRect.blocksX; ++x)
        {
            int srcOffset = memRect.GetSliceOffsetSrc(x, y);
            int dstOffset = memRect.GetSliceOffsetDst(x, y);
            Ziel[dstOffset] = Quelle[srcOffset];
        }
    }
Wenn Sie sicher sind, dass keine weiteren Texturen mehr in den Atlas passen, oder wenn Sie das Hinzufügen von Texturen zu diesem Atlas logischerweise beendet haben, dann rufen Sie die Methode Apply mit einem zusätzlichen Parameter auf:
atlas.Apply(false, makeNoLongerReadable: true); 
Dadurch wird die Textur dieses Atlasses in den Videospeicher geladen und gleichzeitig die Kopie im Systemspeicher zerstört, wodurch genau die Hälfte des Arbeitsspeichers eingespart wird.
Wenn wir diesen Code ausführen, erhalten wir eine zusammengesetzte Texture2D mit demselben Ausgabeformat wie die ursprünglichen Texturen.
Im Folgenden sind einige der Vorteile dieser Lösung aufgelistet:
  • Wir erhalten Atlanten mit höherer Auflösung.
  • Sie verbrauchen deutlich weniger Speicherplatz pro Pixel.
  • Wir werden doppelte Kompressionsartefakte los.
  • Es gibt kein Ausbluten innerhalb der MIPs. Dies könnte passieren, wenn MIPs auf der Grundlage eines bereits vorbereiteten Atlanten erstellt würden.
Was die negativen Aspekte betrifft, so gibt es ziemlich strenge Standards für die ursprünglichen Texturen, die ich oben erwähnt habe.
Schauen wir uns nun den Unterschied zwischen den beiden Atlanten an, die mit unterschiedlichen Ansätzen erstellt wurden (Bild 8).
Unterschiede zwischen den Atlanten (Bild 8)
Quelle: Autor
Da die ursprünglichen Texturen der Figur nicht in 2K passen, hat unser Atlas eine Auflösung von 4K. Wie Sie sehen können, wiegt er jedoch etwas mehr als der grobe ARGB32-Atlas, aber die hohe Auflösung kommt uns letztendlich zugute, worauf ich später noch näher eingehen werde. Sie können das Verhältnis der Auflösungen hier überprüfen, um den potenziellen Qualitätsunterschied zu ermitteln.
Wir können die Genauigkeit des Ansatzes testen, indem wir unsere aktualisierte Version des Atlasses mit der groben Implementierung vergleichen (Bild 9) und (Bild 10).
Detailvergleich der beiden Varianten (Bild 10)
Quelle: Autor
Detailvergleich der beiden Varianten (Bild 10)
Quelle: Autor

Nur noch eine Sache...

Wir haben versucht, mehrere Charaktere auf einmal in einem Atlas zusammenzufassen, und dank der Größe des Atlas in 4K und einer großen Menge an leerem Platz darin eine Seitenimplementierung erstellt.
Um dies zu erreichen, müssen wir zunächst alle Texturen, die dem Atlas hinzugefügt werden müssen, von allen Charakteren sammeln. Die Texturen werden dann in Gruppen aufgeteilt, wobei jede in eine einzelne 4K-Textur passen sollte. Dabei muss eine einfache Regel beachtet werden: Jede Figur muss komplett auf eine Seite passen, sonst wird sie komplett auf eine neue Seite übertragen. Doppelte Texturen können mit dieser Methode wiederverwendet werden, wenn sich die übrigen Texturen der Figur auf derselben Seite befinden.
Nach unseren Schätzungen hätten wir im schlimmsten Fall nicht mehr als 3-4 Seiten des Atlasses sehen sollen, aber die Realität übertraf unsere Erwartungen: Wir sahen nie mehr als zwei Seiten für alle Charaktere in einer Szene (Bild 11).
Kombination von Texturen auf einem Atlanten (Bild 11)
Quelle: Autor

Ergebnisse

Was ist das Ergebnis der Verwendung dieses Systems zum Kombinieren von Texturen in Atlanten? Betrachten wir PVRTC/iOS als Beispiel. Unsere Atlanten nehmen insgesamt 21 MB Speicherplatz in Anspruch, verglichen mit den bisherigen 46 MB für ARGB32-Atlanten. Die für die Erzeugung von zwei PVRTC-Seiten benötigte Zeit wurde auf 70 ms reduziert, im Gegensatz zu 8×220 ms, die allein für die Komprimierung aufgewendet wurden (ohne die Vorbereitung des ARGB32-Textur-Renderings). Die Texturen haben jetzt eine höhere Auflösung, werden nicht mehr doppelt komprimiert und können jetzt wiederverwendet werden, das heißt wir können einige der Duplikate im Videospeicher loswerden.
Yuri Grachev ist Programmierer bei dem zu My.Games gehörenden Whalekit Studio, den Machern des Zombie-Shooters Left to Survive und des mobilen PvP-Shooters Warface Global Operations (oder Warface: GO). Der Artikel ist ursprünglich hier erschienen.


Das könnte Sie auch interessieren