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:
1 parent
0ebe8adb8b
commit
6c291c4106
2 files changed
+239
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user