Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
8.5 KiB
JavaScript
162 lines
8.5 KiB
JavaScript
// Bereitet ein Modell aus dem Editor three-d-stage für die Orte-Schicht auf (src/world/places.js,
|
||
// docs/superpowers/specs/2026-09-14-hood-yard-design.md):
|
||
//
|
||
// node tools/assets/places.mjs <quelle.glb> <id> -> public/models/places/<id>.glb
|
||
//
|
||
// Die Quelle bleibt außerhalb des Repos (hood-yard.glb: 653 MB, 105 PNG in 2048 px, rund 2,9 Mio.
|
||
// gezeichnete Dreiecke). Anders als tools/assets/props.mjs nicht über die gltf-transform-CLI: die
|
||
// verwirft EXT_materials_bump, und darin steckt das Wellblech der Container (bumpExtension.mjs).
|
||
import { mkdir, stat } from "node:fs/promises";
|
||
import { dirname } from "node:path";
|
||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||
import { ExtensionProperty, NodeIO, PropertyType } from "@gltf-transform/core";
|
||
import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions";
|
||
import { compressTexture, dedup, flatten, getBounds, join, listTextureSlots, meshopt, prune,
|
||
simplifyPrimitive, weld } from "@gltf-transform/functions";
|
||
import { MeshoptEncoder, MeshoptSimplifier } from "meshoptimizer";
|
||
import sharp from "sharp";
|
||
import { EXTMaterialsBump } from "./bumpExtension.mjs";
|
||
|
||
export const NEAR_TEXTURE_SIZE = 1024;
|
||
export const FAR_TEXTURE_SIZE = 512;
|
||
const QUALITY = 80;
|
||
// Verlustbehaftetes WebP erzeugt in Normal-Maps sonst Blockmuster, die im Streiflicht auffallen.
|
||
const NORMAL_QUALITY = 92;
|
||
|
||
/** Flächen, an denen der Spieler direkt vorbeiläuft: ihre Texturen in 1024 px, alle anderen in 512 px. */
|
||
export const NEAR_MATERIALS = new Set([
|
||
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass",
|
||
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", "contHardware",
|
||
"ironPlain", "ironTeal", "ironBlue", "osb", "osbB", "rollershutter_window_01", "rollershutter_window_01_graffiti",
|
||
"chainlink_post", "chainlink_wire",
|
||
// Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht
|
||
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale",
|
||
]);
|
||
|
||
/**
|
||
* Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit
|
||
* Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume
|
||
* haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000,
|
||
* quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben
|
||
* Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298.
|
||
*/
|
||
export const SIMPLIFY_RATIOS = new Map([
|
||
["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15],
|
||
["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5],
|
||
["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5],
|
||
["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25],
|
||
["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25],
|
||
["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25],
|
||
]);
|
||
// Anteil am Mesh-Radius. Nach join umspannt ein Pflanzenmaterial den ganzen Hof (~17 m Radius), die
|
||
// Grenze liegt dann bei ~17 cm und greift kaum — die Anteile oben steuern (am Modell gemessen: 0,002
|
||
// und 0,01 ergeben dieselben Dreieckszahlen).
|
||
const SIMPLIFY_ERROR = 0.01;
|
||
|
||
/** Materialien, die eine Textur benutzen: direkt oder über eine Erweiterung wie EXT_materials_bump. */
|
||
function materialsUsing(texture) {
|
||
const materials = new Set();
|
||
for (const parent of texture.listParents()) {
|
||
if (parent.propertyType === PropertyType.MATERIAL) materials.add(parent);
|
||
if (!(parent instanceof ExtensionProperty)) continue;
|
||
for (const owner of parent.listParents()) {
|
||
if (owner.propertyType === PropertyType.MATERIAL) materials.add(owner);
|
||
}
|
||
}
|
||
return [...materials];
|
||
}
|
||
|
||
/** Verkleinert ein eingelesenes Dokument an Ort und Stelle, in der Reihenfolge der Spec. */
|
||
export async function optimizePlace(document) {
|
||
await Promise.all([MeshoptEncoder.ready, MeshoptSimplifier.ready]);
|
||
// Kein instance(): die Kollision des Controllers liest nur mesh.geometry mit matrixWorld.
|
||
// Kein palette(): es fasst texturlose Materialien unter neuem Namen zusammen, die Kollision
|
||
// wählt aber nach Materialnamen (src/world/places.js, SOLID_MATERIALS).
|
||
await document.transform(dedup(), flatten(), join(), weld());
|
||
for (const mesh of document.getRoot().listMeshes()) {
|
||
for (const primitive of mesh.listPrimitives()) {
|
||
const ratio = SIMPLIFY_RATIOS.get(primitive.getMaterial()?.getName());
|
||
if (ratio === undefined) continue;
|
||
simplifyPrimitive(primitive, { simplifier: MeshoptSimplifier, ratio, error: SIMPLIFY_ERROR });
|
||
}
|
||
}
|
||
await document.transform(prune());
|
||
|
||
// createExtension meldet das Bildformat nicht an, das tut sonst erst NodeIO.registerExtensions.
|
||
// Ohne register() liefert texture.getSize() für WebP null (Bericht, Tests).
|
||
EXTTextureWebP.register();
|
||
document.createExtension(EXTTextureWebP).setRequired(true);
|
||
for (const texture of document.getRoot().listTextures()) {
|
||
const near = materialsUsing(texture).some((material) => NEAR_MATERIALS.has(material.getName()));
|
||
const size = near ? NEAR_TEXTURE_SIZE : FAR_TEXTURE_SIZE;
|
||
const quality = listTextureSlots(texture).includes("normalTexture") ? NORMAL_QUALITY : QUALITY;
|
||
await compressTexture(texture, { encoder: sharp, targetFormat: "webp", resize: [size, size], quality });
|
||
}
|
||
|
||
await document.transform(meshopt({ encoder: MeshoptEncoder, level: "medium" }));
|
||
return document;
|
||
}
|
||
|
||
/** Kennzahlen für den Bericht. Dreiecke und Primitive je Knoten gezählt, also wie gezeichnet. */
|
||
export function describePlace(document) {
|
||
const root = document.getRoot();
|
||
let triangles = 0;
|
||
let primitives = 0;
|
||
for (const node of root.listNodes()) {
|
||
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
|
||
primitives++;
|
||
triangles += (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION").getCount()) / 3;
|
||
}
|
||
}
|
||
let gpuBytes = 0;
|
||
const sizes = {};
|
||
for (const texture of root.listTextures()) {
|
||
const [width, height] = texture.getSize() ?? [0, 0];
|
||
gpuBytes += (width * height * 4 * 4) / 3; // RGBA mit Mipmaps
|
||
const key = `${width}x${height} ${texture.getMimeType()}`;
|
||
sizes[key] = (sizes[key] ?? 0) + 1;
|
||
}
|
||
const { min, max } = getBounds(root.listScenes()[0]);
|
||
const round = (values) => values.map((value) => Number(value.toFixed(2)));
|
||
return {
|
||
materials: root.listMaterials().length, primitives, triangles, textures: sizes,
|
||
gpuMB: Math.round(gpuBytes / 1e6), bounds: { min: round(min), max: round(max) },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Zielpfad für ein aufbereitetes Modell, absolut und unabhängig vom Arbeitsverzeichnis (public/
|
||
* models/places/<id>.glb, relativ zu dieser Datei aufgelöst statt zu process.cwd()). id landet auch
|
||
* in src/world/places.js (PLACES) und im Dateinamen — nur Kleinbuchstaben, Ziffern und Bindestrich,
|
||
* damit sie auf jedem Dateisystem gleich aussieht und nicht aus public/models/places/ herausführt.
|
||
*/
|
||
export function placeTarget(id) {
|
||
if (!/^[a-z0-9-]+$/.test(id)) {
|
||
throw new Error(`Ungültige Orts-ID "${id}": erlaubt sind nur Kleinbuchstaben, Ziffern und Bindestrich.`);
|
||
}
|
||
const dir = new URL("../../public/models/places/", import.meta.url);
|
||
return fileURLToPath(new URL(`${id}.glb`, dir));
|
||
}
|
||
|
||
async function main() {
|
||
const [source, id] = process.argv.slice(2);
|
||
if (!source || !id) {
|
||
console.error("Aufruf: node tools/assets/places.mjs <quelle.glb> <id (Kleinbuchstaben, Ziffern, Bindestrich)>");
|
||
process.exit(1);
|
||
}
|
||
const target = placeTarget(id);
|
||
const io = new NodeIO()
|
||
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
|
||
.registerDependencies({ "meshopt.encoder": MeshoptEncoder });
|
||
const megabytes = async (path) => `${((await stat(path)).size / 1e6).toFixed(1)} MB`;
|
||
|
||
const document = await io.read(source);
|
||
console.log("vorher", await megabytes(source), describePlace(document));
|
||
await optimizePlace(document);
|
||
await mkdir(dirname(target), { recursive: true });
|
||
await io.write(target, document);
|
||
console.log("nachher", await megabytes(target), describePlace(document));
|
||
}
|
||
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|