Files
healer-man/scripts/dungeon-pipeline/optimize-shared-creatures.mjs
T
2026-08-14 15:56:39 -04:00

307 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
access,
mkdir,
readFile,
readdir,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Logger, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import {
CREATURE_MESH_CONSOLIDATION_POLICY,
consolidateCreatureSkinnedMeshes,
} from "./creature-mesh-consolidation.mjs";
import {
CREATURE_RUNTIME_ANIMATION_POLICY,
semanticCreatureAnimations,
trimCreatureRuntimeAnimations,
} from "./runtime-creature-animations.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const dungeonRoot = path.join(projectRoot, "public", "assets", "game", "dungeons");
const sharedRoot = path.join(projectRoot, "public", "assets", "game", "creatures", "shared");
const registryFile = path.join(projectRoot, "src", "game", "generated", "fivePlayerDungeonImports.json");
const reportFile = path.join(
projectRoot,
"dungeon-pipeline",
"reports",
"runtime-creature-animation-optimization.json",
);
const dryRun = process.argv.includes("--dry-run");
const exists = (file) => access(file).then(() => true, () => false);
const readJson = async (file) => JSON.parse(await readFile(file, "utf8"));
const writeJson = async (file, value) => {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const sha256 = async (file) => createHash("sha256").update(await readFile(file)).digest("hex");
function missingAnimationSemantics(semantics) {
return [
!semantics.stand && "stand",
!(semantics.walk || semantics.run) && "locomotion",
!semantics.attack && "attack",
!semantics.death && "death",
].filter(Boolean);
}
function roundedBounds(bounds) {
return {
min: bounds.min.map((value) => Number(value.toFixed(6))),
max: bounds.max.map((value) => Number(value.toFixed(6))),
};
}
function protectedNodeInventory(document) {
const root = document.getRoot();
const nodes = new Set();
for (const skin of root.listSkins()) {
if (skin.getSkeleton()) nodes.add(skin.getSkeleton());
for (const joint of skin.listJoints()) nodes.add(joint);
}
for (const animation of root.listAnimations()) {
for (const channel of animation.listChannels()) {
if (channel.getTargetNode()) nodes.add(channel.getTargetNode());
}
}
return [...nodes].map((node) => node.getName()).sort();
}
function materialInventory(document) {
return document.getRoot().listMaterials().map((material) => ({
name: material.getName(),
alphaMode: material.getAlphaMode(),
alphaCutoff: material.getAlphaCutoff(),
doubleSided: material.getDoubleSided(),
baseColorFactor: material.getBaseColorFactor(),
metallicFactor: material.getMetallicFactor(),
roughnessFactor: material.getRoughnessFactor(),
baseColorTexture: material.getBaseColorTexture()?.getName() ?? null,
}));
}
function descriptorReferences(manifests) {
const references = new Map();
for (const manifest of manifests) {
for (const model of Object.values(manifest.creatureModels ?? {})) {
if (typeof model.url !== "string" || !model.url.startsWith("/assets/game/creatures/shared/")) {
continue;
}
const models = references.get(model.url) ?? [];
models.push(model);
references.set(model.url, models);
}
}
return references;
}
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({
"meshopt.decoder": MeshoptDecoder,
"meshopt.encoder": MeshoptEncoder,
});
const manifestFiles = [];
for (const entry of await readdir(dungeonRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const file = path.join(dungeonRoot, entry.name, "dungeon-pack.json");
if (await exists(file)) manifestFiles.push(file);
}
manifestFiles.sort();
const manifests = await Promise.all(manifestFiles.map(readJson));
const references = descriptorReferences(manifests);
const targets = [...references.keys()].sort().map((url) => ({
url,
file: path.join(projectRoot, "public", url.replace(/^\/+/, "")),
descriptors: references.get(url),
}));
if (!targets.length) throw new Error("No shared creature GLBs are referenced by dungeon manifests.");
console.log(JSON.stringify({
mode: dryRun ? "dry-run" : "apply",
policies: {
animation: CREATURE_RUNTIME_ANIMATION_POLICY,
mesh: CREATURE_MESH_CONSOLIDATION_POLICY,
},
targets: targets.length,
}, null, 2));
const results = [];
for (let index = 0; index < targets.length; index += 1) {
const target = targets[index];
const before = await stat(target.file);
const document = await io.read(target.file);
document.setLogger(new Logger(Logger.Verbosity.WARN));
const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
if (!scene) throw new Error(`${target.url}: GLB has no scene.`);
const bounds = roundedBounds(getBounds(scene));
const protectedNodes = protectedNodeInventory(document);
const materials = materialInventory(document);
const animationOptimization = await trimCreatureRuntimeAnimations(document);
if (!animationOptimization.runtimeClipCount) {
throw new Error(`${target.url}: runtime animation policy retained no clips.`);
}
const meshOptimization = await consolidateCreatureSkinnedMeshes(document);
const temporary = path.join(sharedRoot, `.${path.basename(target.file)}.tmp.glb`);
await rm(temporary, { force: true });
await io.write(temporary, document);
const verification = await io.read(temporary);
const verifiedScene = verification.getRoot().getDefaultScene() ?? verification.getRoot().listScenes()[0];
if (JSON.stringify(bounds) !== JSON.stringify(roundedBounds(getBounds(verifiedScene)))) {
await rm(temporary, { force: true });
throw new Error(`${target.url}: optimization changed bounds.`);
}
if (JSON.stringify(protectedNodes) !== JSON.stringify(protectedNodeInventory(verification))) {
await rm(temporary, { force: true });
throw new Error(`${target.url}: protected skeleton/animation nodes changed.`);
}
if (JSON.stringify(materials) !== JSON.stringify(materialInventory(verification))) {
await rm(temporary, { force: true });
throw new Error(`${target.url}: material semantics changed.`);
}
const verifiedNames = verification.getRoot().listAnimations().map((animation) => animation.getName());
if (JSON.stringify(verifiedNames) !== JSON.stringify(animationOptimization.retainedNames)) {
await rm(temporary, { force: true });
throw new Error(`${target.url}: written animation inventory changed.`);
}
const checksum = await sha256(temporary);
const output = path.join(sharedRoot, `${checksum}.glb`);
const outputUrl = `/assets/game/creatures/shared/${checksum}.glb`;
const outputSize = (await stat(temporary)).size;
if (!dryRun) {
if (!await exists(output)) await rename(temporary, output);
else {
if (await sha256(output) !== checksum) throw new Error(`${outputUrl}: hash collision.`);
await rm(temporary, { force: true });
}
} else await rm(temporary, { force: true });
const previousSourceClips = Math.max(
animationOptimization.sourceClipCount,
...target.descriptors.map((descriptor) => Number(descriptor.sourceAnimationClips) || 0),
);
const previousSourceDrawCalls = Math.max(
meshOptimization.before.drawCalls,
...target.descriptors.map((descriptor) => Number(descriptor.meshOptimization?.sourceDrawCalls) || 0),
);
const semantics = semanticCreatureAnimations(animationOptimization.retainedNames);
results.push({
oldUrl: target.url,
url: outputUrl,
checksum,
previousByteLength: before.size,
byteLength: outputSize,
sourceClipCount: previousSourceClips,
runtimeClipCount: animationOptimization.runtimeClipCount,
removedClipCount: previousSourceClips - animationOptimization.runtimeClipCount,
sourceDrawCalls: previousSourceDrawCalls,
runtimeDrawCalls: meshOptimization.after.drawCalls,
savedDrawCalls: previousSourceDrawCalls - meshOptimization.after.drawCalls,
meshGroups: meshOptimization.groups,
mergedMeshNodes: meshOptimization.mergedNodes,
semantics,
missingAnimationSemantics: missingAnimationSemantics(semantics),
});
if ((index + 1) % 10 === 0 || index + 1 === targets.length) {
console.log(`Processed ${index + 1}/${targets.length} shared creature GLBs.`);
}
}
const totals = results.reduce((summary, result) => ({
sourceClips: summary.sourceClips + result.sourceClipCount,
runtimeClips: summary.runtimeClips + result.runtimeClipCount,
sourceDrawCalls: summary.sourceDrawCalls + result.sourceDrawCalls,
runtimeDrawCalls: summary.runtimeDrawCalls + result.runtimeDrawCalls,
previousBytes: summary.previousBytes + result.previousByteLength,
runtimeBytes: summary.runtimeBytes + result.byteLength,
}), {
sourceClips: 0,
runtimeClips: 0,
sourceDrawCalls: 0,
runtimeDrawCalls: 0,
previousBytes: 0,
runtimeBytes: 0,
});
const summary = {
models: results.length,
...totals,
removedClips: totals.sourceClips - totals.runtimeClips,
savedDrawCalls: totals.sourceDrawCalls - totals.runtimeDrawCalls,
savedBytes: totals.previousBytes - totals.runtimeBytes,
};
if (!dryRun) {
const byOldUrl = new Map(results.map((result) => [result.oldUrl, result]));
for (const manifest of manifests) {
for (const model of Object.values(manifest.creatureModels ?? {})) {
const optimized = byOldUrl.get(model.url);
if (!optimized) continue;
model.url = optimized.url;
model.checksum = optimized.checksum;
model.byteLength = optimized.byteLength;
model.animationClips = optimized.runtimeClipCount;
model.sourceAnimationClips = optimized.sourceClipCount;
model.animationOptimization = {
policy: CREATURE_RUNTIME_ANIMATION_POLICY,
removedClips: optimized.removedClipCount,
};
model.meshOptimization = {
policy: CREATURE_MESH_CONSOLIDATION_POLICY,
sourceDrawCalls: optimized.sourceDrawCalls,
runtimeDrawCalls: optimized.runtimeDrawCalls,
savedDrawCalls: optimized.savedDrawCalls,
};
model.animations = optimized.semantics;
model.missingAnimationSemantics = optimized.missingAnimationSemantics;
}
}
for (let index = 0; index < manifestFiles.length; index += 1) {
await writeJson(manifestFiles[index], manifests[index]);
}
await writeJson(registryFile, {
schemaVersion: 1,
generatedAt: "deterministic",
imports: manifests.slice().sort((left, right) => left.slug.localeCompare(right.slug)),
});
const liveUrls = new Set(descriptorReferences(manifests).keys());
for (const result of results) {
if (result.oldUrl !== result.url && !liveUrls.has(result.oldUrl)) {
const oldFile = path.join(projectRoot, "public", result.oldUrl.replace(/^\/+/, ""));
await rm(oldFile, { force: true });
}
}
const report = await readJson(reportFile);
report.shared = { totals: summary, models: results };
report.shippedTotals = {
models: Number(report.totals?.models ?? 0) + summary.models,
sourceClips: Number(report.totals?.sourceClips ?? 0) + summary.sourceClips,
runtimeClips: Number(report.totals?.runtimeClips ?? 0) + summary.runtimeClips,
sourceDrawCalls: Number(report.totals?.sourceDrawCalls ?? 0) + summary.sourceDrawCalls,
runtimeDrawCalls: Number(report.totals?.runtimeDrawCalls ?? 0) + summary.runtimeDrawCalls,
previousBytes: Number(report.totals?.previousBytes ?? 0) + summary.previousBytes,
runtimeBytes: Number(report.totals?.runtimeBytes ?? 0) + summary.runtimeBytes,
removedClips: Number(report.totals?.removedClips ?? 0) + summary.removedClips,
savedDrawCalls: Number(report.totals?.savedDrawCalls ?? 0) + summary.savedDrawCalls,
savedBytes: Number(report.totals?.savedBytes ?? 0) + summary.savedBytes,
};
await writeJson(reportFile, report);
}
console.log(JSON.stringify(summary, null, 2));