199 lines
9.7 KiB
JavaScript
199 lines
9.7 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
|
import { Matrix4, Vector3 } from "three";
|
|
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
|
|
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work");
|
|
const slug = process.argv[2] ?? "aeternal-circle";
|
|
const stageDungeon = path.join(workRoot, "source-anchor-support-correction-staged-v2", slug);
|
|
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
|
|
|
|
function includePoint(bounds, point) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]);
|
|
bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]);
|
|
}
|
|
}
|
|
|
|
function newBounds() {
|
|
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
|
|
}
|
|
|
|
function summarizeCollision(document) {
|
|
const global = newBounds();
|
|
const nativeBounds = newBounds();
|
|
let totalTriangles = 0;
|
|
let totalVertices = 0;
|
|
let degenerateTriangles = 0;
|
|
let invalidIndices = 0;
|
|
let nonFiniteCoordinates = 0;
|
|
const meshes = [];
|
|
const padCenters = [];
|
|
const edgeAb = new Vector3();
|
|
const edgeAc = new Vector3();
|
|
const normal = new Vector3();
|
|
|
|
for (const node of document.getRoot().listNodes()) {
|
|
if (!node.getMesh()) continue;
|
|
const support = node.getExtras()?.sourceChunk === "runewaker_source_anchor_support";
|
|
const nodeMatrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
|
|
let nodeTriangles = 0;
|
|
let nodeVertices = 0;
|
|
let nodeDegenerates = 0;
|
|
const nodeBounds = newBounds();
|
|
for (const primitive of node.getMesh().listPrimitives()) {
|
|
const position = primitive.getAttribute("POSITION");
|
|
const indices = primitive.getIndices();
|
|
if (!position) continue;
|
|
const positions = position.getArray();
|
|
const elements = indices?.getArray() ?? Array.from({ length: position.getCount() }, (_, index) => index);
|
|
nodeVertices += position.getCount();
|
|
totalVertices += position.getCount();
|
|
nodeTriangles += elements.length / 3;
|
|
totalTriangles += elements.length / 3;
|
|
for (let vertex = 0; vertex < position.getCount(); vertex += 1) {
|
|
const point = new Vector3().fromArray(positions, vertex * 3).applyMatrix4(nodeMatrix).toArray();
|
|
if (!point.every(Number.isFinite)) {
|
|
nonFiniteCoordinates += 1;
|
|
continue;
|
|
}
|
|
includePoint(global, point);
|
|
includePoint(nodeBounds, point);
|
|
if (!support) includePoint(nativeBounds, point);
|
|
}
|
|
for (let element = 0; element < elements.length; element += 3) {
|
|
const triangle = [elements[element], elements[element + 1], elements[element + 2]];
|
|
if (triangle.some((index) => !Number.isInteger(index) || index < 0 || index >= position.getCount())) {
|
|
invalidIndices += 1;
|
|
continue;
|
|
}
|
|
const a = new Vector3().fromArray(positions, triangle[0] * 3).applyMatrix4(nodeMatrix);
|
|
const b = new Vector3().fromArray(positions, triangle[1] * 3).applyMatrix4(nodeMatrix);
|
|
const c = new Vector3().fromArray(positions, triangle[2] * 3).applyMatrix4(nodeMatrix);
|
|
if (normal.crossVectors(edgeAb.subVectors(b, a), edgeAc.subVectors(c, a)).lengthSq() <= 1e-16) {
|
|
degenerateTriangles += 1;
|
|
nodeDegenerates += 1;
|
|
}
|
|
}
|
|
}
|
|
if (support) {
|
|
const primitive = node.getMesh().listPrimitives()[0];
|
|
const position = primitive.getAttribute("POSITION");
|
|
const values = position.getArray();
|
|
for (let offset = 0, pointIndex = 0; offset < position.getCount(); offset += 8, pointIndex += 1) {
|
|
const top = [];
|
|
for (let vertex = 0; vertex < 4; vertex += 1) {
|
|
top.push(new Vector3().fromArray(values, (offset + vertex) * 3).applyMatrix4(nodeMatrix).toArray());
|
|
}
|
|
const center = [0, 1, 2].map((axis) => top.reduce((sum, point) => sum + point[axis], 0) / top.length);
|
|
padCenters.push({ pointIndex, center });
|
|
}
|
|
}
|
|
meshes.push({
|
|
node: node.getName(),
|
|
sourceChunk: node.getExtras()?.sourceChunk ?? null,
|
|
generatedSupport: support,
|
|
vertices: nodeVertices,
|
|
triangles: nodeTriangles,
|
|
degenerateTriangles: nodeDegenerates,
|
|
bounds: nodeBounds,
|
|
spans: nodeBounds.minimum.map((value, axis) => nodeBounds.maximum[axis] - value),
|
|
});
|
|
}
|
|
|
|
return {
|
|
totals: { vertices: totalVertices, triangles: totalTriangles, degenerateTriangles, invalidIndices, nonFiniteCoordinates },
|
|
bounds: global,
|
|
nativeOnlyBounds: nativeBounds,
|
|
supportPadCount: padCenters.length,
|
|
supportCenters: padCenters,
|
|
largestMeshes: meshes.slice().sort((left, right) => right.triangles - left.triangles).slice(0, 12),
|
|
widestMeshes: meshes.slice().sort((left, right) => Math.max(...right.spans) - Math.max(...left.spans)).slice(0, 12),
|
|
meshes,
|
|
};
|
|
}
|
|
|
|
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`), "utf8"));
|
|
const stageReport = JSON.parse(await readFile(path.join(stageDungeon, "staging-report.json"), "utf8"));
|
|
const originalFile = path.join(workRoot, slug, "converted", stageReport.staged.collision[0].source);
|
|
const correctedFile = path.join(stageDungeon, stageReport.staged.collision[0].staged);
|
|
const [original, corrected] = await Promise.all([io.read(originalFile), io.read(correctedFile)]);
|
|
const originalSummary = summarizeCollision(original);
|
|
const correctedSummary = summarizeCollision(corrected);
|
|
const oldReport = JSON.parse(await readFile(path.join(workRoot, slug, "converted/recast-report.json"), "utf8"));
|
|
const sourceBounds = oldReport.input.bounds;
|
|
const sourceGrid = oldReport.build;
|
|
const cellSize = Number(recipe.navigation.cellSize);
|
|
const correctedWidth = Math.ceil((correctedSummary.bounds.maximum[0] - correctedSummary.bounds.minimum[0]) / cellSize);
|
|
const correctedHeight = Math.ceil((correctedSummary.bounds.maximum[2] - correctedSummary.bounds.minimum[2]) / cellSize);
|
|
const pointGeometry = sourceAnchorSupportGeometry(recipe.source.sourceAnchorSupport.points, stageReport.source.primaryPlacement, {
|
|
halfExtent: Number(stageReport.source.halfExtentSourceUnits),
|
|
thickness: Number(stageReport.source.thicknessSourceUnits),
|
|
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
|
|
omitPointIndices: stageReport.source.omittedNativeGroundedPointIndices,
|
|
});
|
|
const expectedPads = newBounds();
|
|
for (let offset = 0; offset < pointGeometry.positions.length; offset += 3) {
|
|
includePoint(expectedPads, pointGeometry.positions.slice(offset, offset + 3));
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
slug,
|
|
source: {
|
|
collisionBeforeCorrection: relative(originalFile),
|
|
collisionBeforeCorrectionSha256: stageReport.source.originalCollisionFiles[0].sha256,
|
|
correctedCollision: relative(correctedFile),
|
|
correctedCollisionSha256: sha256(await readFile(correctedFile)),
|
|
oldRecastInputBounds: sourceBounds,
|
|
oldRecastGrid: sourceGrid,
|
|
collisionTrianglesInOldRecastReport: oldReport.input.triangleCount,
|
|
recipeCellSize: cellSize,
|
|
expectedRetainedSupportBounds: expectedPads,
|
|
expectedRetainedSupportTriangles: pointGeometry.indices.length / 3,
|
|
expectedSupportNavigationTriangles: pointGeometry.navigationPositions.length / 9,
|
|
},
|
|
original: originalSummary,
|
|
corrected: correctedSummary,
|
|
diff: {
|
|
triangleDelta: correctedSummary.totals.triangles - originalSummary.totals.triangles,
|
|
nativeBoundsEqual: JSON.stringify(originalSummary.nativeOnlyBounds) === JSON.stringify(correctedSummary.nativeOnlyBounds),
|
|
correctedBounds: correctedSummary.bounds,
|
|
nativeOnlyBounds: correctedSummary.nativeOnlyBounds,
|
|
correctedGridWidthCells: correctedWidth,
|
|
correctedGridDepthCells: correctedHeight,
|
|
correctedGridCellCount: correctedWidth * correctedHeight,
|
|
tilesAtRecipe64: Math.ceil(correctedWidth / 64) * Math.ceil(correctedHeight / 64),
|
|
tilesAtDerived512: Math.ceil(correctedWidth / 512) * Math.ceil(correctedHeight / 512),
|
|
tilesAtDerived1024: Math.ceil(correctedWidth / 1024) * Math.ceil(correctedHeight / 1024),
|
|
allCollisionCoordinatesFinite: correctedSummary.totals.nonFiniteCoordinates === 0,
|
|
allIndicesValid: correctedSummary.totals.invalidIndices === 0,
|
|
},
|
|
conclusion: {
|
|
explanation: "Compare global/native-only bounds and mesh scales with old Recast report; unchanged native bounds mean support correction did not create a new world-space envelope. The all-dungeon rebake cost is therefore in rebuilding the broad provisional primary-ROS collision mesh, which copies decorative renderables, rather than the 1,812 support triangles.",
|
|
sourceAnchorPoint0Omitted: stageReport.source.omittedNativeGroundedPointIndices.includes(0),
|
|
},
|
|
};
|
|
const output = path.join(stageDungeon, "nav-rebake-diagnostic.json");
|
|
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
console.log(JSON.stringify({
|
|
report: relative(output),
|
|
original: originalSummary.totals,
|
|
corrected: correctedSummary.totals,
|
|
nativeBoundsEqual: report.diff.nativeBoundsEqual,
|
|
fullBounds: correctedSummary.bounds,
|
|
nativeOnlyBounds: correctedSummary.nativeOnlyBounds,
|
|
gridCells: report.diff.correctedGridCellCount,
|
|
tilesAtRecipe64: report.diff.tilesAtRecipe64,
|
|
tilesAtDerived512: report.diff.tilesAtDerived512,
|
|
largestMeshes: correctedSummary.largestMeshes,
|
|
widestMeshes: correctedSummary.widestMeshes,
|
|
}, null, 2));
|