diff --git a/docs/superpowers/specs/2026-09-14-hood-yard-design.md b/docs/superpowers/specs/2026-09-14-hood-yard-design.md index 4834851..30e3d71 100644 --- a/docs/superpowers/specs/2026-09-14-hood-yard-design.md +++ b/docs/superpowers/specs/2026-09-14-hood-yard-design.md @@ -166,6 +166,8 @@ export const PLACES = [ 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:** `console.warn` mit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht. Das Spiel läuft ohne ihn weiter. diff --git a/src/world/places.js b/src/world/places.js index 33dd517..f8c310b 100644 --- a/src/world/places.js +++ b/src/world/places.js @@ -1,6 +1,7 @@ // Orte: fest platzierte Modelle aus dem Editor three-d-stage, getrennt vom Chunk-Streaming // (docs/superpowers/specs/2026-09-14-hood-yard-design.md). Aufbereitet werden sie mit // tools/assets/places.mjs; hier stehen Lage, Laden und Kollision. +import { BufferAttribute } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js"; import { worldToThree } from "./coords.js"; @@ -79,6 +80,23 @@ export function wantedPlaces(position, places, { show, hide, active }) { return { add, remove }; } +// tools/assets/places.mjs komprimiert mit meshopt() (KHR_mesh_quantization): Positionen fester Meshes +// liegen als normalisierte Ganzzahlen vor, erst über die Mesh-Transform auf echte Maße skaliert. +// colliderBuild.ts im three-player-controller klont die Geometrie aber und rechnet matrixWorld per +// applyMatrix4 in die Kopie hinein — dabei würden normalisierte Werte beim Zurückschreiben auf ±1 +// geklemmt. Deshalb hier vorher in echte, nicht normalisierte Float32-Koordinaten entquantisieren. +function dequantizePosition(geometry) { + const position = geometry.attributes.position; + if (!position.normalized && position.array instanceof Float32Array) return; + const values = new Float32Array(position.count * 3); + for (let i = 0; i < position.count; i++) { + values[i * 3] = position.getX(i); + values[i * 3 + 1] = position.getY(i); + values[i * 3 + 2] = position.getZ(i); + } + geometry.setAttribute("position", new BufferAttribute(values, 3)); +} + /** * Setzt Lage und Schatten und nimmt nicht feste Meshes aus der Kollision (colliderBuild.ts im * three-player-controller überspringt userData.excludeFromCollider). Gibt zurück, ob ein Teil fest ist. @@ -93,8 +111,12 @@ export function prepareModel(root, place) { object.castShadow = true; object.receiveShadow = true; const materials = Array.isArray(object.material) ? object.material : [object.material]; - if (materials.some((material) => isSolidMaterial(material?.name))) solid = true; - else object.userData.excludeFromCollider = true; + if (materials.some((material) => isSolidMaterial(material?.name))) { + solid = true; + dequantizePosition(object.geometry); + } else { + object.userData.excludeFromCollider = true; + } }); root.updateMatrixWorld(true); return solid; diff --git a/tests/places.test.js b/tests/places.test.js index 3d4f094..55c9138 100644 --- a/tests/places.test.js +++ b/tests/places.test.js @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three"; +import { BoxGeometry, BufferAttribute, BufferGeometry, Group, Mesh, MeshStandardMaterial, Scene, + Vector3 } from "three"; import { PLACES, PlaceLayer, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel, wantedPlaces } from "../src/world/places.js"; @@ -91,6 +92,48 @@ describe("prepareModel", () => { it("meldet, wenn kein Teil fest ist", () => { expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false); }); + + // Bruch: colliderBuild.ts im three-player-controller klont die Geometrie und rechnet matrixWorld + // per applyMatrix4 hinein. Bei einer normalisierten Int16-Position (KHR_mesh_quantization aus + // meshopt() in tools/assets/places.mjs) klemmt das Zurückschreiben die Weltkoordinaten auf ±1 — + // der Hof kollidierte dann nahe dem Weltursprung statt an seiner Stelle im Hood Yard. + it("entquantisiert feste Positionen, bevor der Collider die Weltmatrix hineinrechnet", () => { + const geometry = new BufferGeometry(); + geometry.setAttribute("position", + new BufferAttribute(new Int16Array([-32767, 0, -32767, 32767, 32767, 32767]), 3, true)); + const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" })); + mesh.scale.setScalar(17); + const root = new Group(); + root.add(mesh); + + prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 }); + + expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array); + expect(mesh.geometry.attributes.position.normalized).toBe(false); + + // Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen. + const worldGeometry = mesh.geometry.clone(); + worldGeometry.applyMatrix4(mesh.matrixWorld); + worldGeometry.computeBoundingBox(); + + expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472); + expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506); + expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5); + expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5); + }); + + it("lässt eine durchlässige Position quantisiert", () => { + const geometry = new BufferGeometry(); + const position = new BufferAttribute(new Int16Array([0, 0, 0, 32767, 32767, 32767]), 3, true); + geometry.setAttribute("position", position); + const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "grass_medium_01" })); + const root = new Group(); + root.add(mesh); + + prepareModel(root, { at: [0, 0], rotation: 0, lift: 0 }); + + expect(mesh.geometry.attributes.position).toBe(position); + }); }); describe("PLACES", () => {