Skip to content

Eine offene Welt im Browser bauen, Teil 18: Ein Streupinsel, der wie von einer KI platziert wirkt

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 17 gab der Spielfigur einen kampftauglichen Animationssatz und eine Möglichkeit, beliebige CC0-Modelle in die Welt zu laden. Dieser Teil kehrt zur Seite der Spieleschaffenden zurück. Die Palette aus Spike 34 platziert pro Klick ein Requisit. Das reicht, um ein zentrales Objekt in Szene zu setzen, ist für einen Wald aber nutzlos. Spike 37 ist der Pinsel: Ziehe ihn über das Gelände, und Bäume füllen die Stellen, an die Bäume gehören.

„KI-platziert“ ohne KI

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

Die Frage, die dieser Spike beantwortet, lautet, ob ein rein heuristischer Pinsel intelligent genug wirkt, um auf ein LLM verzichten zu können. Der Test für „KI-platziert“ ist konkret: Bäume bleiben von Klippen fern, Felsen neigen sich in den Hang, Strandkiesel enden an der Wasserlinie – alles mit dem ersten Pinselstrich. Das erreichten wir mit Prädikaten für Neigung und Höhe, gewichteter Auswahl und individuellen Abständen pro Familie, ganz ohne einen einzigen Modellaufruf.

Der Pinsel arbeitet auf einer 257×257 großen CPU-Höhenkarte mit manuell abgestimmten Landschaftsmerkmalen, damit jedes Preset eine passende Stelle findet: Berge im Norden für Auswahlen mit gemischter Neigung, ein Klippenstreifen im Osten für Geröll, eine Küstenebene im Süden für Strand und Wiese sowie eine Seesenke im Südwesten. Das Terrain erzeugt Vertexfarben aus einem Biom-Klassifikator für (altitude, slope). So lässt sich schon vor dem Platzieren des ersten Baums erkennen, wo ein Preset greifen wird. Fünf Presets werden als flache Datenstrukturen ausgeliefert, jeweils als Liste von Auswahlen wie { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope }. Cliff and Scree setzt slopeMin: 0.3, damit Felsen nur auf tatsächlichen Hängen landen, und alignToSlope: true, damit der Aufwärtsvektor jedes Felsblocks der Oberflächennormalen folgt.

Bei jedem Pinselstrich ermittelt die Scatter-Engine densityPerM2 × area Kandidatenpunkte innerhalb der Pinselscheibe, liest für jeden Kandidaten Höhe und Neigung, filtert die Auswahlen des Presets nach erfüllten Prädikaten, trifft eine gewichtete Auswahl und führt anschließend mithilfe eines räumlichen Hashs im Radius eine Abstandsprüfung durch. Das Ganze ist deterministisch: Ein initialisierbarer Mulberry32-Zufallszahlengenerator kontrolliert jede Auswahl, sodass (seed, brush events) jede Sitzung exakt reproduziert. Auf dem Startterrain platzierte ein Mixed-Forest-Pinselstrich auf einer flachen Wiese 139 von 158 Kandidaten in 5 ms. Dasselbe Preset platzierte auf einer Klippe nur 106 von 226, wobei das HUD meldete, dass 81 davon wegen der Neigung abgelehnt wurden. Diese Aufschlüsselung der Ablehnungsgründe macht die gesamte UX aus: Man sieht, warum die Klippe nur wenige Bäume angenommen hat, statt raten zu müssen.

Der Sinn flacher Preset-Daten besteht darin, dass die spätere LLM-Version lediglich einen JSON-Austausch statt einer Neuentwicklung erfordert. paint({ preset }) ist es gleichgültig, ob preset.picks aus einem manuell abgestimmten Rezept oder aus einem Worker stammt, der „Laubwald mit moosigen Felsblöcken“ in Gewichtungen übersetzt hat. Die Engine codiert auch keine Requisiten-ID fest ein, sodass ein anderer Katalog keinerlei Änderungen an der Engine erfordert.

Von 300 Draw Calls auf 49

Die erste Version renderte jede Platzierung als clone(true) einer Gruppe aus mehreren Meshes. Für einige Hundert Requisiten ist das in Ordnung, doch beim Limit von 2.500 entsteht eine harte Grenze, weil die Draw Calls in die Tausende steigen. Bevor wir sie erreichten, wechselten wir zu InstancedMesh, mit einem Bucket pro (propId, partIndex). Jeder Bucket wächst durch Verdopplung: Ein größeres InstancedMesh wird angelegt, die aktiven Matrizen werden kopiert, das übergeordnete Szenenobjekt wird ausgetauscht und das alte Attribut freigegeben. Beim Löschen kommt Swap-Remove zum Einsatz, sodass das Entfernen einer Instanz unabhängig von der Bucket-Größe O(1) benötigt. Determinismus, Abstände und das Ablehnungs-HUD bleiben unverändert, da der Austausch vollständig unterhalb des Platzierungsdatensatzes stattfindet.

Eine Diagnose mit dem MegaKit-Paket klärte eine echte Architekturfrage. Ein glTF-Mesh mit mehreren Primitiven – Stamm plus Blätter – kann in three.js entweder als ein Mesh mit einem Material-Array und geometry.groups oder als mehrere gleichrangige Meshes mit jeweils einem Material ankommen. Für dieses Paket wählt der Loader den zweiten Weg: Jeder Teil ist ein Mesh mit einem einzelnen Material und leeren Gruppen. Das ist für die Streuung die bessere Form, weil separate Buckets pro Primitiv dafür sorgen, dass der Bucket des Stamms unabhängig vom Bucket der Blätter wachsen kann, falls ihre Anzahlen auseinandergehen. Die Zahl der Draw Calls ist in beiden Fällen gleich, die Speicherstruktur ist bei der Aufteilung aber besser. Der gemessene Vorteil bestätigte sich: Ein Wald-Pinselstrich mit etwa 300 Draw Calls kam danach auf 49. Eine vollständige Sitzung mit mehreren Pinselstrichen erreichte 3.221 Instanzen bei 75 FPS und 51 Draw Calls – eine Größenordnung, die der Klonpfad nie erreichen konnte, bevor das Frame-Budget zusammenbrach.

Entfernungs-LOD und vier darin versteckte Fehler

Instancing reduzierte die Draw Calls, doch jede Instanz renderte weiterhin ihre vollständige Dreiecksanzahl – selbst Bäume in 90 m Entfernung, deren Blattdetails nur zwei Pixel beitrugen. Deshalb erzeugten wir mit meshoptimizer drei LOD-Stufen pro Requisitenteil – vollständig, 50 %, 15 % –, erweiterten den Bucket-Schlüssel auf (propId, partIndex, lod) und ergänzten eine move()-Funktion, die eine Platzierung ohne Allokation zwischen benachbarten Buckets verschiebt. Die Entfernungsbereiche liegen bei 0 bis 30 m, 30 bis 90 m und darüber. An jeder Grenze verhindert eine Hysterese von ±4 m, dass eine nahe der Bereichsgrenze schwebende Kamera eine Platzierung ständig hin- und herschaltet und ihre Matrix in jedem Frame erneut hochlädt. Die Neubewertung ist auf 4 Hz begrenzt und erfolgt nur, wenn sich die Kamera tatsächlich bewegt hat. Bei stillstehender Kamera fällt daher pro Frame lediglich ein Vergleich der quadrierten Entfernung an.

In diesem LOD-Pfad steckten die lehrreichen Fehler. Der erste zeigte sich darin, dass Platzierungen verschwanden oder dupliziert wurden, während die Kamera die Szene umkreiste. Je voller die Szene wurde, desto schlimmer war der Effekt. Ursache war eine gemeinsam genutzte temporäre Matrix: move() las die Transformation einer Platzierung in die modulweit definierte _tmpMat, doch das Swap-Remove des Quell-Buckets verwendete dieselbe _tmpMat für sein internes Umsortieren. Dadurch wurde die transportierte Matrix überschrieben, bevor das Ziel sie schreiben konnte. Nur wenn der verschobene Slot bereits der letzte in seinem Bucket war, trat der Fehler nicht auf – mit einer Wahrscheinlichkeit von ungefähr 1/count. Das entspricht genau dem „seltenen Flackern, das mit wachsender Szene schlimmer wird“, das im Spieletest beobachtet wurde. Die Lösung war eine eigene _carryMat, die ausschließlich move() vorbehalten ist. In einem Belastungstest mit insgesamt 1.274 Verschiebungen blieb der Cluster pixelgenau identisch.

Der zweite Fehler war subtiler: Jeder LOD-Übergang wirkte flüssig – außer dem ersten. Bäume, die in LOD1 wechselten, zeigten eine sichtbare Veränderung der Schattierung, obwohl sich ihre Silhouette kaum veränderte. Größere Reduzierungen der Dreiecksanzahl auf späteren Stufen sahen dagegen unauffällig aus. Der Vereinfacher mit LockBorder verschiebt oder erzeugt niemals Vertices, sodass die überlebenden Vertices ihre Normalen exakt behalten. Trotzdem riefen wir nach jeder Vereinfachung computeVertexNormals() auf. LOD0 gibt die ursprünglichen, von den Artists erstellten Normalen unverändert zurück; LOD1 und höher erhielten dagegen die generische, aus gemittelten Flächennormalen berechnete Variante von three.js. Die Grenze zwischen 0 und 1 war die einzige Stelle in der LOD-Kette, an der sich das Normalenverfahren änderte – und genau dort trat das sichtbare Umspringen auf. Das Entfernen dieser einen defensiven Zeile korrigierte die Schattierung und halbierte als Bonus ungefähr die Bake-Zeit pro Requisit, weil wir die Normalen nicht mehr für vier LODs pro Teil neu berechneten.

Die Prüfung der Ergebnisse des Vereinfachers brachte einen dritten Vorteil ans Licht. Jedes LOD war ein original.clone() mit einem neuen Index, und BufferGeometry.clone() erstellt tiefe Kopien sämtlicher Attribute. Dadurch hielten fünf LODs fünf unabhängige Kopien von Positions-, Normalen-, UV- und Farbpuffern, obwohl deren Werte bitgenau identisch waren. Wir stellten auf gemeinsam genutzte Attributreferenzen um, sodass jedes LOD nur noch einen eigenen Indexpuffer besitzt. Damit sank ein typischer Baumteil von 20 unterschiedlichen Attributidentitäten auf 9, und jeder Vertexpuffer musste nur einmal zur GPU hochgeladen werden. Mit aliasiertem Speicher gehen zwei Regeln einher: Attributdaten dürfen nicht über ein einzelnes LOD verändert werden, und die Geometrie eines einzelnen LOD darf nicht mit dispose() freigegeben werden, da beides alle Geschwister betreffen würde, die denselben Puffer teilen.

Der vierte Fehler hatte mit dem Malen überhaupt nichts zu tun. Schon das Bewegen des Mauszeigers über das Terrain ließ die Bildrate einbrechen, ohne dass eine Taste gedrückt war. Der pointermove-Handler führte einen Raycast gegen das Terrain-Mesh aus, eine Ebene mit 131.072 Dreiecken ohne räumliche Struktur. three.js durchlief daher bei jedem Ereignis den gesamten Indexpuffer – bei bis zu 1.000 Ereignissen pro Sekunde. Für diese Abfrage brauchten wir das Mesh überhaupt nicht, weil das Terrain eine parametrische Höhenkarte ist. Ein adaptives Ray-Marching gegen sampleHeight – große Schritte weit über der Oberfläche, eine Untergrenze von 0,4 m in ihrer Nähe und anschließend 12 Bisektionen beim Vorzeichenwechsel – benötigt ungefähr 8 bis 30 Abtastungen pro Strahl statt 131.072 Dreieckstests. Das ist etwa drei Größenordnungen günstiger, und beim Darüberfahren mit dem Mauszeiger wird wieder die maximale Bildrate gehalten.

Die Kosten verlagern sich nur – entscheidend ist, sie vom Klick fernzuhalten

Nach der Umstellung des Spikes auf WebGPURenderer mit three r184, dem Produktionsziel, zeigte ein DevTools-Profil, dass der allererste Pinselstrich 265 ms blockierte, davon 79 % innerhalb des meshoptimizer-WASM. Der Bake-Vorgang war echte Arbeit – ungefähr 180 Vereinfachungsaufrufe für ein kaltes Preset –, lief aber innerhalb des Klick-Handlers, weil preloadProps nur Szenen lud und analysierte, den LOD-Bake jedoch nie auslöste. Die Lösung bestand darin, bei der Preset-Auswahl den vollständigen Bake im Hintergrund auszuführen: preloadProps ruft jetzt den Pfad zur Auflösung der Teile auf, speichert das laufende Promise im Cache, damit sich ein schneller Klick daran anhängt, statt einen doppelten Vorgang zu starten, und memoisiert die geometriespezifische Vorverarbeitung, die der Vereinfacher zuvor viermal pro Teil wiederholte. Die im HUD gemessene Dauer des ersten Pinselstrichs sank von 209 ms auf 4 ms. Die WASM-Zeit verschwand nicht; sie liegt lediglich nicht mehr im kritischen Pfad der Nutzenden und läuft, während sie das Terrain betrachten und entscheiden, wo sie malen möchten.

Das ist die wiederkehrende Erkenntnis dieses Spikes. Fast keine dieser Korrekturen änderte, was der Pinsel tut. Sie änderten, wann die Kosten anfallen: nicht mehr beim Klick, nicht mehr beim Darüberfahren mit dem Mauszeiger und nicht mehr an der Grenze, in deren Nähe die Kamera schwebt. Ein Streuwerkzeug, das sich unmittelbar anfühlt, erledigt nicht weniger Arbeit. Es erledigt sie dort, wo die Nutzenden nicht darauf warten müssen.

In diesem Kapitel erwähnte Technologie

Heuristische Eignungsstreuung. Ein Pinsel ermittelt Kandidatenpunkte in einer Scheibe, liest für jeden Punkt (height, slope) aus einer CPU-Höhenkarte, filtert die Auswahlmöglichkeiten eines Presets anhand von Neigungs- und Höhenprädikaten, trifft eine gewichtete Auswahl und lehnt sie ab, wenn sie den in einem räumlichen Hash erfassten Mindestabstand ihrer Familie verletzt. An der Neigung ausgerichtete Auswahlen drehen ihren Aufwärtsvektor zur Oberflächennormalen. So entsteht eine Platzierung, die beabsichtigt wirkt – Bäume bleiben von Klippen fern, Felsen neigen sich in Hänge und Kiesel enden an der Wasserlinie –, ganz ohne erlernte Gewichtungen. Gleichzeitig bleibt das Preset eine flache Datenstruktur, sodass eine von einem LLM erzeugte Auswahlliste direkt eingesetzt werden kann.

Deterministische Platzierung bei asynchronem Laden. Ein initialisierbarer Mulberry32-Zufallszahlengenerator kontrolliert jede Auswahl, sodass (seed, brush events) eine Sitzung exakt reproduziert. Die Zufallswerte werden vor jedem await gezogen, und Abstandsreservierungen werden in den räumlichen Index eingefügt, bevor der glTF-Klon bereitsteht. Dadurch berücksichtigen gleichzeitig verarbeitete Kandidaten einander, und das asynchrone Laden von Assets kann die Reihenfolge nicht verändern.

In Buckets organisierte InstancedMesh-Objekte mit Änderungen in O(1). Ein InstancedMesh pro (propId, partIndex, lod), dessen Kapazität bei Bedarf verdoppelt wird, indem die aktiven Matrizen in einen größeren Puffer kopiert werden. Löschen und FIFO-Verdrängung verwenden Swap-Remove sowie ein Rückverweis-Array, das den Index der verschobenen Instanz korrigiert. Dadurch benötigt eine Entfernung unabhängig von der Bucket-Größe O(1). Eine Diagnose bestätigte, dass glTF-Teile als Meshes mit jeweils einem Material ankommen. Damit ist ein Bucket pro Primitiv der aktive Pfad, und jedes Primitiv erhält einen unabhängig erweiterbaren Bucket.

Entfernungs-LOD mit Hysterese und gemeinsam genutzten Attributpuffern. Drei mit meshopt vereinfachte Stufen pro Teil, ausgewählt anhand von Entfernungsbereichen mit einer Hysterese von ±4 m, damit eine Kamera nahe einer Grenze kein ständiges Umschalten auslöst. Die Neubewertung erfolgt mit begrenzter Frequenz und nur bei tatsächlicher Kamerabewegung. Da die Vereinfachung mit LockBorder niemals Vertices verschiebt, verwenden alle LODs denselben Satz von Positions-, Normalen-, UV- und Farbpuffern und unterscheiden sich nur durch ihren jeweiligen privaten Indexpuffer. Dadurch halbiert sich die Zahl unterschiedlicher GPU-Vertexpuffer ungefähr. Der Verzicht auf ein defensives computeVertexNormals bewahrt identische Artist-Normalen über alle LODs hinweg und beseitigt die einzige Schattierungsunterbrechung der LOD-Kette. Siehe LOD und meshoptimizer.

Analytischer Höhenkarten-Raycast für hochfrequente Abfragen. Eine mit pointermove-Frequenz ausgeführte Cursorabfrage gegen ein Ebenen-Mesh mit 131.000 Dreiecken durchläuft bei jedem Ereignis den gesamten Indexpuffer. Ein adaptives Ray-Marching gegen die analytische Höhenfunktion – große Schritte weit von der Oberfläche entfernt, eine kleine Untergrenze in ihrer Nähe und Bisektion beim Vorzeichenwechsel von (rayyterrainy) – benötigt nur einige Dutzend Abtastungen statt Zehntausender Dreieckstests und ist damit etwa drei Größenordnungen günstiger. Ein vorab angelegter Ausgabevektor hält den kritischen Pfad außerdem frei von Allokationen.

Arbeit aus dem kritischen Pfad der Interaktion verlagern. Teure einmalige Arbeit – meshopt-LOD-Bakes und WGSL-Pipeline-Kompilierungen – sollte in Leerlaufphasen und nicht innerhalb des Klick-Handlers ausgeführt werden. Das Vorladen des vollständigen Bakes des aktiven Presets bei dessen Auswahl, das Zwischenspeichern des laufenden Promise, damit sich ein schneller Klick daran anhängt, statt einen zweiten Vorgang zu starten, und die Memoisierung der geometriespezifischen Vorverarbeitung reduzierten die Latenz des ersten Pinselstrichs von 209 ms auf 4 ms, ohne den Gesamtarbeitsaufwand zu verringern.


Teil 18 von 29. Zurück: Teil 17 – Animationen, die kein Retargeting benötigten, und eine Live-Asset-Suche Weiter: Teil 19 – Der Impostor, der einen Wald überleben muss Serienübersicht: /de/blog/2026-02-25-open-world-browser-series-guide