Files
2026-10-06 22:25:34 -04:00

325 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Matrix3, Matrix4, Vector3 } from "three";
import { descriptorSourceMatrix } from "../lib/environment-assembly.mjs";
const NORMAL_EPSILON = 1e-12;
export function sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit) {
const meters = Number(metersPerSourceUnit);
if (!Number.isFinite(meters) || meters <= 0) {
throw new Error("metersPerSourceUnit must be a positive finite number.");
}
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
return sourceToThree.multiply(descriptorSourceMatrix(primaryPlacement).invert());
}
function checkArrayLength(array, expectedLength, label) {
if (!ArrayBuffer.isView(array) || array.length !== expectedLength) {
throw new Error(`${label} has ${array?.length ?? "no"} values; expected ${expectedLength}.`);
}
}
function transformPosition(sourcePosition, matrix) {
const position = new Vector3().fromArray(sourcePosition).applyMatrix4(matrix);
if (![position.x, position.y, position.z].every(Number.isFinite)) {
throw new Error("Terrain position transform produced a non-finite coordinate.");
}
return position.toArray();
}
function transformNormal(sourceNormal, normalMatrix) {
const normal = new Vector3().fromArray(sourceNormal).applyMatrix3(normalMatrix);
const lengthSq = normal.lengthSq();
if (!Number.isFinite(lengthSq) || lengthSq < NORMAL_EPSILON) {
throw new Error("Terrain source contains an invalid native normal.");
}
return normal.normalize().toArray();
}
function triangleAreaSquared(positions, a, b, c) {
const ax = positions[a * 3];
const ay = positions[a * 3 + 1];
const az = positions[a * 3 + 2];
const abx = positions[b * 3] - ax;
const aby = positions[b * 3 + 1] - ay;
const abz = positions[b * 3 + 2] - az;
const acx = positions[c * 3] - ax;
const acy = positions[c * 3 + 1] - ay;
const acz = positions[c * 3 + 2] - az;
const nx = aby * acz - abz * acy;
const ny = abz * acx - abx * acz;
const nz = abx * acy - aby * acx;
return nx * nx + ny * ny + nz * nz;
}
function faceNormalDot(positions, normals, a, b, c) {
const ax = positions[a * 3];
const ay = positions[a * 3 + 1];
const az = positions[a * 3 + 2];
const abx = positions[b * 3] - ax;
const aby = positions[b * 3 + 1] - ay;
const abz = positions[b * 3 + 2] - az;
const acx = positions[c * 3] - ax;
const acy = positions[c * 3 + 1] - ay;
const acz = positions[c * 3 + 2] - az;
let nx = aby * acz - abz * acy;
let ny = abz * acx - abx * acz;
let nz = abx * acy - aby * acx;
const length = Math.hypot(nx, ny, nz);
if (length < NORMAL_EPSILON) return null;
nx /= length;
ny /= length;
nz /= length;
const sx = normals[a * 3] + normals[b * 3] + normals[c * 3];
const sy = normals[a * 3 + 1] + normals[b * 3 + 1] + normals[c * 3 + 1];
const sz = normals[a * 3 + 2] + normals[b * 3 + 2] + normals[c * 3 + 2];
const normalLength = Math.hypot(sx, sy, sz);
if (normalLength < NORMAL_EPSILON) return null;
return (nx * sx + ny * sy + nz * sz) / normalLength;
}
/**
* Converts the extracted native grid arrays without changing their vertex grid,
* texture coordinates, or source per-vertex lighting normals. Winding is reversed
* once because the Runewaker-to-Three bridge reflects Z.
*/
export function convertNativeTerrainChunks(chunks, sourceToThree, options = {}) {
if (!Array.isArray(chunks) || chunks.length === 0) {
throw new Error("Native terrain conversion requires at least one chunk.");
}
const positionMatrix = sourceToThree.clone();
const normalMatrix = new Matrix3().getNormalMatrix(positionMatrix);
const convertedChunks = [];
const perChunkSummaries = [];
const edgeVertices = new Map();
const seamPositionToleranceMeters = Number(options.seamPositionToleranceMeters ?? 0.02);
let vertexOffset = 0;
let sourceNormalLengthMin = Number.POSITIVE_INFINITY;
let sourceNormalLengthMax = 0;
let sourceNormalLengthSum = 0;
let sourceNormalLengthCount = 0;
let faceAlignedCount = 0;
let faceOpposedCount = 0;
let faceDegenerateCount = 0;
let sourceTriangleSlots = 0;
let visibleSourceTriangles = 0;
let maskedSourceTriangles = 0;
let collisionTriangles = 0;
let excludedCollisionTriangles = 0;
let maxSeamPositionDeltaMeters = 0;
let maxSeamNormalDelta = 0;
let seamNormalPairs = 0;
let seamNormalPairsWithinOneChannelStep = 0;
const dimensions = new Set(chunks.map((chunk) => Number(chunk.dimension)));
if (dimensions.size !== 1) throw new Error("All native terrain chunks must have the same grid dimension.");
const dimension = [...dimensions][0];
if (!Number.isInteger(dimension) || dimension < 1) throw new Error("Terrain chunk dimension is invalid.");
const side = dimension + 1;
const expectedVertices = side * side;
if (expectedVertices > 0x10000) {
throw new Error("A native terrain chunk exceeds its 16-bit source index contract.");
}
// Each source chunk uses 16-bit local indices. The combined GLB uses global
// offsets, which need 32 bits when more than fifteen 65×65 grids are present.
const IndexArray = expectedVertices * chunks.length > 0x10000 ? Uint32Array : Uint16Array;
for (const chunk of chunks) {
const chunkDimension = Number(chunk.dimension);
const vertexCount = (chunkDimension + 1) ** 2;
checkArrayLength(chunk.positions, vertexCount * 3, `${chunk.id} positions`);
checkArrayLength(chunk.uvs, vertexCount * 2, `${chunk.id} UVs`);
checkArrayLength(chunk.normals, vertexCount * 3, `${chunk.id} native normals`);
if (!Number.isInteger(Number(chunk.coordinate?.x)) || !Number.isInteger(Number(chunk.coordinate?.z))) {
throw new Error(`${chunk.id} requires integer chunk coordinates.`);
}
if (chunk.indices.length % 3 !== 0) throw new Error(`${chunk.id} indices are not triangular.`);
const triangleCount = chunk.indices.length / 3;
checkArrayLength(chunk.triangleVisibilityFlags, triangleCount, `${chunk.id} triangle visibility`);
const convertedPositions = new Float32Array(vertexCount * 3);
const convertedNormals = new Float32Array(vertexCount * 3);
const collisionIndices = [];
const visualIndices = new IndexArray(chunk.indices.length);
let chunkVisibleTriangles = 0;
let chunkMaskedTriangles = 0;
let chunkAlignedFaces = 0;
let chunkOpposedFaces = 0;
let chunkDegenerateFaces = 0;
for (let vertex = 0; vertex < vertexCount; vertex += 1) {
const positionOffset = vertex * 3;
const uvOffset = vertex * 2;
const sourcePosition = [
Number(chunk.positions[positionOffset]),
Number(chunk.positions[positionOffset + 1]),
Number(chunk.positions[positionOffset + 2]),
];
const sourceNormal = [
Number(chunk.normals[positionOffset]),
Number(chunk.normals[positionOffset + 1]),
Number(chunk.normals[positionOffset + 2]),
];
const outputPosition = transformPosition(sourcePosition, positionMatrix);
const outputNormal = transformNormal(sourceNormal, normalMatrix);
convertedPositions.set(outputPosition, positionOffset);
convertedNormals.set(outputNormal, positionOffset);
const sourceLength = Math.hypot(...sourceNormal);
sourceNormalLengthMin = Math.min(sourceNormalLengthMin, sourceLength);
sourceNormalLengthMax = Math.max(sourceNormalLengthMax, sourceLength);
sourceNormalLengthSum += sourceLength;
sourceNormalLengthCount += 1;
const localX = vertex % side;
const localZ = Math.floor(vertex / side);
const globalGridKey = `${Number(chunk.coordinate.x) * dimension + localX},${Number(chunk.coordinate.z) * dimension + localZ}`;
const previous = edgeVertices.get(globalGridKey);
if (previous) {
const dx = previous.position[0] - outputPosition[0];
const dy = previous.position[1] - outputPosition[1];
const dz = previous.position[2] - outputPosition[2];
const positionDelta = Math.hypot(dx, dy, dz);
maxSeamPositionDeltaMeters = Math.max(maxSeamPositionDeltaMeters, positionDelta);
if (positionDelta > seamPositionToleranceMeters) {
throw new Error(`${chunk.id} disagrees with a neighbor at terrain grid vertex ${globalGridKey} by ${positionDelta}m.`);
}
const normalDelta = Math.hypot(
previous.normal[0] - outputNormal[0],
previous.normal[1] - outputNormal[1],
previous.normal[2] - outputNormal[2],
);
seamNormalPairs += 1;
maxSeamNormalDelta = Math.max(maxSeamNormalDelta, normalDelta);
if (normalDelta <= (2 * Math.sqrt(3) / 255) + 1e-6) seamNormalPairsWithinOneChannelStep += 1;
} else {
edgeVertices.set(globalGridKey, { position: outputPosition, normal: outputNormal });
}
if (!Number.isFinite(chunk.uvs[uvOffset]) || !Number.isFinite(chunk.uvs[uvOffset + 1])) {
throw new Error(`${chunk.id} contains non-finite texture coordinates.`);
}
}
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
const offset = triangle * 3;
const a = Number(chunk.indices[offset]);
const b = Number(chunk.indices[offset + 1]);
const c = Number(chunk.indices[offset + 2]);
if ([a, b, c].some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
throw new Error(`${chunk.id} triangle ${triangle} has an invalid source vertex index.`);
}
const visible = Number(chunk.triangleVisibilityFlags[triangle]);
if (visible !== 0 && visible !== 1) throw new Error(`${chunk.id} triangle ${triangle} has invalid visibility flag ${visible}.`);
const outA = vertexOffset + a;
const outB = vertexOffset + b;
const outC = vertexOffset + c;
// Keep the original masked degenerate slots, including [0,0,0], in visual geometry.
visualIndices[offset] = outA;
visualIndices[offset + 1] = outC;
visualIndices[offset + 2] = outB;
sourceTriangleSlots += 1;
if (visible === 0) {
chunkMaskedTriangles += 1;
maskedSourceTriangles += 1;
excludedCollisionTriangles += 1;
continue;
}
chunkVisibleTriangles += 1;
visibleSourceTriangles += 1;
if (a === b || b === c || c === a
|| triangleAreaSquared(convertedPositions, a, b, c) < 1e-24) {
excludedCollisionTriangles += 1;
chunkDegenerateFaces += 1;
continue;
}
collisionIndices.push(outA, outC, outB);
collisionTriangles += 1;
const dot = faceNormalDot(convertedPositions, convertedNormals, a, c, b);
if (dot === null) {
faceDegenerateCount += 1;
chunkDegenerateFaces += 1;
} else if (dot >= 0) {
faceAlignedCount += 1;
chunkAlignedFaces += 1;
} else {
faceOpposedCount += 1;
chunkOpposedFaces += 1;
}
}
convertedChunks.push({
...chunk,
vertexOffset,
positions: convertedPositions,
normals: convertedNormals,
uvs: new Float32Array(chunk.uvs),
visualIndices,
collisionIndices: IndexArray.from(collisionIndices),
});
perChunkSummaries.push({
id: chunk.id,
coordinate: chunk.coordinate,
vertexCount,
sourceTriangleSlots: triangleCount,
visibleSourceTriangles: chunkVisibleTriangles,
maskedSourceTriangles: chunkMaskedTriangles,
collisionTriangles: collisionIndices.length / 3,
excludedCollisionTriangles: chunkMaskedTriangles + chunkDegenerateFaces,
sourceTriangleVisibilitySha256: chunk.triangleVisibilitySha256 ?? null,
sourceLayers: chunk.layers?.map((layer) => ({
layerId: layer.layerId,
tileScale: layer.tileScale,
sourceDDS: layer.sourceDDS,
sourceClientPath: layer.sourceClientPath,
})) ?? [],
sourceWeights: chunk.splatMaps?.map((weight) => ({
mapIndex: weight.mapIndex,
sourceDDS: weight.splatDDS,
linearPngFile: weight.linearPngFile,
channelLayerIds: weight.channelLayerIds,
})) ?? [],
nativeNormalWinding: {
alignedTriangles: chunkAlignedFaces,
opposedTriangles: chunkOpposedFaces,
degenerateTriangles: chunkDegenerateFaces,
},
});
vertexOffset += expectedVertices;
}
return {
chunks: convertedChunks,
vertexCount: vertexOffset,
report: {
chunkCount: chunks.length,
verticesPerChunk: expectedVertices,
sourceVertices: vertexOffset,
sourceTriangleSlots,
visibleSourceTriangles,
maskedSourceTriangles,
collisionTriangles,
excludedCollisionTriangles,
positionWelding: {
performed: false,
reason: "Native normal records can differ at chunk boundaries; retain independent source vertices and normals.",
sharedGridVertexPairs: seamNormalPairs,
seamPositionToleranceMeters,
maxSharedPositionDeltaMeters: maxSeamPositionDeltaMeters,
maxSharedNativeNormalDelta: maxSeamNormalDelta,
normalPairsWithinOneEightBitChannelStep: seamNormalPairsWithinOneChannelStep,
normalPairCount: seamNormalPairs,
},
sourceNativeNormals: {
minLength: sourceNormalLengthMin,
meanLength: sourceNormalLengthSum / Math.max(1, sourceNormalLengthCount),
maxLength: sourceNormalLengthMax,
transform: "inverse-transpose of primary descriptor TRS and reflected-Z meter bridge; normalized",
},
windingValidation: {
rule: "swap triangle indices 1 and 2 after reflected-Z coordinate bridge",
trianglesWithNativeNormalAgreement: faceAlignedCount,
trianglesAgainstNativeNormal: faceOpposedCount,
degenerateTriangles: faceDegenerateCount,
},
chunks: perChunkSummaries,
},
};
}