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 });