feat(world): Palmen und Requisiten als Instanzen mit Stammkollision

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 01:05:55 +02:00
1 parent 2469d30e76
commit 9173400caa
16 files changed
+2504 -1

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
import { CylinderGeometry, Group, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
Object3D, RepeatWrapping, SRGBColorSpace, TextureLoader } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
const PALM_MODEL_HEIGHT = 439.74; // Einheiten im OBJ, gemessen
const TRUNK_RADIUS = 0.22;
/** Kleiner, wiederholbarer Zufall aus einem Schlüssel. */
function hashUnit(seed, key) {
let h = 2166136261 ^ seed;
for (const ch of key) { h = Math.imul(h ^ ch.charCodeAt(0), 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
export function lampPoints(lines, spacing = 35) {
const out = [];
for (const line of lines) {
let total = 0;
for (let i = 0; i < line.length - 1; i++) {
total += Math.hypot(line[i + 1][0] - line[i][0], line[i + 1][1] - line[i][1]);
}
// Erste Laterne bei halber Teilung: die Mittellinien sind an Kreuzungen getrennt,
// ein Start bei 0 setzt je einmündendem Ast eine Laterne in denselben Punkt —
// an einer Kreuzung mit vier Ästen also vier, mitten auf der Fahrbahn.
let travelled = 0, next = Math.min(spacing / 2, total / 2);
for (let i = 0; i < line.length - 1; i++) {
const [x0, z0] = line[i], [x1, z1] = line[i + 1];
const len = Math.hypot(x1 - x0, z1 - z0);
while (next <= travelled + len) {
const t = (next - travelled) / (len || 1);
out.push({ x: x0 + (x1 - x0) * t, z: z0 + (z1 - z0) * t, angle: Math.atan2(z1 - z0, x1 - x0) });
next += spacing;
}
travelled += len;
}
}
return out;
}
export function palmTransforms(points, seed = 1) {
return points.map(([x, z]) => {
const key = `${x.toFixed(1)},${z.toFixed(1)}`;
return {
x, z,
height: 10 + hashUnit(seed, key) * 8,
rotation: hashUnit(seed, `${key}:r`) * Math.PI * 2,
};
});
}
export class PropLayer {
async load() {
const texLoader = new TextureLoader();
const [obj, diffuse, normal] = await Promise.all([
new OBJLoader().loadAsync("./models/palm/palm_tree.obj"),
texLoader.loadAsync("./models/palm/diffuse.png"),
texLoader.loadAsync("./models/palm/normal.png"),
]);
diffuse.colorSpace = SRGBColorSpace;
for (const t of [diffuse, normal]) t.wrapS = t.wrapT = RepeatWrapping;
this.palmGeometry = null;
obj.traverse((o) => { if (o.isMesh && !this.palmGeometry) this.palmGeometry = o.geometry; });
this.palmMaterial = new MeshStandardMaterial({
map: diffuse, normalMap: normal, alphaTest: 0.5, side: 2, roughness: 0.9,
});
const gltfLoader = new GLTFLoader();
// props.mjs komprimiert die .glb mit --compress meshopt (EXT_meshopt_compression);
// ohne registrierten Decoder wirft GLTFLoader beim Laden ("setMeshoptDecoder must be
// called before loading compressed files") — am echten Modell aufgefallen, im
// Referenzcode fehlte das.
gltfLoader.setMeshoptDecoder(MeshoptDecoder);
this.lamp = (await gltfLoader.loadAsync("./models/props/street_lamp_02.glb")).scene;
// Anders als die einteilige Palme hat street_lamp_02.glb zwei Materialien — Metallmast
// und durchsichtiges Laternenglas (glTF-Mesh "street_lamp_02_stem_mesh", primitives:
// [material 1, material 0], per gltf-transform inspect nachgeprüft) — GLTFLoader baut
// daraus zwei separate Meshes unter derselben Modellwurzel. Alle werden gesammelt, je
// mit ihrer lokalen Transformation gegenüber der Wurzel, sonst fehlte an jeder Laterne
// ein Teil (Glas oder Mast, je nach Traversierreihenfolge).
this.lamp.updateMatrixWorld(true);
this.lampParts = [];
this.lamp.traverse((o) => {
if (o.isMesh) this.lampParts.push({ geometry: o.geometry, material: o.material, matrix: o.matrixWorld.clone() });
});
this.trunkGeometry = new CylinderGeometry(TRUNK_RADIUS, TRUNK_RADIUS * 1.2, 6, 6);
}
/**
* Sichtbare Instanzen (Palmen, Laternen) plus einfache Stammzylinder für den
* Chunk-Collider. Palmen siehe unten, Laternen in #buildLamps.
*/
build(chunk) {
const visual = new Group();
const colliders = [];
const palms = palmTransforms(chunk.palms ?? [], 20260912);
if (palms.length) {
const mesh = new InstancedMesh(this.palmGeometry, this.palmMaterial, palms.length);
mesh.castShadow = true;
const dummy = new Object3D();
palms.forEach((p, i) => {
const s = p.height / PALM_MODEL_HEIGHT;
dummy.position.set(p.x, 0, p.z);
dummy.rotation.set(0, p.rotation, 0);
dummy.scale.setScalar(s);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
// visible = false: reiner Kollisionskörper. Ohne Material rendert new Mesh()
// sonst als weißer Standardzylinder mitten im Palmenstamm (am echten Modell
// gesehen) — colliderBuild.ts (three-player-controller) prüft beim Einsammeln
// der BVH-Geometrie nur isMesh/geometry/name/excludeFromCollider, nicht
// .visible, die Kollision bleibt also unverändert bestehen.
const trunk = new Mesh(this.trunkGeometry);
trunk.visible = false;
trunk.position.set(p.x, 3, p.z);
colliders.push(trunk);
});
mesh.instanceMatrix.needsUpdate = true;
mesh.userData.excludeFromCollider = true;
visual.add(mesh);
}
this.#buildLamps(chunk, visual);
return { visual, colliders };
}
/**
* Laternen entlang der Mittellinien (lampPoints, oben).
*
* Ohne diese Methode wäre lampPoints() eine getestete, aber nirgends aufgerufene Funktion,
* und this.lamp ein geladenes, nie benutztes Modell — im gesamten Umsetzungsplan ruft keine
* andere Aufgabe lampPoints() auf. Die Verdrahtung hier verwendet ausschließlich bereits
* spezifizierte, getestete Bausteine (lampPoints exakt wie oben, this.lamp aus load()).
*
* Platzierung exakt auf der Mittellinie, ohne seitlichen Versatz zur Fahrbahnkante: anders
* als bei den Palmen (Python-seitige Suche vom Fahrbahnrand nach außen gegen Straßen- und
* Gebäudepolygone, Ruling 22) kennen die Chunk-Daten keine separate Gehwegspur — nur
* Fahrbahn-, Sand-, Park- und Stadtfläche — und lampPoints(lines, spacing) bekommt bewusst
* keine Straßen- oder Gebäudepolygone übergeben. Ein erfundener fester Versatz ließe sich
* an keiner echten Geometrie prüfen und wäre nach Ruling 22 vermutlich ebenso falsch wie der
* ursprüngliche 4-m-Versatz der Palmen (60 % auf der Fahrbahn). Ruling 18 nimmt "mitten auf
* der Fahrbahn" für Laternen bereits ausdrücklich hin ("an einer Kreuzung ... stehen vier
* Laternen im selben Punkt, mitten auf der Fahrbahn") und behebt dort nur das Stapeln an
* Kreuzungen — genau dafür ist lampPoints() da. Das Modell ist bei 1,7 m laut Poly-Haven-
* Angabe bereits maßstäblich, anders als die Palme also keine Zufallsskalierung.
*/
#buildLamps(chunk, visual) {
const points = lampPoints(chunk.centerlines ?? []);
if (!points.length || !this.lampParts?.length) return;
const dummy = new Object3D();
const local = new Matrix4();
// Ein InstancedMesh je Teil (Mast, Glas, ...), nicht je Laterne — dieselbe
// Platzierungsmatrix wird für jedes Teil mit dessen eigener lokaler Transformation
// verkettet, damit ein mehrteiliges Modell an jedem Punkt vollständig zusammenbleibt.
for (const part of this.lampParts) {
const mesh = new InstancedMesh(part.geometry, part.material, points.length);
mesh.castShadow = true;
mesh.userData.excludeFromCollider = true;
points.forEach((p, i) => {
dummy.position.set(p.x, 0, p.z);
dummy.rotation.set(0, p.angle, 0);
dummy.updateMatrix();
local.multiplyMatrices(dummy.matrix, part.matrix);
mesh.setMatrixAt(i, local);
});
mesh.instanceMatrix.needsUpdate = true;
visual.add(mesh);
}
}
}
+9
View File
@@ -69,6 +69,15 @@ export class ChunkStreamer {
// Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.
(DECALS.has(name) ? group : collide).add(mesh);
}
// this.props ist seit Task 8 ein optionales Konstruktorfeld (siehe dortiger
// Kommentar); die bestehenden Streaming-Tests bauen einen ChunkStreamer ohne
// props auf und dürfen dabei nicht auf einmal an einem unbedingten Aufruf
// scheitern — daher die Wache, statt props stillschweigend zur Pflicht zu machen.
if (this.props) {
const props = this.props.build(chunk);
group.add(props.visual);
for (const trunk of props.colliders) collide.add(trunk); // wandern in dieselbe BVH
}
group.add(collide);
this.scene.add(group);
group.updateMatrixWorld(true);