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() || ""}.`); } 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}`); }