#!/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));