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>
19 KiB
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:
-
Einlesen mit
NodeIO, allen Erweiterungen aus@gltf-transform/extensionsundbumpExtension.mjs. -
dedup(): doppelte Texturen, Materialien, Meshes und Accessoren zusammenlegen (105 → 69 Texturen). Dabei verschmilztsearsia_lucida_twigsmitsearsia_lucida_leaves, die Einstellungen sind gleich und beide sind durchlässig. -
flatten()undjoin(): 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. -
weld()undsimplifyPrimitive()mitMeshoptSimplifier, Anteil der Dreiecke, der bleibt, je Material (error0,01):Anteil Materialien 0,15 quiver_tree_01_leaf,quiver_tree_01_trunk,quiver_tree_020,5 searsia_lucida,searsia_lucida_leaves,searsia_lucida_twigs,grass_medium_01,grass_medium_01_dry,grass_medium_020,25 modular_electricity_poles_pieces,security_camera_01Die Fehlergrenze greift kaum: Nach
joinumspannt ein Pflanzenmaterial den ganzen Hof, der Anteil steuert. Stimmt das Bild im Spiel nicht, werden die Anteile nachgestellt. -
prune(), danachcompressTexture()je Textur mitsharpnach 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_bumpan 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.
- 1024 px für Texturen der Nahflächen:
-
meshopt()mitMeshoptEncoder: Quantisierung undEXT_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 beiadd_buildingincorrections.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
ataus, mit drei Radien:Konstante Wert Wirkung PLACE_PRELOAD_RADIUS600 m Datei laden und vorbereiten, noch nicht einsetzen PLACE_SHOW_RADIUS250 m einsetzen PLACE_HIDE_RADIUS450 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.
ChunkStreamerbaut 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 wiewantedChunks:addabshow,removeerst jenseits vonhide. -
PlaceLayer.update(position)stößt innerhalb von 600 m das Laden an. Dafür gibt es je Ort ein einziges Laden mitGLTFLoaderundMeshoptDecoder. 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 anplaceTransform, jedes MeshcastShadowundreceiveShadow, nicht feste MeshesuserData.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 einwarmUpgesetzt, 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 danachscene.add— wirftaddCollider, bleibt die Szene unverändert (siehe Fehlerfälle). -
Entfernen:
scene.removeundplayer.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 (
prepareLitMaterialsinsun.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.warnmit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht — auch wennaddColliderbeim 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:
removeColliderrä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;
warmUplegt das und die Shader-Übersetzung ins Vorladen.
Tests
tests/places.test.js:placeTransform: Welt-y wird zu −z,liftlandet 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_01undquiver_tree_01_leafnicht.prepareModel: Lage, Schatten, Kollisionsmarke nur an nicht festen Meshes, Rückgabefalseohne feste Teile.PlaceLayermit 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.jsgegen die Chunks unter jedem Ort:- Prüfpunkte alle 0,5 m auf dem Rechteck der Außenmaße an seiner Lage liegen alle auf
landund 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.
- Prüfpunkte alle 0,5 m auf dem Rechteck der Außenmaße an seiner Lage liegen alle auf
- Asset-Test
tests/placesAsset.test.jsaufpublic/models/places/hood-yard.glb, eingelesen mitNodeIO,MeshoptDecoderundbumpExtension.mjs:- kleiner als 30 MB
- Erweiterungen
EXT_meshopt_compression,EXT_texture_webpundEXT_materials_bump, aber nichtEXT_mesh_gpu_instancing - alle Texturen
image/webpund höchstens 1024 px - alle sechs Container-Materialien mit Bump-Textur
- jedes Material aus
SOLID_MATERIALSexistiert getBoundsstimmt auf 5 cm mitboundsausPLACESü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.