// 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 -> public/models/places/.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/.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 "); 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();