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, }; }