Files
gta6/tools/assets/places.mjs
T
knsoandClaude Sonnet 5 ae0f6106bb feat(world): Materialrollen der Tribüne, Asset-Test je Ort
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>
2026-09-14 20:24:09 +02:00

162 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();