street_lamp_02.glb ist nur 1,657 m hoch (per gltf-transform inspect gemessen:
bboxMax.y 1.26478 - bboxMin.y -0.39258) und ihr Fuß liegt unter dem Ursprung.
#buildLamps setzte das Modell bisher unskaliert auf y=0 — der Fuß steckt im Boden,
und 1,66 m ist ohnehin zu klein neben 10-18 m hohen Palmen und 10-28 m breiten
Straßen dieser Karte. Poly Havens eigene Angabe ("1.7m tall") bestätigt, dass die
Rohgröße korrekt gemessen ist — der Fehler liegt in der Verwendung dieser Rohgröße
in diesem Maßstab, nicht in der Messung selbst.
lampFit(minY, maxY, targetHeight) leitet Skalierung und Fuß-Anhebung aus der
gemessenen bbox her; STREET_LAMP_HEIGHT = 4.0 m als benannte Zielgröße (Mitte des
für dekorative Straßenlaternen üblichen 3-6-m-Bereichs). load() wendet beides auf
die Modellwurzel an, bevor die Teil-Matrizen (Mast, Glas) eingesammelt werden, damit
#buildLamps() unverändert bleiben kann.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
257 lines
13 KiB
JavaScript
257 lines
13 KiB
JavaScript
import { Box3, 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;
|
|
const LAMP_VERGE = [6, 8, 10, 12, 14, 16]; // Meter, von innen nach außen (Ruling 60)
|
|
|
|
// Befund B (Fix-Runde 3): street_lamp_02.glb ist mit `npx @gltf-transform/cli@4.5.0 inspect`
|
|
// vermessen nur 1,65736 m hoch (bboxMax.y 1.26478 - bboxMin.y -0.39258) — Poly Haven selbst
|
|
// nennt das Modell "1.7m tall" (https://polyhaven.com/a/street_lamp_02), die Messung bestätigt
|
|
// also die Rohgröße des Assets. Zu klein ist sie trotzdem: dekorative Straßen-/Parklaternen
|
|
// liegen real üblicherweise bei 3-6 m, und neben 10-18 m hohen Palmen (PALM_MODEL_HEIGHT-
|
|
// Skalierung oben) und 10-28 m breiten Straßen dieser Karte wirkt 1,66 m spielzeughaft klein.
|
|
// 4 m liegt mittig in diesem Bereich und ist mehr als das Doppelte der Rohgröße — eine
|
|
// bewusste, keine zufällige Skalierung (anders als bei den Palmen: alle Laternen sind gleich
|
|
// hoch, wie ein echtes Serienprodukt).
|
|
export const STREET_LAMP_HEIGHT = 4.0; // Meter, Zielhöhe (benannte Konstante, s. Bericht)
|
|
|
|
/**
|
|
* Skalierung und Y-Anhebung, damit ein Modell mit gemessener lokaler bbox [minY, maxY] genau
|
|
* targetHeight hoch dasteht und sein tiefster Punkt (der Fuß, minY) nach der Skalierung exakt
|
|
* auf y = 0 zu stehen kommt — unabhängig davon, ob der Modellursprung selbst am Fuß liegt.
|
|
* street_lamp_02.glb liegt das nicht: bboxMin.y ist negativ, der Fuß also unter dem Ursprung
|
|
* (Befund B) — ohne die Anhebung um |bboxMin.y| * scale stünde das skalierte Modell weiterhin
|
|
* teilweise im Boden.
|
|
*/
|
|
export function lampFit(minY, maxY, targetHeight = STREET_LAMP_HEIGHT) {
|
|
const modelHeight = maxY - minY;
|
|
const scale = modelHeight > 0 ? targetHeight / modelHeight : 1;
|
|
return { scale, liftY: -minY * scale };
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** Halbstrahl-Verfahren (Ray Casting): liegt (x, z) im Ring? Ring offen oder geschlossen. */
|
|
function inRing(x, z, ring) {
|
|
let inside = false;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const [xi, zi] = ring[i], [xj, zj] = ring[j];
|
|
if ((zi > z) !== (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
/**
|
|
* Liegt (x, z) auf der Fläche, die dieses Ringpaket beschreibt — Außenring zuerst, danach
|
|
* Löcher (wie chunk.roads[i].poly, Task 6)? Auf der Fläche heißt: im Außenring und in
|
|
* keinem der Löcher.
|
|
*/
|
|
export function pointInPolygon(x, z, rings) {
|
|
return rings.length > 0 && inRing(x, z, rings[0]) && !rings.slice(1).some((hole) => inRing(x, z, hole));
|
|
}
|
|
|
|
/**
|
|
* Versetzt lampPoints()-Punkte seitlich von der Fahrbahn (Ruling 60). lampPoints kennt nur
|
|
* die Mittellinie und setzt jeden Punkt genau darauf — ohne diesen Schritt stünde jede
|
|
* Laterne mitten auf der Fahrbahn. Für jeden Punkt wird senkrecht zur Blickrichtung
|
|
* (Normale zu (cos angle, sin angle) ist (-sin angle, cos angle)) von innen nach außen
|
|
* durch LAMP_VERGE probiert; die Seite wechselt von Punkt zu Punkt (gerader Index links,
|
|
* ungerader rechts), das halbiert die Zahl je Straßenseite und wirkt weniger wie eine
|
|
* durchgehende Reihe. Der erste Abstand, dessen Punkt außerhalb aller Straßenpolygone des
|
|
* Chunks liegt, gewinnt; findet keiner der sechs einen freien Punkt, entfällt die Laterne
|
|
* ganz — wie bei den Palmen (Ruling 22) kein Sonderfall, sondern die vorgesehene Reaktion
|
|
* auf eine Straße, die breiter ist als der Suchbereich.
|
|
*
|
|
* Bewusst nicht in der Python-Pipeline (Ruling 60): chunk.roads kennt nur die Straßen
|
|
* dieses einen Chunks, nicht die des Nachbarn — nahe einer Chunk-Grenze kann eine Laterne
|
|
* deshalb auf der Fahrbahn stehen bleiben, obwohl eine Straße im Nachbarchunk sie deckt.
|
|
* Bekannter, hingenommener Rest (siehe Bericht für Zahlen an echten Daten).
|
|
*/
|
|
export function vergeLampPoints(points, roads) {
|
|
const out = [];
|
|
points.forEach((p, i) => {
|
|
const side = i % 2 === 0 ? 1 : -1;
|
|
const nx = -Math.sin(p.angle) * side, nz = Math.cos(p.angle) * side;
|
|
for (const verge of LAMP_VERGE) {
|
|
const x = p.x + nx * verge, z = p.z + nz * verge;
|
|
if (!roads.some((r) => pointInPolygon(x, z, r.poly))) {
|
|
out.push({ x, z, angle: p.angle });
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
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;
|
|
// Befund B: Skalierung und Fuß-Anhebung auf die Modellwurzel anwenden, BEVOR unten die
|
|
// Teil-Matrizen eingesammelt werden — jedes lampParts[i].matrix ist matrixWorld relativ
|
|
// zu dieser Wurzel und bekommt scale/liftY dadurch automatisch mit; #buildLamps() selbst
|
|
// bleibt unverändert (platziert weiterhin nur bei y = 0 auf dem Boden).
|
|
const bbox = new Box3().setFromObject(this.lamp);
|
|
const { scale, liftY } = lampFit(bbox.min.y, bbox.max.y);
|
|
this.lamp.scale.setScalar(scale);
|
|
this.lamp.position.y = liftY;
|
|
// 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);
|
|
// sharedGeometry: this.trunkGeometry ist wie this.palmGeometry ein einziges,
|
|
// von PropLayer.load() gehaltenes Objekt für alle Chunks (Ruling 62).
|
|
trunk.userData.sharedGeometry = true;
|
|
colliders.push(trunk);
|
|
});
|
|
mesh.instanceMatrix.needsUpdate = true;
|
|
mesh.userData.excludeFromCollider = true;
|
|
// sharedGeometry: this.palmGeometry entsteht einmal in load() und wird von jedem
|
|
// Chunk instanziert — #unload() in streaming.js darf es nicht mit entsorgen,
|
|
// solange andere Chunks noch darauf zeigen (Ruling 62, Fix-Runde 2).
|
|
mesh.userData.sharedGeometry = true;
|
|
visual.add(mesh);
|
|
}
|
|
this.#buildLamps(chunk, visual);
|
|
return { visual, colliders };
|
|
}
|
|
|
|
/**
|
|
* Laternen entlang der Mittellinien (lampPoints), seitlich zur Fahrbahnkante versetzt
|
|
* (vergeLampPoints, Ruling 60 — Fix-Runde 1; siehe dort für die Begründung). Skalierung
|
|
* auf STREET_LAMP_HEIGHT und die Fuß-Anhebung stecken bereits in this.lampParts[*].matrix
|
|
* (s. load(), Befund B / Fix-Runde 3) — hier wird jede Laterne nur noch bei y = 0 auf den
|
|
* Boden gesetzt. Anders als bei der Palme keine Zufallsskalierung je Instanz: jede Laterne
|
|
* ist gleich hoch, wie ein echtes Serienprodukt.
|
|
*/
|
|
#buildLamps(chunk, visual) {
|
|
const centerlinePoints = lampPoints(chunk.centerlines ?? []);
|
|
const points = vergeLampPoints(centerlinePoints, chunk.roads ?? []);
|
|
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;
|
|
// sharedGeometry: part.geometry gehört this.lampParts, einmalig aus load()
|
|
// (Ruling 62, Fix-Runde 2 — dieselbe Begründung wie bei den Palmen oben).
|
|
mesh.userData.sharedGeometry = 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);
|
|
}
|
|
}
|
|
}
|