docs: Umsetzungsplan für den Hood Yard, Spec am Prototyp geschärft

Prototyp am echten Modell: 653,6 MB -> 16,2 MB, 2,9 Mio. -> 658 000 Dreiecke,
Bump-Maps erhalten. Radien geändert: Der Hof erscheint erst ab 250 m und
verschwindet ab 450 m, sonst stünde er vom Startpunkt aus über ungeladenem
Boden. Vereinfachung je Material statt eines gemeinsamen Anteils.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-14 10:24:46 +02:00
1 parent b61d9ab6a1
commit 3321a029fa
2 files changed
+1616 -21

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -16,8 +16,8 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf
|---|---|
| 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, davon rund 560 000 in Bäumen und Büschen, 92 000 in Mast-Kleinteilen |
| Maße | x −17,23 bis 17,00 · z −13,40 bis 8,60 · Höhe bis 8,94 m (Masten), also 34,2 × 22,0 m |
| 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 |
@@ -31,6 +31,7 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf
- 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
@@ -50,11 +51,17 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf
| 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,2 × 22 m: x 1471,8–1506,0, y 1058,9–1080,9) |
| 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) |
@@ -70,9 +77,9 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf
|---|---|
| `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), reine Helfer `placeTransform(place)`, `wantedPlaces(position, places, { load, unload, active })`, `isSolidMaterial(name)` und die Klasse `PlaceLayer`. |
| `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 `@gltf-transform/core`, `@gltf-transform/extensions`, `@gltf-transform/functions` (4.5.0), `sharp`, `meshoptimizer`. |
| `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`)
@@ -80,19 +87,29 @@ Der Hof steht im Spiel an der richtigen Stelle, in Originalgröße, bündig auf
Reihenfolge:
1. **Einlesen** mit `NodeIO`, allen Erweiterungen aus `@gltf-transform/extensions` und `bumpExtension.mjs`.
2. **`dedup()` und `prune()`:** doppelte Texturen, Materialien, Meshes und Accessoren zusammenlegen, Ungenutztes entfernen (105 → 69 Texturen).
3. **`flatten()` und `join()`:** alle Mesh-Knoten direkt unter die Szene, danach Primitive je Material zu einem zusammenfügen. Aus rund 300 Draw Calls werden etwa 45. Materialnamen bleiben erhalten.
4. **`weld()` und `simplifyPrimitive()` mit `MeshoptSimplifier`**, nur für Primitive dieser Materialien: `quiver_tree_01_leaf`, `quiver_tree_01_trunk`, `quiver_tree_02`, `searsia_lucida`, `searsia_lucida_leaves`, `searsia_lucida_twigs`, `grass_medium_01`, `grass_medium_01_dry`, `grass_medium_02`, `modular_electricity_poles_pieces`, `security_camera_01`. Die Stärke (`ratio`, `error`) wird am Bild im Spiel eingestellt und im Skript als Konstante festgehalten.
5. **`textureCompress()` mit `sharp` nach WebP.**
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.
- Hängt eine Textur nach dem Dedup an Materialien beider Gruppen, gilt die größere Kante.
- Normal-Maps werden mit höherer Qualität kodiert als Farb- und Rauheitskarten, damit keine Blockmuster im Licht entstehen.
- 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), Dreiecke, Primitive, geschätzter Grafikspeicher (RGBA mit Mipmaps), Außenmaße. Ziel: unter 30 MB.
**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`)
@@ -100,13 +117,17 @@ Bewusst nicht: `instance()` (die Kollision des Controllers liest nur `mesh.geome
```js
export const PLACES = [
{ id: "hood-yard", url: "./models/places/hood-yard.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 },
{
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
@@ -114,10 +135,20 @@ export const PLACES = [
### Laden und Entfernen
- `wantedPlaces` misst vom Punkt `at`: Laden bei höchstens 400 m, Entfernen erst über 600 m. Das sind dieselben Radien wie `ChunkStreamer.update`.
- `PlaceLayer.update(position)` stößt das Laden an (ein zwischengespeichertes Promise je Ort, `GLTFLoader` mit `MeshoptDecoder`) und setzt einen fertig geladenen Ort ein, sobald er gewünscht ist. Ist der Spieler beim Ende des Ladens schon weiter als 600 m entfernt, bleibt der Ort draußen, bis er wieder näher als 400 m kommt.
- **Einsetzen:** Wurzel an `placeTransform`, jedes Mesh `castShadow` und `receiveShadow`, nicht feste Meshes `userData.excludeFromCollider = true`, `scene.add`, danach `player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`.
- **Entfernen:** `scene.remove` und `player.removeCollider(handle)`. Die geladenen Daten bleiben erhalten, eine Rückkehr setzt sofort wieder ein. Kosten: rund 200 MB Grafikspeicher, sobald der Hof einmal geladen war (der Bericht der Aufbereitung nennt den genauen Wert).
- 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 geladen. 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).
- **Einsetzen:** `scene.add`, danach `player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`.
- **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
@@ -148,11 +179,27 @@ Durchlässig bleiben Gras (`grass_medium_*`), Blätter und Zweige, die Büsche (
- **`tests/places.test.js`:**
- `placeTransform`: Welt-y wird zu −z, `lift` landet in y, 90° drehen +x nach −z.
- `wantedPlaces`: lädt bei 390 m, entfernt bei 500 m nicht, entfernt bei 650 m, meldet nichts doppelt an.
- `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.
- `PlaceLayer` mit Attrappen für Szene, Spieler (`addCollider`, `removeCollider`) und Loader: Einsetzen meldet genau einen Collider an und markiert nur nicht feste Meshes; Entfernen meldet ihn ab; Rückkehr setzt ohne neues Laden ein; ein Ladefehler gibt eine Warnung und wirft nicht.
- **Datentest gegen `public/data/vice-a/chunk_9_5.json` und `chunk_10_5.json`:** Das Rechteck der Außenmaße an seiner Lage schneidet kein Straßen- oder Gebäudepolygon, liegt ganz auf `land` und enthält keine Palme und keinen Punkt aus `vergeLampPoints(lampPoints(...))`.
- **Asset-Test auf `public/models/places/hood-yard.glb`** (liest nur den JSON-Teil der Datei): kleiner als 30 MB; `extensionsUsed` enthält `EXT_meshopt_compression`, `EXT_texture_webp` und `EXT_materials_bump`, aber nicht `EXT_mesh_gpu_instancing`; alle Bilder `image/webp`; jedes Material aus `isSolidMaterial` existiert.
- `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.