Files
healer-man/scripts/dungeon-pipeline/shadowfang-fallback-preprocess.mjs
2026-10-06 22:25:34 -04:00

183 lines
8.5 KiB
JavaScript

import { createHash } from "node:crypto";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { simplifyPrimitive, weldPrimitive } from "@gltf-transform/functions";
import { MeshoptDecoder, MeshoptSimplifier } from "meshoptimizer";
import { nodeInstanceTransforms } from "../runewaker-pipeline/audit-environment-quality.mjs";
import { loadRecipe, projectRoot, workRoot } from "./lib.mjs";
const PINNED_SHADOWFANG_SOURCE_SHA256 = "d78aaa98b1367cc0dda4b7fce81f143b0753a96917cb2aff5f530fb9ef513c74";
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function hashAccessor(accessor) {
const array = accessor?.getArray();
if (!array) return null;
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
}
function primitiveTriangles(primitive) {
const count = primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0;
if (primitive.getMode() !== 4 || count % 3 !== 0) return 0;
return count / 3;
}
function sourceHashSnapshot(document) {
const hashes = new Map();
for (const [meshIndex, mesh] of document.getRoot().listMeshes().entries()) {
for (const [primitiveIndex, primitive] of mesh.listPrimitives().entries()) {
if (!primitive.getMaterial()?.getBaseColorTexture()) continue;
const key = `${meshIndex}:${primitiveIndex}:${primitive.getMaterial()?.getName() ?? ""}`;
const attributes = Object.fromEntries(primitive.listSemantics()
.sort()
.map((semantic) => [semantic, hashAccessor(primitive.getAttribute(semantic))]));
hashes.set(key, { attributes, indices: hashAccessor(primitive.getIndices()) });
}
}
return hashes;
}
function textureHashSnapshot(document) {
return document.getRoot().listTextures().map((texture, index) => ({
index,
name: texture.getName() ?? "",
hash: texture.getImage() ? sha256(texture.getImage()) : null,
}));
}
function meshInstanceCounts(document) {
const counts = new Map(document.getRoot().listMeshes().map((mesh) => [mesh, 0]));
const visit = (node) => {
const mesh = node.getMesh();
if (mesh) counts.set(mesh, (counts.get(mesh) ?? 0) + nodeInstanceTransforms(node).length);
for (const child of node.listChildren()) visit(child);
};
for (const scene of document.getRoot().listScenes()) {
for (const node of scene.listChildren()) visit(node);
}
return counts;
}
export function eligibleFallbackMaterial(material, profile) {
if (!profile?.materialNamePattern) return null;
const match = material?.getName()?.match(new RegExp(profile.materialNamePattern, "u"));
if (!match || material.getBaseColorTexture()) return null;
const category = `ADT_${match[1]}`;
return profile.targetExpandedTriangles?.[category] ? category : null;
}
export function simplifyEligibleFallbackPrimitive(primitive, profile) {
const category = eligibleFallbackMaterial(primitive.getMaterial(), profile);
if (!category || primitive.getMode() !== 4 || primitiveTriangles(primitive) === 0) return false;
const target = profile.targetExpandedTriangles[category];
const source = profile.sourceExpandedTriangles?.[category];
if (!Number.isFinite(source) || source <= 0 || target <= 0 || target > source) {
throw new Error(`${category} needs a valid source and target triangle count in the recipe.`);
}
weldPrimitive(primitive, { overwrite: false });
simplifyPrimitive(primitive, {
simplifier: MeshoptSimplifier,
ratio: target / source,
error: profile.simplifyError,
lockBorder: Boolean(profile.lockBorder),
});
return true;
}
export async function preprocessShadowfangVisual(sourceFile, outputFile, { simplifyError } = {}) {
const canonicalSource = path.join(workRoot, "shadowfang-keep/staging/environment/shadowfang-keep-visual.glb");
if (path.resolve(sourceFile) !== path.resolve(canonicalSource)) {
throw new Error("Selective fallback preprocessing only accepts the canonical Shadowfang source GLB.");
}
const recipe = await loadRecipe("shadowfang-keep");
const profile = recipe.visualOptimization?.selectiveFallbackSimplification;
if (!profile || profile.sourceSha256 !== PINNED_SHADOWFANG_SOURCE_SHA256) {
throw new Error("Shadowfang recipe must opt in with the pinned selective fallback source checksum.");
}
const sourceBytes = await readFile(canonicalSource);
const sourceChecksum = sha256(sourceBytes);
if (sourceChecksum !== profile.sourceSha256) {
throw new Error(`Pinned Shadowfang source checksum changed: expected ${profile.sourceSha256}, got ${sourceChecksum}.`);
}
const effectiveError = simplifyError ?? profile.simplifyError;
if (!Number.isFinite(effectiveError) || effectiveError < 0) {
throw new Error("Selective fallback simplification error must be a non-negative number.");
}
const effectiveProfile = { ...profile, simplifyError: effectiveError };
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = await io.read(canonicalSource);
const texturedBefore = sourceHashSnapshot(document);
const sourceTextures = textureHashSnapshot(document);
const instanceCounts = meshInstanceCounts(document);
const selected = Object.fromEntries(Object.keys(profile.targetExpandedTriangles).map((name) => [name, {
primitiveCount: 0,
trianglesBefore: 0,
trianglesAfter: 0,
expandedTrianglesBefore: 0,
expandedTrianglesAfter: 0,
}]));
await MeshoptSimplifier.ready;
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const category = eligibleFallbackMaterial(primitive.getMaterial(), effectiveProfile);
const counters = selected[category];
if (!category || !counters) continue;
const trianglesBefore = primitiveTriangles(primitive);
if (trianglesBefore === 0) continue;
const instances = instanceCounts.get(mesh) ?? 0;
simplifyEligibleFallbackPrimitive(primitive, effectiveProfile);
const trianglesAfter = primitiveTriangles(primitive);
counters.primitiveCount += 1;
counters.trianglesBefore += trianglesBefore;
counters.trianglesAfter += trianglesAfter;
counters.expandedTrianglesBefore += trianglesBefore * instances;
counters.expandedTrianglesAfter += trianglesAfter * instances;
}
}
const selectedPrimitiveCount = Object.values(selected).reduce((sum, row) => sum + row.primitiveCount, 0);
if (selectedPrimitiveCount === 0) throw new Error("Pinned Shadowfang source has no eligible fallback primitives.");
const expandedFallbackTriangles = Object.values(selected)
.reduce((sum, row) => sum + row.expandedTrianglesAfter, 0);
const overBudget = Object.entries(selected)
.filter(([material, row]) => row.expandedTrianglesAfter > profile.perMaterialMaximumExpandedTriangles?.[material])
.map(([material, row]) => `${material} ${row.expandedTrianglesAfter} > ${profile.perMaterialMaximumExpandedTriangles?.[material]}`);
if (expandedFallbackTriangles > profile.maximumExpandedTriangles || overBudget.length) {
throw new Error(`Selective Shadowfang fallback geometry exceeds its enforced triangle budget (${expandedFallbackTriangles} > ${profile.maximumExpandedTriangles}; ${overBudget.join(", ")}).`);
}
if (JSON.stringify([...sourceHashSnapshot(document)]) !== JSON.stringify([...texturedBefore])) {
throw new Error("Selective simplification changed a textured primitive accessor hash.");
}
if (JSON.stringify(textureHashSnapshot(document)) !== JSON.stringify(sourceTextures)) {
throw new Error("Selective simplification changed source texture bytes.");
}
await mkdir(path.dirname(outputFile), { recursive: true });
await io.write(outputFile, document);
return {
source: { file: path.relative(projectRoot, canonicalSource).replaceAll("\\", "/"), sha256: sourceChecksum, byteLength: sourceBytes.length },
preprocessedFile: outputFile,
profile: {
materialNamePattern: profile.materialNamePattern,
sourceExpandedTriangles: profile.sourceExpandedTriangles,
targetExpandedTriangles: profile.targetExpandedTriangles,
maximumExpandedTriangles: profile.maximumExpandedTriangles,
perMaterialMaximumExpandedTriangles: profile.perMaterialMaximumExpandedTriangles,
expandedFallbackTriangles,
simplifyError: effectiveError,
lockBorder: Boolean(profile.lockBorder),
},
selectedByMaterial: selected,
texturedPrimitiveAccessorHashesUnchanged: true,
sourceTextureBytesUnchanged: true,
};
}