215 lines
10 KiB
JavaScript
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 });
|