Files
healer-man/scripts/runewaker-pipeline/lib/native-water-geometry.mjs
T
2026-10-06 22:25:34 -04:00

215 lines
10 KiB
JavaScript

import { Matrix4, Quaternion, Vector3 } from "three";
import { sourceTerrainToThreeMatrix } from "../sand-vortex/terrain-geometry.mjs";
const EVEN_FACES = [0, 3, 4, 0, 4, 1, 1, 4, 2, 2, 4, 5];
const ODD_FACES = [0, 3, 1, 1, 3, 4, 1, 5, 2, 1, 4, 5];
const NATIVE_UP = [0, 1, 0];
function checkedParcel(layer, parcel, label) {
if (!parcel || !ArrayBuffer.isView(parcel.values)) {
throw new Error(`${layer.waterBodyGuid} ${label} parcel has no decoded values.`);
}
const expected = parcel.width * parcel.height;
if (parcel.values.length !== expected) {
throw new Error(`${layer.waterBodyGuid} ${label} parcel has ${parcel.values.length} samples; expected ${expected}.`);
}
const sideX = parcel.width - parcel.overlap * 2;
const sideZ = parcel.height - parcel.overlap * 2;
if (sideX < 1 || sideZ < 1) throw new Error(`${layer.waterBodyGuid} ${label} parcel has no inner grid.`);
}
function layerSourceTransform(layer) {
const transform = layer.transform;
if (!transform) return new Matrix4();
const rotation = new Quaternion(...transform.rotation);
if (rotation.lengthSq() < 1e-12) throw new Error(`${layer.waterBodyGuid} has a zero rotation quaternion.`);
rotation.normalize();
const scale = new Vector3(...transform.scale);
const translation = new Vector3(...transform.translation);
if (![...scale.toArray(), ...translation.toArray()].every(Number.isFinite)
|| scale.x === 0 || scale.y === 0 || scale.z === 0) {
throw new Error(`${layer.waterBodyGuid} has an invalid entity transform.`);
}
return new Matrix4().compose(translation, rotation, scale);
}
function finitePosition(vector, label) {
if (![vector.x, vector.y, vector.z].every(Number.isFinite)) throw new Error(`${label} transformed to a non-finite position.`);
return vector;
}
function sourceColor(value, flag) {
const packed = Number(value) >>> 0;
const alphaByte = Math.trunc(((Number(flag) & 0x3f) / 63) * 255);
return [
((packed >>> 16) & 0xff) / 255,
((packed >>> 8) & 0xff) / 255,
(packed & 0xff) / 255,
alphaByte / 255,
];
}
function buildLayer(layer, sourceToThree) {
if (!layer.color?.present || !ArrayBuffer.isView(layer.color?.values)) {
throw new Error(`${layer.waterBodyGuid} cannot be built faithfully: ${layer.unbuildableReason ?? "serialized color data is missing"}.`);
}
if (layer.descriptorJoin && layer.descriptorJoin.status !== "matched") {
throw new Error(`${layer.waterBodyGuid} descriptor GUID join is ${layer.descriptorJoin.status}; refusing material guessing.`);
}
const { height, color, flags } = layer;
checkedParcel(layer, height, "height");
checkedParcel(layer, color, "color");
checkedParcel(layer, flags, "flags");
const width = height.width - height.overlap * 2;
const depth = height.height - height.overlap * 2;
if (color.width - color.overlap * 2 !== width || color.height - color.overlap * 2 !== depth
|| flags.width - flags.overlap * 2 !== width || flags.height - flags.overlap * 2 !== depth) {
throw new Error(`${layer.waterBodyGuid} water height/color/flag inner grids differ.`);
}
if (width < 3 || width % 2 !== 1 || depth < 2) {
throw new Error(`${layer.waterBodyGuid} source topology requires an odd inner width ≥3 and a depth ≥2; got ${width}x${depth}.`);
}
const vertexCount = width * depth;
if (vertexCount > 0x100000000) throw new Error(`${layer.waterBodyGuid} water grid is too large.`);
const positions = new Float32Array(vertexCount * 3);
const sourceWorldXZ = new Float32Array(vertexCount * 2);
const normals = new Float32Array(vertexCount * 3);
const colors = new Float32Array(vertexCount * 4);
const entityTransform = layerSourceTransform(layer);
let sourceSplitPlaneHeight = null;
for (let z = 0; z < depth; z += 1) {
for (let x = 0; x < width; x += 1) {
const vertex = z * width + x;
const heightSample = (x + height.overlap) + (z + height.overlap) * height.width;
const colorSample = (x + color.overlap) + (z + color.overlap) * color.width;
const alphaSample = (x + flags.overlap) + (z + flags.overlap) * flags.width;
const source = new Vector3(
x * height.spacing + height.origin[0] + height.spacing * height.overlap,
Number(height.values[heightSample]),
z * height.spacing + height.origin[2] + height.spacing * height.overlap,
);
const sourceWorld = finitePosition(source.applyMatrix4(entityTransform), `Water source vertex ${vertex}`);
if (vertex === 0) sourceSplitPlaneHeight = sourceWorld.y;
const output = finitePosition(sourceWorld.clone().applyMatrix4(sourceToThree), `Water vertex ${vertex}`);
positions.set(output.toArray(), vertex * 3);
// KHR_materials_variants normalTexture carriers require TEXCOORD_0 for
// Khronos validation. This native-world XZ stream is only a glTF
// compatibility carrier; the runtime shader recovers its own source
// position from world transforms and does not sample this attribute.
sourceWorldXZ.set([sourceWorld.x, sourceWorld.z], vertex * 2);
// The native vertex format is POSITION|DIFFUSE. Its water shader uses a
// fixed world-up normal for spherical-harmonic lighting; this NORMAL is
// only a static viewport/rendering carrier for that source behavior.
normals.set(NATIVE_UP, vertex * 3);
colors.set(sourceColor(color.values[colorSample], flags.values[alphaSample]), vertex * 4);
}
}
const indices = [];
const visiblePairs = [];
let consideredPairs = 0;
for (let z = 0; z < depth - 1; z += 1) {
const faces = (z & 1) === 0 ? EVEN_FACES : ODD_FACES;
for (let x = 0; x < width - 1; x += 2) {
consideredPairs += 1;
const rowStart = z * width + x;
const indexSet = [rowStart, rowStart + 1, rowStart + 2,
rowStart + width, rowStart + width + 1, rowStart + width + 2];
const pairIndices = faces.map((slot) => indexSet[slot]);
let visible = false;
for (const sourceIndex of pairIndices) {
// Match Mojo_BuildMeshFromHeightField: visibility checks the direct flat
// attribute index. Per-vertex diffuse alpha, in contrast, accounts for
// the flags parcel's overlap gap above.
if (sourceIndex >= flags.values.length) {
throw new Error(`${layer.waterBodyGuid} visibility index ${sourceIndex} exceeds the native flags array (${flags.values.length}).`);
}
if ((flags.values[sourceIndex] & 0x80) !== 0) visible = true;
}
if (!visible) continue;
for (let face = 0; face < pairIndices.length; face += 3) {
const a = pairIndices[face];
const b = pairIndices[face + 1];
const c = pairIndices[face + 2];
// sourceTerrainToThreeMatrix includes the same reflected-Z bridge used
// by native terrain; invert the source triangle order once for glTF.
indices.push(a, c, b);
}
visiblePairs.push({ gridX: x, gridZ: z });
}
}
const IndexArray = vertexCount > 0xffff ? Uint32Array : Uint16Array;
let minHeight = Number.POSITIVE_INFINITY;
let maxHeight = Number.NEGATIVE_INFINITY;
for (let offset = 1; offset < positions.length; offset += 3) {
minHeight = Math.min(minHeight, positions[offset]);
maxHeight = Math.max(maxHeight, positions[offset]);
}
return {
layer,
positions,
sourceWorldXZ,
normals,
colors,
indices: IndexArray.from(indices),
summary: {
waterBodyGuid: layer.waterBodyGuid,
layerVersion: layer.version,
descriptorJoin: layer.descriptorJoin,
grid: { width, height: depth, sourceVertexCount: vertexCount },
consideredTwoCellQuadPairs: consideredPairs,
emittedTwoCellQuadPairs: visiblePairs.length,
emittedTriangles: indices.length / 3,
emittedIndexCount: indices.length,
planAreaSquareSourceUnits: visiblePairs.length * 2 * height.spacing * height.spacing,
sourceWaterSpacing: height.spacing,
sourceSplitPlaneHeight,
outputHeightRangeMeters: { min: minHeight, max: maxHeight },
alpha: {
sourceBits: "flags & 0x3f",
nativeByteConversion: "trunc((flags & 0x3f) / 63 * 255) / 255",
visibilityBit: "0x80, applied at 2-cell quad-pair topology level",
},
positionsTransform: "terrain sourceToThree matrix (inverse primary WDB TRS + reflected-Z meter bridge) multiplied by serialized water entity TRS",
normals: "constant world-up [0,1,0] carrier; native water source has no NORMAL attribute",
textureCoordinates: "TEXCOORD_0 carries source-world XZ for glTF normal-texture compatibility only; runtime animation samples transformed world coordinates and ignores mesh UVs",
nativePairSamples: visiblePairs.slice(0, 12),
},
};
}
/** Build source water layers in the exact primary-terrain coordinate bridge. */
export function buildNativeWaterGeometry(layers, primaryPlacement, metersPerSourceUnit) {
if (!Array.isArray(layers)) throw new TypeError("Native water layers must be an array.");
const sourceToThree = sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit);
const built = [];
const skipped = [];
for (const layer of layers) {
if (layer.unbuildableReason) {
skipped.push({ waterBodyGuid: layer.waterBodyGuid, reason: layer.unbuildableReason });
continue;
}
built.push(buildLayer(layer, sourceToThree));
}
return {
layers: built,
skipped,
report: {
layerCount: layers.length,
builtLayerCount: built.length,
skippedLayerCount: skipped.length,
sourceToThree: "same sourceTerrainToThreeMatrix used by native terrain: reflected-Z meter bridge multiplied by inverse primary WDB descriptor TRS",
nativeWinding: "source triangle index order is reversed exactly once after the reflected-Z bridge",
nativeGeometryResponsibility: "this module serializes water geometry, native visibility masks, vertex RGBA, topology, and source placement transforms; animated source bump normals are supplied by src/scene/runewakerWaterMaterial.ts",
nativeMaterialLimit: "browser scene reflection/refraction uses the source enable property and Fresnel equation; native SH, reflect-class filtering, and unknown effectType names remain unsupported",
layers: built.map((entry) => entry.summary),
skipped,
},
};
}
export const NATIVE_WATER_FACE_CONSTRUCTION = Object.freeze({ evenRows: EVEN_FACES, oddRows: ODD_FACES });