183 lines
8.5 KiB
JavaScript
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,
|
|
};
|
|
}
|