Skip to content

Eine offene Welt im Browser bauen, Teil 19: Der Imposter, der einen Wald überleben muss

Von Oleg Sidorkin, CTO und Mitgründer von Cinevva

Neu hier? Nutze den Serienleitfaden. Er erklärt, was ein Spike ist, und verlinkt alle Teile.

Teil 18 gab Kreativen einen Pinsel, der einen Hang mit Bäumen füllt. Der Haken sind die Kosten dieser Bäume, wenn Zehntausende davon auf dem Bildschirm zu sehen sind. Ein entfernter Baum braucht keine 2.000 Dreiecke, um vier Pixel beizutragen. In diesem Teil geht es um die tiefste LOD-Stufe: den Imposter, ein flaches Quad mit einem Foto des Baums darauf, und den Weg von „ein Baum, der richtig aussieht“ zu einer Million davon auf der GPU.

Ein Baum besteht aus zwei Texturen auf einem Billboard

Spike 38 in einem neuen Tab öffnen ↗ · Quellcode ansehen

Ein Imposter rendert ein Objekt aus einem Raster von Blickwinkeln vorab in zwei Texturatlanten – einen für die Farbe und einen für Weltkoordinaten-Normalen – und zeigt zur Laufzeit ein einzelnes, zur Kamera ausgerichtetes Quad, das die zum aktuellen Blickwinkel passende Kachel abtastet. Das Baking umfasst zwei Durchläufe pro Kachel: Diffuse mit einem unbeleuchteten Material, damit keine Beleuchtung in die Textur eingebrannt wird, und Normalen, codiert als normalWorld × 0.5 + 0.5, wobei Alpha aus der Quelle übernommen wird, damit die Silhouette Pixel für Pixel übereinstimmt. Das Laufzeitmaterial ist ein vollständiges MeshStandardNodeMaterial, sodass der Imposter wie jede andere Oberfläche weiterhin von Sonne und IBL der Szene beleuchtet wird. Der Gewinn liegt in der Geometrie: ein Quad statt Tausender Dreiecke, wobei die Details in einer 1-MB-Textur stecken.

Dies in einen eigenen Spike auszulagern, war bereits eine Lektion für sich. Der Imposter begann als tiefste LOD-Stufe innerhalb des Scatter-Systems von Spike 37, und jede Iteration am Baking musste durch die vollständige Scatter-Pipeline getestet werden, wobei die Korrektheit des Bakings mit der Migration von Instanzmatrizen und den LOD-Wechseln verflochten war. Die Aufteilung auf ein Objekt und ein Quad, nebeneinander mit dem Original, verkürzte die Iterationszeit von Minuten auf Sekunden.

Wenn die Lehrbuchlösung die falsche Lösung ist

Die erste Implementierung verwendete oktaedrische Codierung, die klassische Methode, um Kugelrichtungen in ein Quadrat zu packen. Sie bestand numerische Roundtrip-Tests, und dennoch schickte der Nutzer immer wieder Screenshots, auf denen der Imposter auf eine Kachel sprang, die den Baum leicht von oben statt geradeaus zeigte. Es folgten sechs Korrekturrunden – ein Ansichtsrichtungs-Uniform pro Quad, ein quadratisches Bake-Seitenverhältnis, Debug-Materialien und statisches Billboarding – und jede davon war tatsächlich nötig, aber keine behob den eigentlichen Fehler. Die Lösung kam erst mit: „Von Grund auf neu denken, KISS, keine Pflasterlösungen.“

Die Neufassung verwarf die oktaedrische Faltung zugunsten von einfachem Azimut und Elevation: az = atan2(dir.x, dir.z), el = asin(dir.y), uv = (az/2π, el/π + ½). Das ist die gesamte Codierung – keine L1-Normalisierung, keine Sonderfälle beim Vorzeichen von null. Der Grund, weshalb sie hier besser ist, liegt nicht in höherer Genauigkeit – sie tastet die Kugel weniger gleichmäßig ab –, sondern darin, dass die CPU-Zellenauswahl und der GPU-Shader dieselben Grundoperationen verwenden. Dadurch können sie sich bei einer Grenzrichtung nicht so unbemerkt widersprechen wie das oktaedrische Paar. Der Atlas liest sich wie ein Kontaktbogen: Die Spalte ist der Winkel um das Objekt, die Zeile die Elevation – im Overlay auf einen Blick verständlich.

Selbst danach blieb die Beschwerde bestehen, es sehe „leicht von oben“ aus. Die Ursache war keine Codierungs-, sondern eine Quantisierungsentscheidung. Bei einem 4×4-Raster liegen die Zeilenmittelpunkte bei ±22,5° und ±67,5°, es gibt also keine Zeile mit exakt 0° Elevation. Ein Betrachter, der horizontal schaut – der mit Abstand häufigste Fall –, landet immer in einer schräg gebackenen Zeile. Die Lösung ist ein ungerades N: Bei einem 5×5-Raster liegen die Zeilenmittelpunkte bei 0°, ±36° und ±72°, sodass ein horizontal blickender Betrachter eine exakt horizontal gebackene Kachel erhält. Dieselbe Art von Fehler „um eine halbe Zelle verschoben“ taucht bei der Parallaxenarbeit im nächsten Teil erneut auf. Die Lösung ist dieselbe Frage: Liegt mein diskreter Abtastpunkt für die kanonische Eingabe tatsächlich dort, wo ich ihn vermute?

Zwei weitere Aspekte waren wichtig. Das Billboard muss abschnittsweise statisch sein und darf nicht fortlaufend zur Kamera zeigen. Der Imposter ist ein flaches Foto, das aus einer bestimmten Bake-Richtung aufgenommen wurde. Deshalb muss die Bildebene des Laufzeit-Quads der Ebene dieser Bake-Kamera entsprechen. Das bedeutet, dass es seine Ausrichtung über den Bogen hinweg beibehält, in dem dieselbe Zelle ausgewählt bleibt, und dann an der Grenze springt. Außerdem sollte sich das Budget danach richten, wohin Spieler tatsächlich schauen: Ein späterer Durchlauf entfernte die geneigten Draufsichtzeilen vollständig und ersetzte sie durch 24 horizontale Ringpositionen im Abstand von 15° plus eine Kachel direkt von oben, weil Bäume fast immer aus Augenhöhe betrachtet werden.

Das Umspringen und wie Blending es beseitigte

Spike 42 in einem neuen Tab öffnen ↗ · Quellcode ansehen

Das abschnittsweise statische Billboard ist aus der Entfernung unsichtbar und springt aus der Nähe sichtbar um. Das ist in Ordnung – bis man die Kamera darum kreisen lässt. Spike 42 stellte vier Varianten nebeneinander – das ursprüngliche Objekt, die Azimut/Elevations-Basis mit 5×5 und zwei hemi-oktaedrische Raster –, um das Flimmern zu isolieren und zu beseitigen. Zwei Artefakte verursachen das Umspringen. Der Zellensprung entsteht, weil der Fragment-Shader die Blickrichtung auf eine von 25 Zellen quantisiert. Beim Überschreiten einer Grenze wird daher im selben Frame sowohl die abgetastete Kachel gewechselt als auch das Quad neu ausgerichtet. Die Poldegeneration tritt bei der Ansicht direkt von oben auf, bei der jeder Azimut auf einen einzigen Punkt zusammenfällt und der Übergang zwischen dem Ring und der obersten Kachel der schlechteste im gesamten Atlas ist.

Hemi-oktaedrische Abbildung behebt beides. Sie bildet die obere Hemisphäre kontinuierlich auf das Einheitsquadrat ab, sodass benachbarte 3D-Richtungen auf benachbarten UVs landen. Dadurch gibt es weder eine Polsingularität noch die Notwendigkeit einer speziellen Kachel für die direkte Draufsicht. Das Flimmern wird durch bilineares Zellen-Blending beseitigt: Statt zur nächsten Kachel zu springen, wird die 2×2-Kachelgruppe ermittelt, welche die codierte Richtung umschließt, und alle vier werden über insgesamt 8 Texturzugriffe gemischt – 4 für Diffuse und 4 für Normalen. Benachbarte Ansichten gehen nun weich ineinander über, statt zu springen. Die Mischung zweier Einheitsnormalen hat selbst keine Einheitslänge und wird daher erneut normalisiert. Bei den kleinen Winkeln zwischen benachbarten Kacheln verhält sich dies wie eine Slerp-Interpolation. Die Kosten sind real – ein 12×12-Atlas belegt etwa 9 MB gegenüber 1,6 MB bei Azimut/Elevation, und das Baking dauert mit 288 Render-Target-Durchläufen ungefähr fünfmal länger. Das Baking erfolgt jedoch nur einmal beim Laden, und das Blending liefert ein Ergebnis ohne sichtbares Umspringen. Erst dadurch werden Imposter brauchbar, während sich die Kamera tatsächlich bewegt.

Eine Million Bäume, eine Kopie der Kameraposition pro Frame

Spike 41 in einem neuen Tab öffnen ↗ · Quellcode ansehen

Die Laufzeitimplementierung von Spike 38 führt pro Quad und Frame ein CPU-lookAt aus. Für einen Baum ist das in Ordnung, für einen Wald fatal. Bei einer Million Bäumen würden die Matrixaktualisierungen pro Frame und das Hochladen des Instanzpuffers alles andere dominieren. Spike 41 verlagert die gesamte Pipeline pro Frame auf die GPU. Mittelpunkt, Gierwinkel und Skalierung jeder Instanz werden beim Aufbau einmal als instanzierte Attribute hochgeladen und ändern sich danach nie wieder. Der Vertex-Shader konstruiert die Billboard-Basis aus der Blickrichtung in Weltkoordinaten camPos − center und erweitert ein gemeinsam genutztes Einheits-Quad in den Weltraum. Der Fragment-Shader führt pro Pixel die hemi-oktaedrische Codierung und das bilineare Blending aus. Die einzige CPU-Arbeit pro Frame für den gesamten Wald ist ein einzelnes Vector3.copy, das das Uniform für die Kameraposition aktualisiert. Dieser Aufwand skaliert überhaupt nicht mit der Anzahl der Bäume.

Ein eleganter Teil der Mathematik ist, dass sich der Gierwinkel pro Instanz bei der Normalendecodierung herauskürzt. Das Baking speichert Normalen im Koordinatensystem der Bake-Kamera. Da eine Gierrotation um die Welt-Hochachse +Y beibehält und das Kreuzprodukt rotationsäquivariant ist, entspricht die mit einer Welt-Hochreferenz konstruierte Laufzeitbasis bereits der rotierten Bake-Basis. Der Shader decodiert die Normalen daher direkt über die Laufzeitbasis-Varyings, ohne jemals den Gierwinkel der einzelnen Instanz anzufassen. Für die Platzierung wird statt rein zufälliger Streuung ein Raster mit Jitter verwendet: Die Fläche wird in Zellen unterteilt, und pro Zelle wird ein Baum in der Mitte plus einem begrenzten Versatz platziert. Das garantiert einen Mindestabstand – keine zwei Bäume stehen übereinander – und wirkt dennoch wie ein natürlicher Wald. Ein leicht zu übersehendes Detail ist die Bounding Sphere. Die Geometrievorlage ist lediglich ein Einheits-Quad, weshalb three.js den gesamten Wald per Frustum Culling ausblenden würde, sobald die Kamera vom Ursprung wegschaut. Eine explizite Bounding Sphere, die die gesamte Fläche plus den Rand eines Quads abdeckt, verhindert, dass Bäume an den Ecken bei flachen Blickwinkeln abgeschnitten werden.

In diesem Kapitel erwähnte Technologien

Oktaedrische und Azimut-Elevations-Imposter-Atlanten. Ein Imposter backt ein Objekt aus einem Raster von Blickrichtungen in einen Diffuse-Atlas und einen Atlas mit Weltkoordinaten-Normalen. Anschließend rendert er ein einzelnes Billboard, das die passende Kachel abtastet, und ersetzt so Tausende Dreiecke durch zwei Texturen. Die klassische oktaedrische Abbildung bietet eine gleichmäßige Kugelabdeckung, neigt an Faltungsgrenzen jedoch zu Abweichungen zwischen CPU und GPU. Ein einfaches Raster aus Azimut und Elevation tastet die Kugel weniger gleichmäßig ab, garantiert aber konstruktionsbedingt, dass Zellenauswahl und Shader übereinstimmen. Verwende ein ungerades N, damit eine Zeile exakt bei 0° Elevation liegt, und investiere das Kachelbudget in den horizontalen Ring, da Objekte meist aus Augenhöhe betrachtet werden.

Abschnittsweise statische Billboard-Ausrichtung. Ein Imposter ist ein Foto aus einer bestimmten Bake-Richtung. Daher muss die Bildebene des Laufzeit-Quads der Ebene der Bake-Kamera entsprechen, statt kontinuierlich zur Laufzeitkamera zu zeigen. Das Quad behält seine Ausrichtung über den Bogen hinweg bei, in dem dieselbe Zelle ausgewählt bleibt, und springt dann an der Grenze. Auf Imposter-Distanz ist dies unsichtbar und fällt nur aus der Nähe auf, wo Imposter ohnehin nicht verwendet werden.

Hemi-oktaedrischer Atlas mit bilinearem Zellen-Blending. Die kontinuierliche Abbildung der oberen Hemisphäre auf das Einheitsquadrat beseitigt die Polsingularität und die spezielle Kachel für die direkte Draufsicht. Das Umspringen wird verhindert, indem die 2×2-Kachelgruppe abgetastet wird, welche die codierte Blickrichtung umschließt, und alle vier Kacheln bilinear gemischt werden – insgesamt 8 Zugriffe. So gehen benachbarte Ansichten weich ineinander über. Gemischte Normalen werden erneut normalisiert, was über den kleinen Winkel zwischen den Kacheln eine Slerp-Interpolation annähert. Der Preis sind ein größerer Atlas – etwa 9 MB bei 12×12 – und ein längeres einmaliges Baking. Dafür bleibt die Schattierung bei Kamerabewegungen frei von sichtbarem Umspringen.

GPU-gesteuerte instanzierte Imposter. Mittelpunkt, Gierwinkel und Skalierung jeder Instanz werden einmal als instanzierte Attribute hochgeladen. Der Vertex-Shader konstruiert die Billboard-Basis und erweitert ein gemeinsam genutztes Einheits-Quad, während der Fragment-Shader pro Pixel Codierung und Blending ausführt. Die CPU-Kosten pro Frame für den gesamten Wald bestehen unabhängig von der Instanzanzahl aus einer einzigen Kopie des Kamerapositions-Uniforms. Der Gierwinkel pro Instanz kürzt sich bei der Normalendecodierung heraus, weil die Gierrotation um die Welt-Hochachse durch die Konstruktion der Kreuzproduktbasis erhalten bleibt. Eine explizite Bounding Sphere für den gesamten Wald verhindert, dass three.js das komplette instanzierte Mesh per Frustum Culling ausblendet, wenn die Kamera vom Ursprung der Einheits-Quad-Vorlage wegschaut. Siehe GPU-gesteuertes LOD.


Teil 19 von 29. Zurück: Teil 18 – Ein Scatter-Pinsel, der wie von einer KI platziert wirkt Weiter: Teil 20 – Tiefe auf einer flachen Ebene vortäuschen Serienleitfaden: /de/blog/2026-02-25-open-world-browser-series-guide