feat(world): Palmen und Requisiten als Instanzen mit Stammkollision
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2469d30e76
commit
9173400caa
16 files changed
+2504
-1
No files matched your search
@@ -13,5 +13,7 @@ __pycache__/
|
||||
# Generierte Zwischenstände
|
||||
tools/extract/cache/
|
||||
tools/extract/debug/
|
||||
# Rohdownload für tools/assets/props.mjs — nur die daraus gebauten .glb werden versioniert.
|
||||
public/models/props/raw/
|
||||
|
||||
.DS_Store
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,6 @@
|
||||
Provided by Nobiax.
|
||||
|
||||
Absolutely free to use or to modify in any kind of work (personal, commercial or else).
|
||||
|
||||
Give me a link of the result at nobiax.deviantart.com or my other account on OpenGameArt.com or ShareCG.com
|
||||
( watch my journal on deviantart ) ;)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
-1
@@ -6,6 +6,7 @@ import { Sky } from "three/examples/jsm/objects/Sky.js";
|
||||
import { playerController } from "three-player-controller";
|
||||
import { ChunkStreamer } from "./world/streaming.js";
|
||||
import { createMaterials } from "./world/materials.js";
|
||||
import { PropLayer } from "./world/props.js";
|
||||
|
||||
const container = document.querySelector("#container");
|
||||
const scene = new Scene();
|
||||
@@ -69,6 +70,10 @@ scene.add(seafloor);
|
||||
seafloor.updateMatrixWorld(true);
|
||||
player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } });
|
||||
|
||||
// Requisiten (Palmen, Laternen) — ein Ladevorgang für alle Chunks, siehe Task 10.
|
||||
const props = new PropLayer();
|
||||
await props.load();
|
||||
|
||||
// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen.
|
||||
function updateShadow(target) {
|
||||
sun.position.set(target.x + 120, 240, target.z + 80);
|
||||
@@ -89,7 +94,7 @@ function updateShadow(target) {
|
||||
// Zeitschranke, die danach die Kollision ohne vorhandenen Boden einschaltet, würde in eine
|
||||
// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt
|
||||
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
|
||||
const streamer = new ChunkStreamer({ scene, player, materials });
|
||||
const streamer = new ChunkStreamer({ scene, player, materials, props });
|
||||
player.setSkipCapsuleCollision(true);
|
||||
let settled = false;
|
||||
let warnedSlowSpawn = false;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { lampPoints, palmTransforms } from "../src/world/props.js";
|
||||
|
||||
describe("props", () => {
|
||||
it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
|
||||
const pts = lampPoints([[[0, 0], [100, 0]]], 35);
|
||||
expect(pts.length).toBe(3); // 17,5 / 52,5 / 87,5 Meter
|
||||
expect(pts[0].x).toBeCloseTo(17.5); // nicht auf der Kreuzung
|
||||
expect(pts[1].x).toBeCloseTo(52.5);
|
||||
});
|
||||
|
||||
it("setzt auf einer kurzen Linie eine Laterne in die Mitte", () => {
|
||||
const pts = lampPoints([[[0, 0], [10, 0]]], 35);
|
||||
expect(pts).toHaveLength(1);
|
||||
expect(pts[0].x).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it("stapelt an einer Kreuzung keine Laternen", () => {
|
||||
// Zwei gegenüberliegende Äste, wie centerlines() sie seit Task 4 liefert.
|
||||
const pts = lampPoints([[[0, 0], [60, 0]], [[0, 0], [-60, 0]]], 35);
|
||||
const keys = new Set(pts.map((p) => `${p.x.toFixed(2)},${p.z.toFixed(2)}`));
|
||||
expect(keys.size).toBe(pts.length);
|
||||
});
|
||||
|
||||
it("skaliert Palmen reproduzierbar zwischen 10 und 18 Metern", () => {
|
||||
const a = palmTransforms([[5, 5], [9, 9]], 7);
|
||||
const b = palmTransforms([[5, 5], [9, 9]], 7);
|
||||
expect(a).toEqual(b);
|
||||
for (const t of a) {
|
||||
expect(t.height).toBeGreaterThanOrEqual(10);
|
||||
expect(t.height).toBeLessThanOrEqual(18);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nobiax-Palme (CC0) holen und die TGA-Texturen nach PNG wandeln.
|
||||
set -e
|
||||
mkdir -p public/models/palm && cd public/models/palm
|
||||
curl -sL -o palm.7z "https://opengameart.org/sites/default/files/palm_tree_v2.7z"
|
||||
tar -xf palm.7z
|
||||
sips -s format png diffus.tga --out diffuse.png > /dev/null
|
||||
sips -s format png normal.tga --out normal.png > /dev/null
|
||||
rm -f palm.7z diffus.tga normal.tga specular.tga palm_tree.FBX preview.jpg
|
||||
echo "Palme bereit: $(ls)"
|
||||
@@ -0,0 +1,41 @@
|
||||
// Lädt die fünf Poly-Haven-Requisiten in 1k (gltf + zugehörige Texturen/`.bin`) nach
|
||||
// public/models/props/raw/ und vereinfacht sie danach mit @gltf-transform/cli zu je einer
|
||||
// kompakten .glb unter public/models/props/. Gleiches Muster wie textures.mjs, aber Modelle
|
||||
// stehen unter dem Schlüssel "gltf" statt unter den Map-Namen ("Diffuse", "nor_gl", "arm"),
|
||||
// und die 1k-Auflösung liefert neben der .gltf-Datei selbst noch eine `include`-Liste mit den
|
||||
// referenzierten Texturen und der gemeinsamen `.bin`-Geometriedatei — ohne die kann
|
||||
// gltf-transform die .gltf-Datei nicht öffnen.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" };
|
||||
const SLUGS = ["street_lamp_02", "painted_wooden_bench", "metal_trash_can", "fire_hydrant", "concrete_road_barrier_02"];
|
||||
const RAW_DIR = "public/models/props/raw";
|
||||
const OUT_DIR = "public/models/props";
|
||||
|
||||
async function fetchTo(url, path) {
|
||||
const buf = Buffer.from(await (await fetch(url, { headers: UA })).arrayBuffer());
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, buf);
|
||||
return buf.length;
|
||||
}
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
for (const slug of SLUGS) {
|
||||
const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json();
|
||||
const entry = files.gltf?.["1k"]?.gltf;
|
||||
if (!entry) { console.warn(`${slug}: keine 1k-gltf-Datei gefunden`); continue; }
|
||||
|
||||
const gltfPath = join(RAW_DIR, slug, `${slug}_1k.gltf`);
|
||||
const gltfSize = await fetchTo(entry.url, gltfPath);
|
||||
console.log(`${slug}_1k.gltf ${(gltfSize / 1e6).toFixed(2)} MB`);
|
||||
for (const [relPath, include] of Object.entries(entry.include ?? {})) {
|
||||
const size = await fetchTo(include.url, join(RAW_DIR, slug, relPath));
|
||||
console.log(` ${relPath} ${(size / 1e6).toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
const outPath = join(OUT_DIR, `${slug}.glb`);
|
||||
execFileSync("npx", ["--yes", "@gltf-transform/cli@4.5.0", "optimize", gltfPath, outPath,
|
||||
"--texture-size", "512", "--simplify-error", "0.01", "--compress", "meshopt"], { stdio: "inherit" });
|
||||
}
|
||||
Reference in new issue
Block a user