ADR-0021: The drawn ground goes below the field the ship lands on

2,098 words

Status: accepted · 2026-08-30 · amended 2026-09-03: the canonical crater ladder is fourteen halvings deep, terrain algorithm v4

Context

ADR-0019 made the ground a geology and ADR-0020 gave it a face. Both stop at the same line: elevationAt carries every band down to about half a meter of amplitude at eight meters of wavelength, and nothing below that exists. Standing at two meters, that line is the whole picture.

The arithmetic is worth writing out, because it explains why the ground was flat and why nothing about the renderer could have fixed it. surfaceDetailFloor refines a patch while the middle of one grid cell differs from the bilinear interpolation of its corners by more than TERRAIN_DETAIL_TOLERANCE, and that tolerance is CANONICAL_AMPLITUDE_FLOOR — the same constant, deliberately, so that the level past which refinement stops buying detail is the level past which the field stops having any. Measured across the zoo, the floor landed at cells of 1.75 to 5.66 m. One of those cells, seen from a two-meter stance, is four hundred display pixels across, and there is nothing inside it.

The loop is closed: a term bounded by the tolerance cannot move the floor, however fine its wavelength. Adding relief below the canonical floor and leaving its amplitude under half a meter — which is the obvious reading of "below the floor" — produces a field the mesh will never go and get.

The other half of the problem is that a heightfield is not a place. A dozen instanced rocks with a rotation and a scale apiece do more for standing on a world than another octave of terrain (content § scatter), and the o: address segment has existed for them since ADR-0004.

Decision

The drawn field and the canonical field are two functions, and the difference between them is a published number.

groundElevation(surface, d)      ─►  the contact test, saves, survey sites
        │                                  canonical · terrain algorithm v2
        ├── + microRelief ──────►  drawnElevation(surface, d)
        │                                  the mesh, the material, the stance
        └── |difference| ≤ drawnDivergence(surface) = 1.25 m

packages/universe/src/micro.ts is the tail: the crater ladder continued from CANONICAL_DETAIL_FLOOR down to MICRO_DETAIL_FLOOR, plus two octaves of regolith grit. generateHeightfield, surfaceDetailFloor and the observatory's stance read the drawn field; elevationAt, surfaceRadius and everything physics touches read the canonical one. Nothing is versioned, because nothing canonical moved.

The tail's amplitude is above the tolerance, and that is the point. An eight-meter crater is 1.6 m deep because that is what an eight-meter crater is. Folded through softLimit at MICRO_CRATER_CEILING, the tail's own bound is 0.8 m — over the half-meter a cell is refined against, so the floor moves, and under the depth of one fresh crater, because a saturated population is in equilibrium and its members destroy each other. Measured RMS slope at a one-meter baseline afterward: Luna 12.3°, Mars 8.4°, Mercury 15.6°, against a published 5–20° for lunar regolith and the MER landing sites — and against 0.2° on Luna canonically, which is what a flat plane measures.

The rungs are numbered from a fixed base, not continued from the canonical ladder. Numbering them by position needs a largest crater to count down from, and grammar.largestCrater is zero on every surface young deletes — Miranda, Enceladus, Europa, three of the most interesting places to stand. Anchored at MICRO_RUNG_BASE, the tail is a property of the body's size and air alone and its hashes cannot collide with any canonical rung. young does not enter it: a resurfacing event deletes a crater population, and retention at a meter is geologically instantaneous, so the moon that was paved last week is saturated at a meter by the afternoon. Air enters harder — an atmosphere screens the small impactor and then fills the hole in — and (1 − air)⁴ orders Luna 1, Mars 0.14, Earth 0.012, Venus 0.

One field at every level, unchanged. The tail is a pure function of the direction and every patch evaluates all of it, so the CDLOD morph endpoint is untouched — a fully morphed child is still the child's own field at the parent's spacing, and that still equals the parent's mesh. Evaluating it only on patches fine enough to resolve it is the saving that would break that, and ADR-0019 carries the argument.

Rocks are addresses. regionScatter(surface, region, slots?) answers "does r:…/o:837 hold a rock" with a hash: 1,024 candidate slots over a 256 m region, gated by the cover the vertex already carries. slots is a half-open range because resolving one candidate is a field sample and a whole region is 2.6 to 5.8 ms across the zoo — a caller streams it a slice at a time, and slot 837 is slot 837 whichever call resolves it.

And a rock wears the ground's own material. render/terrain.ts builds one graph; ScatterRocks draws InstancedMeshes with it, because Three's node material inserts the instancing before positionNode runs, so the graph reads the instanced position and every term it derives — altitude, latitude, the map's UV, the footprint — is right for the rock rather than for the field's anchor. A rock is then bedrock on its steep faces and regolith on its top, in the palette of the ground it lies on, by the same slope term that decides the ground.

Consequences

Every terrain number moves, and the direction is up. The detail floor goes from 15/16/12/10 to 19/17/14/12 across the zoo — cells of 0.35, 0.87, 1.10 and 1.41 m against 5.54, 1.75, 4.40 and 5.66. A patch costs 6 to 15 ms more — 43.4/43.2/49.8/21.6 against 32.5/37.2/35.0/8.7 — because the tail is four more rungs of the crater walk. A whole-disk selection peaks at 1,077 patches against 862, so DEFAULT_MAX_PATCHES is 1,280 and its corner case is 303 MB of vertex buffers — a patch is 237 KB, which is 203 KB of geometry and two four-byte cover attributes rather than one. Generation was already the binding constraint before this phase and is more so now, which is ADR-0023's GPU producer stated as a measurement rather than a plan.

A stance holds still and a landing takes minutes. Measured in the browser: a two-meter stance on Luna is level 17 with 895 patches and 7.33 M triangles at 1600×900, and 1,106 patches and 9.06 M at 1920×1200 over a device pixel ratio of 2 — with every streamer counter a fixed point over 240 and 120 consecutive frames, starved zero and both caches well under their caps, which is what raising DEFAULT_MAX_PATCHES was for. Reaching that state at retina takes between one and three minutes, because 1,562 patches at 43 ms is 67 s of single-core generation and a pool of four does not divide it far enough. That figure is taken at a ceiling of four workers; it is eight now — worth three drawn levels in twenty seconds on a Mars landing, and not re-measured here. apps/game/src/engine/browserWorker.ts carries the measured pool-size table.

A rock's photometry is not settled. Under a low sun a boulder reads as a solid, correctly lit rock; under a high one it reads as a black silhouette against a saturated plain. It is not the palette — on a mapped body every deposit's ratio is exactly 1.000, so a rock's color is the ground's color and the whole difference is in the shading. The suspect is the bump: its height field is the grain band on local, which across a rock changes by the rock's own width over a handful of pixels, while grainFade keys on the footprint, which is the same for a rock and for the ground behind it. It is settled on a GPU or not at all — pnpm test:gpu can draw the graph and read the pixels back, and no test does yet — so it is not settled; sharing the ground's material is what makes it one question rather than two, and the material has no channel left to tell a rock from the ground with.

The contact test and the drawn ground are 1.25 m apart at worst. A landing ship spans tens of meters, so it is invisible to flight; a person will notice, and on foot is where the floor drops and the number is re-measured. The observatory's stance moved to drawnSurfaceRadius for the same reason: a two-meter stance against the contact test's radius puts the eye inside a crater rim.

A world with Earth's air keeps its canonical floor and gets its meter scale from the scatter and the material. (1 − air)⁴ at air 0.602 leaves 2.5% of cells holding a sub-floor crater — far too few to move a residual — so that body's floor stays where the canonical field put it, at 3.66 m cells, while an airless one of the same size goes to 0.90. That is the right answer rather than a gap: an atmosphered world's meter scale is soil, ripples and loose rock, which is scatter.ts and the grain band rather than a band of craters that are not there.

The tail asks for an exact chord and the canonical ladder does not. 2 − 2 cos θ from one dot product cancels: at a one-meter crater on a 1,700 km body it is 4 × 10⁻¹⁴ against a float64 ulp of 2 × 10⁻¹⁶, which is a millimeter on the crater's own depth and a millimeter that differs between two patches computing the same direction by different routes. The sum of squared component differences is the same number with nothing canceling. ChordForm is the parameter, and the canonical ladder keeps the cheap form deliberately: changing it would move elevationAt in its last bits on every body.

The grain band's domain is reduced on the CPU. The material's sub-meter octaves cannot use positionLocal — patch-local, so the phase jumps at every boundary, invisible at seven meters of wavelength and a straight line across the ground at seventy centimeters — and cannot use the body-fixed position, which is 1.7 × 10⁶ on Luna where float32 resolves 0.1 m. So the anchor is reduced modulo GRAIN_PERIOD wavelengths in float64 and the noise is periodic over it: exact, continuous, and repeating every 45 m of ground, which is more than the band survives to.

Alternatives

Deepen the canonical crater ladder instead. MAX_CRATER_LEVELS was 11 when this was decided and the canonical floor would allow 16 to 19, so a body whose largest basin is 2,170 km had canonical craters down to 2.1 km and then nothing until eight meters. Measured, raising it to 14 moves the detail floor by −1 to +2 levels — the floor is a probe search for where refinement stops paying, not a function of the ladder's depth, so Mercury's falls from 16 to 15 while Earth's rises from 15 to 17 — and costs 13% a patch. It works, and it moves the field the contact test integrates, which is a terrain version and every save's landed hull. This phase did not spend it, because the tail was the larger gain for no version at all; terrain algorithm v4 spends it afterward, on its own, and the two are complementary rather than alternatives — the ladder now reaches 265 m on that body and the tail still starts at eight.

Synthesize the tail per vertex only at deep levels. Free detail control, and it breaks the morph: a fully morphed child is the child's own field at the parent's spacing, and two patches with different fields differ by every feature between their two floors. Closing it means reading the morph target from the parent's heightfield, which makes a patch's geometry depend on its parent being resident — a real design, and a larger one than this phase.

Leave the mesh where it is and put everything below it per pixel. A bump map has no silhouette, and at two meters the silhouette is most of the frame: the ground meets the sky four hundred meters away and every rise between here and there is a shape. The material's grain band is that argument's other half and is here as well — below a mesh cell, per pixel is the only option there is.

Give the rocks their own material. A third surface for "never add a shading term to the ground without adding it to the sphere" to police, drifting from the first the next time either was touched.

  • ADR-0019 — the field this extends, and the morph argument it must not break
  • ADR-0020 — the material the grain band is added to
  • ADR-0015 — the quadtree the floor sets the depth of
  • The terrain plan — the milestone this serves
  • On foot — where the divergence stops being invisible
  • Content § scatter — what the rocks are for