From f985fe1675ad4b1c8bad5a51ee979661f81a77dd Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Wed, 29 Apr 2026 22:59:32 +0200 Subject: [PATCH] Implement derived mesh data generation and chunking logic for terrain LOD system --- src/geometry/terrain-mesh.ts | 506 +++++++++++++++++++++++++++++++++++ 1 file changed, 506 insertions(+) diff --git a/src/geometry/terrain-mesh.ts b/src/geometry/terrain-mesh.ts index 3452c178..039d2103 100644 --- a/src/geometry/terrain-mesh.ts +++ b/src/geometry/terrain-mesh.ts @@ -30,6 +30,55 @@ export interface DerivedTerrainMeshData { }; } +export const TERRAIN_LOD_CHUNK_SIZE_CELLS = 64; +export const TERRAIN_LOD_STRIDES = [1, 2, 4, 8, 16] as const; +export const TERRAIN_LOD_DEBUG_COLORS = [ + 0xff4d4d, + 0xffa53d, + 0xffe66d, + 0x4ee06f, + 0x4ba3ff +] as const; + +export interface TerrainLodLevelMeshData { + level: number; + stride: number; + geometry: BufferGeometry; + positions: Float32Array; + normals: Float32Array; + uvs: Float32Array; + layerWeights: Float32Array; + indices: Uint32Array; + skirtVertexCount: number; +} + +export interface TerrainLodChunkMeshData { + chunkX: number; + chunkZ: number; + startSampleX: number; + startSampleZ: number; + endSampleX: number; + endSampleZ: number; + cellCountX: number; + cellCountZ: number; + levels: TerrainLodLevelMeshData[]; + localBounds: { + min: Vec3; + max: Vec3; + }; + localCenter: Vec3; + diagonal: number; +} + +export interface DerivedTerrainLodMeshData { + chunkSizeCells: number; + chunks: TerrainLodChunkMeshData[]; + localBounds: { + min: Vec3; + max: Vec3; + }; +} + function createEmptyLocalBounds(): { min: Vec3; max: Vec3 } { return { min: { @@ -45,6 +94,33 @@ function createEmptyLocalBounds(): { min: Vec3; max: Vec3 } { }; } +function includePointInBounds(bounds: { min: Vec3; max: Vec3 }, point: Vec3) { + bounds.min.x = Math.min(bounds.min.x, point.x); + bounds.min.y = Math.min(bounds.min.y, point.y); + bounds.min.z = Math.min(bounds.min.z, point.z); + bounds.max.x = Math.max(bounds.max.x, point.x); + bounds.max.y = Math.max(bounds.max.y, point.y); + bounds.max.z = Math.max(bounds.max.z, point.z); +} + +function cloneBounds(bounds: { min: Vec3; max: Vec3 }): { + min: Vec3; + max: Vec3; +} { + return { + min: { + x: bounds.min.x, + y: bounds.min.y, + z: bounds.min.z + }, + max: { + x: bounds.max.x, + y: bounds.max.y, + z: bounds.max.z + } + }; +} + function chooseCellDiagonal( topLeft: number, topRight: number, @@ -189,3 +265,433 @@ export function buildTerrainDerivedMeshData( localBounds }; } + +function createLodSampleCoordinates( + startSample: number, + endSample: number, + stride: number +): number[] { + if (startSample === endSample) { + return [startSample]; + } + + const coordinates: number[] = []; + + for ( + let sample = startSample; + sample <= endSample; + sample += Math.max(1, stride) + ) { + coordinates.push(sample); + } + + if (coordinates[coordinates.length - 1] !== endSample) { + coordinates.push(endSample); + } + + return coordinates; +} + +function getUsefulTerrainLodStrides(cellCountX: number, cellCountZ: number) { + const strides: number[] = []; + let previousSignature = ""; + + for (const stride of TERRAIN_LOD_STRIDES) { + const xCount = createLodSampleCoordinates(0, cellCountX, stride).length; + const zCount = createLodSampleCoordinates(0, cellCountZ, stride).length; + const signature = `${xCount}:${zCount}`; + + if (signature === previousSignature) { + continue; + } + + strides.push(stride); + previousSignature = signature; + } + + return strides; +} + +function buildTerrainChunkSourceBounds( + terrain: Terrain, + startSampleX: number, + startSampleZ: number, + endSampleX: number, + endSampleZ: number +) { + const localBounds = createEmptyLocalBounds(); + + for (let sampleZ = startSampleZ; sampleZ <= endSampleZ; sampleZ += 1) { + for (let sampleX = startSampleX; sampleX <= endSampleX; sampleX += 1) { + includePointInBounds(localBounds, { + x: sampleX * terrain.cellSize, + y: getTerrainHeightAtSample(terrain, sampleX, sampleZ), + z: sampleZ * terrain.cellSize + }); + } + } + + return localBounds; +} + +function pushTerrainLodVertex( + terrain: Terrain, + sampleX: number, + sampleZ: number, + yOffset: number, + positions: number[], + uvs: number[], + layerWeights: number[] +) { + const localX = sampleX * terrain.cellSize; + const localY = getTerrainHeightAtSample(terrain, sampleX, sampleZ) + yOffset; + const localZ = sampleZ * terrain.cellSize; + const sampleLayerWeights = getTerrainSampleLayerWeights( + terrain, + sampleX, + sampleZ + ); + + positions.push(localX, localY, localZ); + uvs.push(terrain.position.x + localX, terrain.position.z + localZ); + + for (const weight of sampleLayerWeights) { + layerWeights.push(weight); + } +} + +function pushTerrainLodSkirtSegment( + terrain: Terrain, + startSampleX: number, + startSampleZ: number, + endSampleX: number, + endSampleZ: number, + skirtDepth: number, + positions: number[], + uvs: number[], + layerWeights: number[], + indices: number[] +) { + const topStart = positions.length / 3; + pushTerrainLodVertex( + terrain, + startSampleX, + startSampleZ, + 0, + positions, + uvs, + layerWeights + ); + const topEnd = positions.length / 3; + pushTerrainLodVertex( + terrain, + endSampleX, + endSampleZ, + 0, + positions, + uvs, + layerWeights + ); + const bottomStart = positions.length / 3; + pushTerrainLodVertex( + terrain, + startSampleX, + startSampleZ, + -skirtDepth, + positions, + uvs, + layerWeights + ); + const bottomEnd = positions.length / 3; + pushTerrainLodVertex( + terrain, + endSampleX, + endSampleZ, + -skirtDepth, + positions, + uvs, + layerWeights + ); + + indices.push(topStart, bottomStart, bottomEnd); + indices.push(topStart, bottomEnd, topEnd); +} + +function buildTerrainLodLevelMeshData( + terrain: Terrain, + startSampleX: number, + startSampleZ: number, + endSampleX: number, + endSampleZ: number, + level: number, + stride: number +): TerrainLodLevelMeshData { + const sampleXs = createLodSampleCoordinates( + startSampleX, + endSampleX, + stride + ); + const sampleZs = createLodSampleCoordinates( + startSampleZ, + endSampleZ, + stride + ); + const positions: number[] = []; + const uvs: number[] = []; + const layerWeights: number[] = []; + const indices: number[] = []; + + for (const sampleZ of sampleZs) { + for (const sampleX of sampleXs) { + pushTerrainLodVertex( + terrain, + sampleX, + sampleZ, + 0, + positions, + uvs, + layerWeights + ); + } + } + + const sampleCountX = sampleXs.length; + + for (let zIndex = 0; zIndex < sampleZs.length - 1; zIndex += 1) { + for (let xIndex = 0; xIndex < sampleXs.length - 1; xIndex += 1) { + const topLeft = zIndex * sampleCountX + xIndex; + const topRight = topLeft + 1; + const bottomLeft = (zIndex + 1) * sampleCountX + xIndex; + const bottomRight = bottomLeft + 1; + const sampleX = sampleXs[xIndex]!; + const nextSampleX = sampleXs[xIndex + 1]!; + const sampleZ = sampleZs[zIndex]!; + const nextSampleZ = sampleZs[zIndex + 1]!; + const diagonal = chooseCellDiagonal( + getTerrainHeightAtSample(terrain, sampleX, sampleZ), + getTerrainHeightAtSample(terrain, nextSampleX, sampleZ), + getTerrainHeightAtSample(terrain, sampleX, nextSampleZ), + getTerrainHeightAtSample(terrain, nextSampleX, nextSampleZ) + ); + + if (diagonal === "forward") { + indices.push(topLeft, bottomLeft, bottomRight); + indices.push(topLeft, bottomRight, topRight); + } else { + indices.push(topLeft, bottomLeft, topRight); + indices.push(topRight, bottomLeft, bottomRight); + } + } + } + + const skirtStartVertexCount = positions.length / 3; + const skirtDepth = Math.max(terrain.cellSize * stride * 1.5, 0.5); + + for (let xIndex = 0; xIndex < sampleXs.length - 1; xIndex += 1) { + pushTerrainLodSkirtSegment( + terrain, + sampleXs[xIndex]!, + startSampleZ, + sampleXs[xIndex + 1]!, + startSampleZ, + skirtDepth, + positions, + uvs, + layerWeights, + indices + ); + pushTerrainLodSkirtSegment( + terrain, + sampleXs[xIndex + 1]!, + endSampleZ, + sampleXs[xIndex]!, + endSampleZ, + skirtDepth, + positions, + uvs, + layerWeights, + indices + ); + } + + for (let zIndex = 0; zIndex < sampleZs.length - 1; zIndex += 1) { + pushTerrainLodSkirtSegment( + terrain, + startSampleX, + sampleZs[zIndex + 1]!, + startSampleX, + sampleZs[zIndex]!, + skirtDepth, + positions, + uvs, + layerWeights, + indices + ); + pushTerrainLodSkirtSegment( + terrain, + endSampleX, + sampleZs[zIndex]!, + endSampleX, + sampleZs[zIndex + 1]!, + skirtDepth, + positions, + uvs, + layerWeights, + indices + ); + } + + const typedPositions = new Float32Array(positions); + const typedUvs = new Float32Array(uvs); + const typedLayerWeights = new Float32Array(layerWeights); + const typedIndices = new Uint32Array(indices); + const geometry = new BufferGeometry(); + + geometry.setAttribute("position", new BufferAttribute(typedPositions, 3)); + geometry.setAttribute("uv", new BufferAttribute(typedUvs, 2)); + geometry.setAttribute( + "terrainLayerWeights", + new BufferAttribute(typedLayerWeights, TERRAIN_LAYER_COUNT) + ); + geometry.setIndex(new BufferAttribute(typedIndices, 1)); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const normalAttribute = geometry.getAttribute("normal"); + const normals = new Float32Array(normalAttribute.array.length); + normals.set(normalAttribute.array as ArrayLike); + + return { + level, + stride, + geometry, + positions: typedPositions, + normals, + uvs: typedUvs, + layerWeights: typedLayerWeights, + indices: typedIndices, + skirtVertexCount: typedPositions.length / 3 - skirtStartVertexCount + }; +} + +export function buildTerrainLodMeshData( + terrain: Terrain, + chunkSizeCells = TERRAIN_LOD_CHUNK_SIZE_CELLS +): DerivedTerrainLodMeshData { + const chunks: TerrainLodChunkMeshData[] = []; + const localBounds = createEmptyLocalBounds(); + const maxCellX = terrain.sampleCountX - 1; + const maxCellZ = terrain.sampleCountZ - 1; + + for ( + let startSampleZ = 0, chunkZ = 0; + startSampleZ < maxCellZ; + startSampleZ += chunkSizeCells, chunkZ += 1 + ) { + for ( + let startSampleX = 0, chunkX = 0; + startSampleX < maxCellX; + startSampleX += chunkSizeCells, chunkX += 1 + ) { + const endSampleX = Math.min(startSampleX + chunkSizeCells, maxCellX); + const endSampleZ = Math.min(startSampleZ + chunkSizeCells, maxCellZ); + const cellCountX = endSampleX - startSampleX; + const cellCountZ = endSampleZ - startSampleZ; + const chunkBounds = buildTerrainChunkSourceBounds( + terrain, + startSampleX, + startSampleZ, + endSampleX, + endSampleZ + ); + const strides = getUsefulTerrainLodStrides(cellCountX, cellCountZ); + const levels = strides.map((stride, level) => + buildTerrainLodLevelMeshData( + terrain, + startSampleX, + startSampleZ, + endSampleX, + endSampleZ, + level, + stride + ) + ); + const localCenter = { + x: (chunkBounds.min.x + chunkBounds.max.x) * 0.5, + y: (chunkBounds.min.y + chunkBounds.max.y) * 0.5, + z: (chunkBounds.min.z + chunkBounds.max.z) * 0.5 + }; + const diagonal = Math.hypot( + chunkBounds.max.x - chunkBounds.min.x, + chunkBounds.max.y - chunkBounds.min.y, + chunkBounds.max.z - chunkBounds.min.z + ); + + includePointInBounds(localBounds, chunkBounds.min); + includePointInBounds(localBounds, chunkBounds.max); + + chunks.push({ + chunkX, + chunkZ, + startSampleX, + startSampleZ, + endSampleX, + endSampleZ, + cellCountX, + cellCountZ, + levels, + localBounds: cloneBounds(chunkBounds), + localCenter, + diagonal + }); + } + } + + return { + chunkSizeCells, + chunks, + localBounds: cloneBounds(localBounds) + }; +} + +export function resolveTerrainLodLevelIndex(options: { + levelCount: number; + chunkDiagonal: number; + cameraPosition: Vec3; + chunkWorldCenter: Vec3; + perspective: boolean; +}): number { + if (options.levelCount <= 1) { + return 0; + } + + if (!options.perspective) { + return Math.min(1, options.levelCount - 1); + } + + const distance = Math.hypot( + options.cameraPosition.x - options.chunkWorldCenter.x, + options.cameraPosition.y - options.chunkWorldCenter.y, + options.cameraPosition.z - options.chunkWorldCenter.z + ); + const baseDistance = Math.max(options.chunkDiagonal, 1); + + if (distance < baseDistance * 2) { + return 0; + } + + if (distance < baseDistance * 4) { + return Math.min(1, options.levelCount - 1); + } + + if (distance < baseDistance * 8) { + return Math.min(2, options.levelCount - 1); + } + + if (distance < baseDistance * 16) { + return Math.min(3, options.levelCount - 1); + } + + return options.levelCount - 1; +}