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

392 lines
14 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 shippingRoot = path.join(projectRoot, "public", "assets", "game", "dungeons");
const defaultSourceRoot = path.resolve(
process.env.DUNGEON_PIPELINE_WORK
?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"),
);
const registryFile = path.join(
projectRoot,
"src",
"game",
"generated",
"fivePlayerDungeonImports.json",
);
const reportFile = path.join(
projectRoot,
"dungeon-pipeline",
"reports",
"runtime-creature-animation-optimization.json",
);
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const slugArgument = args.indexOf("--slug");
const onlySlug = slugArgument >= 0 ? args[slugArgument + 1] : null;
const limitArgument = args.indexOf("--limit");
const limit = limitArgument >= 0 ? Number.parseInt(args[limitArgument + 1], 10) : null;
const sourceArgument = args.indexOf("--source-root");
const sourceRoot = sourceArgument >= 0
? path.resolve(args[sourceArgument + 1])
: defaultSourceRoot;
if (slugArgument >= 0 && !onlySlug) throw new Error("--slug requires a dungeon slug.");
if (limitArgument >= 0 && (!Number.isInteger(limit) || limit <= 0)) {
throw new Error("--limit requires a positive integer.");
}
if (sourceArgument >= 0 && !args[sourceArgument + 1]) {
throw new Error("--source-root requires a directory.");
}
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
async function writeJson(file, value) {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function sha256(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
async function listTargets() {
const targets = [];
for (const dungeon of await readdir(shippingRoot, { withFileTypes: true })) {
if (!dungeon.isDirectory() || (onlySlug && dungeon.name !== onlySlug)) continue;
const directory = path.join(shippingRoot, dungeon.name, "creatures");
if (!await exists(directory)) continue;
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const match = entry.name.match(/^display-(\d+)-animated\.glb$/i);
if (!match) continue;
const displayId = Number.parseInt(match[1], 10);
targets.push({
slug: dungeon.name,
displayId,
output: path.join(directory, entry.name),
source: path.join(
sourceRoot,
dungeon.name,
"creature-source",
`display-${displayId}`,
`display-${displayId}.glb`,
),
url: `/assets/game/dungeons/${dungeon.name}/creatures/${entry.name}`,
});
}
}
targets.sort((left, right) => (
left.slug.localeCompare(right.slug) || left.displayId - right.displayId
));
return limit ? targets.slice(0, limit) : targets;
}
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 protectedNodes = new Set();
for (const skin of root.listSkins()) {
if (skin.getSkeleton()) protectedNodes.add(skin.getSkeleton());
for (const joint of skin.listJoints()) protectedNodes.add(joint);
}
for (const animation of root.listAnimations()) {
for (const channel of animation.listChannels()) {
if (channel.getTargetNode()) protectedNodes.add(channel.getTargetNode());
}
}
return [...protectedNodes].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 updateDescriptors(value, byUrl) {
if (Array.isArray(value)) {
for (const child of value) updateDescriptors(child, byUrl);
return;
}
if (!value || typeof value !== "object") return;
const optimized = typeof value.url === "string" ? byUrl.get(value.url) : null;
if (optimized) {
value.checksum = optimized.checksum;
value.byteLength = optimized.byteLength;
value.animationClips = optimized.runtimeClipCount;
value.sourceAnimationClips = optimized.sourceClipCount;
value.animationOptimization = {
policy: CREATURE_RUNTIME_ANIMATION_POLICY,
removedClips: optimized.removedClipCount,
};
value.meshOptimization = {
policy: CREATURE_MESH_CONSOLIDATION_POLICY,
sourceDrawCalls: optimized.sourceDrawCalls,
runtimeDrawCalls: optimized.runtimeDrawCalls,
savedDrawCalls: optimized.savedDrawCalls,
};
value.animations = optimized.semantics;
value.missingAnimationSemantics = missingAnimationSemantics(optimized.semantics);
}
for (const child of Object.values(value)) updateDescriptors(child, byUrl);
}
async function updateManifests(results) {
const byUrl = new Map(results.map((result) => [result.url, result]));
const slugs = [...new Set(results.map((result) => result.slug))].sort();
for (const slug of slugs) {
const manifestFile = path.join(shippingRoot, slug, "dungeon-pack.json");
if (!await exists(manifestFile)) continue;
const manifest = await readJson(manifestFile);
updateDescriptors(manifest, byUrl);
await writeJson(manifestFile, manifest);
}
const imports = [];
for (const entry of await readdir(shippingRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestFile = path.join(shippingRoot, entry.name, "dungeon-pack.json");
if (await exists(manifestFile)) imports.push(await readJson(manifestFile));
}
imports.sort((left, right) => left.slug.localeCompare(right.slug));
await writeJson(registryFile, {
schemaVersion: 1,
generatedAt: "deterministic",
imports,
});
}
async function replaceAtomically(output, temporary) {
const backup = `${output}.pre-runtime-animation`;
await rm(backup, { force: true });
await rename(output, backup);
try {
await rename(temporary, output);
await rm(backup, { force: true });
} catch (error) {
if (await exists(backup) && !await exists(output)) await rename(backup, output);
throw error;
}
}
await MeshoptDecoder.ready;
await MeshoptEncoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({
"meshopt.decoder": MeshoptDecoder,
"meshopt.encoder": MeshoptEncoder,
});
const targets = await listTargets();
if (!targets.length) throw new Error("No shipped dungeon creature GLBs matched the requested scope.");
const missingSources = [];
for (const target of targets) {
if (!await exists(target.source)) missingSources.push(target.source);
}
if (missingSources.length) {
throw new Error(
`Refusing to replace runtime assets: ${missingSources.length} complete source GLBs are missing.\n`
+ missingSources.slice(0, 20).join("\n"),
);
}
console.log(JSON.stringify({
mode: dryRun ? "dry-run" : "apply",
policies: {
animation: CREATURE_RUNTIME_ANIMATION_POLICY,
mesh: CREATURE_MESH_CONSOLIDATION_POLICY,
},
targets: targets.length,
sourceRoot,
}, null, 2));
const results = [];
for (let index = 0; index < targets.length; index += 1) {
const target = targets[index];
const before = await stat(target.output);
const document = await io.read(target.source);
document.setLogger(new Logger(Logger.Verbosity.WARN));
const optimization = await trimCreatureRuntimeAnimations(document);
if (!optimization.runtimeClipCount) {
throw new Error(`${target.slug} display ${target.displayId}: policy retained no animation clips.`);
}
const sourceScene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0];
if (!sourceScene) throw new Error(`${target.slug} display ${target.displayId}: GLB has no scene.`);
const sourceBounds = roundedBounds(getBounds(sourceScene));
const sourceProtectedNodes = protectedNodeInventory(document);
const sourceMaterials = materialInventory(document);
const meshOptimization = await consolidateCreatureSkinnedMeshes(document);
const temporary = `${target.output}.runtime-animation.tmp.glb`;
await rm(temporary, { force: true });
await io.write(temporary, document);
const verification = await io.read(temporary);
const verifiedNames = verification.getRoot().listAnimations().map((animation) => animation.getName());
if (JSON.stringify(verifiedNames) !== JSON.stringify(optimization.retainedNames)) {
await rm(temporary, { force: true });
throw new Error(`${target.slug} display ${target.displayId}: written animation inventory did not verify.`);
}
const verifiedScene = verification.getRoot().getDefaultScene() ?? verification.getRoot().listScenes()[0];
const verifiedBounds = roundedBounds(getBounds(verifiedScene));
if (JSON.stringify(sourceBounds) !== JSON.stringify(verifiedBounds)) {
await rm(temporary, { force: true });
throw new Error(`${target.slug} display ${target.displayId}: mesh consolidation changed bounds.`);
}
if (JSON.stringify(sourceProtectedNodes) !== JSON.stringify(protectedNodeInventory(verification))) {
await rm(temporary, { force: true });
throw new Error(`${target.slug} display ${target.displayId}: protected skeleton/animation nodes changed.`);
}
if (JSON.stringify(sourceMaterials) !== JSON.stringify(materialInventory(verification))) {
await rm(temporary, { force: true });
throw new Error(`${target.slug} display ${target.displayId}: material semantics changed.`);
}
const outputSize = (await stat(temporary)).size;
const checksum = await sha256(temporary);
if (!dryRun) {
await replaceAtomically(target.output, temporary);
} else await rm(temporary, { force: true });
const semantics = semanticCreatureAnimations(optimization.retainedNames);
results.push({
slug: target.slug,
displayId: target.displayId,
url: target.url,
sourceClipCount: optimization.sourceClipCount,
runtimeClipCount: optimization.runtimeClipCount,
removedClipCount: optimization.removedNames.length,
sourceDrawCalls: meshOptimization.before.drawCalls,
runtimeDrawCalls: meshOptimization.after.drawCalls,
savedDrawCalls: meshOptimization.before.drawCalls - meshOptimization.after.drawCalls,
meshGroups: meshOptimization.groups,
mergedMeshNodes: meshOptimization.mergedNodes,
previousByteLength: before.size,
byteLength: outputSize,
checksum,
semantics,
});
if ((index + 1) % 25 === 0 || index + 1 === targets.length) {
console.log(`Processed ${index + 1}/${targets.length} creature GLBs.`);
}
}
if (!dryRun) await updateManifests(results);
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 byDungeon = Object.values(results.reduce((summary, result) => {
summary[result.slug] ??= {
slug: result.slug,
models: 0,
sourceClips: 0,
runtimeClips: 0,
sourceDrawCalls: 0,
runtimeDrawCalls: 0,
previousBytes: 0,
runtimeBytes: 0,
};
const entry = summary[result.slug];
entry.models += 1;
entry.sourceClips += result.sourceClipCount;
entry.runtimeClips += result.runtimeClipCount;
entry.sourceDrawCalls += result.sourceDrawCalls;
entry.runtimeDrawCalls += result.runtimeDrawCalls;
entry.previousBytes += result.previousByteLength;
entry.runtimeBytes += result.byteLength;
return summary;
}, {}));
const report = {
schemaVersion: 2,
generatedAt: "deterministic",
mode: dryRun ? "dry-run" : "apply",
policies: {
animation: CREATURE_RUNTIME_ANIMATION_POLICY,
mesh: CREATURE_MESH_CONSOLIDATION_POLICY,
},
totals: {
models: results.length,
...totals,
removedClips: totals.sourceClips - totals.runtimeClips,
savedDrawCalls: totals.sourceDrawCalls - totals.runtimeDrawCalls,
savedBytes: totals.previousBytes - totals.runtimeBytes,
},
dungeons: byDungeon,
models: results,
};
if (!dryRun) await writeJson(reportFile, report);
console.log(JSON.stringify(report.totals, null, 2));
if (!dryRun) console.log(`Wrote ${path.relative(projectRoot, reportFile).replace(/\\/g, "/")}.`);