Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,324 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user