Mobile Game Optimierung: 17 FPS durch Draw Calls und Overdraw
Meine Tower-Defense-Szene lief auf einem Android-Mittelklassegerät mit 41 FPS. Messungen an Draw Calls, Materialien und Overdraw brachten sie auf 58 FPS.
Eine Tower-Defense-Szene, die bei 41 FPS feststeckte
Letzten Winter habe ich den finalen Optimierungsdurchlauf eines Tower-Defense-Projekts übernommen. Auf einem Redmi Note 10 — Snapdragon 678, Adreno 612 — stieg die Frame Time ab Welle 12 auf 24,3 ms, im Schnitt also 41 FPS. Meine erste Vermutung war die übliche: zu viele Gegner auf dem Bildschirm, folglich müssen KI und Physik teuer sein. Zwei Tage lang habe ich den Task-Planer profiliert und nichts gefunden; der Gameplay-Thread war nach 6,1 ms fertig.
Klar wurde das Bild erst, als der Unity Profiler 14,8 ms auf dem Render-Thread und 26 ms auf der GPU auswies. Der Frame Debugger zählte über 780 draw calls, rund 400 davon Reichweitenringe der Türme, Schadenszahlen, Boden-Decals und Rauchpartikel. Der größte Teil des Bildschirms war mit gestapelten transparenten Schichten bedeckt, von denen jede dieselbe Fläche erneut ausmalte. Die Gegnerzahl zu halbieren brachte gerade einmal 1,2 ms; der Schuldige saß woanders.
Der eigentliche Fehler steckte in meiner eigenen Definition von Mobile Game Optimierung: Jahrelang hatte ich sie als „Polygone reduzieren“ verstanden. Die Szene hatte insgesamt 210k Dreiecke, und der Adreno 612 zeichnete diese Geometrie ohne Murren. Die Last kam von zwei getrennten Fronten: den CPU-Kosten für das Setup pro draw call und der Bandbreite, die pro Pixel in den Hauptspeicher geschrieben wird. Was folgt, ist das Protokoll, wie ich diese beiden Fronten getrennt habe und wie viele Millisekunden jede Änderung tatsächlich zurückgab.
Wann welcher Batching-Pfad wirklich greift
Es gibt drei Mechanismen, und sie erledigen nicht dieselbe Aufgabe. SRP Batcher senkt die Zahl der draw calls nicht; er hält Materialkonstanten in einem persistenten GPU-Buffer für alle Zeichenaufrufe, die sich eine Shader-Variante teilen, und macht damit das CPU-Setup pro Aufruf billiger. 780 Aufrufe bleiben also 780, aber jeder einzelne wird spürbar leichter. Entscheidend ist die Zahl der Shader-Varianten, nicht die der Materialien — dass ich das übersehen hatte, ließ mich lange die falschen Dinge zusammenlegen.
GPU instancing ist der Weg, der die Zahl wirklich senkt: gleiches Mesh, gleiches Material, bis zu 1023 Instanzen in einem einzigen Aufruf. Die Falle dabei — ist der Shader in URP SRP-Batcher-kompatibel, läuft der Instancing-Pfad überhaupt nicht an, weil der SRP Batcher Vorrang hat. Aufgefallen ist mir das erst, als ich die Turmsockel mit Graphics.DrawMeshInstanced selbst gezeichnet habe; bis dahin sah ich im Frame Debugger „SRP Batch“-Zeilen und ging davon aus, dass instancing seine Arbeit tut.
Static batching fasst Meshes zur Build-Zeit in einem großen Vertex-Buffer zusammen. Der Gewinn ist real, hat aber seinen Preis: 38 MB zusätzlichen Mesh-Speicher in unserer Szene und den Verlust an Culling-Granularität, denn eine zusammengefasste Gruppe wird entweder komplett gezeichnet oder gar nicht. Aktiviert gelassen habe ich es nur für Bodenteile, die sich nie bewegen und ohnehin immer gemeinsam sichtbar sind. Für Bäume und Felsen abgeschaltet, sank sowohl der Speicherbedarf als auch die Zahl der tatsächlich eingereichten Dreiecke.
Dazu kommt ein stiller Batch-Killer: MaterialPropertyBlock. Wir hatten an jedem Renderer einen hängen, um die Turmstufen einzufärben, und allein das nahm diesen Objekten die SRP-Batcher-Kompatibilität. Nachdem die Farbvariation in Instanzdaten und Vertex Colors gewandert war, fiel der Render-Thread von 14,8 ms auf 11,0 ms — ohne eine einzige Zeile Shader-Code zu ändern.
Materialanzahl und Aufbau des Texture Atlas
Was die Zahl der draw calls tatsächlich trieb, war die Zahl der Materialien. Die Szene hatte 34 davon, und die meisten unterschieden sich nur durch eine andere 512x512-Textur. Ich habe sie in drei texture atlases mit 2048x2048 gepackt: Umgebung, Türme sowie Effekte und UI. Je mehr Objekte sich ein Material teilen, desto größer ist der Pool, aus dem instancing und batching schöpfen können.
Atlasing ist weniger mechanisch, als es aussieht. Beim Neupacken der UVs musste ich jedes Mesh ausschließen, das auf Tiling setzt, denn wrap mode repeat funktioniert innerhalb eines Atlas nicht; die Nachbarinsel blutet herein. Für die Bodenkacheln habe ich ein eigenes Material behalten und sie stattdessen mit instancing gezeichnet. Außerdem bekam jede Insel 8 Pixel Padding gegen Mipmap-Bleeding — bei 4 Pixeln zeigten sich auf Distanz dünne farbige Nähte.
Bei der Kompression bin ich von ETC2 auf ASTC 6x6 gewechselt, das bei gleichem Speicherbudget sichtbar sauberer ist, besonders bei Verläufen innerhalb eines Atlas. Nach dem Atlasing sanken die Materialien von 34 auf 6 und die draw calls von 780 auf 210. Der Render-Thread fiel von 11,0 ms auf 7,4 ms. Das war der größte Einzelgewinn des gesamten Durchlaufs, und geschrieben habe ich dafür keine Zeile Shader-Code.
Was transparente Schichten an Overdraw kosten
Overdraw ist die Anzahl der Schreibvorgänge auf denselben Pixel innerhalb eines Frames. Bei transparenten Objekten ist ZWrite aus, der Tiefentest verwirft also niemanden; das Objekt dahinter wird genauso schattiert wie das davor. In der Overdraw-Ansicht des Rendering Debugger zeigte die Bildmitte 11x — manche Pixel wurden elfmal pro Frame schattiert. Der Großteil dieser 26 ms GPU-Zeit steckte genau dort.
Ich habe die Schichten einzeln gezählt: Reichweitenring, Boden-Decal, Rauch, Funken, Schadensblitz und obendrauf ein bildschirmfüllendes Vignette-Quad. Die Vignette habe ich in den finalen Post-Process-Shader gefaltet und das separate Quad gelöscht — für sich genommen ist das eine volle Bildschirmschicht in 1080p. Den Reichweitenring zeichne ich nur noch, solange ein Turm ausgewählt ist. Danach habe ich die Rauchpartikel von 240 auf 90 reduziert und jeden einzelnen größer gemacht: dieselbe optische Dichte bei einem Drittel der Fill-Kosten.
Alpha Testing ist nicht die Lösung. clip() auf Laub- und Zaunmaterialien zerstört auf einer tile-basierten GPU early-z und Hidden Surface Removal; in beiden Fällen, die ich gemessen habe, brachte die Rückkehr zu alpha blend 0,6 ms. Aus demselben Grund bringt es nichts, Transparenzen von vorne nach hinten zu sortieren, denn keine von ihnen schreibt Tiefe. Der Gewinn entsteht ausschließlich daraus, die Zahl der Schichten und die von ihnen bedeckte Bildschirmfläche zu senken.
Auf Tile-GPUs ist Bandbreite die eigentliche Grenze
Mobile GPUs zerlegen den Frame in Tiles, verarbeiten jedes Tile in einem kleinen, schnellen On-Chip-Speicher und schreiben das Ergebnis in den Hauptspeicher. Teuer ist nicht die Shader-Mathematik, sondern dieser Schreib- und Leseverkehr. In 1080p ist ein einzelnes RGBA32-Target rund 8 MB pro Frame, bei 60 FPS also 500 MB/s — und das ist ein einziger Pass. Wird das Gerät warm, drosselt zuerst der Speichertakt; Bandbreite ist damit auch ein thermisches Problem.
Deshalb ist ein Wechsel des Render Targets mitten im Frame teuer: Jeder Wechsel zwingt den Tile-Speicher dazu, in den Hauptspeicher aufzulösen. Unsere Post-Process-Kette hatte drei getrennte Blits; Bloom-Downsample und Color Grading in einem Pass zusammenzuziehen nahm 1,7 ms von der GPU-Zeit. Aus demselben Grund ist RenderBufferLoadAction.DontCare bei Targets, deren vorheriger Inhalt egal ist, ein geschenkter Gewinn — ein unnötiger Load bedeutet, das ganze Target aus dem Hauptspeicher zurück in die Tiles zu lesen.
Ein Depth Prepass geht auf Mobile meist nach hinten los. Die Technik, die auf dem Desktop Overdraw wegschneidet, kostete uns hier 0,9 ms, weil sie die Geometrie zweimal verarbeitet und auf einer Tile-Architektur zusätzlichen Tiefenverkehr erzeugt. Adrenos eigene niedrig aufgelöste Z-Verwerfung erledigt einen ähnlichen Job ohnehin gratis. Ausprobiert, gemessen, zurückgenommen — eine der Stellen, an denen Desktop-Reflexe schlicht nicht übertragbar sind.
Die echte Lösung für transparente Schichten war Partikel-Rendering in halber Auflösung. Ich zeichne die Partikel in ein halb so großes Render Target und setze sie mit einem depth-aware Upsample wieder zusammen: Die Zahl der schattierten Pixel fällt auf ein Viertel, das Compositing kostet 0,4 ms, der Nettogewinn lag bei 3,1 ms. An den Kanten gibt es ein leichtes Treppenmuster, bei niederfrequenten Effekten wie Rauch und Staub sieht man es aber nicht. Scharfe, dünne Effekte wie Funken und Geschossspuren blieben in voller Auflösung.
Das gemessene Ergebnis auf einem Android-Mittelklassegerät
Auf derselben 60-Sekunden-Aufnahme von Welle 12 und demselben Redmi Note 10: Die Frame Time fiel von 24,3 ms auf 16,4 ms, der Durchschnitt stieg von 41 FPS auf 58 FPS. Die draw calls sanken von 780 auf 190, die Materialien von 34 auf 6 und der gemessene Spitzen-Overdraw von 11x auf 4x. Über eine Sitzung von 15 Minuten pendelte sich die Akkutemperatur bei 39 °C statt 44 °C ein, das Gerät drosselte also nie und der FPS-Abfall in den letzten fünf Minuten verschwand.
Wie sich dieser Gewinn verteilt, ist mir wichtig, denn er entscheidet, wo ich im nächsten Projekt anfange: rund 40 % kamen aus der Material- und Atlas-Arbeit, 35 % aus der Overdraw-Reduktion samt halb aufgelöster Partikel, 15 % aus den Bandbreiten-Änderungen und der Rest daraus, die SRP-Batcher-Kompatibilität zurückzugewinnen. Polygonreduktion taucht auf dieser Liste nirgends auf. Ich habe kein einziges Mesh angefasst und die LOD-Einstellungen exakt so gelassen, wie sie waren.
Für ein eigenes Projekt würde ich diese Reihenfolge einhalten: erst die Materialanzahl und die Gründe für gebrochene Batches aus dem Frame Debugger notieren, dann in der Overdraw-Ansicht die drei schlimmsten Schichten finden und erst danach einen Shader anfassen. Messen Sie jeden Schritt auf einem echten Gerät bei gleicher Temperatur; ich hatte reichlich Änderungen, die im Editor 200 FPS zeigten und auf dem Telefon 0,2 ms kosteten. Und schauen Sie nie auf eine einzige Zahl — draw calls, Overdraw und Bandbreite sind getrennte Grenzen, und wer eine repariert, bricht leicht die nächste.