forked from phenom/healer-man
325 lines
13 KiB
JavaScript
325 lines
13 KiB
JavaScript
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,
|
||
},
|
||
};
|
||
}
|