// lib/geometry/infinite-ground.ts // Recentering tiled terrain: a fixed pool of displaced plane tiles that follow // a moving center (camera/player). Tiles whose grid cell scrolls out of range // are re-positioned and re-displaced on the far side — memory stays constant // while the world feels endless. Displacement samples WORLD coordinates so // tile seams line up. From stellar-cartogrph's SurfaceViewRenderer. import * as THREE from 'three' import type { Disposable } from '../types.js' /** Options for {@link createInfiniteGround}: tile size/radius/resolution, a `displace` height function, and material. */ export interface InfiniteGroundOptions { tileSize?: number /** Tiles extend gridRadius cells in every direction: (2r+1)² tiles total. */ gridRadius?: number segments?: number /** World-space height function. Keep it pure — it re-runs on recenter. */ displace?: (x: number, z: number) => number material?: THREE.Material } /** Recentering tiled terrain. Call `update(center)` with the camera or player position each frame. */ export interface InfiniteGround extends Disposable { object: THREE.Group /** Recenter tiles around a world position; call with the camera/player pos. */ update (center: THREE.Vector3): void /** Sample the ground height at a world position. */ heightAt (x: number, z: number): number } /** * Endless-looking ground from a fixed grid of displaced plane tiles that * recenter around a focus point — constant memory however far you travel. * * @param options - `tileSize` (default 32), `gridRadius` (default 2 → 5×5 * tiles), per-tile `segments`, a `displace(x, z)` height function, and * material. * @returns An {@link InfiniteGround}; `heightAt(x, z)` samples the same * displacement used for the mesh. * @remarks Recentering costs only the tiles that crossed a cell boundary; * frames with no boundary crossing do zero work. */ export function createInfiniteGround ({ tileSize = 32, gridRadius = 2, segments = 32, displace = () => 0, material, }: InfiniteGroundOptions = {}): InfiniteGround { const group = new THREE.Group() const mat = material ?? new THREE.MeshStandardMaterial({ color: '#3a4a3f', roughness: 1 }) const ownsMaterial = !material interface Tile { mesh: THREE.Mesh cellX: number cellZ: number } const side = gridRadius * 2 + 1 const tiles: Tile[] = [] function displaceTile (tile: Tile): void { const geometry = tile.mesh.geometry const position = geometry.getAttribute('position') as THREE.BufferAttribute const originX = tile.cellX * tileSize const originZ = tile.cellZ * tileSize for (let i = 0; i < position.count; i++) { // plane is rotated -90° around X: local (x, y) -> world (x, -y=z) const wx = originX + position.getX(i) const wz = originZ - position.getY(i) position.setZ(i, displace(wx, wz)) } position.needsUpdate = true geometry.computeVertexNormals() tile.mesh.position.set(originX, 0, originZ) } for (let gx = -gridRadius; gx <= gridRadius; gx++) for (let gz = -gridRadius; gz <= gridRadius; gz++) { const geometry = new THREE.PlaneGeometry(tileSize, tileSize, segments, segments) const mesh = new THREE.Mesh(geometry, mat) mesh.rotation.x = -Math.PI / 2 mesh.receiveShadow = true const tile: Tile = { mesh, cellX: gx, cellZ: gz } displaceTile(tile) tiles.push(tile) group.add(mesh) } function update (center: THREE.Vector3): void { const centerCellX = Math.round(center.x / tileSize) const centerCellZ = Math.round(center.z / tileSize) for (const tile of tiles) { // wrap each tile into the window around the center cell let dx = tile.cellX - centerCellX let dz = tile.cellZ - centerCellZ let moved = false while (dx > gridRadius) { tile.cellX -= side dx -= side moved = true } while (dx < -gridRadius) { tile.cellX += side dx += side moved = true } while (dz > gridRadius) { tile.cellZ -= side dz -= side moved = true } while (dz < -gridRadius) { tile.cellZ += side dz += side moved = true } if (moved) displaceTile(tile) } } return { object: group, update, heightAt: (x, z) => displace(x, z), dispose () { for (const tile of tiles) tile.mesh.geometry.dispose() if (ownsMaterial) mat.dispose() }, } } // perf: medium. steady state is free; each recenter re-displaces only the // wrapped tiles (O(segments²) each). Keep segments ≤ 64.