feat(assets): Aufbereitung für Orte mit Texturgrößen je Material und Vereinfachung

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Haiku 4.5 committed 2026-09-14 10:49:31 +02:00
1 parent 0ebe8adb8b
commit 6c291c4106
2 files changed
+239

No files matched your search

+97
View File
@@ -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");
});
});
+142
View File
@@ -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 <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 { 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 <quelle.glb> <id>");
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();