diff --git a/AGENTS.md b/AGENTS.md index 4176102..2a682c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Renderoni is structured in 4 strict hierarchical layers: - **Rule 1**: NEVER call `Math.random()`, `Date.now()`, `performance.now()`, or `requestAnimationFrame()` inside simulation logic or entity updates. Always use `engine.prng` and `engine.clock.tick`. - **Rule 2**: NEVER bypass the dual-buffer transform pipeline. Write physics transforms into canonical buffer slots, never directly into render scene graphs. - **L1: Batteries & Subsystems (`src/presets/`, `src/animation/`, `src/audio/`, `src/vfx/`, `src/ui/`, `src/scene/`)**: High-level declarative presets (`body`, `sensor`, `light`, `kccPlayer`, `dynamicPlayer`, `proceduralModel`) and compact scene inventories for prompt → img2threejs factories. + - **Terrain (`src/terrain/`, `renderoni/terrain`)**: `TiledHeightfield` (lazily tiled, LRU-evicted cache over a caller's analytic height function; tiles must stay a pure function of it so evictions rebuild identically) and `TerrainMesh` (streamed chunk LOD mesh with skirts, build budget and picking; presentation only; simulation must never depend on it). Keep `heightAt` allocation-free. - **L2: Agent Tooling & MCP (`src/mcp/`, `src/testing/`)**: Stdio Model Context Protocol server, custom Vitest matchers, and headless CLI verification. - **L3: Web Application & Demos (`src/demo/`, `index.html`)**: Interactive playground and multi-archetype web showcases. @@ -27,6 +28,7 @@ import { mountSceneInventory, parseSceneInventory } from 'renderoni/scene'; import { audio } from 'renderoni/audio'; import { animation } from 'renderoni/animation'; import { vfx } from 'renderoni/vfx'; +import { TiledHeightfield, TerrainMesh } from 'renderoni/terrain'; import { ui } from 'renderoni/ui'; import { createMCPServer } from 'renderoni/mcp'; import 'renderoni/testing/matchers'; diff --git a/README.md b/README.md index 65b4f80..82d928f 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,43 @@ test('player collects coin deterministically', async () => { - **Audio (`renderoni/audio`)**: Dual-mode Web Audio in interactive mode with one-shot user gesture autoplay resume (`pointerdown`/`keydown`), HRTF 3D spatial panning, master volume scaling, and zero-DOM deterministic event logging in headless mode. - **VFX (`renderoni/vfx`)**: Preallocated Structure-of-Arrays (SoA) particle pools with zero heap allocation churn during gameplay, billboard `THREE.InstancedMesh` rendering, and deterministic PRNG-driven screen shake. +## 🏔️ Streamed Terrain (`renderoni/terrain`) + +For worlds too large to mesh or sample up front. You supply an analytic height function (and optionally a material and per-vertex attributes); the module handles caching, streaming, LOD and picking. + +- **`TiledHeightfield`**: lazily filled, LRU-evicted tiles over your height function. `heightAt` is an allocation-free bilinear lookup, `slopeAt(x, z, coarse?)` works on the cached surface (or directly on the analytic one, touching no tile), `prewarm` builds the tiles around a point. Tiles are a pure function of the sampler, so an evicted tile rebuilds bit-identically. Subclass and override `analytic` if the function needs your own fields. +- **`TerrainMesh`**: chunked three.js terrain with distance LOD rings, crack-hiding skirts, a per-update build budget and unloading. Works with any material; a `TerrainShading` callback writes extra vertex attributes (colour, texture-layer weights). `pick` hits loaded chunks and falls back to analytic ray marching. Presentation only. +- **`TileCache`**, **`Buckets`**: the tile cache and a uniform-grid spatial hash for bounding boxes (roads, settlements), usable on their own. + +```ts +import * as THREE from 'three'; +import { TiledHeightfield, TerrainMesh } from 'renderoni/terrain'; + +const ground = new TiledHeightfield({ + size: 12_000, + sample: (x, z) => Math.sin(x * 0.004) * 30 + Math.cos(z * 0.003) * 20, +}); +ground.prewarm(0, 0, 600); + +const terrain = new TerrainMesh(ground, { + shading: { + attributes: [{ name: 'color', itemSize: 3, skirtScale: 0.8 }], + vertex(v, out, i) { + const rock = Math.min(1, v.slope * 1.5); + out[0][i * 3] = 0.45 + rock * 0.1; + out[0][i * 3 + 1] = 0.5 - rock * 0.1; + out[0][i * 3 + 2] = 0.3; + }, + }, +}); + +const scene = new THREE.Scene(); +scene.add(terrain.group); +terrain.update({ x: 0, z: 0 }); // every frame, with the camera target +const hit = terrain.pick(new THREE.Ray(new THREE.Vector3(0, 200, 0), new THREE.Vector3(0, -1, 0))); +void hit; +``` + --- ## 🤖 MCP Agent Tools @@ -231,6 +268,7 @@ import { body, kccPlayer, sensor, light, definePreset } from 'renderoni/presets' import { SceneManager, mountSceneInventory, parseSceneInventory } from 'renderoni/scene'; import { audio, AudioManager } from 'renderoni/audio'; import { vfx, ParticleEmitter, ScreenShake } from 'renderoni/vfx'; +import { TiledHeightfield, TerrainMesh, TileCache, Buckets } from 'renderoni/terrain'; import { ui } from 'renderoni/ui'; import { animation } from 'renderoni/animation'; import { startEditorServer, generateAsset, scaffoldAsset } from 'renderoni/editor'; diff --git a/package.json b/package.json index 02e2fca..c16da10 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,10 @@ "types": "./dist/scene/index.d.ts", "import": "./dist/scene/index.js" }, + "./terrain": { + "types": "./dist/terrain/index.d.ts", + "import": "./dist/terrain/index.js" + }, "./mcp": { "types": "./dist/mcp/index.d.ts", "import": "./dist/mcp/index.js" diff --git a/src/terrain/heightfield.ts b/src/terrain/heightfield.ts new file mode 100644 index 0000000..291842e --- /dev/null +++ b/src/terrain/heightfield.ts @@ -0,0 +1,132 @@ +import { TileCache, type Tile } from './tiles.js'; + +/** Anything a terrain mesh can be built from: a square world centred on the origin. */ +export interface HeightSource { + /** World side length; the surface spans [-size/2, size/2] on x and z. */ + readonly size: number; + heightAt(x: number, z: number): number; +} + +export interface TiledHeightfieldOptions { + /** World side length; the surface spans [-size/2, size/2] on x and z. */ + size: number; + /** + * The expensive analytic height. Must be a pure function of (x, z) so an evicted tile + * rebuilds identically. Omit it when subclassing and overriding `analytic` instead. + */ + sample?: (x: number, z: number) => number; + /** Distance between cached samples (metres). Default 9.4. */ + step?: number; + /** Lattice cells per tile side. Default 64 (≈ 600 m at the default step). */ + tileSamples?: number; + /** Maximum resident tiles before the oldest quarter is evicted. Default 512 (≈ 8.5 MB per channel). */ + cacheTiles?: number; + /** + * Channels cached per sample, height included. Default 1. Channels 1.. are written by + * `extra` (or an overridden `sampleExtra`) and read back bilinearly with `channel()`. + */ + channels?: number; + /** Writes channels 1..channels-1 for world (x, z) into `out[offset]`, `out[offset + 1]`, ... */ + extra?: (x: number, z: number, out: Float32Array, offset: number) => void; + /** Half-width of the finite-difference stencil in `slopeAt` (metres). Default 2. */ + slopeDelta?: number; +} + +export interface TiledHeightfieldStats { + /** Resident height tiles. */ + tiles: number; + /** Tile fills so far (a refill after eviction counts again). */ + builds: number; + /** Resident tile cap. */ + cap: number; +} + +/** + * A tiled cache of an expensive analytic height function. + * + * Nothing is precomputed for the whole world: heights live in lazily filled, LRU-evicted + * tiles (see `TileCache`), so construction is O(1) and memory stays flat at any world size. + * `heightAt` is a bilinear lookup and allocation-free; consecutive lookups in the same tile + * skip the hash map entirely. + * + * Supply the analytic function as `sample`, or subclass and override `analytic`. Tiles fill + * on first use, never in the constructor, so a subclass may read its own fields in `analytic`. + * Methods here that need the cached surface (`slopeAt`, `prewarm`) read the tiles directly, + * so a subclass may override `heightAt` (for example to add a detail term) without changing them. + */ +export class TiledHeightfield implements HeightSource { + readonly size: number; + readonly step: number; + readonly channels: number; + protected readonly tiles: TileCache; + private readonly sampleFn: ((x: number, z: number) => number) | undefined; + private readonly extraFn: ((x: number, z: number, out: Float32Array, offset: number) => void) | undefined; + private readonly slopeDelta: number; + + constructor(options: TiledHeightfieldOptions) { + this.size = options.size; + this.step = options.step ?? 9.4; + this.channels = Math.max(1, options.channels ?? 1); + this.sampleFn = options.sample; + this.extraFn = options.extra; + this.slopeDelta = options.slopeDelta ?? 2; + this.tiles = new TileCache(options.tileSamples ?? 64, this.step, this.channels, options.cacheTiles ?? 512, this.size / 2, (t, x0, z0) => this.fillTile(t, x0, z0)); + } + + /** The uncached analytic height. Override in a subclass, or pass `sample` to the constructor. */ + analytic(x: number, z: number): number { + if (!this.sampleFn) throw new Error('TiledHeightfield: pass `sample` or override `analytic`'); + return this.sampleFn(x, z); + } + + /** Writes channels 1..channels-1 at (x, z). Override in a subclass, or pass `extra`. */ + protected sampleExtra(x: number, z: number, out: Float32Array, offset: number): void { + this.extraFn?.(x, z, out, offset); + } + + private fillTile(t: Tile, x0: number, z0: number) { + const W = this.tiles.W, step = this.step, s = this.channels, d = t.data; + for (let j = 0; j < W; j++) for (let i = 0; i < W; i++) { + const x = x0 + i * step, z = z0 + j * step, k = (j * W + i) * s; + d[k] = this.analytic(x, z); + if (s > 1) this.sampleExtra(x, z, d, k + 1); + } + } + + /** Bilinearly interpolated cached height. */ + heightAt(x: number, z: number): number { return this.tiles.sample(0, x, z); } + + /** Bilinearly interpolated cached channel `ch` (0 is height). */ + channel(ch: number, x: number, z: number): number { return this.tiles.sample(ch, x, z); } + + /** + * Ground slope (rise per metre). Central differences over the cached surface; `coarse` + * instead takes forward differences of the analytic surface (three samples, no tile touched), + * for far-from-camera work that must not churn the cache. + */ + slopeAt(x: number, z: number, coarse = false): number { + const d = this.slopeDelta; + if (coarse) { + const h0 = this.analytic(x, z); + return Math.hypot(this.analytic(x + d, z) - h0, this.analytic(x, z + d) - h0) / d; + } + const T = this.tiles; + const dx = T.sample(0, x + d, z) - T.sample(0, x - d, z); + const dz = T.sample(0, x, z + d) - T.sample(0, x, z - d); + return Math.hypot(dx, dz) / (2 * d); + } + + /** Build every tile overlapping the square of half-width `r` around (x, z), e.g. before the first frame. */ + prewarm(x: number, z: number, r: number): void { + const T = this.tiles, span = T.T * this.step, half = this.size / 2; + const i0 = Math.floor((x - r + half) / span), i1 = Math.floor((x + r + half) / span); + const j0 = Math.floor((z - r + half) / span), j1 = Math.floor((z + r + half) / span); + for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) T.tile(i, j); + } + + /** Resident tile counts (diagnostics). */ + get stats(): TiledHeightfieldStats { return { tiles: this.tiles.resident, builds: this.tiles.builds, cap: this.tiles.cap }; } + + /** Drop every cached tile (e.g. after the analytic function's inputs change). */ + clear(): void { this.tiles.clear(); } +} diff --git a/src/terrain/index.ts b/src/terrain/index.ts new file mode 100644 index 0000000..23c634d --- /dev/null +++ b/src/terrain/index.ts @@ -0,0 +1,22 @@ +/** + * renderoni/terrain: large, streamed heightfield terrain. + * + * - `TiledHeightfield`: a lazily tiled, LRU-evicted cache over an expensive analytic height + * function with bilinear `heightAt`, `slopeAt` and `prewarm`. Deterministic: tiles are a pure + * function of the sampler, so an evicted tile rebuilds identically. + * - `TerrainMesh`: chunked, distance-LOD three.js mesh with skirts, a per-update build budget, + * unloading and ray picking. Presentation only; any material and per-vertex attributes. + * - `TileCache`, `Buckets`: the underlying sample-tile cache and a uniform-grid spatial hash. + */ +export { TileCache, Buckets, type Tile, type TileFill } from './tiles.js'; +export { TiledHeightfield, type HeightSource, type TiledHeightfieldOptions, type TiledHeightfieldStats } from './heightfield.js'; +export { + TerrainMesh, + type TerrainMeshOptions, + type TerrainShading, + type TerrainAttribute, + type TerrainChunkInfo, + type TerrainVertex, + type TerrainChunk, + type TerrainRaymarchOptions, +} from './mesh.js'; diff --git a/src/terrain/mesh.ts b/src/terrain/mesh.ts new file mode 100644 index 0000000..154bf52 --- /dev/null +++ b/src/terrain/mesh.ts @@ -0,0 +1,392 @@ +import * as THREE from 'three'; +import type { HeightSource } from './heightfield.js'; + +/** One extra per-vertex attribute written by a `TerrainShading`. */ +export interface TerrainAttribute { + /** Geometry attribute name, e.g. `color` or a custom `layer` read by the material's shader. */ + name: string; + itemSize: number; + /** Skirt vertices copy their edge vertex's value times this factor. Default 1 (a colour might use 0.8). */ + skirtScale?: number; +} + +/** The chunk being meshed, passed to `TerrainShading.beginChunk` and on every `TerrainVertex`. */ +export interface TerrainChunkInfo { + i: number; + j: number; + lod: number; + /** World position of the chunk's (0, 0) vertex. */ + x0: number; + z0: number; + /** Chunk side length (metres). */ + size: number; + /** Vertex spacing (metres). */ + step: number; + /** Quads per side. */ + segs: number; +} + +/** + * The vertex being written. One object is reused for every vertex of every chunk, so read what + * you need and do not keep a reference. + */ +export interface TerrainVertex { + chunk: TerrainChunkInfo; + /** World position. */ + x: number; + y: number; + z: number; + /** Position relative to the chunk origin. */ + lx: number; + lz: number; + /** Grid index within the chunk (0..segs). */ + gi: number; + gj: number; + /** Heights one step to the left (-x), right (+x), down (-z) and up (+z). */ + hl: number; + hr: number; + hd: number; + hu: number; + /** Unit normal from central differences. */ + nx: number; + ny: number; + nz: number; + /** Rise per metre. */ + slope: number; +} + +/** + * Per-vertex attributes beyond position and normal (colour, texture-layer weights, ...). + * `vertex` is called once per grid vertex with the attribute arrays in `attributes` order; + * write `itemSize` values at `index * itemSize`. Skirt vertices are copied from the edge. + */ +export interface TerrainShading { + attributes: TerrainAttribute[]; + /** Called before a chunk's vertices; the place to gather per-chunk data (nearby roads, a coarse colour grid). */ + beginChunk?(chunk: TerrainChunkInfo): void; + vertex(v: TerrainVertex, out: Float32Array[], index: number): void; + /** Called after a chunk's vertices. */ + endChunk?(chunk: TerrainChunkInfo): void; +} + +export interface TerrainMeshOptions { + /** Material for every chunk. Default: a `MeshStandardMaterial` (vertex colours on when a `color` attribute is shaded), disposed with the mesh. A supplied material is never disposed here. */ + material?: THREE.Material; + shading?: TerrainShading; + /** Vertex spacing per LOD (metres). Default [2.5, 6.25, 18.75]. */ + lodSpacing?: number[]; + /** Outer distance from the target per LOD; the last is the streaming radius. Default [220, 520, 1150]. */ + lodRadius?: number[]; + /** Target chunk side (metres); the world is split into round(size / chunkSize) chunks per side. Default 187.5. */ + chunkSize?: number; + /** How far skirts hang below chunk edges (metres). Default 6. */ + skirt?: number; + /** Chunk builds per `update`; a LOD-0 chunk costs 2. Default 2. */ + buildBudget?: number; + /** Chunks farther than the streaming radius plus this are unloaded. Default 2 chunk sides. */ + unloadMargin?: number; + /** Chunks at this LOD or finer cast shadows. Default 0. */ + castShadowMaxLod?: number; + receiveShadow?: boolean; + /** Mesh name for every chunk. Default `terrain`. */ + name?: string; + /** Clock for the `buildMs` statistic. Default `performance.now`. */ + now?: () => number; +} + +export interface TerrainChunk { + i: number; + j: number; + lod: number; + mesh: THREE.Mesh; +} + +export interface TerrainRaymarchOptions { + /** Farthest distance along the ray (metres). Default 4000. */ + maxDist?: number; + /** Surface floor, e.g. a water level: the ray stops at max(floor, height). Default -Infinity. */ + floor?: number; + /** An upward ray starting above this height cannot hit and returns null at once. Default Infinity. */ + ceiling?: number; +} + +interface Pending { i: number; j: number; lod: number; d: number } + +const DEFAULT_SPACING = [2.5, 6.25, 18.75]; +const DEFAULT_RADIUS = [220, 520, 1150]; + +/** + * Streaming, level-of-detail terrain mesh over any `HeightSource`. + * + * The world is split into a grid of chunks. Each chunk is meshed lazily at a vertex spacing that + * depends on its distance from the target and re-meshed when it should change level; a small + * build budget per `update` keeps the frame time flat. Chunks carry a skirt hanging down their + * edges so different LODs never show cracks. Normals come from finite differences of the height + * on a padded grid, so LOD borders shade identically. + * + * Presentation only: it reads heights, never writes simulation state. + */ +export class TerrainMesh { + readonly group = new THREE.Group(); + readonly material: THREE.Material; + /** Chunks per side. */ + readonly n: number; + /** Actual chunk side (metres). */ + readonly chunkSize: number; + readonly lodSpacing: readonly number[]; + readonly lodRadius: readonly number[]; + /** Build statistics (diagnostics). */ + readonly stats = { builds: 0, buildMs: 0 }; + + private readonly chunks = new Map(); + private pending: Pending[] = []; + private pendingAt = 0; + private lastCx = NaN; + private lastCz = NaN; + private readonly ownsMaterial: boolean; + private readonly shading: TerrainShading | undefined; + private readonly skirt: number; + private readonly buildBudget: number; + private readonly unloadMargin: number; + private readonly castShadowMaxLod: number; + private readonly receiveShadow: boolean; + private readonly name: string; + private readonly now: () => number; + private readonly vtx: TerrainVertex; + private readonly info: TerrainChunkInfo = { i: 0, j: 0, lod: 0, x0: 0, z0: 0, size: 0, step: 0, segs: 0 }; + private readonly raycaster = new THREE.Raycaster(); + private readonly hits: THREE.Intersection[] = []; + private readonly p = new THREE.Vector3(); + + constructor(readonly source: HeightSource, options: TerrainMeshOptions = {}) { + this.lodSpacing = options.lodSpacing ?? DEFAULT_SPACING; + this.lodRadius = options.lodRadius ?? DEFAULT_RADIUS; + if (this.lodSpacing.length === 0 || this.lodSpacing.length !== this.lodRadius.length) throw new Error('TerrainMesh: lodSpacing and lodRadius must be non-empty and the same length'); + this.n = Math.max(1, Math.round(source.size / (options.chunkSize ?? 187.5))); + this.chunkSize = source.size / this.n; + this.shading = options.shading; + this.skirt = options.skirt ?? 6; + this.buildBudget = options.buildBudget ?? 2; + this.unloadMargin = options.unloadMargin ?? this.chunkSize * 2; + this.castShadowMaxLod = options.castShadowMaxLod ?? 0; + this.receiveShadow = options.receiveShadow ?? true; + this.name = options.name ?? 'terrain'; + this.now = options.now ?? (() => performance.now()); + this.ownsMaterial = !options.material; + this.material = options.material ?? new THREE.MeshStandardMaterial({ vertexColors: !!this.shading?.attributes.some((a) => a.name === 'color'), roughness: 0.96, metalness: 0 }); + this.group.name = this.name; + this.vtx = { chunk: this.info, x: 0, y: 0, z: 0, lx: 0, lz: 0, gi: 0, gj: 0, hl: 0, hr: 0, hd: 0, hu: 0, nx: 0, ny: 1, nz: 0, slope: 0 }; + } + + /** Loaded chunk meshes (for picking). */ + get meshes(): THREE.Mesh[] { return this.group.children as THREE.Mesh[]; } + /** Number of loaded chunks. */ + get loaded(): number { return this.chunks.size; } + /** Chunk builds still queued for the current target cell. */ + get queued(): number { return this.pending.length - this.pendingAt; } + getChunk(i: number, j: number): TerrainChunk | undefined { return this.chunks.get(this.key(i, j)); } + + /** Chunk indices containing world (x, z), clamped to the grid. */ + chunkOf(x: number, z: number): { i: number; j: number } { + const h = this.source.size / 2, n = this.n; + return { i: Math.min(n - 1, Math.max(0, Math.floor((x + h) / this.chunkSize))), j: Math.min(n - 1, Math.max(0, Math.floor((z + h) / this.chunkSize))) }; + } + + /** LOD for a distance: the first ring whose radius exceeds it, else the coarsest. */ + lodFor(d: number): number { + const R = this.lodRadius; + for (let k = 0; k < R.length - 1; k++) if (d < R[k]) return k; + return R.length - 1; + } + + private key(i: number, j: number) { return j * this.n + i; } + private centreX(i: number) { return -this.source.size / 2 + (i + 0.5) * this.chunkSize; } + + /** + * Stream chunks around a target (typically the camera target). Call every frame. The queue is + * rebuilt when the target enters a new chunk; at most `budget` chunk costs are built per call + * (pass Infinity to build everything queued, e.g. before the first frame). + */ + update(target: { x: number; z: number }, budget: number = this.buildBudget): void { + const h = this.source.size / 2, cs = this.chunkSize, n = this.n; + const tx = target.x, tz = target.z; + const cx = Math.floor((tx + h) / cs), cz = Math.floor((tz + h) / cs); + const far = this.lodRadius[this.lodRadius.length - 1]; + if (cx !== this.lastCx || cz !== this.lastCz) { + this.lastCx = cx; this.lastCz = cz; + this.pending.length = 0; this.pendingAt = 0; + const reach = Math.ceil(far / cs) + 1; + for (let j = Math.max(0, cz - reach); j <= Math.min(n - 1, cz + reach); j++) for (let i = Math.max(0, cx - reach); i <= Math.min(n - 1, cx + reach); i++) { + const d = Math.hypot(this.centreX(i) - tx, this.centreX(j) - tz) - cs * 0.7; + if (d > far) continue; + const lod = this.lodFor(Math.max(0, d)); + const cur = this.chunks.get(this.key(i, j)); + if (!cur || cur.lod !== lod) this.pending.push({ i, j, lod, d }); + } + this.pending.sort((a, b) => a.d - b.d); + // unload chunks well outside the streaming radius + for (const [k, c] of this.chunks) { + if (Math.hypot(this.centreX(c.i) - tx, this.centreX(c.j) - tz) > far + this.unloadMargin) { this.disposeChunk(c); this.chunks.delete(k); } + } + } + while (budget > 0 && this.pendingAt < this.pending.length) { + const p = this.pending[this.pendingAt++]; + const k = this.key(p.i, p.j); + const cur = this.chunks.get(k); + if (cur && cur.lod === p.lod) continue; + const t0 = this.now(); + const mesh = this.buildChunk(p.i, p.j, p.lod); + this.stats.builds++; this.stats.buildMs += this.now() - t0; + if (cur) this.disposeChunk(cur); + this.chunks.set(k, { i: p.i, j: p.j, lod: p.lod, mesh }); + this.group.add(mesh); + // static: skipped by the per-frame matrix walk + mesh.matrixAutoUpdate = false; mesh.updateMatrixWorld(true); mesh.matrixWorldAutoUpdate = false; + budget -= p.lod === 0 ? 2 : 1; + } + } + + private disposeChunk(c: TerrainChunk) { this.group.remove(c.mesh); c.mesh.geometry.dispose(); } + + /** Mesh one chunk at a LOD. Public so callers can build a chunk outside streaming (tests, map previews). */ + buildChunk(ci: number, cj: number, lod: number): THREE.Mesh { + const src = this.source, cs = this.chunkSize, half = src.size / 2; + const x0 = -half + ci * cs, z0 = -half + cj * cs; + const segs = Math.max(3, Math.round(cs / this.lodSpacing[lod])); + const step = cs / segs; + const nv = segs + 1; + // exactly the edge ring: spare zeroed vertices would drag the bounding sphere to the origin + const gridCount = nv * nv, skirtCount = (nv - 1) * 4; + const total = gridCount + skirtCount; + const pos = new Float32Array(total * 3), nor = new Float32Array(total * 3); + const shading = this.shading, attrs = shading?.attributes ?? []; + const out: Float32Array[] = attrs.map((a) => new Float32Array(total * a.itemSize)); + + const info = this.info; + info.i = ci; info.j = cj; info.lod = lod; info.x0 = x0; info.z0 = z0; info.size = cs; info.step = step; info.segs = segs; + shading?.beginChunk?.(info); + + // one height sample per vertex on a padded grid; normals come from neighbours + const pn = nv + 2; + const H = new Float32Array(pn * pn); + for (let j = 0; j < pn; j++) for (let i = 0; i < pn; i++) H[j * pn + i] = src.heightAt(x0 + (i - 1) * step, z0 + (j - 1) * step); + const v = this.vtx; + for (let gj = 0; gj < nv; gj++) for (let gi = 0; gi < nv; gi++) { + const idx = gj * nv + gi; + const lx = gi * step, lz = gj * step; + const y = H[(gj + 1) * pn + gi + 1]; + const hl = H[(gj + 1) * pn + gi], hr = H[(gj + 1) * pn + gi + 2], hd = H[gj * pn + gi + 1], hu = H[(gj + 2) * pn + gi + 1]; + const hx = hr - hl, hz = hu - hd; + const nx = -hx, ny = 2 * step, nz = -hz, nl = Math.hypot(nx, ny, nz) || 1; + pos[idx * 3] = x0 + lx; pos[idx * 3 + 1] = y; pos[idx * 3 + 2] = z0 + lz; + nor[idx * 3] = nx / nl; nor[idx * 3 + 1] = ny / nl; nor[idx * 3 + 2] = nz / nl; + if (shading) { + v.x = x0 + lx; v.y = y; v.z = z0 + lz; v.lx = lx; v.lz = lz; v.gi = gi; v.gj = gj; + v.hl = hl; v.hr = hr; v.hd = hd; v.hu = hu; + v.nx = nx / nl; v.ny = ny / nl; v.nz = nz / nl; v.slope = Math.hypot(hx, hz) / (2 * step); + shading.vertex(v, out, idx); + } + } + shading?.endChunk?.(info); + + // skirt ring: copy edge vertices, drop them by `skirt` + const edge = new Int32Array(skirtCount); + let e = 0; + for (let i = 0; i < nv; i++) edge[e++] = i; // bottom row (j = 0) + for (let j = 1; j < nv; j++) edge[e++] = j * nv + nv - 1; // right column + for (let i = nv - 2; i >= 0; i--) edge[e++] = (nv - 1) * nv + i; // top row reversed + for (let j = nv - 2; j >= 1; j--) edge[e++] = j * nv; // left column reversed + for (let q = 0; q < skirtCount; q++) { + const src3 = edge[q] * 3, s = gridCount + q, s3 = s * 3; + pos[s3] = pos[src3]; pos[s3 + 1] = pos[src3 + 1] - this.skirt; pos[s3 + 2] = pos[src3 + 2]; + nor[s3] = nor[src3]; nor[s3 + 1] = nor[src3 + 1]; nor[s3 + 2] = nor[src3 + 2]; + for (let a = 0; a < attrs.length; a++) { + const w = attrs[a].itemSize, f = attrs[a].skirtScale ?? 1, arr = out[a]; + for (let c = 0; c < w; c++) arr[s * w + c] = arr[edge[q] * w + c] * f; + } + } + + const index = new (total > 65535 ? Uint32Array : Uint16Array)(segs * segs * 6 + skirtCount * 12); + let t = 0; + for (let j = 0; j < segs; j++) for (let i = 0; i < segs; i++) { + const a = j * nv + i, b = a + 1, c = a + nv, d = c + 1; + index[t++] = a; index[t++] = c; index[t++] = b; index[t++] = b; index[t++] = c; index[t++] = d; + } + for (let q = 0; q < skirtCount; q++) { + const q2 = (q + 1) % skirtCount; + const a = edge[q], b = edge[q2], sa = gridCount + q, sb = gridCount + q2; + // both windings: skirts are seen from either side + index[t++] = a; index[t++] = b; index[t++] = sa; index[t++] = b; index[t++] = sb; index[t++] = sa; + index[t++] = a; index[t++] = sa; index[t++] = b; index[t++] = b; index[t++] = sa; index[t++] = sb; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + geo.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); + for (let a = 0; a < attrs.length; a++) geo.setAttribute(attrs[a].name, new THREE.BufferAttribute(out[a], attrs[a].itemSize)); + geo.setIndex(new THREE.BufferAttribute(index, 1)); + geo.computeBoundingSphere(); + const mesh = new THREE.Mesh(geo, this.material); + mesh.receiveShadow = this.receiveShadow; + mesh.castShadow = lod <= this.castShadowMaxLod; + mesh.name = this.name; + mesh.userData.chunk = { i: ci, j: cj, lod }; + return mesh; + } + + /** + * Ray/terrain intersection. Loaded chunks are hit-tested first (exact to the displayed + * triangles); if none is hit, falls back to `raymarch` against the height source. + * Writes into `out` and returns it, or returns null. + */ + pick(ray: THREE.Ray, out: THREE.Vector3 = new THREE.Vector3(), options?: TerrainRaymarchOptions): THREE.Vector3 | null { + const rc = this.raycaster, hits = this.hits; + rc.set(ray.origin, ray.direction); + rc.far = options?.maxDist ?? 4000; + hits.length = 0; + rc.intersectObjects(this.group.children, false, hits); + if (hits.length) { + const p = hits[0].point; + hits.length = 0; + const floor = options?.floor; + if (floor === undefined || p.y >= floor) return out.copy(p); + } + return this.raymarch(ray, out, options); + } + + /** Analytic ray/heightfield intersection by marching with growing steps, then bisecting the crossing. */ + raymarch(ray: THREE.Ray, out: THREE.Vector3 = new THREE.Vector3(), options?: TerrainRaymarchOptions): THREE.Vector3 | null { + const src = this.source, o = ray.origin, dir = ray.direction; + const maxDist = options?.maxDist ?? 4000, floor = options?.floor ?? -Infinity, ceiling = options?.ceiling ?? Infinity; + if (dir.y >= 0 && o.y > ceiling) return null; + const p = this.p; + let t = 0, step = 2; + let prevAbove = o.y - Math.max(floor, src.heightAt(o.x, o.z)) > 0; + while (t < maxDist) { + t += step; + p.copy(o).addScaledVector(dir, t); + const above = p.y - Math.max(floor, src.heightAt(p.x, p.z)) > 0; + if (prevAbove && !above) { + let lo = t - step, hi = t; + for (let k = 0; k < 8; k++) { + const m = (lo + hi) / 2; + p.copy(o).addScaledVector(dir, m); + if (p.y - Math.max(floor, src.heightAt(p.x, p.z)) > 0) lo = m; else hi = m; + } + return out.copy(o).addScaledVector(dir, hi); + } + prevAbove = above; + step = Math.min(24, step * 1.08); + } + return null; + } + + /** Dispose every chunk's geometry, detach the group, and dispose the material if this mesh created it. */ + dispose(): void { + for (const c of this.chunks.values()) this.disposeChunk(c); + this.chunks.clear(); this.pending.length = 0; this.pendingAt = 0; + this.lastCx = NaN; this.lastCz = NaN; + this.group.parent?.remove(this.group); + if (this.ownsMaterial) this.material.dispose(); + } +} diff --git a/src/terrain/tiles.ts b/src/terrain/tiles.ts new file mode 100644 index 0000000..5cbeaf3 --- /dev/null +++ b/src/terrain/tiles.ts @@ -0,0 +1,119 @@ +/** + * Lazily filled, LRU-evicted sample tiles over an unbounded 2-D lattice. + * + * A tile holds (T + 1) × (T + 1) samples with `stride` channels each, so a bilinear lookup + * always finds all four corners in one tile. Tiles are filled on first touch by `fill` and + * dropped (oldest first) once more than `cap` are resident, which keeps memory flat however + * large the world is. Filling must be a pure function of the tile coordinates so an evicted + * tile rebuilds identically (determinism across runs and clients). + * + * The lattice origin is `-half` on both axes, so a world centred on the origin with side + * `2 * half` starts at tile (0, 0). Tile indices must stay within ±32768. + */ +export interface Tile { + ti: number; + tj: number; + /** `W * W * stride` samples, row-major by z then x; zero-filled before `fill` runs. */ + data: Float32Array; + use: number; +} + +/** Fills a fresh tile whose first sample sits at world (x0, z0). */ +export type TileFill = (tile: Tile, x0: number, z0: number) => void; + +const tileKey = (ti: number, tj: number) => (ti + 32768) * 65536 + (tj + 32768); + +export class TileCache { + private tiles = new Map(); + private tick = 0; + private last: Tile | null = null; + /** Samples per tile side (T + 1). */ + readonly W: number; + /** Number of tile fills so far (diagnostics). */ + builds = 0; + + /** + * @param T lattice cells per tile side + * @param step world distance between samples + * @param stride channels per sample + * @param cap maximum resident tiles before the oldest quarter is evicted + * @param half world offset of the lattice origin (samples start at -half) + * @param fill pure function of the tile coordinates that writes `tile.data` + */ + constructor( + readonly T: number, + readonly step: number, + readonly stride: number, + readonly cap: number, + readonly half: number, + private fill: TileFill, + ) { + this.W = T + 1; + } + + /** The tile at tile coordinates (ti, tj), filling it on first touch. */ + tile(ti: number, tj: number): Tile { + const last = this.last; + if (last && last.ti === ti && last.tj === tj) { last.use = ++this.tick; return last; } + const key = tileKey(ti, tj); + let t = this.tiles.get(key); + if (!t) { + if (this.tiles.size >= this.cap) this.evict(); + t = { ti, tj, data: new Float32Array(this.W * this.W * this.stride), use: 0 }; + this.fill(t, -this.half + ti * this.T * this.step, -this.half + tj * this.T * this.step); + this.builds++; + this.tiles.set(key, t); + } + t.use = ++this.tick; this.last = t; + return t; + } + + /** Bilinear sample of channel `ch` at world (x, z). */ + sample(ch: number, x: number, z: number): number { + const fx = (x + this.half) / this.step, fz = (z + this.half) / this.step; + const gi = Math.floor(fx), gj = Math.floor(fz); + const tx = fx - gi, tz = fz - gj; + const T = this.T, ti = Math.floor(gi / T), tj = Math.floor(gj / T); + const t = this.tile(ti, tj), d = t.data, W = this.W, s = this.stride; + const k = ((gj - tj * T) * W + (gi - ti * T)) * s + ch; + const a = d[k], b = d[k + s], c = d[k + W * s], e = d[k + W * s + s]; + return (a + (b - a) * tx) + ((c + (e - c) * tx) - (a + (b - a) * tx)) * tz; + } + + /** Whether tile (ti, tj) is resident (does not fill or touch it). */ + has(ti: number, tj: number): boolean { return this.tiles.has(tileKey(ti, tj)); } + + /** Drop the oldest quarter of resident tiles. */ + private evict() { + const arr = [...this.tiles.entries()].sort((a, b) => a[1].use - b[1].use); + const n = Math.max(1, arr.length >> 2); + for (let i = 0; i < n; i++) this.tiles.delete(arr[i][0]); + if (this.last && !this.tiles.has(tileKey(this.last.ti, this.last.tj))) this.last = null; + } + + get resident(): number { return this.tiles.size; } + clear(): void { this.tiles.clear(); this.last = null; } +} + +/** + * Uniform-grid spatial hash for things with a bounding box (settlements, road segments). + * Items are inserted into every cell their box overlaps; a query returns the items of the + * one cell containing the point (the caller does the exact test). Cell indices must stay + * within ±4096. + */ +export class Buckets { + private map = new Map(); + constructor(readonly cell: number) {} + private key(bi: number, bj: number) { return (bi + 4096) * 8192 + (bj + 4096); } + insert(minX: number, minZ: number, maxX: number, maxZ: number, item: T): void { + const i0 = Math.floor(minX / this.cell), i1 = Math.floor(maxX / this.cell), j0 = Math.floor(minZ / this.cell), j1 = Math.floor(maxZ / this.cell); + for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) { + const k = this.key(i, j); let l = this.map.get(k); if (!l) { l = []; this.map.set(k, l); } l.push(item); + } + } + /** Items whose box overlaps the cell containing (x, z); `undefined` when the cell is empty. */ + query(x: number, z: number): T[] | undefined { return this.map.get(this.key(Math.floor(x / this.cell), Math.floor(z / this.cell))); } + /** Number of non-empty cells. */ + get cells(): number { return this.map.size; } + clear(): void { this.map.clear(); } +} diff --git a/tests/terrain.test.ts b/tests/terrain.test.ts new file mode 100644 index 0000000..4dfda44 --- /dev/null +++ b/tests/terrain.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from 'vitest'; +import * as THREE from 'three'; +import { Buckets, TileCache, TiledHeightfield, TerrainMesh, type HeightSource, type TerrainShading } from '../src/terrain/index.js'; + +const wavy = (x: number, z: number) => Math.sin(x * 0.013) * 40 + Math.cos(z * 0.021) * 25 + Math.sin((x + z) * 0.002) * 60; +const plane = (x: number, z: number) => 0.3 * x - 0.4 * z + 5; + +describe('Terrain', () => { + describe('TileCache', () => { + it('fills a tile once and serves repeat lookups from cache', () => { + let fills = 0; + const cache = new TileCache(4, 1, 1, 8, 0, (t, x0, z0) => { + fills++; + for (let j = 0; j < 5; j++) for (let i = 0; i < 5; i++) t.data[j * 5 + i] = x0 + i + (z0 + j) * 100; + }); + expect(cache.sample(0, 1.5, 2)).toBeCloseTo(201.5, 5); + expect(cache.sample(0, 3, 3.25)).toBeCloseTo(328, 5); + expect(fills).toBe(1); + expect(cache.has(0, 0)).toBe(true); + expect(cache.has(1, 0)).toBe(false); + expect(cache.resident).toBe(1); + }); + + it('evicts the oldest quarter once the cap is reached', () => { + const cache = new TileCache(2, 1, 1, 8, 0, () => {}); + for (let i = 0; i < 8; i++) cache.tile(i, 0); + expect(cache.resident).toBe(8); + cache.tile(8, 0); + expect(cache.resident).toBe(7); + expect(cache.has(0, 0)).toBe(false); + expect(cache.has(1, 0)).toBe(false); + expect(cache.has(7, 0)).toBe(true); + }); + }); + + describe('TiledHeightfield', () => { + it('rebuilds evicted tiles bit-identically', () => { + const hf = new TiledHeightfield({ size: 4000, sample: wavy, tileSamples: 8, step: 5, cacheTiles: 4 }); + const pts: number[] = []; + for (let k = 0; k < 400; k++) pts.push(-1900 + ((k * 7919) % 3800), -1900 + ((k * 104729) % 3800)); + const first = pts.filter((_, k) => k % 2 === 0).map((x, k) => hf.heightAt(x, pts[k * 2 + 1])); + const builds = hf.stats.builds; + expect(hf.stats.tiles).toBeLessThanOrEqual(4); + const second = pts.filter((_, k) => k % 2 === 0).map((x, k) => hf.heightAt(x, pts[k * 2 + 1])); + expect(hf.stats.builds).toBeGreaterThan(builds); + expect(second).toEqual(first); + + const fresh = new TiledHeightfield({ size: 4000, sample: wavy, tileSamples: 8, step: 5, cacheTiles: 1024 }); + const third = pts.filter((_, k) => k % 2 === 0).map((x, k) => fresh.heightAt(x, pts[k * 2 + 1])); + expect(third).toEqual(first); + }); + + it('interpolates bilinearly: exact on a plane and at lattice points, close on a smooth surface', () => { + const flat = new TiledHeightfield({ size: 1000, sample: plane, step: 4 }); + for (const [x, z] of [[0, 0], [1.3, -7.9], [123.4, 321.1], [-499, 499]]) expect(flat.heightAt(x, z)).toBeCloseTo(plane(x, z), 3); + + const hf = new TiledHeightfield({ size: 1000, sample: wavy, step: 4 }); + // lattice point: -500 + 4k + expect(hf.heightAt(-500 + 4 * 37, -500 + 4 * 91)).toBeCloseTo(wavy(-500 + 4 * 37, -500 + 4 * 91), 4); + let worst = 0; + for (let x = -480; x < 480; x += 13.7) for (let z = -480; z < 480; z += 17.3) worst = Math.max(worst, Math.abs(hf.heightAt(x, z) - wavy(x, z))); + // bilinear error ≤ step² · max|f''| / 8 per axis ≈ 16 · (40·0.013² + 25·0.021²) / 8 + expect(worst).toBeLessThan(0.05); + expect(hf.analytic(12.5, 3)).toBe(wavy(12.5, 3)); + }); + + it('computes slope from the cached surface and coarse slope without building tiles', () => { + const hf = new TiledHeightfield({ size: 1000, sample: plane }); + expect(hf.slopeAt(10, 20, true)).toBeCloseTo(0.5, 6); + expect(hf.stats.builds).toBe(0); + expect(hf.slopeAt(10, 20)).toBeCloseTo(0.5, 3); + expect(hf.stats.builds).toBeGreaterThan(0); + + const level = new TiledHeightfield({ size: 1000, sample: () => 3 }); + expect(level.slopeAt(0, 0)).toBe(0); + expect(level.slopeAt(0, 0, true)).toBe(0); + }); + + it('prewarms every tile around a point so later lookups build nothing', () => { + const hf = new TiledHeightfield({ size: 6000, sample: wavy, tileSamples: 16, step: 10 }); // 160 m tiles + hf.prewarm(100, -200, 500); + const built = hf.stats.builds; + // x: [-400, 600] + 3000 → tiles 16..22; z: [-700, 300] + 3000 → tiles 14..20 + expect(built).toBe(7 * 7); + for (let x = -400; x <= 600; x += 25) for (let z = -700; z <= 300; z += 25) hf.heightAt(x, z); + expect(hf.stats.builds).toBe(built); + expect(hf.stats.tiles).toBe(built); + hf.clear(); + expect(hf.stats.tiles).toBe(0); + }); + + it('caches extra channels alongside height', () => { + const hf = new TiledHeightfield({ size: 1000, sample: plane, channels: 3, extra: (x, _z, out, o) => { out[o] = x * 0.5; out[o + 1] = 7; } }); + expect(hf.channel(1, 10.5, 3)).toBeCloseTo(5.25, 4); + expect(hf.channel(2, -200, 100)).toBe(7); + expect(hf.heightAt(10.5, 3)).toBeCloseTo(plane(10.5, 3), 3); + }); + + it('supports subclassing: override analytic, then heightAt, without changing slope', () => { + class Game extends TiledHeightfield { + private readonly bias = 2; + constructor() { super({ size: 1000 }); } + override analytic(x: number, z: number) { return plane(x, z) + this.bias; } + override heightAt(x: number, z: number, detail = true) { return super.heightAt(x, z) + (detail ? 100 : 0); } + } + const g = new Game(); + expect(g.heightAt(0, 0, false)).toBeCloseTo(7, 4); + expect(g.heightAt(0, 0)).toBeCloseTo(107, 4); + expect(g.slopeAt(0, 0)).toBeCloseTo(0.5, 3); + expect(() => new TiledHeightfield({ size: 10 }).heightAt(0, 0)).toThrow(/sample/); + }); + }); + + describe('Buckets', () => { + it('returns every item whose box overlaps the query cell', () => { + const b = new Buckets(100); + b.insert(-50, -50, 50, 50, 'centre'); // cells (-1..0, -1..0) + b.insert(120, 10, 180, 20, 'east'); // cell (1, 0) + b.insert(-250, 250, -210, 260, 'far'); // cell (-3, 2) + expect(b.query(10, 10)).toEqual(['centre']); + expect(b.query(-10, -10)).toEqual(['centre']); + expect(b.query(150, 90)).toEqual(['east']); + expect(b.query(-220, 299)).toEqual(['far']); + expect(b.query(500, 500)).toBeUndefined(); + b.insert(0, 0, 150, 10, 'wide'); + expect(b.query(150, 5)).toEqual(['east', 'wide']); + expect(b.cells).toBe(6); + b.clear(); + expect(b.query(10, 10)).toBeUndefined(); + }); + }); + + describe('TerrainMesh', () => { + const flat: HeightSource = { size: 2000, heightAt: () => 4 }; + const sloped: HeightSource = { size: 2000, heightAt: plane }; + const small = { chunkSize: 200, lodSpacing: [5, 20, 50], lodRadius: [150, 400, 700] }; + + it('builds a chunk with a skirt ring and exact vertex counts', () => { + const tm = new TerrainMesh(flat, small); + expect(tm.n).toBe(10); + expect(tm.chunkSize).toBe(200); + for (const [lod, segs] of [[0, 40], [1, 10], [2, 4]]) { + const geo = tm.buildChunk(3, 4, lod).geometry; + const nv = segs + 1; + expect(geo.getAttribute('position').count).toBe(nv * nv + 4 * segs); + expect(geo.getIndex()!.count).toBe(segs * segs * 6 + 4 * segs * 12); + const pos = geo.getAttribute('position'); + for (let k = nv * nv; k < pos.count; k++) expect(pos.getY(k)).toBe(4 - 6); + for (let k = 0; k < nv * nv; k++) expect(pos.getY(k)).toBe(4); + expect(geo.getAttribute('normal').getY(0)).toBe(1); + geo.dispose(); + } + // chunk (3, 4) spans x ∈ [-400, -200], z ∈ [-200, 0] + const box = new THREE.Box3().setFromBufferAttribute(tm.buildChunk(3, 4, 2).geometry.getAttribute('position') as THREE.BufferAttribute); + expect(box.min.toArray()).toEqual([-400, -2, -200]); + expect(box.max.toArray()).toEqual([-200, 4, 0]); + }); + + it('follows a sloped surface with matching positions and normals', () => { + const tm = new TerrainMesh(sloped, { ...small, skirt: 10 }); + const geo = tm.buildChunk(5, 5, 1).geometry; + const pos = geo.getAttribute('position'), nor = geo.getAttribute('normal'); + const nv = 11; + for (let k = 0; k < nv * nv; k++) expect(pos.getY(k)).toBeCloseTo(plane(pos.getX(k), pos.getZ(k)), 3); + for (let k = nv * nv; k < pos.count; k++) expect(pos.getY(k)).toBeCloseTo(plane(pos.getX(k), pos.getZ(k)) - 10, 3); + const n = new THREE.Vector3(-0.3, 1, 0.4).normalize(); + expect(nor.getX(60)).toBeCloseTo(n.x, 5); + expect(nor.getY(60)).toBeCloseTo(n.y, 5); + expect(nor.getZ(60)).toBeCloseTo(n.z, 5); + }); + + it('streams chunks by distance, spends the build budget, and picks LODs by ring', () => { + const tm = new TerrainMesh(flat, small); + tm.update({ x: 0, z: 0 }); + expect(tm.stats.builds).toBe(1); // the nearest chunk is LOD 0 and costs the whole budget + expect(tm.queued).toBeGreaterThan(0); + while (tm.queued > 0) tm.update({ x: 0, z: 0 }); + expect(tm.getChunk(5, 5)!.lod).toBe(0); + expect(tm.getChunk(4, 4)!.lod).toBe(0); + expect(tm.getChunk(7, 5)!.lod).toBe(1); // centre 500 m away, minus 140 → 360 + expect(tm.getChunk(8, 5)!.lod).toBe(2); // 560 m + expect(tm.getChunk(9, 5)).toBeUndefined(); // 760 m, beyond the streaming radius + expect(tm.getChunk(0, 0)).toBeUndefined(); + for (const m of tm.meshes) { + const c = m.userData.chunk as { i: number; j: number; lod: number }; + const d = Math.hypot(-1000 + (c.i + 0.5) * 200, -1000 + (c.j + 0.5) * 200) - 140; + expect(d).toBeLessThanOrEqual(700); + expect(tm.lodFor(Math.max(0, d))).toBe(c.lod); + expect(m.castShadow).toBe(c.lod === 0); + expect(m.receiveShadow).toBe(true); + } + expect(tm.meshes.length).toBe(tm.loaded); + }); + + it('re-meshes at a new LOD and unloads chunks as the target moves', () => { + const tm = new TerrainMesh(flat, small); + tm.update({ x: -900, z: -900 }, Infinity); + const corner = tm.getChunk(0, 0)!; + expect(corner.lod).toBe(0); + const geo = corner.mesh.geometry; + let disposed = false; + geo.addEventListener('dispose', () => { disposed = true; }); + tm.update({ x: -500, z: -900 }, Infinity); + expect(tm.getChunk(0, 0)!.lod).toBe(1); + expect(disposed).toBe(true); + tm.update({ x: 900, z: 900 }, Infinity); + // unload beyond 700 + 2 · 200 from the target + expect(tm.getChunk(0, 0)).toBeUndefined(); + for (const m of tm.meshes) { + const c = m.userData.chunk as { i: number; j: number }; + expect(Math.hypot(-1000 + (c.i + 0.5) * 200 - 900, -1000 + (c.j + 0.5) * 200 - 900)).toBeLessThanOrEqual(1100); + } + const scene = new THREE.Scene(); + scene.add(tm.group); + tm.dispose(); + expect(tm.loaded).toBe(0); + expect(tm.group.parent).toBeNull(); + }); + + it('writes caller attributes per vertex and copies them to the skirt', () => { + let chunks = 0; + const shading: TerrainShading = { + attributes: [{ name: 'color', itemSize: 3, skirtScale: 0.5 }, { name: 'layer', itemSize: 4 }], + beginChunk: () => { chunks++; }, + vertex(v, out, i) { + out[0][i * 3] = v.slope; out[0][i * 3 + 1] = v.y / 100; out[0][i * 3 + 2] = 1; + out[1][i * 4] = v.gi; out[1][i * 4 + 1] = v.gj; out[1][i * 4 + 2] = v.chunk.lod; out[1][i * 4 + 3] = v.lx; + }, + }; + const tm = new TerrainMesh(sloped, { ...small, shading }); + expect((tm.material as THREE.MeshStandardMaterial).vertexColors).toBe(true); + const geo = tm.buildChunk(2, 2, 2).geometry; + expect(chunks).toBe(1); + const col = geo.getAttribute('color'), lay = geo.getAttribute('layer'); + expect(lay.itemSize).toBe(4); + expect(col.getX(7)).toBeCloseTo(0.5, 4); + expect(lay.getX(7)).toBe(2); + expect(lay.getY(7)).toBe(1); + expect(lay.getW(7)).toBe(100); + const nv = 5, first = nv * nv; // skirt vertex 0 copies grid vertex 0 + expect(col.getZ(first)).toBe(0.5); + expect(col.getY(first)).toBeCloseTo(col.getY(0) * 0.5, 6); + expect(lay.getZ(first)).toBe(2); + }); + + it('uses a caller material and leaves it undisposed', () => { + const material = new THREE.MeshBasicMaterial(); + let disposed = false; + material.addEventListener('dispose', () => { disposed = true; }); + const tm = new TerrainMesh(flat, { ...small, material }); + tm.update({ x: 0, z: 0 }, Infinity); + expect(tm.meshes.every((m) => m.material === material)).toBe(true); + tm.dispose(); + expect(disposed).toBe(false); + }); + + it('picks against loaded chunks and falls back to analytic marching', () => { + const tm = new TerrainMesh(sloped, small); + const ray = new THREE.Ray(new THREE.Vector3(30, 400, -20), new THREE.Vector3(0.2, -1, 0.1).normalize()); + const marched = tm.pick(ray); + expect(marched).not.toBeNull(); + expect(marched!.y).toBeCloseTo(plane(marched!.x, marched!.z), 1); + + tm.update({ x: 0, z: 0 }, Infinity); + const out = new THREE.Vector3(); + const hit = tm.pick(ray, out); + expect(hit).toBe(out); + expect(out.y).toBeCloseTo(plane(out.x, out.z), 3); + expect(out.distanceTo(marched!)).toBeLessThan(0.2); + + const flatTm = new TerrainMesh(flat, small); + const down = new THREE.Ray(new THREE.Vector3(0, 50, 0), new THREE.Vector3(0, -1, 0)); + expect(flatTm.raymarch(down)!.y).toBeCloseTo(4, 1); + expect(flatTm.raymarch(down, undefined, { floor: 10 })!.y).toBeCloseTo(10, 1); + const up = new THREE.Ray(new THREE.Vector3(0, 300, 0), new THREE.Vector3(0, 1, 0)); + expect(flatTm.raymarch(up, undefined, { ceiling: 200 })).toBeNull(); + expect(flatTm.raymarch(new THREE.Ray(new THREE.Vector3(0, 50, 0), new THREE.Vector3(1, 0, 0)), undefined, { maxDist: 500 })).toBeNull(); + }); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 4c756fb..ada72d2 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'ui/index': 'src/ui/index.ts', 'vfx/index': 'src/vfx/index.ts', 'scene/index': 'src/scene/index.ts', + 'terrain/index': 'src/terrain/index.ts', 'mcp/index': 'src/mcp/index.ts', 'testing/index': 'src/testing/index.ts', 'testing/matchers': 'src/testing/matchers.ts',