Files
gta6/docs/superpowers/specs/2026-09-14-hood-yard-design.md
T
knsoandClaude Sonnet 5 7e4af1d0bc docs: Hood-Yard-Spec mit Messwerten und Vorwärmen
Wortlaut: die Radien garantieren nur, dass die Chunks unter einem Ort
angefordert sind, solange er in der Szene ist — nicht, dass ihr Boden
schon gebaut ist (ChunkStreamer baut höchstens einen Chunk je Bild).
"geladen" -> "angefordert" im Kommentar über den Radien
(src/world/places.js), über der PlaceLayer-Konstruktion (src/main.js),
im Testnamen (tests/placesSite.test.js) und in der Spec, dort mit
einem Satz zu den paar Bildern nach einem Kartensprung.

Spec zusätzlich: warmUp in den Bullets "Vorbereiten"/"Einsetzen",
robustes Einsetzen (Warnung, kein erneuter Versuch) im Bullet zum
Ladefehler, und ein neuer Messwerte-Bullet in "Fehlerfälle und
Risiken" (+0,4–0,6 ms je Bild, ~130 ms einmaliger Texturupload ohne
warmUp).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:43:23 +02:00

19 KiB
Raw Blame History

Hood Yard im Park J6/K6 — Entwurf

Datum: 2026-09-14 Zusammenhang: Der Nutzer hat im eigenen Editor „three-d-stage“ einen Hof mit Containern, Schuppen, Zaun, Bäumen und Strommasten gebaut (~/Downloads/hood-yard.glb, dazu eine .mtl nur mit Materialnamen). Er soll 1:1 an die Stelle, an der im echten Flamingo Park (Miami Beach) die kleinen Gebäude zwischen Baseball- und Fußballfeld stehen. Das Spiel zeigt diesen Park in J6/K6, oben an der Grenze der beiden Planquadrate. Vorlage ist ein Satellitenbild des Nutzers (~/Desktop/Bildschirmfoto 2026-09-14 um 00.15.53.png, 1552 × 474 px). Baut auf: src/world/coords.js (Welt ↔ three, Planquadrate), src/world/props.js (GLTFLoader mit Meshopt-Decoder), src/world/streaming.js (Lade- und Entladeradius, Collider je Chunk), src/world/shadows.js (prepareLitMaterials), tools/assets/props.mjs (bisherige Modell-Aufbereitung über npx @gltf-transform/cli).

Ziel

Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf dem Gelände, mit Schatten und Kollision, und das Spiel bleibt flüssig. Weitere Modelle aus dem Editor lassen sich später mit einem Listeneintrag und einem Skriptaufruf dazustellen.

Befund (gemessen)

Modell

Punkt Wert
Datei 653,6 MB, THREE.GLTFExporter r184, 1103 Knoten, 294 Meshes, 44 Materialien
Texturen 105 PNG in 2048 × 2048, davon 69 verschieden (409 MB)
Dreiecke 750 826 in den Meshes. Weil Knoten Meshes mehrfach benutzen, zeichnet die Szene rund 2,9 Mio. Die vier Köcherbäume allein haben 2,2 Mio.: quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000, quiver_tree_01_trunk 370 000.
Maße x −17,15 bis 17,00 · z −13,40 bis 8,60 · Höhe bis 8,94 m (Masten), also 34,15 × 22,0 m (getBounds, Eckpunkte)
Einheit Meter: ein Container ist 2,9 × 6,3 m und 2,6 m hoch
Ausrichtung Rückseite mit Zaun und Hintergrundbäumen bei −z, Front mit Straße und Toren bei +z. Im Spiel ist −z Norden, das passt ohne Drehung zum Satellitenbild.
Ursprung Mitte der Gebäudereihe: Container x −10,17 bis −0,72, Schuppen x −0,55 bis 10,35
Boden lot_gravel ist eine 34 cm dicke Kiesplatte von y 0 bis 0,34. Die Wurzel ist um 0,34 angehoben, alles andere steht auf der Plattenoberfläche.
Erweiterungen KHR_texture_transform, EXT_materials_bump, KHR_materials_emissive_strength
Wellblech Die sechs Container-Materialien (cont_*_side, cont_*_end) sind flache Kästen, ihr Relief kommt nur aus EXT_materials_bump. three r184 liest die Erweiterung, @gltf-transform 4.5.0 kennt sie nicht und verwirft sie beim Einlesen.

Stelle im Spiel (Chunk-Daten chunk_9_5, chunk_10_5)

  • Zwischen der Straße am Nordrand des Parks und dem Baseballfeld (surfaces, kind: "park") liegt ein Streifen Land: bei x 1472 von y 1057,25 bis 1082,2 (25 m), bei x 1506 von 1058,0 bis 1086,1 (28 m). Die Straße steigt nach Osten um etwa 5° an.
  • Das Fußballfeld beginnt bei x 1550,5, seine Oberkante liegt bei y 1073,5.
  • Im Bereich x 1465–1520, y 1055–1095 stehen keine Palmen und keine Gebäude. Zwei Laternen (vergeLampPoints, eine je Chunk) stehen bei (1478,1 | 1081,6) und (1478,0 | 1083,6).
  • Vom Startpunkt (x 1600, y 690) sind es 393 m bis zur Hofmitte.
  • Der Hof liegt auf den Chunks 9_5 und 10_5. Ihre Mittelpunkte sind 128 m und 148 m von der Hofmitte entfernt. Chunks laden erst, wenn ihr Mittelpunkt 400 m nah ist. Vom Startpunkt aus sind beide weiter weg, der Boden unter dem Hof fehlt dort also noch.

Satellitenbild

  • Maßstab etwa 0,15 m je Pixel, geschätzt aus Autos und Feldmarkierungen (±15 %).
  • Anker: Nordost-Ecke des Baseballfelds, im Bild (1000 | 213), im Spiel (1525 | 1058).
  • Die echte Reihe ist etwa 37 m lang: drei Container bei x 1470,6–1487,9, danach eine Reihe kleiner Schuppen bis x 1507,8. Die Schuppenfronten liegen bei y ≈ 1066,7.
  • Die Reihe im Modell ist 20,5 m lang. Beide Enden können deshalb nicht zugleich passen.

Entscheidungen des Nutzers

Frage Entscheidung
Größe 1:1, nichts skalieren (Modell ist in Metern gebaut)
Lage Übergang von Containern zu Schuppen an derselben Stelle wie in echt: Hofmitte x 1489, y 1067,5, keine Drehung (Vergleichsbild im Gespräch bestätigt)
Weg ins Spiel Eigene Schicht „Orte“ mit Laden und Entfernen je nach Abstand, getrennt vom Chunk-Streaming
Aufbereitung Eigenes Node-Skript mit @gltf-transform, das EXT_materials_bump erhält
Abschnitte Aufbereitung, Laufzeit, Prüfung bestätigt
Arbeitsweise Eigener Arbeitsbaum, weil im Hauptordner nicht committete Änderungen einer anderen Sitzung liegen

Nachtrag beim Planen (Prototyp am echten Modell)

  • Radien: Mit Laden bei 400 m und Entfernen bei 600 m wäre der Hof vom Startpunkt aus zu sehen, bevor der Boden unter ihm geladen ist, und stünde über dem Meer. Er erscheint deshalb erst ab 250 m und verschwindet ab 450 m. Heruntergeladen wird er schon ab 600 m, damit das Einsetzen nicht auf die Datei wartet. Folge: Er lädt beim Start im Hintergrund, sichtbar wird er, wenn der Spieler rund 150 m nach Norden gelaufen ist.
  • Vereinfachung: Es gibt einen Anteil je Material statt eines gemeinsamen Werts, weil die vier Köcherbäume 2,2 Mio. Dreiecke haben.
  • Messung: Das Ergebnis ist 16,2 MB groß, siehe Abschnitt Aufbereitung.

Lage

at = [1489, 1067.5], rotation = 0, lift = −0.28.

Nachbar Abstand zur Hofkante (Außenmaße 34,15 × 22 m: x 1471,85–1506,0, y 1058,9–1080,9)
Straßenrand im Norden 1,3 m (Westende) bis 5,2 m (Ostende)
Baseballfeld im Süden 1,65 m (Westende) bis 0,9 m (Ostende)
Laterne (1478,1 | 1081,6) 0,7 m nördlich der Kante, die zweite Laterne 2,7 m
Fußballfeld 44 m östlich
J6/K6-Grenze (x 1500) 0,65 m östlich des letzten Schuppens

Höhe: Mit lift = −0.28 liegt die Kiesoberfläche 6 cm über dem Gelände (Oberkante Land y = 0). Man läuft ohne Stufe hinein. Bei 600 m Abstand löst der Tiefenpuffer etwa 4,3 cm auf (Nahwert 0,5 m, 24 Bit), 6 cm flackern also nicht. Alle Teile sinken mit, die Maße im Modell bleiben gleich.

Aufbau

Einheit Aufgabe
tools/assets/places.mjs Neu. node tools/assets/places.mjs <quelle.glb> <id> schreibt public/models/places/<id>.glb und gibt einen Vorher-Nachher-Bericht aus.
tools/assets/bumpExtension.mjs Neu. Durchreich-Erweiterung EXT_materials_bump für @gltf-transform: bumpFactor und bumpTexture als echte Texturreferenz, damit Dedup, Prune und Texturkompression sie sehen.
src/world/places.js Neu. PLACES (Liste), SOLID_MATERIALS, reine Helfer placeTransform(place), placeDistance(position, place), wantedPlaces(position, places, { show, hide, active }), isSolidMaterial(name), prepareModel(root, place) und die Klasse PlaceLayer.
src/main.js Legt PlaceLayer neben dem ChunkStreamer an und ruft in tick() direkt nach streamer.update places.update(playerPosition(player)).
package.json Neue Entwicklungsabhängigkeiten, exakt gepinnt: @gltf-transform/core, @gltf-transform/extensions, @gltf-transform/functions 4.5.0, sharp 0.35.4, meshoptimizer 1.2.0. Das sind die Versionen, die @gltf-transform/cli@4.5.0 selbst benutzt.
public/models/places/hood-yard.glb Neu, versioniert. Die Quelldatei bleibt außerhalb des Repos.

Aufbereitung (tools/assets/places.mjs)

Reihenfolge:

  1. Einlesen mit NodeIO, allen Erweiterungen aus @gltf-transform/extensions und bumpExtension.mjs.

  2. dedup(): doppelte Texturen, Materialien, Meshes und Accessoren zusammenlegen (105 → 69 Texturen). Dabei verschmilzt searsia_lucida_twigs mit searsia_lucida_leaves, die Einstellungen sind gleich und beide sind durchlässig.

  3. flatten() und join(): alle Mesh-Knoten direkt unter die Szene, danach Primitive je Material zu einem zusammenfügen. Das ergibt 43 Primitive, also 43 Draw Calls. Materialnamen bleiben erhalten.

  4. weld() und simplifyPrimitive() mit MeshoptSimplifier, Anteil der Dreiecke, der bleibt, je Material (error 0,01):

    Anteil Materialien
    0,15 quiver_tree_01_leaf, quiver_tree_01_trunk, quiver_tree_02
    0,5 searsia_lucida, searsia_lucida_leaves, searsia_lucida_twigs, grass_medium_01, grass_medium_01_dry, grass_medium_02
    0,25 modular_electricity_poles_pieces, security_camera_01

    Die Fehlergrenze greift kaum: Nach join umspannt ein Pflanzenmaterial den ganzen Hof, der Anteil steuert. Stimmt das Bild im Spiel nicht, werden die Anteile nachgestellt.

  5. prune(), danach compressTexture() je Textur mit sharp nach WebP.

    • 1024 px für Texturen der Nahflächen: gravel, asphalt, curbAsphalt, padAsphalt, puddleAsphalt, cobble, grass, cont_*, contFrame, contHardware, ironPlain, ironTeal, ironBlue, osb, osbB, rollershutter_window_01, rollershutter_window_01_graffiti, chainlink_post, chainlink_wire.
    • 512 px für alle übrigen.
    • Welche Materialien eine Textur benutzen, ergibt sich aus ihren Eltern: direkt ein Material oder eine Erweiterung wie EXT_materials_bump an einem Material. Hängt sie an beiden Gruppen, gilt die größere Kante.
    • Qualität 80, Normal-Maps 92, damit dort keine Blockmuster im Licht entstehen.
  6. meshopt() mit MeshoptEncoder: Quantisierung und EXT_meshopt_compression, wie bei der Straßenlaterne.

Bewusst nicht: instance() (die Kollision des Controllers liest nur mesh.geometry mit matrixWorld, Instanzmatrizen gingen verloren), palette() (fasst texturlose Materialien unter neuem Namen zusammen), KTX2 (bräuchte toktx und einen Transcoder im Spiel).

Bericht: Megabyte, Texturen (Anzahl, Kantenlängen), gezeichnete Dreiecke und Primitive (je Knoten gezählt), geschätzter Grafikspeicher (RGBA mit Mipmaps), Außenmaße. Ziel: unter 30 MB.

Gemessen am Prototyp (2026-09-14): 16,2 MB, 657 653 Dreiecke, 43 Primitive, 32 Texturen in 1024 px und 37 in 512 px WebP, geschätzt 231 MB Grafikspeicher, Außenmaße unverändert, 11 s Laufzeit. Die Bump-Map aller sechs Container-Materialien und ihre Wellenteilung (KHR_texture_transform) sind erhalten.

Orte-Schicht (src/world/places.js)

Liste

export const PLACES = [
    {
        id: "hood-yard", url: "./models/places/hood-yard.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28,
        bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 },
    },
];
  • at: Welt-x und Welt-y in Metern, wie auf der Minimap. Nie three-z.
  • rotation: Grad gegen den Uhrzeigersinn, von oben mit Norden oben gesehen. Das ist dieselbe Festlegung wie bei add_building in corrections.toml (docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md).
  • lift: Meter, auf die Modellwurzel.
  • bounds: Außenmaße in Modellkoordinaten (x, z). Das Spiel braucht sie nicht, die Tests prüfen damit Datei (Asset-Test) und Lage (Datentest) gegeneinander.

Umrechnung

placeTransform(place) liefert { position: { x: at[0], y: lift, z: -at[1] }, rotationY: rotation · π / 180 }. Positives rotationY dreht in three +x nach −z, von oben also gegen den Uhrzeigersinn.

Laden und Entfernen

  • Gemessen wird vom Punkt at aus, mit drei Radien:

    Konstante Wert Wirkung
    PLACE_PRELOAD_RADIUS 600 m Datei laden und vorbereiten, noch nicht einsetzen
    PLACE_SHOW_RADIUS 250 m einsetzen
    PLACE_HIDE_RADIUS 450 m herausnehmen

    Chunks laden bei 400 m und bauen bei 600 m ab, beides gemessen zu ihrem Mittelpunkt. Der fernste Chunk-Mittelpunkt unter dem Hof liegt 148 m von dessen Mitte entfernt. 250 + 148 ≤ 400 und 450 + 148 ≤ 600 heißt: Solange der Hof in der Szene ist, ist sein Boden angefordert. ChunkStreamer baut aber höchstens einen Chunk je Bild; direkt nach einem Kartensprung neben den Hof kann er deshalb ein paar Bilder lang vor seinem Boden erscheinen. Der Datentest prüft das für jeden Ort.

  • wantedPlaces(position, places, { show, hide, active }) arbeitet wie wantedChunks: add ab show, remove erst jenseits von hide.

  • PlaceLayer.update(position) stößt innerhalb von 600 m das Laden an. Dafür gibt es je Ort ein einziges Laden mit GLTFLoader und MeshoptDecoder. Danach setzt es einen fertig geladenen Ort ein oder nimmt ihn heraus. Ist der Spieler beim Ende des Ladens schon jenseits von 450 m, bleibt der Ort draußen, bis er wieder näher als 250 m kommt.

  • Vorbereiten (prepareModel, einmal nach dem Laden): Wurzel an placeTransform, jedes Mesh castShadow und receiveShadow, nicht feste Meshes userData.excludeFromCollider = true. Hat kein Mesh ein festes Material, gibt es eine Warnung und später keinen Collider (eine leere Quelle liefert im Controller nur ein Handle, das nie fertig wird, vgl. Ruling 51). Ist ein warmUp gesetzt, läuft es danach einmalig (siehe „Gemessen im Spiel" unten).

  • Einsetzen: Collider-Handle bauen (player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })), erst danach scene.add — wirft addCollider, bleibt die Szene unverändert (siehe Fehlerfälle).

  • Entfernen: scene.remove und player.removeCollider(handle). Die geladenen Daten bleiben erhalten, eine Rückkehr setzt sofort wieder ein. Kosten: geschätzt 231 MB Grafikspeicher, sobald der Hof einmal gezeichnet war.

  • Die Kaskaden-Schatten richten die neuen Materialien selbst ein (prepareLitMaterials in sun.update()).

Kollision

isSolidMaterial(name) gibt für diese Materialien true zurück, alle anderen laufen nicht in den Collider:

Gruppe Materialien
Boden gravel, asphalt, curbAsphalt, padAsphalt, puddleAsphalt, cobble, grass, concrete
Container cont_a_side, cont_a_end, cont_b_side, cont_b_end, cont_c_side, cont_c_end, contFrame
Schuppen und Tore ironPlain, ironTeal, ironBlue, galv, rust, darkTrim, osb, osbB, rollershutter_window_01, rollershutter_window_01_graffiti
Zaun chainlink_post, chainlink_wire
Masten, Fass, Kiste modular_electricity_poles, barrel_03, plastic_container
Bäume quiver_tree_01_trunk, quiver_tree_02

Durchlässig bleiben Gras (grass_medium_*), Blätter und Zweige, die Büsche (searsia_lucida*), contHardware, modular_electricity_poles_pieces, security_camera_01* und trashbag.

Positionen fester Meshes, die quantisiert vorliegen (KHR_mesh_quantization aus meshopt()), wandelt prepareModel in Float32 um: colliderBuild.ts rechnet matrixWorld per applyMatrix4 in eine Kopie der Geometrie ein, und normalisierte Int16-Werte würden dabei auf ±1 geklemmt.

Fehlerfälle und Risiken

  • Datei lädt nicht oder lässt sich nicht einsetzen: console.warn mit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht — auch wenn addCollider beim Einsetzen wirft (die Szene bleibt dann unverändert statt einer Wurzel ohne Collider). Das Spiel läuft ohne ihn weiter.
  • Entfernen, bevor der Collider fertig ist: removeCollider räumt auch unfertige Worker-Collider ab, genau wie beim Chunk-Abbau.
  • Beleuchtung weicht vom Editor ab: Das Spiel leuchtet mit HDRI ×5 und Sonne 2,5. Wirkt der Hof zu hell oder zu dunkel, wird das im Browser verglichen und im Gespräch entschieden, nicht still angepasst.
  • Vereinfachung dünnt Blätter aus: Die Stärke wird nur an Pflanzen und Kleinteilen eingestellt, mit Vorher-Nachher-Screenshot.
  • Collider zu schwer: Wird der BVH-Aufbau im Worker spürbar langsam, fallen die Baumkronen (quiver_tree_02) aus der Kollisionsliste.
  • Neue Karte: Erzeugt die Pipeline Straßen, Palmen oder Laternen neu, prüft der Datentest die Hoffläche erneut.
  • Gemessen im Spiel (2026-09-14, 2560 × 1600, mit Schattenstufen): +0,4–0,6 ms je Bild (Blick vom Baseballfeld 2,7–2,9 ms statt 2,3 ms, im Hof 2,1 statt 1,6 ms). Das erste Zeichnen kostete einmalig rund 130 ms Texturupload; warmUp legt das und die Shader-Übersetzung ins Vorladen.

Tests

  • tests/places.test.js:
    • placeTransform: Welt-y wird zu −z, lift landet in y, 90° drehen +x nach −z.
    • wantedPlaces: setzt bei 240 m ein, bei 260 m nicht; nimmt bei 400 m nicht heraus, bei 460 m schon; meldet nichts doppelt an.
    • isSolidMaterial: je ein Material jeder Gruppe fest, grass_medium_01 und quiver_tree_01_leaf nicht.
    • prepareModel: Lage, Schatten, Kollisionsmarke nur an nicht festen Meshes, Rückgabe false ohne feste Teile.
    • PlaceLayer mit Attrappen für Szene, Spieler (addCollider, removeCollider) und Loader:
      • lädt bei 500 m vor, setzt aber erst bei 240 m ein
      • nimmt erst jenseits von 450 m heraus und meldet den Collider ab
      • setzt nach der Rückkehr ohne neues Laden wieder ein
      • lässt einen Ort draußen, wenn der Spieler beim Ende des Ladens schon fort ist
      • ohne feste Teile kein Collider
      • ein Ladefehler gibt eine Warnung, wirft nicht und wird nicht wiederholt
  • Datentest tests/placesSite.test.js gegen die Chunks unter jedem Ort:
    • Prüfpunkte alle 0,5 m auf dem Rechteck der Außenmaße an seiner Lage liegen alle auf land und auf keiner Straße und in keinem Gebäude.
    • Keine Palme und kein Punkt aus vergeLampPoints(lampPoints(...)) dieser Chunks und ihrer Nachbarn liegt im Rechteck.
    • Fernster Chunk-Mittelpunkt + 250 ≤ 400 und + 450 ≤ 600.
  • Asset-Test tests/placesAsset.test.js auf public/models/places/hood-yard.glb, eingelesen mit NodeIO, MeshoptDecoder und bumpExtension.mjs:
    • kleiner als 30 MB
    • Erweiterungen EXT_meshopt_compression, EXT_texture_webp und EXT_materials_bump, aber nicht EXT_mesh_gpu_instancing
    • alle Texturen image/webp und höchstens 1024 px
    • alle sechs Container-Materialien mit Bump-Textur
    • jedes Material aus SOLID_MATERIALS existiert
    • getBounds stimmt auf 5 cm mit bounds aus PLACES überein
  • Browser:
    • Über die Minimap zum Hof springen.
    • Screenshots in Augenhöhe von der Straße und vom Baseballfeld aus, dazu eine Draufsicht zum Vergleich mit dem Satellitenbild.
    • Gegen Container und Zaun laufen, durch Gras laufen.
    • Konsole ohne Fehler. renderer.info (Draw Calls, Texturen, Geometrien) und Ladezeit festhalten.

Nicht Teil dieser Arbeit

  • Die Minimap zeigt den Hof nicht, ihr Kartenbild kommt aus der Extraktion.
  • Keine Detailstufen und kein Ausblenden von Palmen oder Laternen. An dieser Stelle überschneidet sich nichts, der Datentest wacht darüber.
  • Das Modell des Nutzers wird nur verkleinert, nicht umgebaut.
  • Kein Sprungziel im Debug-Panel. Die Minimap springt schon an jede Stelle.
  • Die Feinkalibrierung (corrections.toml) bleibt eine eigene Aufgabe.