Files
healer-man/scripts/dungeon-pipeline/creature-mesh-consolidation.mjs
T
2026-08-14 15:56:39 -04:00

246 lines
8.3 KiB
JavaScript

import { PropertyType } from "@gltf-transform/core";
import { prune } from "@gltf-transform/functions";
export const CREATURE_MESH_CONSOLIDATION_POLICY = "wow-creature-material-join-v1";
function stableJson(value) {
return JSON.stringify(value, Object.keys(value ?? {}).sort());
}
function isEmptyObject(value) {
return !value || Object.keys(value).length === 0;
}
function propertyIndexMap(properties) {
return new Map(properties.map((property, index) => [property, index]));
}
function parentOf(root, node) {
const parentNode = node.getParentNode();
if (parentNode) return parentNode;
return root.listScenes().find((scene) => scene.listChildren().includes(node)) ?? null;
}
function vectorKey(values) {
return values.map((value) => Object.is(value, -0) ? 0 : value).join(",");
}
function hasCustomExtensions(property) {
return typeof property.listExtensions === "function" && property.listExtensions().length > 0;
}
function indexArrayType(maxIndex) {
if (maxIndex <= 0xff) return Uint8Array;
if (maxIndex <= 0xffff) return Uint16Array;
return Uint32Array;
}
function primitiveCount(root) {
return root.listMeshes().reduce((total, mesh) => total + mesh.listPrimitives().length, 0);
}
function triangleCount(root) {
return root.listMeshes().reduce((meshTotal, mesh) => (
meshTotal + mesh.listPrimitives().reduce((primitiveTotal, primitive) => {
const indices = primitive.getIndices();
const vertices = primitive.getAttribute("POSITION");
const count = indices?.getCount() ?? vertices?.getCount() ?? 0;
return primitiveTotal + (primitive.getMode() === 4 ? Math.floor(count / 3) : 0);
}, 0)
), 0);
}
/**
* Consolidates sibling skinned render nodes that differ only by material/index
* range. WoW character exports commonly emit one node per geoset while all
* geosets share the same vertex attributes and skin. Concatenating those index
* ranges is lossless and changes N draw calls into one draw call per material.
*
* The transform intentionally skips animated nodes, joints, morph targets,
* nodes with children/custom metadata, and primitives with extensions/custom
* metadata. Skeleton and animation node names are therefore never rewritten.
*/
export async function consolidateCreatureSkinnedMeshes(document) {
const root = document.getRoot();
const nodes = root.listNodes();
const accessors = root.listAccessors();
const materials = root.listMaterials();
const skins = root.listSkins();
const scenes = root.listScenes();
const nodeIndices = propertyIndexMap(nodes);
const accessorIndices = propertyIndexMap(accessors);
const materialIndices = propertyIndexMap(materials);
const skinIndices = propertyIndexMap(skins);
const parentIndices = propertyIndexMap([...scenes, ...nodes]);
const animatedNodes = new Set(root.listAnimations().flatMap((animation) => (
animation.listChannels().map((channel) => channel.getTargetNode()).filter(Boolean)
)));
const jointNodes = new Set(skins.flatMap((skin) => [skin.getSkeleton(), ...skin.listJoints()]).filter(Boolean));
const before = {
drawCalls: primitiveCount(root),
triangles: triangleCount(root),
meshNodes: nodes.filter((node) => node.getMesh()).length,
};
const candidateGroups = new Map();
let skippedNodes = 0;
for (const node of nodes) {
const mesh = node.getMesh();
const skin = node.getSkin();
const parent = parentOf(root, node);
const primitives = mesh?.listPrimitives() ?? [];
if (
!mesh
|| !skin
|| !parent
|| primitives.length !== 1
|| node.listChildren().length > 0
|| animatedNodes.has(node)
|| jointNodes.has(node)
|| !isEmptyObject(node.getExtras())
|| !isEmptyObject(mesh.getExtras())
|| hasCustomExtensions(node)
|| hasCustomExtensions(mesh)
) {
if (mesh) skippedNodes += 1;
continue;
}
const primitive = primitives[0];
const indices = primitive.getIndices();
const position = primitive.getAttribute("POSITION");
if (
!indices
|| !indices.getArray()
|| !position
|| primitive.listTargets().length > 0
|| !isEmptyObject(primitive.getExtras())
|| hasCustomExtensions(primitive)
|| hasCustomExtensions(indices)
) {
skippedNodes += 1;
continue;
}
const semantics = primitive.listSemantics().slice().sort();
const compatibilityKey = [
parentIndices.get(parent),
skinIndices.get(skin),
vectorKey(node.getTranslation()),
vectorKey(node.getRotation()),
vectorKey(node.getScale()),
vectorKey(node.getWeights()),
vectorKey(mesh.getWeights()),
primitive.getMode(),
materialIndices.get(primitive.getMaterial()) ?? -1,
...semantics.map((semantic) => `${semantic}:${accessorIndices.get(primitive.getAttribute(semantic))}`),
stableJson(primitive.getExtras()),
].join("|");
const group = candidateGroups.get(compatibilityKey) ?? [];
group.push({ node, mesh, skin, parent, primitive, indices, semantics });
candidateGroups.set(compatibilityKey, group);
}
let groups = 0;
let sourceDrawCalls = 0;
let runtimeDrawCalls = 0;
let mergedNodes = 0;
let mergedIndexCount = 0;
for (const group of candidateGroups.values()) {
if (group.length < 2) continue;
const representative = group[0];
const arrays = group.map(({ indices }) => indices.getArray());
const total = arrays.reduce((sum, array) => sum + array.length, 0);
let maximum = 0;
for (const array of arrays) {
for (let index = 0; index < array.length; index += 1) maximum = Math.max(maximum, array[index]);
}
const IndexArray = indexArrayType(maximum);
const combined = new IndexArray(total);
let offset = 0;
for (const array of arrays) {
combined.set(array, offset);
offset += array.length;
}
const suffix = String(groups + 1).padStart(2, "0");
const parentName = representative.parent.getName() || "creature";
const mergedPrimitive = document.createPrimitive()
.setMode(representative.primitive.getMode())
.setMaterial(representative.primitive.getMaterial())
.setIndices(document.createAccessor(
`${parentName}_merged_indices_${suffix}`,
representative.indices.getBuffer(),
).setType("SCALAR").setArray(combined));
for (const semantic of representative.semantics) {
mergedPrimitive.setAttribute(semantic, representative.primitive.getAttribute(semantic));
}
const mergedMesh = document.createMesh(`${parentName}_MergedMaterial_${suffix}`)
.setWeights(representative.mesh.getWeights())
.addPrimitive(mergedPrimitive);
const sourceNames = group.map(({ node }) => node.getName()).filter(Boolean);
const mergedNode = document.createNode(`${parentName}_MergedSkinned_${suffix}`)
.setTranslation(representative.node.getTranslation())
.setRotation(representative.node.getRotation())
.setScale(representative.node.getScale())
.setWeights(representative.node.getWeights())
.setMesh(mergedMesh)
.setSkin(representative.skin)
.setExtras({
healerMan: {
meshConsolidationPolicy: CREATURE_MESH_CONSOLIDATION_POLICY,
sourceNodes: sourceNames,
},
});
representative.parent.addChild(mergedNode);
for (const { node } of group) node.dispose();
groups += 1;
sourceDrawCalls += group.length;
runtimeDrawCalls += 1;
mergedNodes += group.length;
mergedIndexCount += total;
}
if (groups) {
await document.transform(prune({
propertyTypes: [
PropertyType.ANIMATION_CHANNEL,
PropertyType.ANIMATION_SAMPLER,
PropertyType.ACCESSOR,
PropertyType.MESH,
PropertyType.PRIMITIVE,
],
keepAttributes: true,
keepExtras: true,
keepLeaves: true,
keepSolidTextures: true,
}));
}
const after = {
drawCalls: primitiveCount(root),
triangles: triangleCount(root),
meshNodes: root.listNodes().filter((node) => node.getMesh()).length,
};
if (before.triangles !== after.triangles) {
throw new Error(
`Creature mesh consolidation changed triangle count (${before.triangles} -> ${after.triangles}).`,
);
}
return {
policy: CREATURE_MESH_CONSOLIDATION_POLICY,
groups,
mergedNodes,
mergedIndexCount,
skippedNodes,
sourceDrawCalls,
runtimeDrawCalls,
savedDrawCalls: sourceDrawCalls - runtimeDrawCalls,
before,
after,
};
}