Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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';
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
132 changes: 132 additions & 0 deletions src/terrain/heightfield.ts
Original file line number Diff line number Diff line change
@@ -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(); }
}
22 changes: 22 additions & 0 deletions src/terrain/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading