Files
healer-man/scripts/prepareAnimatedCreatures.mjs
2026-08-14 15:56:39 -04:00

321 lines
12 KiB
JavaScript

import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { prune } from "@gltf-transform/functions";
import {
CREATURE_RUNTIME_ANIMATION_POLICY,
trimCreatureRuntimeAnimations,
} from "./dungeon-pipeline/runtime-creature-animations.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectDirectory = path.resolve(scriptDirectory, "..");
const defaultAutomationResult = path.resolve(
projectDirectory,
"../wow335a/dungeon-export-work/WailingCaverns/creatures/animated-source/automation-result.json",
);
const defaultCompositeAutomationResult = path.resolve(
projectDirectory,
"../wow335a/dungeon-export-work/WailingCaverns/creatures/composite-animated-source/automation-result.json",
);
const defaultOutputDirectory = path.resolve(
projectDirectory,
"public/assets/creatures/wailing-caverns",
);
const automationResultPath = path.resolve(process.argv[2] ?? defaultAutomationResult);
const outputDirectory = path.resolve(process.argv[3] ?? defaultOutputDirectory);
const compositeAutomationResultPath = path.resolve(process.argv[4] ?? defaultCompositeAutomationResult);
const compositeResultWasExplicit = process.argv[4] !== undefined;
async function loadAutomationResult(resultPath, { optional = false } = {}) {
let source;
try {
source = JSON.parse(await readFile(resultPath, "utf8"));
} catch (error) {
if (optional && error?.code === "ENOENT") return null;
throw error;
}
if (!source.ok || source.animated !== true || source.staticGeometryOnly !== false) {
throw new Error(`Animated source export is not valid: ${resultPath}`);
}
if (!Array.isArray(source.creatures) || source.creatures.length === 0) {
throw new Error(`Animated source export contains no creatures: ${resultPath}`);
}
return { resultPath, source };
}
const automationResults = [
await loadAutomationResult(automationResultPath),
await loadAutomationResult(compositeAutomationResultPath, {
optional: !compositeResultWasExplicit,
}),
].filter(Boolean);
const creatures = [];
const creatureSourcesBySlug = new Map();
const TRIANGLES = 4;
const TRIANGLE_STRIP = 5;
const TRIANGLE_FAN = 6;
const NORMAL_LENGTH_EPSILON = 1e-6;
const NORMAL_UNIT_TOLERANCE = 1e-4;
function forEachPrimitiveTriangle(primitive, visit) {
const position = primitive.getAttribute("POSITION");
if (!position) return;
const indices = primitive.getIndices()?.getArray();
const vertexCount = indices?.length ?? position.getCount();
const vertexAt = (index) => indices?.[index] ?? index;
const mode = primitive.getMode();
if (mode === TRIANGLES) {
for (let index = 0; index + 2 < vertexCount; index += 3) {
visit(position, vertexAt(index), vertexAt(index + 1), vertexAt(index + 2));
}
} else if (mode === TRIANGLE_STRIP) {
for (let index = 2; index < vertexCount; index += 1) {
const even = index % 2 === 0;
visit(
position,
vertexAt(even ? index - 2 : index - 1),
vertexAt(even ? index - 1 : index - 2),
vertexAt(index),
);
}
} else if (mode === TRIANGLE_FAN) {
for (let index = 2; index < vertexCount; index += 1) {
visit(position, vertexAt(0), vertexAt(index - 1), vertexAt(index));
}
}
}
function visitFaceNormal(position, aIndex, bIndex, cIndex, visit) {
const a = position.getElement(aIndex, []);
const b = position.getElement(bIndex, []);
const c = position.getElement(cIndex, []);
const abX = b[0] - a[0];
const abY = b[1] - a[1];
const abZ = b[2] - a[2];
const acX = c[0] - a[0];
const acY = c[1] - a[1];
const acZ = c[2] - a[2];
const x = abY * acZ - abZ * acY;
const y = abZ * acX - abX * acZ;
const z = abX * acY - abY * acX;
const lengthSquared = x * x + y * y + z * z;
if (lengthSquared <= Number.EPSILON) return;
visit(aIndex, bIndex, cIndex, x, y, z, lengthSquared);
}
/**
* Repairs invalid source normals without rebuilding or unwelding geometry.
* Some legacy exports contain coincident triangles with opposite winding, so
* degenerate authored normals use an area-weighted adjacent-face fallback with
* face directions aligned before averaging.
*/
function repairInvalidNormals(root) {
const primitiveGroups = new Map();
for (const mesh of root.listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const normal = primitive.getAttribute("NORMAL");
if (!normal) continue;
const primitives = primitiveGroups.get(normal) ?? [];
primitives.push(primitive);
primitiveGroups.set(normal, primitives);
}
}
let repairedAccessors = 0;
let repairedVectors = 0;
for (const [normal, primitives] of primitiveGroups) {
const count = normal.getCount();
const current = [];
let requiresRepair = false;
for (let index = 0; index < count; index += 1) {
normal.getElement(index, current);
const length = Math.hypot(current[0], current[1], current[2]);
if (!Number.isFinite(length) || Math.abs(length - 1) > NORMAL_UNIT_TOLERANCE) {
requiresRepair = true;
break;
}
}
if (!requiresRepair) continue;
const reference = new Float64Array(count * 3);
const referenceAreaSquared = new Float64Array(count);
const accumulated = new Float64Array(count * 3);
for (const primitive of primitives) {
forEachPrimitiveTriangle(primitive, (position, a, b, c) => {
visitFaceNormal(position, a, b, c, (aIndex, bIndex, cIndex, x, y, z, areaSquared) => {
for (const vertexIndex of [aIndex, bIndex, cIndex]) {
if (areaSquared <= referenceAreaSquared[vertexIndex]) continue;
const offset = vertexIndex * 3;
referenceAreaSquared[vertexIndex] = areaSquared;
reference[offset] = x;
reference[offset + 1] = y;
reference[offset + 2] = z;
}
});
});
}
for (const primitive of primitives) {
forEachPrimitiveTriangle(primitive, (position, a, b, c) => {
visitFaceNormal(position, a, b, c, (aIndex, bIndex, cIndex, x, y, z) => {
for (const vertexIndex of [aIndex, bIndex, cIndex]) {
const offset = vertexIndex * 3;
const direction = x * reference[offset]
+ y * reference[offset + 1]
+ z * reference[offset + 2] < 0 ? -1 : 1;
accumulated[offset] += x * direction;
accumulated[offset + 1] += y * direction;
accumulated[offset + 2] += z * direction;
}
});
});
}
for (let index = 0; index < count; index += 1) {
const vector = normal.getElement(index, []);
const originalLength = Math.hypot(vector[0], vector[1], vector[2]);
if (Number.isFinite(originalLength) && originalLength >= NORMAL_LENGTH_EPSILON) {
normal.setElement(index, vector.map((component) => component / originalLength));
continue;
}
const offset = index * 3;
let x = accumulated[offset];
let y = accumulated[offset + 1];
let z = accumulated[offset + 2];
let fallbackLength = Math.hypot(x, y, z);
if (fallbackLength < NORMAL_LENGTH_EPSILON) {
x = reference[offset];
y = reference[offset + 1];
z = reference[offset + 2];
fallbackLength = Math.hypot(x, y, z);
}
if (fallbackLength < NORMAL_LENGTH_EPSILON) {
throw new Error(`Cannot reconstruct normal ${index} in accessor ${normal.getName() || "<unnamed>"}.`);
}
if (originalLength > 0 && x * vector[0] + y * vector[1] + z * vector[2] < 0) {
fallbackLength *= -1;
}
normal.setElement(index, [x / fallbackLength, y / fallbackLength, z / fallbackLength]);
repairedVectors += 1;
}
repairedAccessors += 1;
}
return { repairedAccessors, repairedVectors };
}
for (const automationResult of automationResults) {
for (const creature of automationResult.source.creatures) {
if (typeof creature?.slug !== "string" || creature.slug.length === 0) {
throw new Error(`Animated source export contains a creature without a slug: ${automationResult.resultPath}`);
}
const previousResultPath = creatureSourcesBySlug.get(creature.slug);
if (previousResultPath) {
throw new Error(
`Duplicate animated creature slug "${creature.slug}" in ${previousResultPath} and ${automationResult.resultPath}`,
);
}
creatureSourcesBySlug.set(creature.slug, automationResult.resultPath);
creatures.push(creature);
}
}
await mkdir(outputDirectory, { recursive: true });
const io = new NodeIO();
const prepared = [];
for (const creature of creatures) {
if (typeof creature.outputGLB !== "string" || creature.outputGLB.length === 0) {
throw new Error(`${creature.slug} does not specify an outputGLB.`);
}
const inputPath = path.resolve(creature.outputGLB);
const outputName = `${creature.slug}-animated.glb`;
const outputPath = path.join(outputDirectory, outputName);
const document = await io.read(inputPath);
const root = document.getRoot();
const animations = root.listAnimations();
const stand = animations.find((animation) => /^Stand \(ID 0 variation 0\)$/i.test(animation.getName()));
const walk = animations.find((animation) => /^Walk \(ID 4 variation 0\)$/i.test(animation.getName()));
const run = animations.find((animation) => /\(ID 5 variation \d+\)$/i.test(animation.getName()));
const death = animations.find((animation) => /\(ID 1 variation \d+\)$/i.test(animation.getName()));
const attack = animations.find((animation) => /\(ID (?:16|17|18|19|85|87|88|95|57|58|118) variation \d+\)$/i.test(animation.getName()));
if (!stand || !walk || !run || !death || !attack) {
throw new Error(`${creature.slug} is missing a native Stand, Walk, Run, Attack, or Death sequence.`);
}
if (root.listSkins().length === 0) {
throw new Error(`${creature.slug} does not contain a skin.`);
}
const normalRepair = repairInvalidNormals(root);
// The complete AnimationData library remains in the exporter source GLB.
// Ship only the families MobPopulation can request in the browser.
const animationOptimization = await trimCreatureRuntimeAnimations(document);
await document.transform(prune({ keepAttributes: true, keepSolidTextures: true }));
await io.write(outputPath, document);
const outputStats = await stat(outputPath);
prepared.push({
slug: creature.slug,
name: creature.name,
creatureEntry: creature.entry,
displayId: creature.displayId,
sourceModel: creature.modelPath,
url: `/assets/creatures/wailing-caverns/${outputName}`,
bytes: outputStats.size,
skins: root.listSkins().length,
animations: root.listAnimations().map((animation) => animation.getName()),
sourceAnimationClips: animationOptimization.sourceClipCount,
animationOptimization: {
policy: CREATURE_RUNTIME_ANIMATION_POLICY,
removedClips: animationOptimization.removedNames.length,
},
...(normalRepair.repairedAccessors > 0 ? { normalRepair } : {}),
});
}
const manifestPath = path.join(outputDirectory, "animated-manifest.json");
const manifestInputs = automationResults.map(({ resultPath, source }) => ({
generatedFrom: path.relative(projectDirectory, resultPath).replaceAll("\\", "/"),
source: source.source,
}));
await writeFile(
manifestPath,
`${JSON.stringify({
generatedFrom: manifestInputs[0].generatedFrom,
source: manifestInputs[0].source,
inputs: manifestInputs,
staticGeometryOnly: false,
creatures: prepared,
}, null, 2)}\n`,
"utf8",
);
console.log(`Prepared ${prepared.length} animated creature GLBs in ${outputDirectory}`);
for (const creature of prepared) {
const repairSummary = creature.normalRepair
? `, repaired ${creature.normalRepair.repairedVectors} invalid normals`
: "";
console.log(`${creature.slug}: ${(creature.bytes / 1024).toFixed(1)} KiB, ${creature.animations.join(", ")}${repairSummary}`);
}