307 lines
12 KiB
JavaScript
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));
|
|
|