392 lines
14 KiB
JavaScript
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, "/")}.`);
|