---
name: incanto-environment
description: Generating game environments in Incanto — the deterministic `incanto-env` CLI and `incanto/env` library. EXACTLY three themed 3D generators (arena with boxes/ruins/garden, Terrain3D heightfield worlds with island/alpine/plains/desert/meadow/forest/savanna/snow/wetland/volcanic themes, maze with stone/hedge/canyon) plus 2D mazes, roguelike dungeons and platform courses; insertIntoScene, the GENERATORS catalog + runGenerator; and the environment nodes — Terrain3D (biome texture splatting, heightAt, heightfield collider), shader-water Water3D (FBM waves, anisotropic wind ripple, sparse sun glints, crystal shallows, refraction + depth absorption, reflections, shoreline foam, splash signals + ripples), River3D running water (path-derived course, terrain-cut banks, derived rapids/riffles, a current that sweeps bodies), shader-grass Foliage3D (real curved blade meshes, clumps, rolling wind, distance LOD, character bend), Flowers3D flower plants (lush/sparse/none density dial, daisy/cosmos/bellflower varieties, Voronoi patches, head-bob sway) and ez-tree Tree3D groves (branchy trunks, textured wind-swayed leaves, budget-checked tiers). Use when a game needs a level, stage, arena, terrain, heightfield, island, meadow, grass field, flowers, flower bed, forest, trees, mountain, desert, savanna, snow, tundra, swamp, wetland, marsh, volcano, lava field, maze, dungeon, platform course, scattered props, clouds, water, a river, stream, creek, brook, rapids, a ford, or a splash effect.
---

# Environment generation — levels from a seed

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

Generators are PURE and DETERMINISTIC: all randomness flows through a seeded
`Rng`, so the same `--seed` always emits byte-identical JSON. A generated
level is reproducible from its seed alone — keep the seed in your scene
notes/PROJECT docs, and change it (not the output) to reroll a layout.

## CLI

```bash
bunx incanto-env --list                                           # every generator + params
bunx incanto-env arena --seed 42 --theme ruins                    # print the NodeJson
bunx incanto-env terrain --seed 42 --theme island --json
bunx incanto-env terrain --seed 42 --theme meadow --size 120
bunx incanto-env maze --seed 7 --theme hedge --width 10 --depth 10

# Insert straight into a scene file (validates with incanto-check afterwards):
bunx incanto-env terrain --seed 42 --theme forest --into src/game.scene.json --at Root/Level
```

- `--seed` is REQUIRED — determinism is the contract.
- Subcommands and `--<param>` flags come from the `GENERATORS` metadata
  (camelCase params become kebab-case flags: `wallHeight` → `--wall-height`;
  boolean params are bare flags: `--water`). `--list` prints the full table.
- Without `--into` the pretty-printed NodeJson goes to stdout (`--json`
  silences the stderr summary for piping).
- `--into <file>` reads the scene, appends the generated node under `--at`
  (default: the scene root) and writes the file back pretty-printed.
- `--at` is the '/'-joined chain of node NAMES from the root, root included:
  `Root/Level` targets the root's child `Level`. A missing path is a hard
  `NODE_NOT_FOUND` listing the children that DO exist.
- `scatter` is library-only — its item templates don't fit CLI flags.
- The scene editor (`bunx incanto-editor`) drives the same catalog visually:
  the ✦ Generate dialog builds its param form from `GENERATORS` and inserts
  under the selected node as one undoable step (see the incanto-editor skill).

## The catalog: 3 themed 3D generators + 3 2D generators

The 3D set is EXACTLY `arena`, `terrain`, `maze` — the `theme` param does
the heavy lifting (one knob, big payoff). Every generator: same skeleton,
themed palette/dressing, deterministic child names, sun/fill lighting.

| generator | theme | what you get |
|---|---|---|
| `terrain` | `island` (default) | Terrain3D island splat (beach→grass→cliff→snow), edge-wrapped rim, a `Sea` Water3D at a COMPUTED drownable level, conifers on the grass band, a `Clouds` group |
| `terrain` | `alpine` | rugged alpine splat (meadow valleys, stone, snow from 0.55), conifer treeline at mid heights, boulders in the snow band |
| `terrain` | `plains` | rolling plains splat (grass + dirt patches), scattered broadleaf trees + boulders |
| `terrain` | `desert` | dune splat (sand, gravel pans, stone mesas), dead trees + boulders |
| `terrain` | `meadow` (잔디밭) | LOW grassland splat (ez-tree ground set, noise dirt patches), dense `Foliage3D` TUFT carpets + sparse `Flowers3D` beds at flat spots, broadleaf groves, boulders, a HIGH HOT sun (sunlit-meadow stage) |
| `terrain` | `forest` | LAYERED woodland on a litter splat (ez-tree dark soil + noise moss patches): ~480 trees in 40 region-mixed groves (low-freq noise picks pine- vs broadleaf-dominant patches) + high-tier ELDERS, aspen/birch accent clumps and sapling fringes; bush undergrowth + FERN tuft beds near trunks; fallen logs (tipped dead trunks) near elders, stumps, mossy boulders; a meadow-treated central clearing (bright tuft carpets + sparse `Flowers3D`) |
| `terrain` | `savanna` | dry GOLDEN plains on a dry-grass splat (grass + sand pans + dirt patches): sparse wide-LOW-canopy acacias (broadleaf, gold-green tint, 3.5–5 m), cured gold tuft carpets, dry-scrub bush clumps, scattered boulders; warm hazy low sun (exposure 1.05), no water |
| `terrain` | `snow` | snow-dominant TUNDRA splat (snow at every height, rock on steep) — distinct from alpine's banded peaks: sparse cool-DARK conifers (snow-dusted tint), snow-dusted boulders, a LOW pale crisp sun (exposure 0.9), light fog, no water |
| `terrain` | `wetland` | SWAMP on a mossy-grass + dark-mud splat near a HIGH water table: a STRUCTURAL broad `Swamp` Water3D (always shipped — `quality: 'simple'`, murky green, not the blue fancy sea), lush `Foliage3D` REED beds at the waterline, mossy ground tufts, dead snags + bush undergrowth, mossy boulders; soft overcast green-grey haze |
| `terrain` | `volcanic` | dark basalt/ash splat (stone + gravel ash beds): bare DEAD snags, dark basalt boulders, 3 `Particles3D` smoke + ember vent pairs; ember-warm low sun + DENSE dark fog (the smoky read rides the fog + ember light — the atmosphere sky stays blue up high, see LEARNINGS), no water |
| `arena` | `boxes` (default) | the classic FPS stage: floor, 4 walls, colorful random crate obstacles |
| `arena` | `ruins` | stone palette, obstacles become broken colonnade rows (standing columns + toppled stumps) |
| `arena` | `garden` | hedge-green walls/blocks, `Foliage3D` grass patches, lush `Flowers3D` beds, a calm `Water3D` pool at the center |
| `maze` | `stone` (default) | brick-textured walls (`wall/blocks` color + normal maps) with coping caps, stone floor, west entrance → east exit |
| `maze` | `hedge` | foliage-look walls (grass map) with trimmed `Foliage3D` grass strips along the wall tops, lawn floor, corridor grass patches |
| `maze` | `canyon` | warm sandstone walls, sand floor, boulders perched on the wall rim |

**`terrain`** (3D heightfield world): emits the canonical physics recipe —
a `Ground` `StaticBody3D` with `collider: {shape: 'heightfield'}` and a
`Surface` `Terrain3D` child — plus the theme dressing above, ALL placed by
probing the exact heightmap the node rebuilds at runtime (`heightAt` for
tree/rock/grass ground contact, slope + height-band rejection so trees skip
sand/snow/steep cells). Options: `theme`, `size` (200 m square), `maxHeight`
(0 = theme default: island 4.5, alpine 8, plains 4, desert 5, meadow 1.2,
forest 2.5, savanna 3, snow 2.2, wetland 1.4, volcanic 4), `water` (adds a
low valley `Lake` to non-island themes; the island ALWAYS ships its sea and
the wetland ALWAYS ships its broad swamp water — see the sea-level math
below). Islands
auto-fit `maxHeight` DOWN when a seed realizes rim hills no drownable sea
could cover. Live (conifer/broadleaf) trees get a per-grove SPECIES color
drawn from the seed: conifers deep blue-green (pine/spruce/fir) with red-brown
bark, broadleaf mid-warm-green oak/maple, lighter yellow-green aspen/birch
(grey bark), plus a few autumnal gold/amber groves — grove-to-grove drift on
top of Tree3D's per-instance jitter, so a forest reads varied, not one flat
green. Same seed → byte-identical colors (deterministic); dead/bush keep their
node defaults.

**Themed atmosphere — `terrainEnvironment(theme, size?)`**: generators emit
node trees, so the matching scene `environment` header ships as a separate
one-liner. It composes the whole rendering stage per theme — physical `sky`
(island: bright maritime; forest: low WARM sun raking dappled pools under a
close green-grey haze, lifted ambient so shadowed floor stays readable; desert:
warm dusty; alpine: crisp far fog + cooler sun + 0.92 exposure so the snow
keeps texture; meadow/plains: pleasant mid-morning; savanna: warm hazy late
afternoon over the gold grass; snow: low pale crisp cold-blue light + 0.9
exposure; wetland: soft overcast green-grey humid haze + lifted ambient;
volcanic: ember-warm low sun + DENSE dark fog + 0.86 exposure — the smoky
mood rides the fog + ember light, NOT the sky dome, which stays Preetham-blue
up high), size-scaled `fog`, `shadows: true`, a LOW ambient (0.18 — the sky's
image-based ambience is the fill; the generator's `Sun` light runs at ~1.7
against it so shadows stay visible under ACES; deep-haze themes lift it:
forest/wetland 0.24–0.26, volcanic 0.22) and a per-theme `iblIntensity` (the
`?scene=tree` stand's lit-FORM dial: cuts the physical-sky IBL below full so
the hot sun carves each crown's lit-vs-shadow side instead of a flat-bright
wash — volcanic 0.5, forest 0.55, plains 0.58, savanna/meadow 0.6–0.62,
wetland 0.66, alpine/island/snow 0.72, desert 0.75; island stays mild so the
deep cut never dulls its sea). Spread it into your scene:

```ts
import { runGenerator, terrainEnvironment } from 'incanto/env';
const scene = {
  …,
  environment: { background: '#9bc4e2', ...terrainEnvironment('island') },
  root: { …, children: [camera, runGenerator('terrain', { seed: 7, theme: 'island' })] },
};
```

**`arena`** (3D FPS stage): a `StaticBody3D` floor, 4 perimeter walls and N
obstacles — every body a box collider with a `MeshInstance3D` `Skin` child
(the canonical visible+collidable composition) — plus `Sun`/`FillLight`/
`Lamp` lights. Children are deterministically named (`Wall1`…`Wall4`,
`Obstacle1`…`ObstacleN`). Options: `theme`, `width`, `depth` (meters,
default 30), `wallHeight` (3), `obstacles` (8).

**`maze`** (3D): a recursive-backtracker maze — entrance on the west edge,
exit on the east, every cell reachable — as one-tile-thick `StaticBody3D`
wall boxes (merged into runs) over a floor slab, with lights. Every theme
textures its walls and floor (worldspace `repeat` tiling on the
`MeshInstance3D` skins) and adds structure: junction `Pillar`s where runs
meet, `Gate1`–`Gate4` posts framing the entrance/exit plus a tinted
`EntrancePath`/`ExitPath` floor tile; 'stone' caps every run with coping
trim, 'hedge' grows `HedgeTop` grass strips along the longest rims, 'canyon'
keeps its rim boulders. All dressing is decorative `MeshInstance3D` — the
COLLISION layout (walls only) is identical across themes, and the carved
LAYOUT is identical across themes for the same seed. Options: `theme`,
`width`/`depth` (corridor cells, default 8), `cellSize` (2 m), `wallHeight`
(2.5 m). The boolean grid is also available in the library as
`carveMaze(rng, cols, rows)` for pathing/item placement.

**Maze mood — `mazeEnvironment(theme, span?)`**: the terrainEnvironment
contract for mazes. Maze scenes should read MOODY at corridor level, not
showroom-bright: every theme gets a LOW hazy sun, exposure 0.85–0.95, dim
ambient (≤0.15) and a close fog window (scaled by `span` — pass
`(2·width+1)·cellSize` if you changed the size, default 34). 'stone' is the
coolest and foggiest (dungeon courtyard), 'hedge' an overcast garden,
'canyon' a warm dusk raking the sandstone. The generator's `Sun`/`FillLight`
are theme-graded to match — pair both halves:

```ts
import { mazeEnvironment, runGenerator } from 'incanto/env';
const scene = {
  …,
  environment: { background: '#9bc4e2', ...mazeEnvironment('stone') },
  root: { …, children: [camera, runGenerator('maze', { seed: 7, theme: 'stone' })] },
};
```

**`maze2d`** (2D): the SAME algorithm as `ColorRect2D` floor +
`StaticBody2D` wall tiles with `ColorRect2D` skins (px, y-down, centered).
Options: `cols` (10), `rows` (8), `cellPx` (64).

**`dungeon2d`** (2D roguelike): rectangular rooms joined by 1-tile
L-corridors on a 32px tile grid — `ColorRect2D` floor rects (`Room1`…,
`Corridor1H`/`1V`…) ringed by merged `StaticBody2D` wall segments with
skins. Options: `rooms` (5), `size` (px; square number on the CLI,
`[width, height]` via the library; default [960, 720]).

**`platforms2d`** (2D course): `StaticBody2D` rect colliders with
`ColorRect2D` skins, left to right. Spacing is caller-tunable — keep `gapX`
and `stepY` inside your character's jump arc (px, y-DOWN: negative `stepY`
steps UP).

### Migrating from the removed generators

`meadow`, `forest`, `island`, `rocks` and `clouds` left the catalog — they
are terrain themes now (`runGenerator('meadow', …)` is a hard error that
says so):

| before | now |
|---|---|
| `incanto-env meadow --seed 42` | `incanto-env terrain --seed 42 --theme meadow` |
| `incanto-env forest --seed 42` | `incanto-env terrain --seed 42 --theme forest` |
| `incanto-env island --seed 42` | `incanto-env terrain --seed 42 --theme island` (smooth Terrain3D island, computed sea level, heightfield collider — no longer voxel) |
| `incanto-env rocks` / `clouds` | themes scatter their own; the library still exports `generateRocks`/`generateClouds` (deprecated) for custom compositions |
| `incanto-env terrain` (voxel) | library-only `generateVoxelTerrain` — see the voxel worlds note |

The old functions stay exported from `incanto/env` (deprecated, see jsdoc)
so library code keeps compiling.

### Voxel worlds

The old voxel terrain lives on as **`generateVoxelTerrain`** (library-only,
NOT in the catalog, not deprecated): a `VoxelGrid3D` whose `voxels` prop
carries the heightfield as `[x, y, z, tile]` tuples (value noise; grass
top, dirt fill, bedrock base). Reach for it when BLOCKS are the point —
digging, building, minecraft-style games on `VoxelGrid3D` (colliders are
the game's job: chunk trimeshes near the player, see the minecraft
template). Baked `voxels` land WHEN THE PROP DOES — at load, before any
`onReady` — so a chunk builder at ready already sees them; a generator that
also fills the grid at ready must MERGE them, because `setBlocks` replaces
the map: `grid.setBlocks([...generated, ...grid.blocks()])`, baked blocks
last so a baked block wins over the terrain the noise put there (the
minecraft template's beacon tower is exactly this). Options: `seed`, `size` (32 blocks), `height` (8), `water`. The
voxel `generateIsland` (radial dome falloff, sandy shore) also remains for
the same use case.

**Digging and building** is the node's own API, and for a long time none of it
was written down here — the whole point of a voxel world is that it changes:

```ts
const world = this.node.getNode('World') as VoxelGrid3D;
world.tileAt(x, y, z);              // the tile there, or undefined
world.removeBlockAt(x, y, z);       // dig — true if there was one
world.addBlock({ x, y, z, tile: 3 });
world.setBlocks(blocks);            // replace the lot (emits `blocksChanged`)
world.blockCount();                 // how many are standing
world.blocks();                     // every one of them, to save or inspect
```

Grid coordinates, not world ones — multiply by the node's `blockSize` to place
something on a block. `TileMap2D` answers the same questions in two dimensions
(`cellAt`/`tileAt`/`worldAt`/`setTile`), and that one takes WORLD coordinates
because a flat world has a cursor in it.

## Library

```ts
import {
  generateArena, generateTerrain, generateMaze, generateMaze2D,
  generateDungeon2D, generatePlatforms2D, generateScatter,
  generateVoxelTerrain, GENERATORS, runGenerator, carveMaze, insertIntoScene,
} from 'incanto/env';

const world = generateTerrain({ seed: 42, theme: 'forest', size: 160 });

// Every generator is also invokable uniformly by name — what the CLI and
// editor use. Unknown names are a hard IncantoError listing the valid ones:
const island = runGenerator('terrain', { seed: 7, theme: 'island' });

// GENERATORS is the machine-readable catalog (description, dimension, typed
// params with defaults/min/max/options) — render UIs and help from it. The
// theme params carry options arrays that drive the editor dropdowns:
console.log(GENERATORS.terrain.params.theme.options);
// ['island','alpine','plains','desert','meadow','forest','savanna','snow','wetland','volcanic']

// Scatter takes weighted node JSON templates — YOU say what to strew:
const props = generateScatter({
  seed: 42,
  count: 30,
  area: [36, 20],
  items: [
    { type: 'MeshInstance3D', props: { mesh: 'cylinder', size: [0.3, 2, 0.3],
        material: { color: '#6b4a2b' } }, weight: 3 },                  // trees (3x as likely)
    { type: 'MeshInstance3D', props: { mesh: 'sphere', size: [0.5, 0.5, 0.5],
        material: { color: '#777777' } } },                             // rocks
  ],
});
// Each instance gets a random position in `area`, y-rotation, and ±20% scale
// jitter; the template's position[1] survives as the ground offset.

// insertIntoScene is PURE — it returns a NEW scene, inputs untouched:
let scene = JSON.parse(readFileSync('src/game.scene.json', 'utf-8'));
scene = insertIntoScene(scene, meadow, 'Root/Level');
scene = insertIntoScene(scene, props);          // default: under the root
writeFileSync('src/game.scene.json', JSON.stringify(scene, null, 2));
```

Tune `generatePlatforms2D` spacing for reachability:

```ts
generatePlatforms2D({
  seed: 3,
  count: 14,
  width: [80, 160],   // platform width range, px
  gapX: [40, 120],    // edge-to-edge horizontal gap, px
  stepY: [-80, 40],   // vertical step, px y-down — negative climbs
  start: [0, 300],    // first platform center
});
```

## Foliage3D

An instanced vegetation field (in `incanto/3d`, registered by
`registerNodes3D`): grass, flowers or reeds scattered over an XZ `area`.
Placement is deterministic from the `seed` PROP (never the engine rng), so
the same JSON grows the identical field on every machine.

Four looks, picked by `style`:

| style | what renders | when to pick it |
|---|---|---|
| `"tufts"` | ONE InstancedMesh of 3-crossed-quad CARDS (12 verts each) carrying a baked multi-blade alpha-cutout texture, per-instance clump-coherent green tints + dry patches, ported sin·cos sway — kind `grass` only | the dense 잔디밭 — the ez-tree demo's actual grass mechanism; overlapping textured fans read as a full meadow at a fraction of the instances. Layer a sparse `mesh` field on top for close-up blade silhouettes. ⚠️ Cards are BIG (drawn ~1.5× `height`, wide) + opaque, so over a field of small gameplay sprites (mobs/items) they hide actors shorter than the grass — there, prefer `"blades"` (thin) or keep `height` low |
| `"mesh"` (default) | ONE InstancedMesh where EVERY instance is a real tapered blade (7-vertex strip) curved along a vertex-shader quadratic bezier — kind `grass` only | real per-blade geometry — best up close; reads thin at field scale |
| `"blades"` | TWO crossed quads per slot; the fragment shader draws 8 procedural SDF blades per quad — kind `grass` only; bends to the player (`interaction`) and receives scene `fog` (distant fields melt into the haze like the mesh grass / trees / water) | pinning the pre-mesh ported look |
| `"simple"` | legacy crossed quads, per-instance colorA→colorB lerp, whole-field shear sway | `flowers`/`reeds` (their only look); ultra-cheap set dressing |

What the **tufts** style does (the recon finding behind it: the famous
ez-tree meadow is NOT per-blade geometry — its `grass.glb` is three crossed
quads with one grayscale tall-grass cutout texture, instanced 5 000× with
random green instance colors):

- the card texture is **baked at runtime** (~80 tapered blade silhouettes
  fanning from a clumped base, dark roots → bright tips) — no asset fetch,
  headless-safe (no DOM → no texture, geometry still deterministic);
- count is `⌊area·density/6⌋` (cards are BIG); cards stand ~2.2× `height`
  and ride the same regional height / dry-patch / coverage noise as mesh;
- per-tuft luminance varies ~2× (upstream-style) — that brightness
  diversity is what carves depth into the canopy;
- tufts skip the character bend (a card fan has no blade tops to push);
  generators emit `style: "tufts"` for their carpets — meadow density ≈ 30.

What the **mesh** style adds (all deterministic from `seed`):

- **Real curved blades** — each blade bends along a quadratic bezier whose
  tip is displaced by resting lean + wind + character bend; the root never
  moves. Blades are fully opaque: no alpha sorting, no transparency cost.
- **8× instances** — one blade per instance needs more instances to match
  the SDF coverage, so mesh plants `⌊area·density·8⌋` (still capped at
  `maxInstances`; past the cap the LOD keeps the NEAR field dense).
- **Clumps** — a Voronoi-ish cell id from the placement position gives each
  tuft a SHARED hue shift and lean direction, so the field grows patchy
  like a real meadow instead of uniformly green.
- **Regional height field** — low-frequency noise scales blade height
  0.55–1.7× in ~6 m patches (tall meadow tufts beside short worn spots),
  and tall blades lean/curve harder — they DROOP like real long grass.
- **Dry-hue patches** — a second noise channel lerps blades toward a dry
  yellow-green ramp, mixing cured-grass hues through the green.
- **Coverage holes** — `coverage` < 1 (default 0.85) drops blades in
  noise-carved patches so dirt breaks the carpet like a real field;
  `coverage: 1` restores the wall-to-wall carpet (and the exact
  `⌊area·density·8⌋` instance count).
- **Flowers** — DEPRECATED: `flowers` (0–0.2, default 0) renders that
  fraction of instances as cheap 5-vertex heads riding the canopy. They
  read as confetti — use a **`Flowers3D` node** (real plants, below)
  instead; the generators already do. The prop keeps working for existing
  scenes.
- **Base-to-tip ramp** — blades root in `groundColor` (soil, with DEEP
  root occlusion — the inside of the volume reads near-black), pass through
  `colorA` and tip out at `colorB`. **Pair `groundColor` with the ground
  under the field** (the `MeshInstance3D` floor color or the Terrain3D
  grass band) so roots melt into the terrain instead of floating on it.
- **Sun sheen** — fake-cylinder normals across each blade feed a cheap
  Blinn-Phong vs the fixed `sunDirection`; tips glint as they sway. Match
  it to the scene's key light direction.
- **Rolling wind** — 2-octave directional gusts (large rolling wave + slow
  swell) plus per-blade flutter: waves visibly travel across the field.
- **Distance LOD** — blades shrink to zero between `fadeStart`..`fadeEnd`
  meters from the camera, and HALF the blades (instance-hash) bow out by
  the midpoint, halving far-field work. Far blades also widen slightly so
  the carpet stays closed instead of aliasing away.

mesh + blades share the character bend: with `interaction: true` the
nearest ≤4 moving bodies (`CharacterBody3D` / `RigidBody3D`) push blade
tops radially away with a smooth falloff as a character wades through.

```json
{ "name": "Grass", "type": "Foliage3D",
  "props": { "area": [30, 30], "density": 10, "seed": 7,
             "groundColor": "#2e4a26" } }
```

| prop | default | meaning |
|---|---|---|
| `kind` | `"grass"` | `grass` \| `flowers` \| `reeds` — blade silhouette (load-time check) |
| `style` | `"mesh"` | `mesh` (curved real blades) \| `tufts` (ez-tree textured cards — the dense 잔디밭) \| `blades` (SDF quads) \| `simple` (legacy quads) |
| `tuftStyle` | `"grass"` | tufts only: `grass` (dense blade-fan bake) \| `fern` (fewer, wider, arching fronds with leaflet notches — forest undergrowth) |
| `area` | `[20, 20]` | [x, z] extent in meters, centered on the node |
| `density` | `12` | instances per m² (mesh plants 8× — total capped at `maxInstances`) |
| `maxInstances` | `50000` | instance cap (hard ceiling 200000 — split bigger fields) |
| `colorA` | `"#4d7232"` | bottom/body color (mesh/blades) / lerp start (simple) — warm sunlit meadow green |
| `colorB` | `"#9aab55"` | tip color (mesh/blades) / lerp end (simple) |
| `groundColor` | `"#1f3015"` | mesh: soil tint at the blade ROOT — match the ground/terrain color |
| `sunDirection` | `[0.5, 0.8, 0.3]` | mesh: fixed sun for the specular sheen — match the key light (load-time check: non-zero [x,y,z]) |
| `fadeStart` | `60` | mesh LOD: camera distance where blades start shrinking, meters |
| `fadeEnd` | `90` | mesh LOD: gone by here (load-time check: > `fadeStart`) |
| `height` | `0.25` | base blade height, meters (mesh scales it 0.55–1.7× regionally) |
| `sway` | `0.6` | wind strength; 0 disables wind entirely |
| `interaction` | `true` | mesh/blades styles: moving bodies bend nearby grass |
| `coverage` | `0.85` | mesh: carpet fill in (0, 1] — below 1 noise carves dirt patches (load-time check) |
| `flowers` | `0` | DEPRECATED — confetti heads; use a `Flowers3D` node (load-time check 0–0.2) |
| `seed` | `1` | placement seed — identical field across runs/machines |
| `drape` | `null` | **AUTO by default**: whenever the scene has a Terrain3D, every blade roots on the ground under it (samples `heightAt`) — omit this prop and rolling terrain just works. `true` forces it (keeps retrying until a terrain appears — streamed worlds), `false` opts out (flat carpet at the node's Y) |
| `avoidWater` | `true` | blades keep OUT of the water — any instance whose ground sits under a `Water3D` or `River3D` surface is dropped, so a creek that cuts its bed through a meadow gets banks instead of blades waving in the current. `false` plants reeds and paddy fields |
| `terrain` | `""` | drape target node path; empty = **auto-find the first Terrain3D** in the tree. A non-empty path that is NOT a Terrain3D fails at load — no silent floating |

> **Rolling terrain just works.** Since 0.14 draping is on by default: with a
> Terrain3D in the scene the blades root on the ground per-instance, no wiring.
> Only set `drape: false` if you WANT a flat carpet at the node's Y (e.g. grass
> on a flat MeshInstance3D platform above the terrain).

## Flowers3D

A first-class flower FIELD (in `incanto/3d`, registered by
`registerNodes3D`): instanced procedural flower PLANTS — a curved stem,
2-3 leaf blades low on the stem, a 5-8 petal head cupped around a
contrasting center disc, 1-3 blooms per plant at staggered heights — not
billboard confetti. Deterministic from the `seed` PROP.

- **`density` is the vibe dial** — `'lush'` (풍성하게, 2.2 plants/m²),
  `'sparse'` (듬성듬성, 0.35 — the default), `'none'` (없게, renders
  nothing), or any number in plants/m². Load-time checks: valid
  preset/number AND `area × density ≤ 10 000` plants (~1.3M tris budget).
- **Varieties** — `varieties` picks a unique subset of
  `daisy` (8 flat petals, 3 blooms), `cosmos` (8 cupped petals, 2 tall
  blooms), `bellflower` (5 petals in a deep nodding bell, 3 blooms);
  `[]` (default) = all three. Each variety is ONE merged template geometry,
  instanced per plant as a green-structure mesh + a petal mesh —
  **≤3 varieties → ≤6 draw calls**.
- **Patches, not confetti** — placement gathers plants into jittered-Voronoi
  patches (`clustering` 0 = uniform … 1 = tight patches, default 0.6), and
  each patch blooms ONE variety in ONE palette color, like real meadows.
- **`palette`** — head colors as `'#rrggbb'` strings; `[]` (default) = the
  reference white/yellow/violet trio. The center disc keeps its contrasting
  per-variety color.
- **`height`** — plant height in meters (default 0.45, ±25% per-plant
  jitter; petals scale along). Pick it ABOVE the surrounding grass canopy
  (e.g. 0.6–0.7 over `height: 0.35` Foliage3D) so heads ride the grass.
- **`sway`** — gentle vertex-shader head bob, phase-hashed per plant; roots
  never move. 0 holds still.
- Like Foliage3D the field is planar — on rolling terrain place small
  patches at flat spots (the meadow/garden generators do this for you).

```json
{ "name": "Flowers", "type": "Flowers3D",
  "props": { "area": [26, 26], "density": "sparse", "seed": 7,
             "height": 0.7 } }
```

| prop | default | meaning |
|---|---|---|
| `density` | `"sparse"` | `lush` \| `sparse` \| `none` \| number (plants/m²) — budget-checked at load |
| `area` | `[20, 20]` | [x, z] extent in meters, centered on the node |
| `seed` | `1` | placement seed — identical field across runs/machines |
| `varieties` | `[]` | unique subset of `daisy`/`cosmos`/`bellflower`; `[]` = all three (load-time check) |
| `palette` | `[]` | head colors, `'#rrggbb'` each; `[]` = white/yellow/violet (load-time check) |
| `height` | `0.45` | plant height, meters (±25% per-plant jitter) |
| `clustering` | `0.6` | 0 uniform … 1 tight Voronoi patches (load-time check) |
| `sway` | `0.5` | head-bob wind strength; 0 disables |
| `drape` | `null` | AUTO by default — plants root on the scene's Terrain3D when one exists; `true` forces, `false` opts out — see the Foliage3D drape note above |
| `avoidWater` | `true` | plants keep OUT of the water (see the Foliage3D note) — `false` for lilies and marsh planting |
| `terrain` | `""` | drape target node path; empty = auto-find the first Terrain3D (a wrong path fails at load) |

## Tree3D
Procedural trees grown by the ported ez-tree generator (MIT, Dan Greenheck;
in `incanto/3d`, registered by `registerNodes3D`) — recursive gnarled
branches plus alpha-cutout leaf billboards sampling the packaged leaf
textures. One node is a whole grove in ≤6 draw calls: up to 3 seed VARIANTS
(differently-grown trees), each one branches-InstancedMesh + one
leaves-InstancedMesh, `count` instances scattered over `area` and dealt
round-robin among the variants. Construction is deterministic from the
`seed` PROP: identical grove on every machine. Per-instance
hue/scale/rotation jitter keeps a patch from reading as copies; `height`
jitters ±20% per tree; leaves ride a ported simplex-wind vertex sway.

**Crown shading (automatic):** leaf billboards do NOT light like flat
quads — the generator bakes crown-VOLUME normals (deciduous crowns shade as
an ellipsoid, conifers as a cone) plus per-leaf occlusion vertex colors
(interior leaves darken toward 0.45) and per-cluster hue/value jitter, so
canopies read as lit/shaded three-dimensional masses with a dark heart, not
paper cut-outs. The leaf cutout threshold relaxes with camera distance
(1.0× → 0.25× over 40–240 m) and edges ride MSAA alpha-to-coverage, so far
groves keep solid crowns instead of mip-ghosting away (~300 m). Leaf albedo
is calibrated for the atmosphere-sky + ACES stage; `canopyColor` still
tints on top.

**Bark** (medium/high tiers): trunks and branches sample the packaged
ez-tree BARK sets — color + normal + roughness (1k, ambientcg CC0 packaged
by ez-tree) — tiled over the generator's ring UVs with the upstream
per-preset repeat, so trunks show ridged bark up close instead of smooth
plastic. The mapping follows upstream: oak/ash → oak bark, aspen → birch,
pine → pine; `dead` trees stay barkless weathered gray. Default texture
source is the agent8 CDN (the only sanctioned external host, like leaves and
the terrain splat); the same JPGs also ship in `incanto/assets/vegetation/bark/`
for offline serving.

**Tiers are a quality/cost dial** (ported preset → tris per tree):

| tier | what grows | tris/tree |
|---|---|---|
| `simple` | the original primitive low-poly trees (cones/blobs) — cheapest, unchanged | ~0.1–0.5k |
| `medium` (default) | light `*-forest` presets — use for FORESTS (many trees) | ≤3k |
| `high` | full ez-tree presets (oak/ash rotation, airy pine whorls) — hero trees | 7–20k |

**Perf budget (load-time check):** `count × tris-per-tree ≤ 1 500 000` —
the error states the math and the max count for the tier. 500 medium trees
fit; `high` caps at ~75 trees per node (worst variant ≈ 20k). Geometry
grows lazily once (1–30 ms per variant), then scatter is matrix-only.
```json
{ "name": "Pines", "type": "Tree3D",
  "props": { "type": "conifer", "tier": "medium",
             "count": 24, "area": [40, 40], "seed": 11 } }
```
| prop | default | meaning |
|---|---|---|
| `tier` | `"medium"` | `simple` \| `medium` \| `high` — see the dial above (load-time check) |
| `type` | `"conifer"` | `conifer` (pine whorls) \| `broadleaf` (oak/ash greens — autumn-gold aspen left out of the mix on purpose) \| `dead` (bare branches, no leaves) \| `bush` (ported bush_1 shrub — undergrowth; pair with height 2–4) |
| `seed` | `1` | construction + scatter seed |
| `height` | `6` | tree height, meters (±20% per-instance jitter) |
| `count` | `1` | instances; > 1 scatters a forest patch (max 500, budget-checked) |
| `area` | `[10, 10]` | [x, z] scatter extent when count > 1 |
| `trunkColor` | `"#7a5a3a"` | bark tint — the DEFAULT hands color to the upstream preset tint (the bark maps carry it); a custom value keeps its hue, max-channel-normalized so it tints the map instead of darkening it. Headless / `dead`: flat untextured tint |
| `canopyColor` | `"#4a7c3f"` | leaf tint — hue kept, value normalized over the leaf textures |

Every instance in a grove also gets a deterministic per-instance color jitter (±~20° hue, ±0.11 lightness off the base canopy/trunk color) so neighbouring trees read varied, not a flat wall of one green — the `terrain` generator stacks per-grove SPECIES colors on top (see above).
| `leafTexture` | `""` | leaf texture URL override; `""` = packaged per-type ez-tree texture (agent8 CDN — the only sanctioned external host; loads zero-setup). The same PNGs also ship in `incanto/assets/vegetation/` (`incanto-assets list`, kind `foliage`) — copy one next to your game and point here to serve offline |
| `leafFadeStart` | `0` | **leaf LOD** — leaf cards begin collapsing toward their branch at this camera distance (m). `0`/`0` disables (full leaves at any range) |
| `leafFadeEnd` | `0` | leaf LOD — cards fully collapsed (zero overdraw) by here; tune ≈ the scene `fog.far` so the thinning hides in fog (load-time check: `> leafFadeStart`) |
| `leafShadows` | `true` | `false` keeps trunk/branch shadows but drops the expensive alpha-cutout LEAF shadow pass — big FPS in dense forests |
| `drape` | `null` | AUTO by default: each scattered instance roots at the ground height under it (instead of the node's single Y) whenever the scene has a Terrain3D — a `count > 1` grove on rolling/carved terrain never floats or buries. `true` forces, `false` opts out |
| `avoidWater` | `true` | keep trunks out of standing and running water — a valley a rill runs through has a grove on its BANKS. A wet draw is re-rolled (up to eight times) rather than dropped, so a grove keeps the `count` you asked for even over a lake. `false` is the mangrove |
| `terrain` | `""` | drape target node path; empty = auto-find the first Terrain3D (a wrong path fails at load) |

> **Tree LOD / forest FPS.** Leaf overdraw + the leaf shadow pass dominate a
> dense-forest frame (~90% in profiling). For big groves set `leafFadeStart`/
> `leafFadeEnd` (e.g. `340`/`620` under fog far ~620) so far crowns shed their
> leaf cards (branch silhouette stays), and `leafShadows: false` to drop the leaf
> shadow pass. Both default off → existing scenes are unchanged.

**`grove.trunks()`** answers where every trunk of this grove stands, in world
coordinates — recomputed from the seed, so it works headless where there is no
mesh at all. It is how a harness asks whether the drape happened:
`Math.abs(y - terrain.heightAt(x, z)) < 0.01` for every trunk. (Until it
existed, nothing could: `Tree3D` read the tri-state `drape` as a boolean, so
AUTO — the documented default, and what this table has said since 0.14 — meant
FLAT, and no check in the repo could see it.)

> **Floating trees?** Since 0.14 groves drape automatically whenever the scene
> has a Terrain3D — each instance roots on the ground under it. The drape is a
> pure heightAt lookup — no rng draw, so the seed/grove layout is
> byte-identical, only the Y shifts. `drape: false` restores the flat
> single-Y placement.

## Terrain3D

**Putting things on it.** `snapToGround: true` (a `Node3D` prop) sets a node's Y
from the terrain under it at load — and on a node with a COLLIDER it places the
collider's FOOT on the ground, not the origin. That is what every hand-written
lift in this repo already computed (`beacon-isle-3d`'s walker says `0.95` for a
capsule of radius 0.4 and height 1.1). It matters more than tidiness: a body
whose origin sits on the surface starts half INSIDE the heightfield, and a
penetrating body is ejected the nearest way out — which is down, through the
world, forever. A number still means "place the origin and lift by this".

And a terrain is not SOLID on its own: put it under a `StaticBody3D` with
`collider: { shape: "heightfield" }`, which pulls the terrain's own grid.

Procedural heightfield terrain with biome texture splatting (in `incanto/3d`,
registered by `registerNodes3D`), ported from the agent8 starter terrain:
seeded simplex octaves displace a plane grid ONCE on the CPU, and a patched
MeshStandardMaterial blends up to 4 biome textures by height and slope
(per-vertex weights in vertex colors, world-UV sampling with fbm anti-tiling
jitter — mip-correct: every sample passes the un-jittered UV's gradients via
`textureGrad` — normal maps via a screen-space TBN). Same `seed` → identical
terrain on every machine. Within 25 m of the camera every active layer is
re-sampled at 10× repeat and added as zero-mean grain (colors AND normals,
plus a crevice micro-AO), fading out by 60 m — the ground resolves into crisp
grain up close instead of bilinear mush without shifting tone; textures load
with anisotropy 8 so grazing angles stay sharp into the distance. All
automatic, no props.
{ "name": "Island", "type": "StaticBody3D",
  "props": { "collider": { "shape": "heightfield" } },
  "children": [
    { "name": "Terrain", "type": "Terrain3D",
      "props": { "size": [200, 200], "maxHeight": 6, "seed": 1, "theme": "island" } }
  ] }
**`reflectivity` (0..1, null = the preset's own)** dials how much sky the
surface throws back at grazing angles. Turn it DOWN under a bright or overcast
sky: that sky's radiance runs far past 1, so a high ceiling paints the water
white — and a Water3D doing that beside a River3D (which paints an AUTHORED sky
palette and never blows out) reads as two unrelated materials meeting at a line.
The river template's plunge pool runs 0.06 for exactly that reason.

### Condition a traced course before a river runs it

```ts
import { smoothCourse, traceDownhillPath } from 'incanto/3d';
const path = smoothCourse(traceDownhillPath(heightAt, opts), widestWidth / 2);
```

**Do this every time you feed `traceDownhillPath` into a River3D.** A steepest-
descent trace zigzags at the terrain's noise scale and can hook back within
metres of a reach it already ran. A ribbon on that course cannot be well formed:
tight corners fold its inner edge, and a hook stacks two sheets of water on the
same ground — which renders as translucent sheets of paper pasted over one
another, with no rendering fix available. `smoothCourse` relaxes the bends to
what the width can take, drops sub-width noise, and CUTS the course where it
folds back (a creek does not flow over its own reach; ending is the better
answer).

### A river's course sets its maximum width

A `River3D` surface is a RIBBON swept along `path`, and a swept ribbon cannot be
wider than the bend it is taking — past that, its inner edge folds through
itself. The engine holds the INNER bank inside the local turn radius (the outer
bank keeps every metre the ground gives it, which is what a real bend looks
like: point bar inside, cut bank outside).

The failure this prevents is worth recognising, because it is the one visual bug
this node keeps producing: **bare channel floor beside the water — the creek
appears to stop in the middle of its own bed.** It means the ground is wetter
than the course can carry. River3D says so at load:

```
[incanto] River3D 'Creek': the course is too tight for the water it holds —
41 of 608 stations leave bare channel beside the ribbon (worst at 210 m: ground
wet to 11.3 m from the centre, a bend that can only carry 2.1 m). Fix by EASING
THE BENDS in `path` (fewer, straighter points), NARROWING `widths` there, or
placing standing water (a Water3D) if that reach is really a pool.
```

Three rules that keep a river out of this entirely:

1. **Widths ≤ ⅓ of the tightest bend.** A `traceDownhillPath` result zig-zags;
   thin it or raise its `step` before handing it to a wide river.
2. **A wide slow reach is still the river.** Don't put a Water3D pool at the
   foot of a River3D — they are different shaders with different sky and foam
   models, they cannot be tuned to match, and they meet along a visible seam.
   Open the `widths` profile instead (`[2.4, 4.5, 7, 9]` is a spring→pool run).
3. **Verify it, don't eyeball it.** `findRiverCoverageGaps(rings)` (exported from
   `incanto/3d`) reports the reaches where the ribbon falls short, and a probe a
   metre outside each bank — ground still BELOW the waterline out there — counts
   bare channel headlessly. The river template gates on both.

That snippet is the canonical PHYSICS recipe: the `heightfield` collider has
no params of its own — it pulls the height grid from its Terrain3D CHILD, so
visuals and collision can never drift apart. It is static-only (load-time
check) and hard-fails at physics start if the Terrain3D child is missing.
Skip the StaticBody3D wrapper when the terrain is scenery-only.
**Themes** preset the splat layers (and the island edge wrap) — load-time
check lists them on a typo:
| theme | layers (height × slope placement) | edge wrap |
| `island` | sand beach 0–0.12 · grass 0.1–0.7 · stone on slopes >45° · snow 0.8–1 | ON — borders curl down a quarter-circle to −50 m (sea floor) |
| `alpine` | grass valleys 0–0.45 · stone mid+high · gravel on slopes >36° · snow from 0.55 | off |
| `plains` | grass everywhere · dirt patches 0.35–0.65 · gravel on slopes >45° | off |
| `desert` | sand everywhere · gravel pans 0–0.15 · stone mesas on slopes >45° | off |
| `grassland` | the ez-tree demo GROUND set (mossy grass + dense crisp dirt, 30 m tiling): grass at EVERY height · dirt on slopes >45° AND in noise-carved flat patches (the demo's exact 100 m simplex / 0.7 patchiness shaping). With a custom `textureBase` it falls back to the `<base>/grass.png` + `dirt.png` contract | off |
| `forest` | grassland INVERTED — the LITTER floor: the ez-tree dark organic soil at every height/slope, moss-grass ONLY in noise-carved patches (55 m simplex, ~1/4 coverage). Custom `textureBase`: `<base>/dirt.png` + `grass.png` | off |
| `savanna` | dry GOLDEN plains: grass at every height · sand pans 0–0.22 · dirt on slopes >45° AND in noise-carved patches (the warm sun does the golden tinting) | off |
| `snow` | snow-dominant TUNDRA: snow at every height · stone (rock) on slopes >45° only — flat white field, distinct from alpine's banded peaks | off |
| `wetland` | SWAMP floor: mossy grass at every height · dark dirt (mud) on slopes >45° AND in frequent noise patches (the soft overcast light reads it humid) | off |
| `volcanic` | dark BASALT/ash: stone (basalt) at every height/slope · gravel (ash beds) pooling in the lows 0–0.28 | off |
| `custom` | YOUR `layers` (1–4 entries, load-time checked) | off |
Custom layers look like
`{ "texture": "url.png", "normalMap": "url_normal.png", "heightRange": [0, 0.3], "slopeRange": [0, 0.8], "repeat": 2, "roughness": 0.9 }`
— `heightRange` is normalized 0..1 over the realized height span, `slopeRange`
is radians of `atan(|∇h|)`. Default textures stream from the live agent8 CDN
(`textureBase` prop) — override it to self-host.
| `size` | `[200, 200]` | [width, depth] meters, centered on the node |
| `maxHeight` | `28` | height SCALE — realized heights land at ~3.5–8× this (the ported pipeline sums positive noise octaves); read real numbers off `heightAt` |
| `seed` | `1` | integer seed — deterministic terrain |
| `resolution` | `128` | grid segments per side (2–256, load-time check). 128 ≈ 2 m cells on a 260 m map; raise to 256 when the map carries something finer, e.g. a creek bed |
| `theme` | `"island"` | see table — `custom` requires `layers` |
| `roughness` | `0.5` | detail-octave persistence (higher = more rugged) |
| `detail` | `4` | detail octave count |
| `flatThreshold` | `0.95` | snap-flatten heights within ±0.6 m of `maxHeight·flatThreshold` — a fraction of the SCALE, not of the peak, so the default plateau lands around 18% of the realized height (measured: maxHeight 28 → band at 26.6, peak 150). Read `heightAt` before choosing one |
| `islandEdge` | `null` | `null` = theme decides; force the rim wrap on/off with a boolean |
| `layers` | `[]` | custom splat layers (theme `custom` only) |
| `textureBase` | agent8 CDN | base URL: `<base>/<name>.png` + `<base>/<name>_normal.png` |
| `basins` | `[]` | lake/pond bowls — `[{ x, z, radius, depth }]` (centered meters) carved into the surface (smooth, 0-slope rim); **negative `depth` RAISES a smooth dome mound instead** (a hill, an islet); `heightAt`, the collider AND draped grass all see them |
| `channels` | `[]` | river/road TRENCHES carved along polylines — `[{ path, width, depth, taper? }]` (the line counterpart of `basins`). Points are `[x, z]` **or `[x, y, z]`** (the y ignored), the same as `River3D.path`, so one array feeds both. This is what gives a `River3D` a bed to sit in; `heightAt`, the collider and draped vegetation all see it |
| `wetline` | `null` | WET SAND band — `{ y, band? }` darkens + glosses the splat in a noisy, breathing band just above height `y` (a waterline): the trace of the last swash runup. `band` (default `1.1` m) caps how high above `y` reads wet. `null` = off |

**A `Terrain3D` DRAWS a landscape; it is SOLID only under a body.** Colliders are
props on bodies in this engine, never implicit, so a bare terrain is scenery the
player falls through — while it renders perfectly, `heightAt` answers, and
`snapToGround` puts the whole scene on it. Wrap it:

```json
{ "name": "Floor", "type": "StaticBody3D", "props": { "collider": { "shape": "heightfield" } },
  "children": [ { "name": "Ground", "type": "Terrain3D", "props": { "size": [180, 180] } } ] }
```

`auditScene` (and so `incanto-check`) says so now, naming the node — a game
spent an hour falling through the world before it did. It only speaks when
NOTHING in the scene is solid: a decorative terrain beside a solid one (a lagoon
bed under the waves, a far ridge) is ordinary.


**Wet sand at a shoreline.** Set `wetline.y` to the SAME world height as the
adjoining `Water3D` surface and the beach keeps a damp, glossy apron that
breathes with the swash — sell any beach/lakeshore in one prop:
```ts
// Sea sits at y = 10 → the sand within ~1 m above the waterline reads wet
"wetline": { "y": 10, "band": 1.0 }
```
On a near-flat sandbar remember the band is VERTICAL meters: a 1 m band on a
1:50 slope is a ~50 m apron — drop `band` to `0.4–0.6` if only a narrow strip
should read wet.

**A round sand islet in the open sea** (the postcard drone shot): don't hunt
for a dune that pokes out — SHAPE the island with nested negative-depth
basins on a low desert field, then flood to just below the dome top:
```ts
// all-sand splat, ~0.5 m of noise so the waterline contour stays organic
{ theme: 'desert', size: 150, maxHeight: 0.1, islandEdge: true,
  flatThreshold: 20,   // disable the flat-snap — at tiny maxHeight the ±0.6 m
                       // band swallows the field and its per-vertex boundary
                       // rips a sawtooth across the underwater flat
  basins: [
    { x: 0, z: 0, radius: 55, depth: -3 },    // broad shield → turquoise ring
    { x: 0, z: 0, radius: 30, depth: -4.2 },  // dome → the sand cap (max nests)
  ] }
// water at domeTop − 0.9: a ~20 m round cap, glowing shallows, rim wraps deep
```
The sea cuts the dome's CURVED flank, so the waterline is a round organic
contour — a flat bar's waterline slices along the desert lows' planar patches
and reads as long straight cellophane edges. Pair with a second deep Terrain3D
(e.g. 12 m down) so the surround reads saturated blue, not endless pale flat.

**Carving a lake.** A flat Water3D plane on rolling/dome terrain reads as a wet
patch, not a lake. Carve a `basins` bowl so water fills a real depression with a
shoreline, then drop a `Water3D` at the floor + a little:
```ts
// Terrain3D prop: a 9 m-radius, 3 m-deep bowl at the interior point (20, -15)
"basins": [{ "x": 20, "z": -15, "radius": 9, "depth": 3 }]
// then place water just above the carved floor (heightAt sees the bowl):
const floor = terrain.heightAt(20, -15);          // ≈ surroundingGround - 3
pond.position = [20, floor + 1.2, -15];            // ~60% of the bank submerged
```
Keep `radius`/`depth` gentle (depth/radius ≈ 0.3–0.5) so the bank stays walkable
and the shore splat reads. Interior ponds/lakes are cheapest as `Water3D
quality:'simple'` — the lake shader (fresnel sky tint + ripple normals + sun
glint + soft edge fade) with ZERO scene re-renders, vs fancy's ~2 full-scene
re-renders a frame. Give simple ponds a vivid water `color` (a muddy/dark hue
reads grey, since the surface IS mostly that color plus a sky sheen) and a
`sunDirection` matching the key light so the glint lands; its soft alpha edge
melts the rectangular plane into the shore (no hard rim poking out of the bowl).
**`heightAt(x, z)`** answers the surface height (world y) at WORLD x/z —
bilinear over the displaced grid, works headless and before any render.
Spawn points, prop placement, AI ground checks:
```ts
const terrain = scene.root.getNode('Island/Terrain') as Terrain3D;
crate.position = [x, terrain.heightAt(x, z) + 0.5, z];
```

Splat bands follow the surface the eye sees: the island rim's wrap shoulder
descends through the bands (snow → grass → sand) and every shoreline ends in
the bottom layer, so beaches happen automatically at the waterline.

**Island sea level is a TWO-sided constraint** — probe heights with
`heightAt` (or `buildHeightmap`) instead of guessing:

- the edge wrap only drops the rim 20 m before the vertical skirt to −50 m,
  so the waterline must sit ABOVE `(highest border height − 20 m)` or bare
  cliff walls with stretched stripes poke out of the sea;
- it should also sit inside the sand band (bottom 12% of the realized span)
  so the visible shore reads as beach.

Both fit comfortably at `maxHeight ≈ 4–5` with `size: [200, 200]` (e.g.
seed 1 → surface 10.4–32 m, cliffs top out at 12.0, sea at 12.8 works).
Tall islands (`maxHeight ≥ 8` at that size) have rim heights no drownable
sea level can cover — scale height with care, the wrap radius does not grow.

`runGenerator('terrain', { seed, theme: 'island' })` does ALL of this math
for you: it probes the border with `buildHeightmap`, places the sea above
every rim cliff top and inside the sand band, and auto-fits `maxHeight`
down for seeds whose rim is too tall. Hand-author only when you need a
non-default composition.

## Weather3D — rain and snow that follow the camera

The scene had a sky, a fog, a cloud deck and a sun, and no weather: rain was
forty lines of `Particles3D` tuning an author had to know, plus a script to
keep the emitter over the camera. One node now:

```json
{ "name": "Rain", "type": "Weather3D",
  "props": { "kind": "rain", "intensity": 0.7, "wind": [2, 0] } }
```

| prop | default | what it does |
|---|---|---|
| `kind` | `"rain"` | `rain` — fast, straight, a shower · `snow` — slow, drifting flakes |
| `intensity` | `0.6` | how much of it, 0..1; scales the rate LIVE (a storm that builds, a shower that passes); `0` = a dry sky |
| `radius` | `20` | half-width (m) of the box the drops are born in, around what is followed |
| `height` | `12` | how far above it they are born (m) |
| `wind` | `[0, 0]` | a steady drift, m/s, on the ground plane `[x, z]` — every drop is born with it |
| `follow` | `""` | what to follow; `""` = the current `Camera3D` |

- It is a `Particles3D`: every particle prop is still there to override (a
  colour, a size, `maxParticles`), but `kind` sets them all and the drops
  fall in WORLD space, so what fell stays where it fell when the camera
  moves on. Headless it simulates like any emitter (`aliveCount`), so a
  harness can assert that it rains.
- **A raindrop is a STREAK.** The particle system draws soft round dots, which
  is right for smoke and sparks and reads as a dirty lens for rain: measured in
  a browser at `intensity: 1`, 2,600 dots the size of dust and nothing that
  looked like weather. Rain now draws with its own stamp — a bright vertical
  line with soft ends — and the drop is 55 px long (0.55 m) instead of 10.
  Snow keeps the flake.
- **The sun goes BEHIND the camera in rain.** Lit from ahead the streaks wash
  into a bright sky and vanish; from behind they read against it. The rest of a
  storm is the atmosphere block, written live as the weather builds:

  ```ts
  env.sky = { type: 'atmosphere', elevationDeg: mix(30, 13), azimuthDeg: 10,
              turbidity: mix(5, 14), rayleigh: mix(1.8, 0.65) };
  env.ambient = { color: '#9fb0cc', intensity: mix(0.62, 0.3) };
  fog.far = mix(120, 34);
  ```

  Every one of those is picked up per frame, so a storm is a behaviour writing
  numbers — there is no weather state machine to learn.
- **`intensity` is what a game holds on to.** Nothing else about the weather is
  gameplay: whether the rain MEANS anything is the game's own wiring. In
  `examples/moor-3d` it drives the fog, the sky and how far a hound's `Sight`
  reaches — the same hound closes 4.1 m on the player in the clear and 0.0 m in
  the downpour.
- Composed in `examples/rooftops-3d` (a shower over the run) and
  `examples/moor-3d` (a storm that IS the game).

**It OWNS the emitter it inherits.** `Weather3D` extends `Particles3D` and tunes
the whole thing from `kind` and `intensity` on the first frame — `rate`,
`speed`, `lifetime`, `drift`, `emitBox`, the colours, the sizes, the blend. Those
props are inherited, so the loader takes them and the editor offers them, and
they are DISCARDED. `kind`, `intensity`, `radius`, `height`, `wind` and `follow`
are what steer it; `auditScene` names any of the others you write.

Two props `Weather3D` needed became general: **`emitBox`** on
`Particles2D/3D` (half-extents of a box the particles are born in — a
cloud, a dusty field, sparks along a bench; `[0,0,0]` = a point) and
**`drift`** (a steady velocity every particle is born with — wind on smoke,
a current in water).

## Flock3D — birds and fish

Forty-eight examples and not one flock — no gulls over the harbor, no fish
under the boat — because a flock was a behaviour per bird and a body per
bird, and nobody paid that. `Flock3D` is an `InstancedMesh3D` that flies its
own instances:

```json
{ "name": "Gulls", "type": "Flock3D",
  "props": { "position": [0, 14, 0], "count": 40, "radius": 30, "height": 6,
             "mesh": "box", "size": [0.5, 0.08, 0.3],
             "material": { "color": "#f4f4f4" }, "follow": "/root/Boat" } }
```

| prop | default | what it does |
|---|---|---|
| `count` | `30` | how many; live — a change reseeds |
| `radius` / `height` | `20` / `8` | the bound: an ellipsoid this wide and this tall around the centre, which they turn back inside of. A flat one (`height` 1.5 at y −2) keeps fish under the surface |
| `speed` | `6` | every bird's pace, m/s — always, so a flock never stalls |
| `turn` | `2.5` | the sharpest turn, rad/s; lower is lazier, gull-like |
| `separation` | `1.5` | closer than this and two birds push apart |
| `sight` | `5` | how far a bird sees neighbours for the next two |
| `alignment` / `cohesion` | `1` / `1` | match the neighbours' heading; drift toward their centre. `0` switches one off |
| `follow` | `''` | a node the centre follows, offset by this node's `position` — gulls 14 m over the boat, wherever it sails |

Plus everything `InstancedMesh3D` has (`mesh`, `size`, `material`, shadows);
`transforms` is driven for you and `collider` is refused — birds are not
solid. Each instance faces +z along its flight, so a mesh longer in z reads
as a body with a beak. One draw call; forty birds cost forty-squared distance
checks a frame, so keep `count` in the dozens, not the thousands.

Seeded from `engine.rng`: a seeded run flies the same flight. **`boids()`**
answers headless — `{x, y, z, vx, vy, vz}` per bird in world metres — so a
harness can ask that the fish stayed under the water and the gulls over it
(`examples/harbor-3d` does).

**It owns `transforms`.** `Flock3D` extends `InstancedMesh3D` and rewrites them
every frame from the boids, so authoring a row there does nothing — `count`,
`radius`, `height`, `speed`, `turn`, `separation`, `sight`, `alignment`,
`cohesion` and `follow` are what steer it, and `auditScene` says so if you write
the other. `boids()` answers where every one of them is, in world metres and
m/s, which is how a harness asks whether they kept station.

## Water3D

### The player's graphics setting reaches this node

At **quality `low`** a fancy surface collapses to the single-pass lake shader —
automatically, at runtime, no props involved. That turns several full scene
re-renders a frame (planar mirror, reflection cube, refraction grab, depth
completion) into **none**, which is the largest single saving available on a
weak device.

At **`medium`** the surface keeps the fancy shader and loses only the planar
`mirror` — the most expensive single thing it does, a whole extra render of the
scene per frame. It still reflects sky and far shore through the cube; what goes
is the sharp reflection of nearby geometry. So a mirror lake still reads as a
lake on a mid-range phone. See `incanto-performance.md`.

Two rules you can rely on:

- **A tier only ever takes away.** Water authored `quality: 'simple'` stays
  simple at `high` — a tier never upgrades a scene into something it did not
  ask for. And the cap is not an edit: the scene file still says what you wrote.
- **`heightAt()` follows what is actually drawn**, so buoyancy and interaction
  ripples match the surface on every device.

A big surface (over ~110 m a side — an ocean, not a lake) stays on the fancy
shader at `low` and takes `medium`'s behaviour instead. The simple shader's only
waterline is a fade of its own rectangle, which lands hundreds of metres out to
sea on an ocean plane; without this it met the beach in a razor-straight line.

It is not free: the simple shader has no samplers, so at low the shoreline
dissolve, contact foam and depth absorption go away. If your game's look depends
on them — a resort pool, a mirror lake — say so in the scene by pinning
`quality`, and accept the cost on low-end devices.

A commercial-quality water surface (in `incanto/3d`, registered by
`registerNodes3D`). **Four curated water types are one prop away:**
`preset: 'ocean' | 'pool' | 'lake' | 'pond'` — see the prop table; the four
`examples/water-*-3d` templates stage one each (cove pier, resort pool,
mirror-lake dock, walled garden pond). The default `quality: 'fancy'` is a
real water shader:
9-iteration simplex-FBM wave displacement with analytic normals — or, with
`wind` > 0, a real JONSWAP SPECTRUM inverse-FFT'd on the GPU every frame (the
ocean preset: 65 536 waves, each at its own deep-water speed, choppy crests,
whitecaps where the surface folds, and a per-pixel slope texture whose mips
hand a far pixel the slope VARIANCE it integrates — the roughness of a distant
sea, measured rather than guessed) — plus optional
DIRECTIONAL TRAVELING SWELL trains + open-water whitecaps (`swell` /
`whitecaps` — the open-ocean look; see the prop table), a DISPERSION-TIMED
micro-ripple spectrum — four octaves (λ 6 m → 0.38 m), each advected at its
REAL deep-water phase speed so short ripples flicker ~2×/s (slow fine ripple
is what makes water read as syrup), the fine octaves interference-PAIRED so
the surface churns instead of sliding, each octave distance-faded, plus a
16 cm "spark" octave inside arm's reach — the SUN as a real GGX microfacet
highlight off that spectrum (near the camera every resolved facet
that faces the sun flashes, HDR so the bloom pass reads it; with distance
the roughness grows and the same lobe widens into the continuous sun path a
far sea shows — no azimuth gate, no hand-shaped powers), a SEA-STATE
fresnel (the far, wind-roughened field reflects the sky at the ~0.5-0.6 a
rough mirror really does at grazing angles, so the ocean stays a darker band
under its sky instead of whiting out into the horizon haze; calm near water
is untouched), light THROUGH the crest (with `swell`: looking toward the sun
the thin crests glow translucent turquoise — lit from inside, the one cue
that says water rather than painted lumps), per-channel
Beer's-law depth `absorption` (red dies first — turquoise shallows fade
WIDE into deep teal-blue, like a real sea), CRYSTAL shallows (inside the
first ~1.5 m of water the refracted bottom dominates, ripple-distorted —
you see the sand through the surface), screen-space `refraction`
(submerged geometry shimmers SOFTLY through the surface — the offset reads
the smooth gloss normal, fades to zero at object waterlines, mip-blurs the
grab pass and blends instead of snapping at above-water silhouettes, so
contact lines stay clean with no comb-pattern streaks), always-on wispy
noise-broken shoreline `foam` (the foam band's depth range scales with the
water's extent: ponds get a contact kiss, open seas get whole shallow
channels), a trough/surface/peak color ramp (teal sea-blue defaults),
fresnel-mixed CubeCamera reflections re-rendered every `reflectionInterval`
ms and an edge-fade ring. Fresnel/reflection read a SMOOTH long-wavelength
normal (never the ridged ripple — that's what kept turning HDR sky
reflections into white sequins), HDR-capped per sample. `quality: 'simple'`
is the cheap LAKE shader — fresnel sky-tint reflection (a procedural
horizon→zenith gradient, NO cube map), animated procedural ripple normals
(`detailStrength`), a sun glint+sheen (`sunDirection`/`sunColor`/
`sunIntensity`), a depth-free view-angle body tint (clearer looking down,
deeper/more-reflective grazing) and a soft alpha edge fade that melts the
plane's rim into the shore. It does ZERO scene re-renders (no CubeCamera, no
depth/refraction pre-pass — the three things that drop fancy to ~30 fps at a
pond), so it's the low-end/perf path AND the deterministic headless path. A
lake reads as ONE hue derived from `color` (deep = darkened, surface =
lifted, sky = mostly real sky-blue), so give it a vivid color — a dark/muddy
hue reads grey.

```json
{ "name": "Water", "type": "Water3D",
  "props": { "size": [40, 40], "position": [0, 3.2, 0] } }
```

| prop | default | meaning |
|---|---|---|
| `size` | `[40, 40]` | [width, depth] meters — must be positive (load-time check) |
| `color` | `"#2a6fbe"` | THE lake hue for `simple` (deep/surface/sky derived from it — use a vivid color); if customized it also tints the fancy ramp — but only the parts `colors` (or a preset) leaves unnamed: with trough, surface and peak all set it only seeds the underwater murk, and the editor's inspector says so under the field |
| `opacity` | `0.8` | water-body density: lower = clearer (scales how much the refracted scene shows through) |
| `waveHeight` | `0.04` | wave intensity, meters-ish (0 = mirror-flat; 0.04 = calm sea; 0.08 = the older choppier look) |
| `waveSpeed` | `1` | animation speed multiplier |
| `quality` | `"fancy"` | `fancy` full shader water (CubeCamera + scene pre-pass) \| `simple` cheap lake shader, no scene re-renders (load-time check) |
| `colors` | `{}` | fancy ramp overrides: `{"trough", "surface", "peak"}` hex strings |
| `reflection` | `true` | live CubeCamera reflections (fancy only; needs a renderer) |
| `reflectionInterval` | `1000` | ms between CubeCamera reflection re-renders |
| `mirror` | `false` | TRUE planar reflection (see below) — the only way the dock, the boat and the player appear IN the water. One extra scene render per frame |
| `ssr` | `true` | screen-space reflections (fancy only): the scene ABOVE the waterline is rendered once at half res and every water pixel marches its reflected ray through that image — a rock, a pier, a wader, a boat mirrored on the water with NO planar pass. What the screen cannot see (behind the camera, past its edges) falls back to the sky cube, so a mirror-calm lake still wants `mirror: true`; with `mirror` on this is skipped as redundant, and a tier that forbids the mirror pass forbids this too (same cost class: one more half-res scene submission). `false` keeps the cube-only sky reflection |
| `mirrorInterval` | `33` | ms between planar-mirror re-renders. A reflection seen through a rippling surface does not need 60 Hz; 33 halves the pass's cost. 0 = every frame |
| `swellSteepness` | `0.35` | GERSTNER pinch on the swell: 0 = height-only trains, 1 = maximum trochoid (crests narrow to a peak, troughs broaden). It also decides where whitecaps break — the surface's Jacobian collapses on a folding crest, and that is where foam belongs |
| `foam` | `true` | noise-broken shoreline foam band + crest foam near shores |
| `interaction` | `true` | character interaction: bodies (CharacterBody3D/RigidBody3D) inside the XZ footprint whose FEET dip below the surface count as in-water — so a WADING character (origin above water, feet below, sized from its collider) splashes, not only a fully-submerged one. Entry/exit emit `entered`/`exited` + a splash ripple; striding through water trails a WAKE (a ripple every ~0.45 m); floating bodies bob gently. Ripples drive both shaders' `uRipples[8]` (fancy) and CPU-displace the mesh (`simple`) |
| `splash` | `true` | built-in splash WHITEWATER (needs `interaction`): the shaders paint expanding **surface foam** where bodies interact — a **"풍덩"** froth bloom when a body plunges in (bigger the faster it falls) and a **foam trail ("물살")** behind a body wading/running through. Real frothy whitewater on the surface, not flying droplets; works on both `fancy` and `simple` quality. `false` keeps the ripples/waves but paints no foam. No wiring needed; for an extra custom burst add your own `Particles3D` on the `entered` signal |
| `sunDirection` | `[0.5, 0.8, 0.3]` | TOWARD the sun, for the specular glint — match the scene's key light (non-zero, load-time check) |
| `sunColor` | `"#fff5d6"` | sun highlight tint |
| `sunIntensity` | `1` | sun highlight strength (≥ 0; 0 = off). The highlight is a GGX lobe over the detail normal and runs HDR on purpose — the sun's reflection is the brightest thing on any water and is what `environment.bloom` catches; 0.7-1.25 is the presets' range |
| `detailStrength` | `0.26` | animated detail-normal strength (≥ 0; 0 = vertex normals only). Under it runs a LIVE ripple FFT (a second 6 m spectrum patch, 4.7 cm texels, inverse-transformed every frame): the wind ripple every fancy surface wears — real churn with a mip chain for distance instead of scrolling noise — normalized so its rms slope is 0.8 × this value; three short procedural octaves stay on at reduced weight for the sub-pixel facets that flash (a filtered texture alone hands the sun one soft lobe). Without float render targets the noise octaves carry it all, as before |
| `absorption` | `0.15` | Beer's-law constant per meter of water depth (≥ 0; lower = clearer/wider turquoise band; red absorbs ~3× faster than blue under the hood) |
| `refraction` | `true` | screen-space refraction of the submerged scene |
| `preset` | `"custom"` | **THE one-choice water type** (0.16.0): `ocean` \| `pool` \| `lake` \| `pond` bundles every wave/color/clarity/caustics knob into a hand-tuned sea state. A preset value applies ONLY where a prop still sits at its schema default — pick the type, then override any detail prop and your value wins. ocean = traveling swell + whitecaps + wide turquoise→deep absorption · pool = near-flat crystal water + strong above-water caustics · lake = calm high-reflectivity mirror, green-teal murk, short low wind chop (a 9 m set at 3 cm — a lake has no swell, and the 24 m one it used to ship was the single strongest source of the 꿀렁꿀렁 gel read) · pond = still, mossy, opaque quickly. `custom` (default) changes nothing |
| `shoreWaves` | `0` | **traveling shore BREAKERS** 0–1 (fancy + depth pre-pass, 0.17.0): foam fronts that roll down the depth gradient and break toward every beach — fronts follow the shore's depth contours automatically (bays curve them, points wrap them, zero wiring). Sharp collapsing face + long noisy wash, alive in the 0.1–3.2 m shoaling band. Ocean preset ships 0.7 |
| `causticsAbove` | `0` | above-water CAUSTICS intensity 0–3 (fancy + `refraction` only): the dancing light web on the submerged bottom seen THROUGH the surface — the pool / tropical-shallows look. Reconstructed on the real refracted bottom position, strongest through the first meters of water, distance-faded. Presets set it (pool 1.0); override freely |
| `swell` | `0` | **THE open-ocean dial** (fancy only, 0.15.0): directional traveling swell amplitude in meters. Three peaked wave trains (primary + two crossing sets) march across the surface at real deep-water speeds; crests light up toward the peak color and wave FACES get sun-lit/shadow shading, so the swell reads at any distance. 0.15 = lively lake chop, 0.4 = coastal sea, 0.7 = heavy weather. `0` (default) keeps the legacy calm look byte-identical |
| `swellDirectionDeg` | `0` | compass direction the swell TRAVELS toward (0 = +z, 90 = +x) |
| `swellWavelength` | `30` | primary swell wavelength, meters (≥ 2, load-time check); the crossing trains derive from it |
| `whitecaps` | `0` | open-water whitecap foam on the tallest crests, 0–1 (fancy only; load-time check). Pairs with `swell` — the caps ride its crest lines and fade with distance so the horizon stays clean. With `wind` on, the caps are born where the FFT sea's surface actually FOLDS (its Jacobian) and fade over seconds |
| `bed` | `""` | node path to the Terrain3D under this water (fancy only). With it the swell KNOWS the beach: once the water is shallower than a quarter wavelength a wave RISES by Green's law (depth^-1/4, capped 2×) and its crest peaks; at 0.78× the depth it BREAKS — the height is capped there and the crest turns to torn whitewater — and the FFT sea shoals and breaks the same way. Spray sprites burst on the 0.3–0.9 m break line when the `shoreWaves` foam fronts pass (needs `shoreWaves` > 0; a few hundred camera-facing puffs, one draw). Sampled once into a 256² depth grid over the terrain's footprint; beyond it the water is assumed 12 m deep. The ocean template points it at its island |
| `wind` | `0` | **THE realistic-sea dial** (fancy only; m/s; ≥ 0, load-time check). Above 0 the surface stops being a noise field and becomes a JONSWAP SPECTRUM — thousands of waves at every wavelength and heading, each moving at the speed deep water gives it — inverse-FFT'd on the GPU every frame (256², ~37 tiny passes) into a 200 m tiling patch with real crest shapes, choppy horizontal drag and fold-born whitecaps. Blows toward `swellDirectionDeg`. 4 = a light breeze (20 cm ripple), 6.5 = the ocean preset's fresh breeze (Hs ≈ 1.4 m), 12 = rough. `waveHeight` is inert while wind > 0 (the FBM is replaced, not added); `swell` still rides underneath as the long-period set a 200 m patch cannot hold. `heightAt` / buoyancy / waders sum the strongest 2048 modes of the SAME spectrum on the CPU (~8% of the rms height, ~45 µs a call). Needs float render targets (every WebGL2 desktop, iOS 15+) — without them the analytic waves stay and the CPU twin follows |
| `choppiness` | `0.85` | horizontal drag of the FFT sea toward its crests (≥ 0): 0 = pure height field, ~0.9 = narrow crests over broad troughs, past ~1.2 the surface folds and foams everywhere |
| `underwater` | `true` | submerged-camera look: ONLY when the camera EYE is below this surface (inside its XZ footprint) — wading with the camera above the water never tints the view — the scene switches to short-range underwater fog + tint, the sky is hidden, and animated **caustics** (depth-aware, projected onto the submerged floor/props) play. `false` disables it; an object `{ color?, visibility?, caustics? }` overrides the murk hue / view distance (m, default 22) / caustics. `caustics` is `true` (default) \| `false` \| `{ color?, intensity?, scale?, speed? }`. The murk color defaults to a darkened shade of `color`. Works for both qualities (the caustics composite runs only while the camera is genuinely underwater) |

Performance notes (fancy): beyond drawing the surface, each rendered frame
pays ONE half-resolution scene pre-pass (clamped at 1024px) that feeds the
depth texture (absorption + foam + soft intersections) AND the refraction
color grab; the CubeCamera reflection stays on its `reflectionInterval`
throttle. Measured on the generated island (sea 1600×1600 m, ~206k tris):
60 fps, frame times indistinguishable from the pre-v2 water. Headless runs
never invoke any of it. Multiple fancy waters each pay their own pre-pass —
prefer one large surface over many small ones.

Two placement rules keep the surface artifact-free:

- **Waves dip below the waterline.** Troughs reach ≈6.5× `waveHeight` meters
  below `position.y` (the default 0.04 ≈ 0.26 m). Float the waterline at
  least that far above any ground/terrain it overlaps — otherwise the ground
  pokes through wave troughs as dark bites in the surface.
- **Reflections mirror DISTANT content only.** The cube map renders from the
  water's center with everything inside the footprint near-plane-culled (a
  nearby mesh would smear across the whole surface as a giant false-color
  patch). Shoreline, terrain, and sky reflect; a boat floating ON the water
  intentionally does not — its submerged hull shows through `refraction`
  instead.

### `mirror` — a TRUE planar reflection

The default reflection is a CubeCamera env map: it carries the sky and the far
shore, and it deliberately near-clips everything inside the water's own
footprint (a nearby crate sampled as an infinite environment smears across the
whole surface). So the boat, the dock, the pier lamp and the player standing at
the edge are exactly what it CANNOT show.

`mirror: true` re-renders the scene from the camera mirrored across the water
plane and samples it per fragment, so those things appear in the water:

```json
{ "type": "Water3D", "props": { "preset": "lake", "mirror": true,
    "waveHeight": 0.012, "detailStrength": 0.07 } }
```

- Costs **one extra scene render per frame** (half the drawing buffer, clamped)
  — off by default; a still lake or a pool earns it, open ocean rarely does.
- A mirror needs a CALM surface to read: chop that shatters the reflection is
  what makes game lakes look like blue plastic. Pair it with a low `waveHeight`
  and a gentle `detailStrength`.
- Rays that leave the screen have no planar sample; those fragments fall back to
  the cube map (keep `reflection: true`), so there is no hard cut at the edges.
- Geometry below the waterline is clipped out of the mirror pass, and the whole
  pass is skipped while the camera is submerged.

### Asking how deep something is — `depthAt(x, y, z)`

```ts
const pool = game.scene.root.getNode('Pool') as Water3D;
const under = pool.depthAt(...player.position);   // metres below the surface, 0 above
if (under > 0.5) breath -= dt;
```

Metres BELOW this water's surface, `0` above it — and `0` outside the pool's
footprint, because a point that is not over the water is not in it. That last
part is the whole reason it exists: `heightAt(x, z)` answers the surface height
EVERYWHERE, so the obvious spelling of "am I under water"
(`y < water.heightAt(x, z)`) calls a player standing in a cellar a hundred
metres inland submerged. The wave is included, so a swimmer bobbing under a
trough and under a crest get different answers, which is what they feel.

A breath meter, muffled audio, a "you surfaced" banner and an enemy that only
strikes below are all this one call. With several pools, ask the one the player
is over — or take the largest answer.

### Under the surface

Swimming down is a different optical world, and the engine renders it as one:

- **Light shafts** hang in the water column toward the sun — `underwater.rays`
  (`true` by default, a number sets the strength, `false` turns them off). The
  composite marches each view ray and, at every step, walks UP the sun direction
  to the surface: the pattern sampled there is what that beam carries down, so
  shafts converge with depth and shimmer with the same surface that makes the
  caustics. They need DEPTH to read — a knee-deep pool has no column to light.
- **Caustics** light what those beams land on (`underwater.caustics`).
- **Snell's window**: light can only leave into the air inside a ~48.6° cone,
  so from below the surface is a bright disc of sky ringed by a mirror of the
  water you are in. That image is what everyone recognises as "underwater", and
  the surface shader draws it whenever the camera eye is submerged.

```json
{ "type": "Water3D", "props": { "preset": "ocean",
    "underwater": { "visibility": 26, "rays": 0.8,
                    "caustics": { "intensity": 0.6 } } } }
```

### `WaterCutout3D` — the thing that holds the water back

Water is ONE surface; it has no idea a boat has an inside. Float an open hull
and every crest that rises above its floor renders as sea sloshing through the
deck. Freeboard cannot promise otherwise — a big enough swell always wins for a
frame. So name the geometry that keeps the water out:

```json
{ "name": "Rowboat", "type": "RigidBody3D",
  "script": { "name": "Buoyancy", "props": { "draft": 0.2 } },
  "children": [
    { "name": "DryInside", "type": "WaterCutout3D",
      "props": { "size": [2.55, 1.2, 0.95], "position": [-0.1, 0.4, 0] } },
    … hull, seats, oars …
  ] }
```

It MUST be a CHILD of what it belongs to — a hull, a diving bell, a well under a
dock, a cave mouth. That parent is the mechanism, not a convenience: the water
re-draws it (the part inside the box) into the depth pre-pass at the near plane,
and then hides itself behind it exactly as it hides behind any solid thing. At
the scene root a cutout has nothing to stand in for, and the loader says so.

| prop | default | meaning |
|---|---|---|
| `size` | `[2, 1, 1]` | box extent in meters, centered on the node — which PART of the parent holds water back |
| `enabled` | `true` | live toggle — a scuttled hull can start letting the sea in |

**A plunge pool is a `Water3D`, not a river.** A `River3D` is a current: a
ribbon fitted to the ground it runs over. Run one across a carved basin and it
lays a strip of water over the bowl with its edges hanging in mid-air — end the
creek at the lip and put a pool in the basin instead. Size a square surface to
`side = radius · √2` so its corners land on the rim rather than over the bank.

**A cutout cannot punch a hole in open water**, because nothing is ever cut out
of the water. The mask is the parent's own silhouette, so water can only be
hidden behind geometry the player is already looking at. Overshoot the box and
more of that hull holds water back; undershoot and water stands in the bow. Size
it to the interior and err large.

Needs the depth pre-pass, so `"quality": "fancy"` (the default). Four cutouts per
water surface; each costs one extra draw of its parent in that pass. Skinned and
instanced meshes re-draw at their rest pose — hang cutouts on ordinary geometry.

### Where water meets ground

The waterline is the thing that gives game water away. Incanto fades it in
SCREEN SPACE, not in metres: a beach's water column climbs from nothing to
knee-deep in centimetres of ground on a steep shore and in metres on a flat
one, so any fixed depth ramp is invisible on the first and swallows the second.
The shader divides the column by its own screen-space gradient, which turns the
fade into a constant band of pixels — no slope and no camera angle can cut it
back into a line. The band is then broken with world noise, breathed in and out
with the swash (`shoreWaves`), and its last stretch froths.

That means a shoreline needs nothing wired: no foam prop, no decal, no second
mesh. If you want MORE of it, `shoreWaves` drives the breathing and the
breakers, and `Terrain3D.wetline` darkens the sand the water just left.

### What water COSTS, and every dial that lowers it

Fancy water is not one draw — it is extra SCENE RENDERS, and each one
re-submits the world. Measured on the lake template (1600×900, one Water3D
with grass, trees and a mirror):

| pass | when | size | scene submissions |
|---|---|---|---|
| refraction/depth grab | `quality: 'fancy'` | ½ buffer, ≤1024 | 2 (colour + depth completion) |
| planar mirror | `mirror: true` | ⅓ buffer, ≤640 | 1, throttled by `mirrorInterval` |
| cube reflection | `reflection: true` | 256² × 6 faces | 6, every `reflectionInterval` ms (≥2 s while `mirror` is on — it is only the off-screen fallback then) |
| the surface itself | always | — | a ~1.5 quads/m grid, capped 160² |

The fragment shader also LODs itself: every ripple octave, the caustic loops
and the whitecap noise are SKIPPED once distance has faded their contribution,
rather than being computed and multiplied by zero. A pixel of horizon water
used to pay for four noise octaves to throw three away.

Grass, flowers, particles and trails are dropped from the side passes: they are
most of a meadow's triangles and none of what anyone reads in a reflection or a
depth mask.

**The dials, cheapest first.** Each is a normal prop, so a scene can trade look
for frames per water body:

| dial | effect |
|---|---|
| `quality: 'simple'` | ZERO extra passes — sine waves, fresnel sky tint, edge fade. The low-end/mobile answer |
| `mirror: false` | −1 scene render (the default) |
| `mirrorInterval` | ms between mirror re-renders (default 33 ≈ 30 Hz — halves the pass; 100 is still convincing on rippled water) |
| `reflection: false` | −6 cube faces; the surface falls back to its colour ramp |
| `reflectionInterval` | ms between cube refreshes (default 1000) |
| `refraction: false` | the grab colour is ignored (depth still drives absorption/foam) |
| `foam: false` | drops the shoreline foam work |
| `interaction: false` | stops the per-frame body scan |

Two more, outside water. **`environment.bloom`** adds a full-resolution HDR
pass of its own. And PIXELS: a retina display renders **4× the pixels** of a
1× one, which is why a scene that profiles at 690k triangles and 270 draw calls
can still miss frames — it is fill-bound, not geometry-bound.

The engine handles that itself: **adaptive resolution is ON by default**. It
watches the median frame time over a window and, when frames run slow, renders
the same world at fewer pixels (down to 60% of the requested ratio, in 15%
steps), handing them straight back when the frames recover. One hitch never
moves it; a backgrounded tab never counts. Turn it off for screenshots, video
capture or pixel-diff tests — and pin the ratio yourself if you would rather
choose:

```ts
createGame3D({ canvas, scene, adaptiveResolution: false, pixelRatio: 1.5 });
```

If a scene feels heavy, measure before guessing — the browser console has the
whole story:

```js
const gl = game.renderer.webgl;
gl.info.autoReset = false; gl.info.reset();
requestAnimationFrame(() => console.log(gl.info.render)); // calls + triangles for ONE frame
```

### Character interaction — `entered(body)` / `exited(body)`

With `interaction: true` the water scans the tree each frame for
`CharacterBody3D`/`RigidBody3D` inside its XZ footprint, compares their world
y against the waterline at their xz (wave offset included), and:

- emits `entered(body)` / `exited(body)` signals (the argument is the node),
- pushes a ripple impulse into a fixed 8-slot ring buffer that the shader
  turns into expanding ring waves (faster falls splash harder; floating
  bodies feed gentle bob rings on an interval),
- and (with `splash: true`, the default) paints expanding **surface FOAM** —
  a **"풍덩"** froth bloom on entry whose size tracks the plunge speed, plus a
  **foam trail ("물살")** behind a body wading/running through. This is real
  whitewater on the water surface (driven by the same ripple buffer, in both
  shaders), not flying droplets. Automatic; zero wiring. Set `splash: false`
  to keep only the ripples/waves.

The surface has no bottom — anything below the waterline inside the
footprint counts as in-water. Keep using an `Area3D` for volumetric gameplay
(swim zones, drowning); the signals are for SPLASH moments. The built-in
`splash` particles already cover the common case — reach for a hand-wired
emitter only when you want a specific custom burst:

```json
{
  "root": { "name": "World", "type": "Node3D", "children": [
    { "name": "Lake", "type": "Water3D",
      "props": { "size": [60, 60], "position": [0, 2.8, 0] } },
    { "name": "Splash", "type": "Particles3D",
      "props": { "position": [0, 3, 0], "preset": "explosion", "emitting": false,
                 "rate": 0, "burst": 40, "colorStart": "#dff3ff", "colorEnd": "#9bd8ff" } }
  ] },
  "connections": [
    { "signal": "entered", "from": "Lake", "to": "Splash", "handler": "replay" }
  ]
}
```

(`replay` re-arms the one-shot burst; a small behavior can also move the
emitter to the body first — the signal hands you the node:
`onEntered(body) { splash.position = body.position; splash.replay(); }` — give
that emitter `"worldSpace": true`, or the second splash drags the first one to
the new spot.)

### Things that float — `RigidBody3D.buoyancy`

Two ways to float a body, and the difference is who decides the waterline.
The **`Buoyancy` behaviour** (`incanto-gameplay-behaviors.md`) is the one
you AUTHOR: a `draft`, a hull `size`, a `stiffness`, and `drift` — a wave
face is a slope and a raft slides down it — which is what a raft, a cast
bobber or a boat with a tuned feel wants (`beacon-isle-3d`, `fishing-3d`,
`water-ocean-3d`). The **`buoyancy` prop** below is the one PHYSICS decides:
Archimedes on the collider, so `mass` against its volume says whether it
floats and how deep — a crate that sinks when it is heavy, a hull that sits
lower with cargo, an anchor that goes down by the same rule as the crates
that do not. Use one, never both on one body.

```json
{ "name": "Crate", "type": "RigidBody3D",
  "props": { "position": [4, 2, 0], "mass": 150, "buoyancy": 1,
             "collider": { "shape": "box", "size": [1, 1, 1] } } }
```

- **`buoyancy: 1` is water's own upthrust** — the submerged part of the
  collider's volume at 1000 kg/m³ — so `mass` against the collider decides
  everything: a 1 m³ crate at 200 kg floats a fifth under (its centre 0.3 m
  over the surface), at 600 kg it sits deeper, at 3000 kg it sinks to the bed.
  A boat is a box hull at a third to a half of its volume in kg: `[2.2, 0.8,
  4.4]` at 3000 kg sits 0.3 m in. `0` (the default) is air. Under 1 lifts
  less for the same mass; nothing needs it, and mass is the honest knob.
- **It rides the waves.** The upthrust reads `heightAt` under eight sample
  points across the collider, so the surface it floats on is the one the
  player sees — and a tilted hull's low corners push harder, which is what
  rights it. A CUBE a fifth under has a metacentric height of 1.7 cm, all but
  neutral, so it rolls easily; a wide flat hull rights itself firmly. Author
  crates light (150 kg for 1 m³) and hulls wide.
- **It settles in about one bob whatever it weighs**: the vertical motion is
  damped to 0.7 of critical for the hull's own spring (a crate dropped from
  3 m bounced out of the water and back for half a minute before that).
- **`Water3D.drag`** (1.5) holds travel and spin back, per second, in full
  once a quarter of the hull is under: a pushed crate coasts a few lengths and
  stops; `0` is ice, `6` is syrup. Spin loses three times that — a hull turning
  drags its whole wetted side.
- The shape is what Rapier built: a box, sphere, capsule or cylinder by its
  own volume; an `auto` fit as the box it made; a hull or mesh as the box
  around its vertices. The swimmer's body (a `CharacterController3D` under
  it) is never lifted here — the controller's swim spring owns that one.
- Any footprint works: a body beside the lake, over dry ground, is not lifted;
  one over the water is, from the moment its lowest sample dips under.

Thrust and steering are yours — `applyImpulse` along the hull's +z and a
written `angularVelocity[1]` for the rudder are all `examples/harbor-3d`
needs to drive a boat, push six crates into a pen and lose one to the rocks.

## River3D — running water

**A river with no `path` is not a river.** `path` defaults to `[]` and one point
is a hard load error, so the shape that is checked is the one nobody writes: with
NO points the node builds no channel — it draws nothing, carves nothing and
pushes nothing, whatever `width`, `flowSpeed` and `flowForce` say. It warns once
on its first frame now, rather than being an inert node in a scene that audits
clean. Give it at least two `[x, z]` control points, source → mouth, in the
node's own local frame.


`Water3D` is a *surface*; `River3D` is a *current*. A river is authored as a
line, not a shape: give it a centerline `path` and a width, and the terrain
underneath decides everything else.

```json
{ "name": "Creek", "type": "River3D", "props": {
    "path": [[-80, -30], [-20, 5], [30, 10], [90, -10]],
    "widths": [2.5, 5, 9],
    "depth": 1,
    "flowSpeed": 1.6,
    "colors": { "shallow": "#79b6a4", "deep": "#103c49", "bank": "#39472f" }
} }
```

| prop | default | meaning |
|---|---|---|
| `path` | `[]` | centerline `[[x, z], …]` in NODE-LOCAL meters, Catmull-Rom smoothed (2+ points). **`[x, y, z]` is accepted too** and the y is ignored — a course has no height of its own, and every other world coordinate in a 3D scene is three numbers, so one array can feed both this and `Terrain3D.channels` |
| `width` | `6` | channel width in meters |
| `widths` | `[]` | width profile lerped source→mouth (e.g. `[2.5, 5, 9]`); empty = constant `width` |
| `depth` | `0.8` | water column at the centerline — also the scale foam thresholds read as "shallow" |
| `flowSpeed` | `1.6` | reference current in m/s (the mean; reaches speed up and slow down) |
| `colors` | `{}` | `{ shallow, deep, sky, horizon, bank }` — the body ramp plus what the surface mirrors. In a live renderer the river keeps its OWN sky cube (the world beyond its banks, refreshed every 2 s, near-clipped so the banks never smear across it) and mirrors THAT through a sea-state fresnel; `sky`/`horizon` are then the headless fallback and `bank` is still what the lowest rays meet. A 6 m ripple FFT rides the current in the ribbon's own frame under `ripples` |
| `absorption` | `0.5` | how fast the water hides its bed (Beer's law) |
| `opacity` | `0.85` | upper bound on the body's opacity |
| `foam` | `1` | whitewater dial — 0 = a glassy canal, 2 = raging |
| `ripples` | `1` | surface-detail dial (ripple relief, the fine near-field wavelets, and how much sun the water throws) |
| `terrain` | `""` | drape target path; empty = auto-find the first Terrain3D (a wrong path fails at load) |
| `carve` | `true` | cut the bed. The river trenches its own channel into the terrain it drapes on — see below |
| `flowForce` | `1` | how hard the current sweeps bodies downstream (0 = visual only) |
| `spray` | `1` | mist at the foot of every drop the course turns out to contain — the node finds them and hangs a Particles3D plume on each (0 = off) |
| `sunDirection` / `sunColor` / `sunIntensity` | sky / `#fff6e0` / `1` | the sun highlight — a GGX lobe over the wavelet normal, HDR so the bloom pass reads it (the environment sun wins while `sunDirection` is default) |

**What the node derives for you**, all from `path` + the ground:

- a surface that follows the bed's DESCENDING envelope — water pours over a lip
  and never climbs one (a rising bed dams it into a still pool);
- **the visible width**: every vertex carries its own water column
  (`surfaceY − bed`) and the shader stops drawing where that runs out, so
  banks, gravel bars and half-drowned boulders cut the ribbon;
- **the current**: continuity (a pinched channel runs faster) × grade (a chute
  runs faster), which is what puts rapids where a real river has them;
- **whitewater**: grade × speed makes rapids, the bank shear line makes edge
  froth, a thinning column riffles over rock. Foam then rides downstream.
- **a dry bank**: `Foliage3D` and `Flowers3D` refuse to plant where a river
  runs (`avoidWater`, on by default), so a creek cutting its bed through a
  meadow gets bare wet ground at the edge instead of blades waving in the
  current;
- **what stands in the water**: drop boulders in the channel and the current
  answers them — a cushion of white piled on the upstream face, a torn wake
  opening downstream, and the surface sheen bending around the stone. Nothing
  is wired: the node scans the tree for `MeshInstance3D` and `InstancedMesh3D`
  footprints (a rock scatter is one node with fifty stones in it), keeps the
  ones whose body actually intersects the water column — a footbridge deck
  three metres up is not a boulder — and writes the eight nearest the camera
  each frame, ramped in by distance so nothing pops. A drowned stone still
  bends the flow; only one standing proud tears it white.

It costs NO extra render passes (unlike fancy `Water3D`), so a map can carry a
dozen rivers.

### The bed digs itself, and the water can never hang in the air

Two promises hold a river to the ground, and both are automatic:

**1. It carves its own bed** (`carve`, on by default). Real water erodes a
channel; a ribbon laid on raw ground has nothing holding it and reads as a
strip of blue hovering over the grass. So the node cuts one — a trench per
reach, as wide as the creek is there, with banks that stand above the water.
`heightAt`, the heightfield collider, the splat and draped vegetation all see
it, because it IS the terrain. The floor it cuts only ever FALLS: where the
path crosses a rise the bed digs through it (up to 7 water columns deep) so the
water keeps running instead of burying itself.

```json
{ "name": "Creek", "type": "River3D", "props": {
    "path": [[-80, -30], [-20, 5], [30, 10], [90, -10]],
    "widths": [2.4, 4.5, 7, 9], "depth": 1 } }
```

That is the whole scene. No `channels` on the terrain, no bank meshes.

**2. Its surface is FITTED to the ground it finds.** Each station reads its own
cross-section: the water stands no higher than the lowest rim holding it, ends
where the ground comes up to meet it (so the ribbon is asymmetric on a sloping
reach — a near bank and a far one), and where a bank has fallen away entirely
the ribbon lies DOWN on the slope as a film instead of hanging off it. Authored
width is how much water there is; the ground decides where it goes.

Carve it yourself with `Terrain3D.channels` when you want a bed the river does
not own — a stone aqueduct, a canal with masonry sides, a dry wash that only
floods in a cutscene — and set `"carve": false`.

**Terrain resolution is the one thing that can defeat this.** A trench thinner
than a grid cell smooths away to nothing. 128 segments on a 260 m map is ~2 m
per cell, so a 2.4 m headwater creek needs `"resolution": 256` (~1 m cells) on
the Terrain3D. The node widens its cut to survive the grid rather than cutting
nothing, so a too-coarse terrain shows up as a creek in a bed wider than it
should be.

```ts
// the classic setup: let the terrain choose the line, then pour water on it
const path = traceDownhillPath(terrain.heightAt, { x: peakX, z: peakZ, step: 7 });
```

**Placing things against a self-carved bed?** Ask the engine what the cut will
be — never mirror it by hand. `riverCarveChannels` is the same function the node
runs at load, so a generator that assigns its result to a scratch Terrain3D gets
the EXACT ground the game will have; a hand-rolled approximation puts the player
inside a hill.

```ts
const carved = new Terrain3D('probe');
Object.assign(carved, terrainProps);
carved.channels = riverCarveChannels({
  path, widths, depth,
  cell: size / resolution,   // the terrain's own grid
  groundAt: (x, z) => rawTerrain.heightAt(x, z),
});
const groundAt = (x: number, z: number) => carved.heightAt(x, z); // place against THIS
```


### Waterfalls happen — you do not author them

Nothing in a scene says "waterfall". Where the ground gives way the surface
profile falls with it, and past ~20° of grade the shader stops treating the
sheet as flowing water: it aerates (white, diffuse, no sky reflection), its
pattern stretches into strands, the standing waves of a rapid switch off, and
the lip goes glassy-dark for the moment before it breaks. `spray` then hangs a
mist plume at the foot of each drop, sized by how far the water fell.

The cheapest way to put one in a scene is to take the ground away — a
`basins` pocket on the course, no wider than the creek's own reach:

```ts
const plungeAt = path[Math.round(path.length * 0.82)];
"basins": [{ "x": plungeAt[0], "z": plungeAt[1], "radius": 7, "depth": 4.5 }]
```

Keep it a POCKET: a wide shallow bowl reads as a crater with a puddle, and the
drop has to be short and sharp for the curtain to read at all. For your own
effects (sound, a rainbow, a cave behind the water), `river.fallsAt()` returns
every drop with its plunge point in WORLD coordinates and its height:

```ts
for (const fall of creek.fallsAt()) {
  if (fall.drop > 4) playAt('$sfx/falls', fall.worldX, fall.worldY, fall.worldZ);
}
```

A step shallower than the water's own `depth` is deliberately NOT a fall — the
surface profile treats it as bed grain, which is what stops every riffle in a
creek from spawning a spray plume.

### Let the terrain choose the course — `traceDownhillPath`

```ts
import { traceDownhillPath } from 'incanto/3d';
const path = traceDownhillPath((x, z) => terrain.heightAt(x, z), {
  x: sourceX, z: sourceZ,   // start high
  step: 7,                  // meters per step (also the gradient probe radius)
  bounds: 100,              // stay inside ±100 m
  maxPoints: 46,
});
```

Steepest descent with momentum and a thalweg snap, so the line follows the
valley floor instead of rattling between its walls. It ends in a pool, at the
map edge or at `maxPoints`; `[]` means the start was flat.

### The current is gameplay

`flowForce` drags bodies toward the water's own velocity — drag, never thrust,
so a raft accelerates until it matches the current and then coasts, and a
character can fight across a slow ford but loses ground in a fast one. For
scripts, `sampleAt(x, z)` (WORLD coords) answers where a point sits:

```ts
const hit = creek.sampleAt(player.position[0], player.position[2]);
if (hit?.inside && hit.speed > 3) hud.warn('too fast to ford here');
// hit: { inside, across (±1 = banks), along, surfaceY, dirX, dirZ, speed, halfWidth }
```

The course is derived on the CPU in `update()`, so `sampleAt` works headlessly
(servers, `bun verify.ts`) before anything has ever been rendered.

## Determinism, verified

`--seed` makes every run reproducible — keep the seed in your scene comments
or PROJECT docs. Regenerating with the same seed after an engine upgrade is
the cheap way to diff what changed; `bunx incanto-check` validates the scene
after every insert.
