321 lines
12 KiB
JavaScript
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}`);
|
|
}
|