7,478 words · 9 diagrams
The question: a bolt is 1 m away and a star is 4 light-years away. How do both end up in one float32 scene with a usable depth buffer? The answer: a floating origin that follows the camera and snaps to a power- of-two grid, plus logarithmic distance compression that preserves angular size exactly and lies only about depth.
Decision record: ADR-0003 · Code:
packages/rendering/,packages/spatial/src/origin.ts
rendering does not import Three.js
Worth stating first, because it explains the shape of everything below. The
package computes what should be drawn, where, at what size, at which level of
detail and emits it as plain data. apps/game turns that into Three.js objects.
flowchart LR
SNAP["WorldSnapshot<br/><i>canonical, immutable</i>"] --> BUILD["buildScene()"]
ORIGIN["RenderOrigin"] --> BUILD
BUILD --> SCENE["RenderScene<br/><i>positions · scales · tiers · buffers</i>"]
SCENE --> R3F["React Three Fiber<br/><i>mutates Three.js objects</i>"]
R3F --> GPU["WebGPURenderer + TSL<br/><i>WebGL 2 backend as fallback</i>"]
SCENE -.- NOTE["plain data — which is why the<br/>'where should this be drawn' logic<br/>has tests, in Node"]
classDef note fill:none,stroke:none,color:#64748b,font-style:italic
class NOTE note
style SCENE fill:#0369a1,stroke:#0c4a6e,color:#fffThe floating origin
Render space is a metric space whose origin sits at some UniverseVector. The
origin follows the camera; everything the GPU sees is relative to it.
flowchart TB
CAM["camera drifts"] --> CHECK{"more than<br/>4096 m from origin?"}
CHECK -->|no| KEEP["keep the origin<br/><i>hysteresis: most frames do nothing</i>"]
CHECK -->|yes| SNAP["snap to the nearest<br/>1024 m grid point"]
SNAP --> SHIFT["every render coordinate shifts<br/>by an exact power-of-two multiple"]
SHIFT --> GEN["generation++<br/><i>renderers rebuild static geometry</i>"]
style SNAP fill:#065f46,stroke:#064e3b,color:#fffSnapping is what makes it exact. The shift is an integer multiple of 1024, so it is exactly representable in float64 and float32. Ten thousand rebases accumulate zero drift rather than ten thousand roundings — asserted directly:
origin.test.ts→ after 10,000 rebases along a flight path, the origin is still exactly on the grid and the canonical position decoded back from render space is unchanged.
Within ±2048 m of the origin float32 resolves 0.24 mm, and better than half a millimeter all the way out to the ±4096 m rebase threshold — which is why a meter-scale object beside the ship is exact no matter where in the galaxy the ship is. Capability check 8 measures it: two points 1 m apart at 8.18 kpc render 1.000 m apart after rounding to float32.
And because the origin is a view onto canonical state — nothing is written back — a rebase cannot move an entity. That is capability check 9.
Distance compression
The origin solves precision. It does not solve range: a star is still 4e16 m from the camera, and no depth buffer spans 1e16:1.
Anything whose surface is beyond the near limit (2e6 m) is moved onto a logarithmic radial scale, and its radius is scaled by the same factor:
flowchart TB
D["true distance d, radius r"]
SURF["surfaceDistance = d − r"]
TEST{"surfaceDistance<br/>≤ 2e6 m?"}
NEAR["<b>untouched</b><br/>true meters, true scale"]
COMP["compressed = 2e6 + 2e6·ln(1 + (s − 2e6)/2e6)<br/>factor = (r + compressed) / d<br/>position ×= factor<br/>radius ×= factor"]
OUT["angular size <b>exactly</b> preserved<br/>only depth is a lie"]
D --> SURF --> TEST
TEST -->|yes| NEAR
TEST -->|no| COMP --> OUT
style NEAR fill:#065f46,stroke:#064e3b,color:#fff
style OUT fill:#0369a1,stroke:#0c4a6e,color:#fffBecause position and radius scale together, the object subtends exactly the angle it should. The image is correct; only the depth value is fictional. A property test asserts the rendered angle matches the true angle to within 1e-9 relative across ten orders of magnitude of distance.
Three properties, and one honest limitation
| Property | Status |
|---|---|
| Angular size preserved | exact, property-tested |
| Continuous at the boundary | factor is exactly 1 there |
| C¹ at the boundary (no change in apparent rate of approach) | requires SHELL_SPAN === NEAR_LIMIT — which is how they are now defined, as |
| module constants rather than a config object nothing ever varied — it was not C¹ until a test said so | |
| Strictly increasing (depth ordering) | non-decreasing everywhere; strictly increasing only while the separation survives double precision |
That last row is a real limitation stated honestly rather than papered over. Past ~1e17 m the compression slope is ~1e-11, so two objects 100 m apart map to the same depth. They are also the same pixel. The tests say exactly this: one asserts never inverts with no preconditions, and a second asserts strictly increasing given a relative separation above 1e-9.
A first version of that test asserted strict monotonicity everywhere and was intermittently red — which was the mapping telling the truth about itself.
The bug that made terrain invisible
Compression originally keyed off the distance to a body's center. In a 400 km orbit around a 2,864 km planet:
flowchart LR
subgraph BROKEN["keyed off the center"]
C1["planet center 3,264 km away<br/>→ beyond the 2,000 km near limit<br/>→ <b>compressed</b>"]
C2["terrain patches 400 km away<br/>→ inside the near limit<br/>→ <b>not compressed</b>"]
C1 --> GAP["datum sphere and the ground it<br/>represents ended up 30 km apart<br/><b>no terrain visible at all</b>"]
end
style GAP fill:#7f1d1d,stroke:#450a0a,color:#fffKeying off the surface distance fixes it and is also what makes the transition continuous — at the boundary the factor is exactly 1, so a planet neither pops nor changes its apparent rate of approach as you arrive.
The bug that made the small moons vibrate
Compression is radial, so the point it is measured from is the one place in the image that stays honest. That point is the eye, and for a long time it was the render origin instead — which is a different point. The origin is the snapped grid point above: it lags the camera by up to 4096 m and then jumps a whole grid step to catch up.
flowchart TB
OFF["eye sits up to 4096 m from the origin,<br/>sawtoothing as the camera drives the rebase"]
ERR["parallax error ≈ eyeOffset · (1/compressed − 1/true)<br/><i>a fixed angle, whatever is being drawn</i>"]
BIG["Mars, 25,000 km away<br/>0.0003× its angular radius<br/><b>invisible</b>"]
SMALL["Phobos, 11 km of radius, same place<br/>0.8× its angular radius — Deimos 1.6×<br/><b>vibrating in its orbit</b>"]
OFF --> ERR --> BIG
ERR --> SMALL
style SMALL fill:#7f1d1d,stroke:#450a0a,color:#fff
style BIG fill:#065f46,stroke:#064e3b,color:#fffThe error is scale-free in meters, so what decides whether anyone sees it is how
big the object is on screen — which is why it was reported as a fact about the
two smallest bodies in the Solar System model rather than as a fact about the
whole scene. placeAt therefore takes the eye in render space, buildScene
computes it once, and the property is stated as an angle: the drawn direction
from the eye is the true direction from the eye, at any separation.
It also restores the independence of the two mechanisms. A rebase is a rigid translation of render space and nothing else; measured from the origin, compression made it a distortion as well.
Level of detail
LOD is chosen by angular size, not distance — a gas giant at 1e9 m and a boulder at 10 m subtend the same angle and deserve the same treatment.
flowchart LR
ANG["angular radius = asin(r / d)"] --> T1{"≥ 0.12 rad"}
T1 -->|yes| SURFACE["<b>surface</b><br/>sphere + streamed terrain"]
T1 -->|no| T2{"≥ 2e-3"}
T2 -->|yes| SPHERE["<b>sphere</b><br/>resolvable disc"]
T2 -->|no| T3{"≥ ⅓ px of diameter<br/><i>1.97e-4 at the flight lens</i>"}
T3 -->|yes| BILLBOARD["<b>billboard</b><br/>a few pixels"]
T3 -->|no| POINT["<b>point</b><br/>sub-pixel"]
style SURFACE fill:#065f46,stroke:#064e3b,color:#fffOnly the first step is a claim about pixels, and it follows the lens.
lodThresholds(lens, viewport) puts the point-to-billboard boundary at a third
of a pixel of diameter — sub-pixel on purpose, because a star is always smaller
than a pixel and must still draw, so what the threshold decides is when a point
cloud stops being an honest description, not when something becomes resolvable.
A constant can only be right at one lens: the boundary at the telephoto end of
the slider is an eighth of the boundary at the wide end
(ADR-0017), which is why Atlas at 104,146 km draws as
a point at 110° and as a billboard at 20°. sphere and surface stay
constants, because they are claims about representation — a disk with a
terminator on it, and ground close enough to be the picture — and a player who
fits a telephoto has not moved closer to the planet. A caller with no lens gets
the flight one over 1920×1080.
The architectural point: representation is separate from identity. A planet is the same planet — same address, same entity, same canonical position — whether it is one pixel or ground you are standing on. Only which renderer draws it changes, and nothing downstream may branch on the tier for any purpose other than drawing.
Stars beyond the local system are drawn as a point cloud projected onto a fixed shell around the camera. Direction is what matters; their distance is neither representable nor observable.
The projection is placeOnStarShell(origin, position) with
STAR_SHELL_RADIUS = 8e7, and it lives here rather than in the R3F layer for a
concrete reason. It used to be open-coded in the component over the raw sector
fields, with 2 ** 40 written out by hand — the sector size spatial owns — and
that copy skipped the origin's orientation, which toRenderSpace applies and
every body therefore got. Whenever the origin was anchored to an oriented frame,
the stars were rotated out of alignment with the planets in front of them.
Terrain meshing
This section is the geometry; the ground's own material is what shades it.
Patches are built from a heightfield into vertex buffers in body-fixed axes,
relative to the patch's own anchor — the datum-sphere point at its middle. The
pose goes back on at draw time, which is why a rebase costs nothing and why the
ground does not slide away from a ship landed on a world orbiting at 52 km/s.
The resolution is HEIGHTFIELD_RESOLUTION (65), exported once and shared by the
streamer, the worker task and capability check 10 — which compares worker output
to main-thread output sample by sample, and would otherwise be capable of
comparing two differently sized grids and calling them equal.
The heightfield is drawnGroundElevation, not the bare elevationAt: the
presentational tail below the canonical floor, and no water clamp where a
sheet is drawn — the mesh is the bed, and the water is a sheet drawn over it.
The request carries the flag (seabed), and a body that gets no sheet — a
mapped one, whose photograph is its sea — is built from the clamped
drawnElevation instead, or its ocean floor is a trench under the photograph
kilometers below the datum the ship lands on. The clamp still has exactly one
owner, groundElevation, and drawnElevation keeps it for the two readers
that stand on the water rather than look through it — the observatory's
stance and the detail-floor search. What it clamps to is the water that
stands at the sample: the sea's datum on a body with no drainage graph, and
on a body with one the level the drainage stage wrote — the sea where the
sea reaches, a lake's spill, a river's surface — so a basin the sea never
reached is dry ground (ADR-0043). The
tail is the one term the mesh has that the contact test does not, it is
bounded by drawnDivergence at 1.25 m, and
streaming has why
the two fields have to be two.
A patch water reaches carries a second grid, RenderPatch.water: the
patch's own vertices at each one's water level, anchor-relative, with the
depth over each and a morph target for both, so the sheet hands over to its
parent where the bed does. The level is the heightfield's water — a float a
vertex beside the cover, NaN where nothing stands — and a body with no graph
is handed the sea's datum instead, which is every vertex's level. A vertex
under nothing sits a cell's width of ground under the ground, so the sheet
hides rather than fights. render/water.ts draws it, and
ADR-0026 says how.
sequenceDiagram
participant S as TerrainStreamer
participant P as the producer<br/>(GPU tile kernel, or a worker)
participant B as buildPatch()
participant G as GPU
S->>P: heightfield(region, 65×65 + 2 border)
Note right of P: 4,761 samples ×<br/>six bands and a crater ladder —<br/>sixteen tiles a dispatch on the GPU,<br/>one patch a job on a worker
P-->>S: Float32Array + Uint8Array cover<br/>(read back, or transferred)
S->>B: heightfield + cover + body radius
B->>B: positions, anchor-relative
B->>B: <b>central-difference normals, everywhere</b>
B->>B: <b>morph target: the parent's grid</b>
B->>B: <b>morph cover: the parent's vertex</b>
B-->>G: BufferAttributes, one shared index buffer
Note over S,G: built once and never rebuilt —<br/>the vertices do not know where the planet isThe normals bug
buildPatch originally emitted radial normals — each vertex's normal
pointing straight out from the planet's center. That shades a mountain range
exactly like a smooth sphere.
Real relief was being generated, transferred, and drawn, and it was completely invisible. The fix is a second pass computing central differences over neighboring vertices. It is not a polish detail: without it, terrain generation has no observable effect.
The difference has to be central everywhere, including the edge. A one-sided difference there is half the gradient over half the span, which draws as a lit hairline along every patch boundary — so a patch is generated with two rings of border outside it and the loop never asks where its edge is. Taking those rows from the neighboring patch is the other way to get them and is strictly worse: it makes a patch's geometry depend on which of its neighbors happen to be loaded, which is an order dependency in a system whose premise is that a patch is a pure function of its address.
The morph, and why the shader's share of it is one mix
Every patch also carries where each of its vertices goes when it hands over to its parent. That is the CDLOD morph: a vertex slides toward the position the parent's coarser grid holds for it, arriving exactly as the parent takes over, so the switch has nothing left to pop.
It is exact rather than approximate, and for a reason worth stating. A child covers half its parent's side, 64 quads halved is 32, so every even index of the child lands on a parent grid point — and the field is a pure function of direction, so the elevation there is the same number both patches computed. Snapping each vertex to the even index below it therefore lands the whole patch on its parent's vertices. The normals morph too, over two cells, which is one of the parent's: shading has to hand over with the geometry or the switch trades a pop for a shimmer.
The surface cover makes the same journey, and for the same reason one step
further out. A fully morphed child sits exactly where its parent sits; if it is
still wearing its own vertex's cover when it gets there, the frame the parent
takes over is the frame every crater ray and every mare margin jumps by one
child cell. That is worse than a pop, because it slides continuously as the
camera moves rather than happening once. morphCover is the parent's vertex's
eight bytes, and it indexes off the patch's own grid rather than the bordered
one — the cover carries no border, because the border rows exist to be
differenced against and nothing differences the cover.
The arithmetic lives here rather than in the shader so that the claim needs no
GPU: the shader's whole share of it is one mix between two attributes, and
the endpoint is a claim about two Float32Arrays that terrainPatch.test.ts
checks on any Node. The graph itself is compiled and run on the real GPU by
pnpm test:gpu (testing), which is where the
attribute set a patch has to supply is held.
Two things a couple of hundred patches made matter
The index buffer is a function of the resolution alone — 24,576 indices, 98 KB — so it is built once and shared. Per patch it was 20 MB of identical numbers and, worse, two hundred GPU buffers where one will do.
The inner loops are written in scalars against flat arrays, which is not a
style choice. The readable version — a Vec3 per direction, per scaled position,
per difference, and a pair of three-element arrays per normal — allocated about
forty thousand short-lived objects per patch and cost 6.26 ms, which is six
frames of terrain budget for one patch. It is 0.25 ms now.
The datum sphere sits below the terrain
Terrain dips below the datum as often as it rises above it. A sphere drawn at exactly the datum radius hides every valley on the planet — and with only a few patches streamed, that means hiding most of the terrain. So the fallback sphere is drawn one full relief below the datum, and patches always win.
Planetary surfaces
A body is shaded from its own photometry, and from measured maps where a map
exists. apps/game/src/render/planet.ts is the material;
the catalog guide is where the
maps come from.
The lighting is hand-written, and the reason is not performance
A point light and a standard material would be less code. It would also be the wrong model, and not by a little:
Planetary surfaces are not Lambertian. The full Moon is famously flat — no limb darkening at all — because regolith backscatters. A Lambertian moon has a bright center and a dark rim, which is what every naive renderer produces and what nobody has ever photographed.
So the diffuse term is the lunar-Lambert function planetary scientists use, a blend of Lambert and Lommel-Seeliger:
I = albedo · μ₀ · [ (1 − k) + k · 2/(μ₀ + μ) ]k = 0 is Lambert, right for a thick atmosphere. k → 1 is Lommel-Seeliger,
right for airless regolith. It is set per body from whether the body has air, and
it is the single largest difference between a moon that looks photographed and
one that looks like a billiard ball.
What the maps carry
| map | carries |
|---|---|
albedo |
the surface, sRGB |
normal |
tangent-space slopes in RG, an ocean mask in B |
night |
city lights, revealed just before the terminator |
clouds |
coverage in alpha, on its own shell — and its shadow, on the surface |
The normal's Z is reconstructed as √(1 − x² − y²), which is exact for a unit normal, because the third color channel is carrying the mask. Alpha is the obvious place for a mask and is the one place it cannot go: alpha is semantic to everything that touches an image, and libwebp's lossless encoder zeroes the RGB under transparent pixels — which erased the whole Moon map the day that encoder went lossless. A color channel is just data.
The tangent frame is built analytically from the body's spin axis rather than
from a UV channel: geographic north is the axis with its radial part removed, and
east is north × up. Every shadowing decision uses the geometric normal rather
than the mapped one, because a mountain on the night side is still on the night
side — letting a normal-mapped slope catch the sun across the terminator produces
lit specks floating in the dark, which is the classic normal-map-on-a-planet
artifact.
Normal maps are exaggerated, and the honest name for it is in tuningFor. At
4096 across, one texel of Earth is ten kilometers and the real slope across ten
kilometers is a fraction of a degree — the map's standard deviation is 2.4 out of 255. docs/design/art.md licenses exactly this ("roughness and detail are art")
and forbids the thing next door to it: the elevation is the published one and the
terrain is where it really is; only how sharply it catches the light is turned up.
A body with none of them is most of the galaxy, and it wears the ground. The
orbital bake (ADR-0026) draws the streamed ground
material from the body's center into six faces, and the sphere samples them by
direction, so what the sphere shows from orbit is the geology the descent
arrives at rather than a base color standing in for it. It takes two pictures:
the reflectance, and a relief record carrying the mesh normal's slopes along
geographic east and north with the sea mask beside them — the same three
lanes, the same x / 2 + 1/2 encoding and the same tangent frame as the table
above, so the decode is one path and the sphere cannot tell a bake from a
photograph. The record is half float rather than the byte a photograph's map is
stored in, because at forty kilometers a texel the real slope is a few
hundredths: a byte resolves that to five steps and the relief exaggeration draws
every step as a facet.
The baker owns the jobs and both cube targets for each held body. An address locates the entry; the loaded body's object identity validates it. Replacing the body at the same address retires its old bake, and a late tile completion checks that identity before drawing. Eviction and disposal use the same retirement path. Flat bodies are checked from the current body rather than remembered by address. An unchanged body keeps its ready maps across frames.
Two shapes, and which one is not a rendering choice
A body is drawn as an oblate spheroid or as a measured figure, and what decides is whether gravity rounded it off.
Spheroids are the unit sphere scaled on the spin axis, so the quaternion tilts the bulge with it. Saturn is 9.8% flattened and Jupiter 6.5%, which reads as wrong long before anyone can say why. Real bodies carry their measured polar radius; generated ones derive it from their own rotation through the Darwin–Radau relation, which reaches Jupiter, Saturn, Earth and Neptune within 5% of the published figure given each body's own moment of inertia factor, and within 10% on the factor for its class, which is all a generated body gets — and a sphere is not the neutral choice here, it is the wrong one. A generated planet's spin is floored at a fifth of its own equatorial gravity, a little past Saturn, so the most oblate giant the generator draws is about a seventh flattened rather than the lens a six-hour day on a puffy giant made.
Figures are for everything below about 200 km, where self-gravity loses to material strength and the body keeps whatever the last collision left. That is 92 of the Solar System's 129 bodies and a large fraction of every generated system. Phobos is 27 × 22 × 18 km with a nine-kilometer crater in one end; 216 Kleopatra is a dog bone; Bennu is a spinning top with a ridge its own rotation raised. A sphere is not an approximation of those, it is a different object.
Body.figure is present exactly when a body is not a spheroid. Null means
round, not unknown — a renderer that read it as "no data" and fell back to a
sphere would be right by accident. flattening is not applied to a body that
has one: the mesh already carries all three half-extents, and applying both
squashes it twice, which on Phobos is 26%. ADR-0013.
Shape models are radius grids
A figure is a latitude/longitude grid of radii — packages/rendering/src/shape.ts
holds the format, the decoder, the mesh builder and the generator, and
data/shapes/ holds twenty-five measured ones from the NASA Planetary Data
System. The mesh is built in Three.js's own SphereGeometry layout, and that
is load-bearing rather than incidental: every surface map in the project is
equirectangular and was written for that sphere, so a grid mesh is that sphere
with the radii moved, and Phobos takes an albedo map through the same material as
Mars — seam, poles and all.
It buys three more things a mesh would not. Level of detail is subsampling,
so a coarse tier is the fine one's own samples rather than a decimation with its
own error and a body cannot change size when it crosses a tier. The file is the
data — sixteen bytes of header and one uint16 per sample, 65 KB for Phobos.
And the generated case and the measured case are the same case: a body with
no shipped model gets a field out of its own address seed on its measured
half-extents, through the same builder, so there is one code path for "not a
sphere" and it does not know which kind it is holding.
What it cannot represent is an overhang, which is the cost and is measured rather than assumed — see the catalog guide for the volume check that refuses a model the format cannot hold.
Exposure, at both ends
A star stops down as it fills the frame: a sun across the whole viewport is exposed for its surface, not for the scene it lights. A very dark body does the same thing in reverse. Bennu reflects 4.4% of the light that reaches it and Halley's nucleus 4% — darker than charcoal — and an eye that spends a minute looking at one from five hundred meters adapts to it, which a renderer with one exposure for the whole scene cannot.
adaptationFor opens albedoScale toward a 0.12 target, scaled by how much of
the frame the body covers, and returns exactly 1 above 0.12 geometric albedo
— below Mercury at 0.142 and the Moon at 0.136, so no planet and no major moon
ever sees anything but 1. It is applied as an exposure: the albedo is still the
published one and the body is still the darkest thing in the frame.
Rings
Three things have to be right or a ring system reads as a decal, and all three are geometry rather than art:
- The planet's shadow falls on the rings. A cylinder test along the sun direction, with a soft edge because the Sun has an angular diameter.
- The rings' shadow falls on the planet. Follow the sun ray from a surface point to the equatorial plane; if it lands between the ring radii, sample the ring's opacity there. Three dot products, and it is what makes Saturn look photographed.
- The lit and unlit faces are different pictures. The sunward face shows single scattering and the dense B ring is the brightest thing on it; cross the plane and you are looking at transmitted light, so the dense parts go dark and the thin ones glow. The whole image inverts.
The scattering is the standard slab result rather than a Lambert stand-in:
I/F = (ω₀/4) · μ₀/(μ₀ + μ) · [1 − e^(−τ(1/μ₀ + 1/μ))]which gets saturation, edge-on brightening and the seasonal fade to nothing as
the rings turn edge-on to the sun, all from one expression. ω₀ is 0.9 — clean
water ice in the visible — and the strip's own color is the other half of that
product: Saturn's B ring reads 0.51 in its photograph and Uranus's rubble
0.06, so the two together are the 0.5 to 0.6 particle albedo Cassini measured.
Put the darkening in both and a τ = 1 sheet's lit face sits at a sixth of the
brightness of its planet. Both scattering terms carry the same ω₀, which is
what keeps the lit-to-backlit crossover near unit optical depth where it is.
A body with no published ring map gets a strip generated from a character, seeded from its address: a bright sheet, a system of dark threads, or a sheet with threads shepherded outside it. Branching on the host's kind instead is the version that reads as a decal at the scale of a galaxy — every generated ice giant wearing one look and every gas giant another, two pictures across a hundred worlds, for a feature that exists to be a find. The host leans the draw rather than deciding it, because a body massive enough to hold a sheet is likelier to have one, and the particle albedo and tint follow the architecture rather than being drawn beside it: a sheet is held up by clean water ice and a thread system is the dark rubble the collision that made it ground. What keeps the strip off the decal is the profile — plateaus with a diffuse inner edge and a knife-sharp outer one, a grain of density waves at up to sixty cycles across the strip, Cassini divisions, ringlets shepherded in the gaps, and one dominant outer thread. One strip feeds both consumers, the slab and the shadow it casts on its planet, so the bands in the shadow are the bands in the rings.
Sol's seven mapless ring systems — Jupiter, Uranus, Neptune, Haumea, Quaoar, Chariklo and Chiron — are looked up by address instead, because a lean is a probability and their architecture is published. Left to the draw, Uranus, whose thirteen narrow rings are the reason the thread architecture exists at all, takes a Saturn sheet two times in five.
Tessellation
Spheres are tiered by angular radius: 512×256 for a body filling the view, down to 32×16 for a body that is a few pixels. A planet from orbit is a silhouette problem before it is a shading one — no amount of normal mapping hides a faceted limb, and the limb is exactly where the eye goes. Ring meshes are 768 segments around, because a ring seen nearly edge-on is a straight line a thousand pixels long and any faceting shows as a scalloped edge.
Figures are tiered the same way but one step coarser across the board, and by stride rather than by segment count: a shape mesh at 128 columns already has its silhouette right, because the silhouette is where its samples are. The reason a sphere needs 512 is that a sphere's error is entirely in its limb, and a figure does not have that problem to begin with.
The haze is not the atmosphere
Atmosphere.ceiling is a physics number — where the drag model stops
integrating — and for a gas giant it is a thousand kilometers of "there is no
surface". Rendered as a shell that thick, Saturn wore a halo 3% of its own radius
wide and Earth's Rayleigh blue, because the scattering color was a constant.
Both are now per body: BodyAppearance.haze carries a rendered thickness and the
published limb colors, and Titan's haze is orange in every direction because
tholins are.
The ground's own material
apps/game/src/render/terrain.ts is a hand-written node graph, not a
MeshStandardNodeMaterial, and it does not see the scene's ambientLight — the
sun reaches it through its own uniform, the way it reaches every planet and
every atmosphere. The reason is the same one that put the quadtree there:
the ground is the picture of the planet, so a rough dielectric sphere under
a uniform fill is not what a regolith world looks like at any phase angle.
It shades from the same lunar-Lambert split
render/planet.ts uses, because a descent crosses between the two at the
eight-pixel gate and that switch has to be invisible. Measured either side of
it, the streamed ground and the archive's sphere are 3.1% apart in mean value on
Mars and 1.5% apart on Earth. Anything added to one of the two materials and
not the other is a step at the switch — the aerial veil is the case that proves
it, because the atmosphere shell is a back-side sphere and only survives the
depth test outside the planet's silhouette. Everything the air does in front
of the ground therefore happens in the surface material, on both sides.
The lookup is split by who can answer. Latitude is the direction against the
spin axis, altitude is the radius, slope is the normal against the radial — a
fragment has all three for free, and shipping them per vertex would be paying to
send the renderer something it is standing on. What a shader cannot derive is
the body's past: whether this plain is flood basalt, whether this ground was
excavated last week or three billion years ago, which way the crust's
composition varies, where the volatiles have condensed, where the liquid runs
and where something grows beside it. Those six are the cover — eight bytes a
vertex, two attributes of four, through render/groundWear.ts —
generated with the heightfield.
Deposits are layered rather than splatted — seven mixes in the order the
material is laid down: regolith wherever a slope holds it, basalt in the flooded
basins, wind-sorted fines on the low flat ground, evaporite on the flattest and
lowest, silt on the seabed, the biosphere's pigment over every soil it grows
in, volatiles on top. A six-way normalized splat has to invent a rule for
what happens when three weights all say 0.4; a stack says the ice is on the
sand, which is true. The angle of repose does most of the visible work, and 33°
is a fact about friction rather than about a planet.
The palette is ratios against the body's own published color, never absolute
values. packages/rendering/src/terrainPalette.ts holds it. Absolute colors
make every rocky world the same sandstone, and they make the ground disagree
with the datum sphere, the orbital tier and the dossier swatch, all of which
read appearance.color. As ratios, Mars stays ochre and Callisto stays gray
while both get the same internal contrast — lunar mare is 0.07 geometric albedo
against 0.13 for the highlands, so basalt is 0.54 of the reference.
A mapped body's ground wears its published map, sampled by direction in
SphereGeometry's own layout: the same photograph the sphere in front of it is
drawn from. On those bodies the cover's invented channels switch off, because
the maria and the ray systems are in the photograph already and a second set on
top of them is two disagreeing planets in one frame. The ocean is an invented
channel too — the generated field and the archive's photograph disagree about
where Earth's land is, and that disagreement is the carve-out, so painting sea
wherever the generated datum says water goes puts open water over the map's
continents.
Where a photograph exists it supplies the albedo outright, and the deposits keep only what a map at ten kilometers a texel has no opinion on: the roughness, the grain, the bump, and which of them the slope under the camera exposes. Halving their brightness instead of dropping it was still 9% of the drawn value across the gate on Mars, almost all of it evaporite lifting ground the photograph had already drawn pale.
Everything in the graph is in body-fixed axes — the normal because the geometry is, the eye because the morph already needed it, and the sun because the host rotates one vector on the CPU rather than transforming a normal per fragment. Skylight comes out of the direct beam rather than beside it: light scattered into the sky is light that did not arrive along the sun ray, and added beside it the streamed ground came out 15% brighter than the photograph of the same planet. ADR-0020 has the alternatives.
Below a mesh cell there is a grain band, and its domain is the interesting
part of it. A patch at the detail floor is 0.35 to 1.41 m a cell, and from a
two-meter stance one of those cells is two hundred display pixels across — so
everything between a cell and a pixel is per-pixel or it is nothing at all. The
band runs from 0.7 m down to 9 cm, at about fifteen degrees of slope, which is
what lunar regolith measures at centimeter baselines. What it may not use is
either of the two positions already in the graph. positionLocal is
patch-local, so a noise on it jumps phase at every patch edge — invisible at
the seven-meter octave above it, and a straight line across the ground at seventy
centimeters. The body-fixed position is continuous and useless: 1.7 × 10⁶ on
Luna, where float32 resolves 0.1 m and quantizes a nine-centimeter octave out of
existence. So the anchor is reduced modulo GRAIN_PERIOD wavelengths in
float64 on the CPU and the noise is written out to be periodic over that
period — exact, continuous across every boundary, and repeating every 45 m of
ground, which is further than the band survives to. It is hand-written rather
than taken from mx_* because periodicity is the one property none of the
built-ins has.
The rocks wear this material rather than one of their own, which is what
keeps a boulder and the regolith it is lying on agreeing about the palette, the
photometry, the terminator, the aerial veil and the published photograph. They
are the same body's surface seen a meter apart. Sharing costs nothing and needs
no branch, because Three's node material inserts the instancing before
positionNode runs — instancedMesh( object ) assigns into positionLocal and
normalLocal, and this graph reads exactly those — so the altitude, the
latitude, the map's UV and the footprint the detail fades on are all right for
the rock rather than for the field's anchor. A rock comes out bedrock on its
steep faces and regolith on its top, in the palette of the ground under it, by
the same slope term that decides the ground.
ADR-0021.
Two float32 rules that only a planetary shader needs
Both were reported as one defect — a coastline warping several times a second, seen from two kilometers up over an island chain.
Never subtract two planetary radii from each other in a shader.
length(anchor + local) − datumRadius puts both terms at 6.4 × 10⁶ on Earth,
where one float32 step is half a meter, so an altitude computed that way arrives
quantized to half a meter — inside a water band four meters wide. The shoreline
becomes a stair, and the morph walks local across those steps every frame, so
the stair crawls. The cancellation is avoided rather than tolerated:
|p|² − |a|² = 2(a·l) + l·l exactly, and |p| − |a| is that over |p| + |a|,
so the large numbers never meet.
Never take a screen-space derivative of a planetary position. It is worse
than the value: half a meter of quantization against a pixel covering a few
meters is a tenth of noise in the derivative and a constant bias per patch, so
the map's mip level changed at every patch boundary. And local is linear
across a triangle, which means dFdx(local) is constant over the whole of one —
a detail fade measured that way steps per polygon. Both are analytic instead:
the UV gradient from the tangential part of a precise step, the fade from
distance times the lens's own pixel angle.
Two more are backend facts rather than numerical ones, and they fail the same way: a message on a channel the page console does not carry, and a planet that draws nothing.
A varying may not take an attribute's name. varying(vec4(), 'terrainCover')
beside attribute('terrainCover') is a redeclaration in the generated shader,
and it surfaces as [Invalid ShaderModule "vertex"].
And two attribute names may not share one BufferAttribute object. The
backend builds its vertex layout by asking the geometry for each attribute the
graph names, and keys the GPU buffer on the object it gets back — so one object
answering to two names is one buffer at two shader locations, and the pipeline
does not build at all. It reports [Invalid ShaderModule "fragment"] is invalid due to a previous error, and warmCompile swallows its rejection, so a
compile-ahead making the same mistake fails silently before the real draw hits
the same wall. Two attribute objects over one array is a few bytes and one fewer
trap; ScatterRocks gives its rocks their morph targets that way, because the
terrain graph reads morph attributes a rock has no coarser version to morph to.
ADR-0021.
Depth buffer settings
The camera spans 0.05 m to 10¹⁰ m. WebGPU uses reversed depth with a
FloatType scene attachment, so distant separation keeps floating-point
relative precision and temporal reconstruction can linearize it correctly.
WebGL selects the logarithmic buffer before backend initialization. It does not
run FSR. ADR-0044 records
the depth and reconstruction contract.
The renderer
WebGPURenderer with TSL, built in apps/game/src/render/. The reasoning is in
docs/design/technical.md; what
matters here is the shape.
WebGL is a backend, not a second renderer. WebGPURenderer carries its own
WebGL 2 backend and swaps to it when the device request fails. So the fallback
runs the same node graphs, and there is no second set of materials to keep in
sync. The fallback loses extended-range output because WebGL 2 has no
rgba16float canvas equivalent. It also lacks the supported histogram readback
path. Enhanced works in SDR; Automatic visibly falls back to lens-controlled
Manual while preserving the selected preference.
flowchart TB
PROBE["probeOutputCapability()"]
Q1{"navigator.gpu?"}
Q2{"(dynamic-range: high)?"}
Q3{"rgba16float canvas<br/>configures?"}
PREF["three-state preference"]
EXT["<b>extended</b><br/>outputType: HalfFloatType<br/>tone curve headroom 2×"]
STD["<b>sRGB</b><br/>tone curve headroom 1×"]
PROBE --> Q1 --> Q2 --> Q3
Q3 --> PREF
PREF -->|auto: all three| EXT
PREF -->|extended: probe only| EXT
PREF -->|standard| STD
Q3 -.->|refused| STD
style EXT fill:#065f46,stroke:#064e3b,color:#fff
style STD fill:#0369a1,stroke:#0c4a6e,color:#fffTwo of those three signals are opinions and one is a fact.
Spike 1 put them in front of three
browsers on one physical panel: (dynamic-range: high) came back true, true and
false for the same display, and there is no headroom API anywhere, so the page
cannot tell a 2×-EDR laptop from an XDR display. The configure() probe is the
only signal that cannot be argued with, which is why extended may overrule the
media query and may not overrule the probe.
Camera processing and output range
Enhanced composes bright surfaces and faint sky with an authored visibility response. Automatic and Manual share the hue-preserving photographic response; Automatic meters, and Manual uses the lens. A photographic look changes the response shoulder without changing source light or exposure policy. The ACES fit remains a measured diagnostic and an explicit cinematic staging choice.
Every mode reaches the same output owner. SDR bounds luminance at display white; negotiated extended output permits the authored headroom up to the player's peak cap. P3 identifies gamut and does not imply HDR output. Changing gamut or headroom preserves the selected mode and physical field calibration.
The frame is the sensor's
Nothing draws straight onto the canvas. scene/Sensor.tsx takes the frame from
React Three Fiber at priority 1 and hands it to render/sensor.ts: one
RenderPipeline around pass(scene, camera), ending in its own renderOutput
with the house curve and the canvas's color space baked in, writing opaque
alpha. The scene is multisampled where it has edges — on the pass target — and
the canvas is single-sampled, because what reaches it is one full-screen quad.
The chain applies pre-exposure, near/far defocus, shutter motion, a threshold-free
PSF halo, detector noise, white balance and the selected response. Enhanced
reads retained physical sky radiance and compresses its luminance before the
half-float scene target can lose the faint signal. That composed light enters
the common glare and noise stages. Enhanced owns dark-body lift, integrated
star visibility and analytic solar-core treatment. Automatic and Manual keep
the physical inputs and the same photographic response.
Automatic meters physical scene radiance with instrument-covered samples excluded by an R8 mask. It weights log luminance by light over the 40th–99.9th percentile interval after excluding empty bin zero, so a small lit disk is not discarded with the brightest five percent. The retained physical reading receives the current compensation and comfort clamps. Holding adaptation keeps the current exposure and the Automatic mode.
Adaptation runs on engine.presentationTime, accumulated with bounded frame
deltas, separately from the photographic instant that places bodies and seeds
noise. Camera cuts, mode changes and time scrubs invalidate meter history and
pending asynchronous readbacks. Manual and pinned staging add no automatic gain.
The live effective EV, gain, selected mode, staging override and optical pass
counts are available as engine.exposure and engine.sensorDiagnostics.
ADR-0037 records the current processing policy and remaining image acceptance. ADR-0031 retains the measured optical and ACES diagnostic baseline.
Two things the chain has to do that a reader would not guess, both measured
rather than argued in ADR-0029. The pass is
keyed on the render call rather than on three's own frame counter, which only
three's own animation loop advances, so forty render() calls in one task — the
GPU measurement — are forty frames and not one frame and thirty-nine quads. And
render() restores the renderer's tone mapping and color space in a finally,
because RenderPipeline.render swaps both around the quad with no finally of
its own, and one throw from the scene inside that swap left every later frame
one sRGB transfer too dark.
The picture record and reconstruction
render.picture owns anti-aliasing, render scale and reconstruction sharpness.
Its default is native MSAA. Raw and MSAA pictures can use spatial FSR at a
reduced scale; temporal FSR uses single-sampled input at any scale, including
native anti-aliasing. Supersampling doubles each drawing-buffer axis and permits
only native scale. Quality, Balanced, Performance and Ultra performance divide
each scene-target axis by 1.5, 1.7, 2 and 3 respectively, rounded down to whole
pixels.
The scene pass renders at that smaller size. render/upscale.ts dispatches the
library kernels once per render call and returns display-size radiance before
defocus, shutter motion, glare, noise and output encoding. Temporal input adds
unjittered velocity and a reactive attachment. The sensor clears projection
jitter in finally; resize, declared camera cuts and incompatible processing
changes discard history. Its existing exposure owner supplies FSR's
conditioning, with unity for Enhanced's composed visibility domain.
The picture is a presentation preference. Its changes rebuild the sensor in
place; they do not remount the renderer. WebGL resolves temporal to MSAA and
reduced scales to native without overwriting the stored choice. The graphics
panel shows the actual integer render dimensions and disables unsupported
choices. A ?picture=temporal:quality page override leaves storage unchanged;
?picture=native selects the default baseline and ?picture=bilinear:quality
is the diagnostic comparison path.
Terrain selection and lens angles continue to use display pixels. Reducing the
scene resolution does not reduce patch demand. The optical passes keep their
surface motion and reciprocal-depth attachment, whose overlay semantics differ
from FSR's temporal guides. ir.picture() reports reconstruction dimensions,
history and costs; the harness guide
defines those readings.
Three passes that stayed separable
Terrain, atmosphere and the star field are the three places technical says a hand-written pipeline might one day be worth it, so each is a self-contained material that reaches into nothing else.
The atmosphere is the interesting one. It integrates along the view ray rather than shading a surface: the shell is drawn back-side, so the fragment is always on its far wall and the opaque planet has already depth-killed the middle. What is left is a ray–sphere intersection with the near end clamped to the camera, which is what lets one expression serve both an orbital limb and a sky seen from the ground. It is not scattering — uniform density, a path length, authored constants — and the replacement is named: Bruneton's precomputed LUTs, which spike 2 promoted from optimization to requirement when a 256-sample raymarch measured 7.27 ms against a 3.0 ms budget.
The star field is instanced sprites rather than a point cloud, and that is not a
stylistic choice: WebGPU renders point primitives at exactly one pixel, so
PointsNodeMaterial.sizeNode is silently ignored on a Points object under the
WebGPU backend and honored under the WebGL one. The field would have shrunk on
the primary backend and looked correct on the fallback.
The full path, one more time
flowchart TB
UV["UniverseVector<br/><i>canonical</i>"]
RS["render space<br/><i>toRenderSpace(origin)</i>"]
TIER["LOD tier<br/><i>by angular size</i>"]
PLACE["placement<br/><i>position + scale, compressed if far</i>"]
THREE["Three.js object"]
UV --> RS --> TIER --> PLACE --> THREE
RS -.- N1["±4096 m of the camera"]
PLACE -.- N2["angular size exact,<br/>depth compressed"]
classDef note fill:none,stroke:none,color:#64748b,font-style:italic
class N1,N2 note
style UV fill:#0369a1,stroke:#0c4a6e,color:#fffRelated
- ADR-0013 — why a body that gravity never rounded off carries a radius grid rather than a sphere
- ADR-0020 — the cover field, the palette and the one terrain material
- ADR-0021 — the grain band, and the rocks that wear the same material
- Coordinates — what render space is derived from
- Streaming — how terrain patches are chosen and reconciled
- Time — where the interpolation alpha comes from
- ADR-0003 — alternatives considered