diff --git a/tests/placesPipeline.test.js b/tests/placesPipeline.test.js new file mode 100644 index 0000000..8dc8cec --- /dev/null +++ b/tests/placesPipeline.test.js @@ -0,0 +1,97 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Document } from "@gltf-transform/core"; +import sharp from "sharp"; +import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs"; +import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace } from "../tools/assets/places.mjs"; + +/** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */ +function stripes(seed) { + const size = 1100; + const data = Buffer.alloc(size * size * 3); + for (let i = 0; i < data.length; i++) data[i] = (Math.floor(i / 3) % size) * seed & 0xff; + return sharp(data, { raw: { width: size, height: size, channels: 3 } }).png().toBuffer(); +} + +/** n × n Quadrate in der Ebene (2·n² Dreiecke), indiziert. */ +function grid(document, n) { + const positions = []; + const indices = []; + for (let z = 0; z <= n; z++) for (let x = 0; x <= n; x++) positions.push(x, 0, z); + for (let z = 0; z < n; z++) { + for (let x = 0; x < n; x++) { + const a = z * (n + 1) + x, b = a + 1, c = a + n + 1, d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + return document.createPrimitive() + .setAttribute("POSITION", document.createAccessor().setType("VEC3").setArray(new Float32Array(positions))) + .setIndices(document.createAccessor().setType("SCALAR").setArray(new Uint32Array(indices))); +} + +/** Ein Container (Nahfläche mit Bump) und ein Grasstück (Pflanze mit Normal-Map), je ein Knoten. */ +async function sampleDocument() { + const document = new Document(); + document.createBuffer(); + const bumpExtension = document.createExtension(EXTMaterialsBump); + const [near, nearBump, far, farNormal] = await Promise.all([1, 3, 5, 7].map(stripes)); + const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/png"); + + const container = document.createMaterial("cont_a_side").setBaseColorTexture(texture("near", near)); + container.setExtension(EXT_MATERIALS_BUMP, + bumpExtension.createBump().setBumpFactor(2.2).setBumpTexture(texture("nearBump", nearBump))); + const grass = document.createMaterial("grass_medium_01") + .setBaseColorTexture(texture("far", far)) + .setNormalTexture(texture("farNormal", farNormal)); + + const scene = document.createScene(); + scene.addChild(document.createNode("container").setMesh(document.createMesh().addPrimitive(grid(document, 1).setMaterial(container)))); + scene.addChild(document.createNode("grass").setMesh(document.createMesh().addPrimitive(grid(document, 40).setMaterial(grass)))); + return { document, container, grass }; +} + +const trianglesOf = (document, name) => document.getRoot().listMeshes() + .flatMap((mesh) => mesh.listPrimitives()) + .filter((primitive) => primitive.getMaterial().getName() === name) + .reduce((sum, primitive) => sum + primitive.getIndices().getCount() / 3, 0); + +describe("optimizePlace", () => { + let sample; + beforeAll(async () => { + sample = await sampleDocument(); + await optimizePlace(sample.document); + }, 60000); + + // Bruch: Nahflächen so unscharf wie Pflanzen, Größen vertauscht, oder die Bump-Map zählt nicht + // zu ihrem Material (sie hängt über die Erweiterung dran, nicht direkt am Material). + it("verkleinert Texturen je nach Material auf 1024 oder 512 px und wandelt sie in WebP", () => { + const { document, container, grass } = sample; + const bumpTexture = container.getExtension(EXT_MATERIALS_BUMP).getBumpTexture(); + + expect(container.getBaseColorTexture().getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]); + expect(bumpTexture.getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]); + expect(grass.getBaseColorTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]); + expect(grass.getNormalTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]); + for (const texture of document.getRoot().listTextures()) expect(texture.getMimeType()).toBe("image/webp"); + }); + + // Bruch: die Vereinfachung trifft Container und Schuppen (Löcher in Wänden) oder lässt Pflanzen + // unberührt (die Köcherbäume blieben bei 2,2 Mio. Dreiecken). + it("vereinfacht nur Pflanzen und Kleinteile und behält die Materialnamen", () => { + const { document } = sample; + + expect(trianglesOf(document, "cont_a_side")).toBe(2); + expect(trianglesOf(document, "grass_medium_01")).toBeGreaterThan(0); + expect(trianglesOf(document, "grass_medium_01")).toBeLessThanOrEqual(3200 * 0.5); + }); + + // Bruch: das Wellblech geht verloren, die Geometrie bleibt unkomprimiert, oder es entstehen + // GPU-Instanzen, deren Matrizen die Kollision nicht liest. + it("erhält die Bump-Map, komprimiert mit meshopt und legt keine Instanzen an", () => { + const { document, container } = sample; + const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName); + + expect(container.getExtension(EXT_MATERIALS_BUMP).getBumpFactor()).toBe(2.2); + expect(used).toEqual(expect.arrayContaining([EXT_MATERIALS_BUMP, "EXT_meshopt_compression", "EXT_texture_webp"])); + expect(used).not.toContain("EXT_mesh_gpu_instancing"); + }); +}); diff --git a/tools/assets/places.mjs b/tools/assets/places.mjs new file mode 100644 index 0000000..1557111 --- /dev/null +++ b/tools/assets/places.mjs @@ -0,0 +1,142 @@ +// 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 { 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", +]); + +/** + * Anteil der Dreiecke, der je Material bleibt; Container, Schuppen, Zaun und Boden 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. + */ +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], +]); +// 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) }, + }; +} + +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 = `public/models/places/${id}.glb`; + 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();