1
0

fix: the ground's detail was switched off everywhere, and the map was a grid of stars

Three defects in the round before this one, all of them found by rendering the
board and looking at it, none of them by a test that existed.

**1. The fade was wrong by a factor of nine, so the map did nothing.** It copied
`SEA_CALM_NEAR`'s form: `length( vViewPosition )` against a window converted from
real metres by `metresPerUnit`. That conversion assumes the camera's distance is
a plan measurement. On this board it is not — `verticalExaggeration` is 15, so a
camera lifted to 0.6 of its stand-off sits `sqrt(1 + (0.6 x 15)²) = 9.06`
stand-offs away in scene units — so a window written as "25 km to 70 km" closed
at about 2.8 km of stand-off and the detail was off everywhere a viewer can
actually go. Proved rather than guessed: a fivefold amplitude produced a
byte-identical PNG of the Central Valley.

It now measures the quantity instead of inferring it. `fwidth` of the detail UV
is exactly how much of one repeat a pixel covers, so its reciprocal is the
repeat's width in pixels — independent of board scale, exaggeration, field of
view, viewport and device pixel ratio, all five of which the old form was
guessing at. Off under 2 px, full over 8: the same thresholds the stand-off study
arrived at, without any of its assumptions. The geometric mean of the two axes
floored at an eighth of the larger, because that is what `anisotropy = 8`
resolves.

**2. The repeat went 220 m -> 440 m, and the recipe with it.** With the map
finally visible, the flat valley came out as a regular grid of four-pointed
stars, one per repeat, marching to the horizon. The cause is `tileableNoise` at a
`scale` of 2: below about 4, the blend's own wrapped cross-fade is the largest
feature in the tile. The layers now run 8 / 22 / 60 on a 128 lattice at 512², so
a repeat holds eight or so features instead of one and nothing in it is
identifiable. Mean slope 9.9°, peak 39.4°.

**3. The shore plate needed the map, not just the material.** A textured terrain
against a smooth plate makes the grid's stair-stepped rim legible — which is the
one thing `createShorePlates` exists to hide — and at San Francisco it came back
as a zigzag. Both now come out of one `groundMaterial`, which is two material
objects (the plate is a flat colour, the terrain a vertex ramp, and three
compiles those to different programs regardless) and the same one material per
mesh as before. The bytes of the detail map are built once and shared; the
`DataTexture`s are not, because `scene.ts` disposes a board by traversing it.

Zero new draw calls, unchanged: 437 at the whole-board pose, 348 at 7.7 km over
SF, 389 at 7.7 km over LA. Triangles unchanged to within the drift of the moving
vessels and aircraft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-24 20:13:35 -07:00
parent 0d7eb28bb5
commit 76ca903cd2
3 changed files with 371 additions and 255 deletions
+2 -2
View File
@@ -106,7 +106,7 @@ const NOISE_RES = 64;
* Exported because the ground uses it too. `engine/terrain.ts` builds one * Exported because the ground uses it too. `engine/terrain.ts` builds one
* tiling detail normal map for the land out of these three functions — the * tiling detail normal map for the land out of these three functions — the
* blend, the bilinear read and the Sobel below — rather than reimplementing * blend, the bilinear read and the Sobel below — rather than reimplementing
* them at a 220 m repeat, for the same reason this file takes `fbm` from * them at a 440 m repeat, for the same reason this file takes `fbm` from
* `engine/world.ts` rather than writing its own: a second noise is a second * `engine/world.ts` rather than writing its own: a second noise is a second
* thing to keep tiling, and a second Sobel is a second place for the * thing to keep tiling, and a second Sobel is a second place for the
* flipY/perUv reasoning to be got wrong. Nothing here touches the DOM, so the * flipY/perUv reasoning to be got wrong. Nothing here touches the DOM, so the
@@ -1089,7 +1089,7 @@ function heightField(recipe: HeightRecipe, size: number): Float32Array {
* higher-resolution floor looking twice as smooth. * higher-resolution floor looking twice as smooth.
* *
* `tileMetres` is a parameter rather than the constant it used to read, because * `tileMetres` is a parameter rather than the constant it used to read, because
* the ground's detail map repeats every 220 m and the office's finishes every * the ground's detail map repeats every 440 m and the office's finishes every
* 2 m, and the whole point of the conversion is that the number is the tile's * 2 m, and the whole point of the conversion is that the number is the tile's
* own. Every caller in this file passes `TEXTURE_TILE_METRES`, which is what * own. Every caller in this file passes `TEXTURE_TILE_METRES`, which is what
* the default preserves. * the default preserves.
+312 -238
View File
@@ -139,61 +139,72 @@ const GROUND_ROUGHNESS = 0.92;
* whole reason it is not `TEXTURE_TILE_METRES`. * whole reason it is not `TEXTURE_TILE_METRES`.
* *
* The office's constant is 2 m, which is correct for a carpet and meaningless * The office's constant is 2 m, which is correct for a carpet and meaningless
* here. Work out what a tile is worth in pixels before choosing one. The * here. Work out what a repeat is worth in pixels before choosing one. The
* desktop budget viewport is 1440 x 900 at a 42° *vertical* field of view, so * desktop budget viewport is 1440 x 900 at a 42° *vertical* field of view, so at
* looking straight down from a stand-off of `d` metres one device pixel covers * a camera-to-ground distance of `D` scene units one device pixel covers
* `d · 2·tan(21°) / 900 = d / 1173` metres of ground, and an oblique look * `D · 2·tan(21°) / 900` scene units — 1.64 m of California's 1,919 m per unit.
* divides that by `sin(pitch)`. Against the poses this board can actually be * Against the distances this board can actually be put at, quoted the way
* put in: * `TODO.md` and `cost-at.mjs` quote them, as `D × metresPerUnit`:
* *
* | stand-off | m/px | a 2 m tile | a 220 m tile | * | camera distance | m/px | a 2 m repeat | a 440 m repeat |
* |---|---|---|---| * |---|---|---|---|
* | 400 km — the whole state, and where the budget measures | 341 | 0.006 px | 0.6 px | * | 400 km — the whole state, and where the budget measures | 341 | 0.006 px | 1.3 px |
* | 110 km — Los Angeles wide | 94 | 0.02 px | 2.3 px | * | 110 km — Los Angeles wide | 94 | 0.02 px | 4.7 px |
* | 45 km — the Bay rung | 38.4 | 0.05 px | 5.7 px | * | 45 km — the Bay rung | 38.4 | 0.05 px | 11 px |
* | 7.7 km | 6.6 | 0.3 px | 33 px | * | 7.7 km | 6.6 | 0.3 px | 67 px |
* | 2.5 km | 2.13 | 0.9 px | 103 px | * | 2.5 km | 2.13 | 0.9 px | 207 px |
* | 1.9 km — `ORBIT_MIN_STANDOFF_M`, the floor | 1.62 | 1.2 px | 136 px | * | 1.9 km — `ORBIT_MIN_STANDOFF_M`, the floor | 1.62 | 1.2 px | 272 px |
* *
* **A 2 m tile is sub-pixel at every stand-off the board can reach**, right * **A 2 m repeat is sub-pixel at every distance the board can reach**, right
* down to the orbit floor, so reusing the office's number would have bought a * down to the orbit floor, so reusing the office's number would have bought a
* texture fetch per ground fragment in exchange for a map the mip chain has * texture fetch per ground fragment in exchange for a map the mip chain has
* already flattened. 220 m is legible from about 45 km down and is noise above * already flattened.
* about 110 km, which is one decade of the board's three — and it is the decade
* where the ground looks worst, because the terrain's 300 m cells are collapsed
* by `lodPatches` into flat patches up to eight cells across.
* *
* **Not 300 m, deliberately.** The obvious choice is one repeat per terrain * **440 m, and it was 220 m first, and the difference was a photograph.** The
* cell, and it is the one number that must be avoided: the lattice is very * arithmetic says a repeat wants to be a few hundred metres and stops there; it
* nearly axis-aligned, so a tile the size of a cell paints the *same* pattern * cannot tell you that at 220 m, with `tileableNoise` at a `scale` of 2, the
* on every cell and the repeat lands exactly on the facet edges it exists to * blend's own four-way cross-fade is the largest thing in the tile and the flat
* hide. 220 beats against them instead. * Central Valley comes out as a **regular grid of little four-pointed stars**,
* one per repeat, marching off to the horizon. That was rendered and looked at.
* At 440 m with the layers below there are eight or so features across a repeat
* instead of one, so nothing in the tile is identifiable and the repetition
* stops being legible as repetition.
* *
* Magnification is never the failure mode. At `GROUND_DETAIL_SIZE` a texel is * **Not 300 m, and not 600.** The obvious choice is one repeat per terrain cell,
* 0.86 m, which first exceeds one device pixel at a stand-off of about a * and it is the one number to avoid: the lattice is very nearly axis-aligned, so
* kilometre — below the orbit floor. Inside the reachable range the map is * a repeat the size of a cell paints the *same* pattern on every cell and lands
* always minified, so the mip chain decides everything and the magnifier * its seam exactly on the facet edges it exists to hide. 440 beats against them.
* decides nothing.
*/ */
const GROUND_DETAIL_TILE_METRES = 220; const GROUND_DETAIL_TILE_METRES = 440;
/** /**
* The detail map's resolution, and the lattice its height field is built on. * The detail map's resolution, and the lattice its height field is built on.
* *
* 64 is `textures.ts`' own `NOISE_RES` and is used here for the reason that * Four texels per lattice cell, which is the ratio that matters. `textures.ts`
* file records: the bilinear upsample off a coarse lattice is what makes the * records why the noise is evaluated on a coarse lattice and bilinearly
* field cheap, and `normalMapData`'s Sobel is what buries the lattice in the * upsampled — four `fbm` calls per pixel at 512² is about sixteen million
* derivative. Sixty-four samples across a 220 m tile puts the finest thing in * `Math.sin`es — and why `normalMapData` uses a Sobel rather than a two-tap
* the height field at 3.4 m, so 256 texels lands four of them across it and 512 * difference: the upsample reproduces the lattice as a faint grid of creases in
* would be storing the interpolation between them. That is also 262 KB rather * the derivative, and averaging three rows buries it. 128 over a 440 m repeat
* than 1.05 MB, against a board whose whole resident geometry is about 40 MB. * puts the finest thing the height field can hold at 3.4 m, the same floor the
* office's 64-over-2 m gives at its own scale.
*
* 512 rather than 256 because of the *bottom* of the descent, not the top. At
* 256 a texel is 1.72 m, which is exactly one device pixel at the 1.9 km orbit
* floor — no margin at all, and a grazing look has none to give. At 512 it is
* 0.86 m and the map is minified everywhere in the reachable range, so the mip
* chain decides everything and the magnifier decides nothing. The price is
* 1.05 MB, 1.4 MB with its mips, against a board whose whole resident geometry
* `boards.ts` measures at about 40 MB — and one of it, shared by all 18 ground
* objects. It costs about 50 ms of the board build, against `createTerrain`'s
* own 159 ms on the Bay Area.
* *
* The Sobel converts gradient to slope per metre, so the two resolutions light * The Sobel converts gradient to slope per metre, so the two resolutions light
* identically — `textureMaps.test.ts` pins that property for the office's maps * identically — `textureMaps.test.ts` pins that property for the office's maps
* and it is the same arithmetic here. * and it is the same arithmetic here, checked again below for this one.
*/ */
const GROUND_DETAIL_LATTICE = 64; const GROUND_DETAIL_LATTICE = 128;
const GROUND_DETAIL_SIZE = 256; const GROUND_DETAIL_SIZE = 512;
/** /**
* The height field the ground's normal map is differentiated from, in metres of * The height field the ground's normal map is differentiated from, in metres of
@@ -204,50 +215,68 @@ const GROUND_DETAIL_SIZE = 256;
* the merged California board — while a horizontal metre is divided by * the merged California board — while a horizontal metre is divided by
* `metresPerUnit` alone. A normal map encodes a slope in scene units per scene * `metresPerUnit` alone. A normal map encodes a slope in scene units per scene
* unit, so the numbers below are already on the drawn side of that * unit, so the numbers below are already on the drawn side of that
* multiplication: the ±5.9 m of relief these three layers sum to stands for * multiplication: the ±5 m of relief these three layers sum to stands for about
* about ±0.4 m of real ground, which is a 1% grade over 220 m and is about * ±0.35 m of real ground, which is a sub-1% grade over 440 m and is about right
* right for field, scrub and alluvium. * for field, scrub and alluvium.
* *
* **They are deliberately not exaggerated to match the geography.** Carrying * **They are deliberately not exaggerated to match the geography.** Carrying the
* the real relief through the board's own 15× would put the map's mean slope * real relief through the board's own 15× would put the map's mean slope near
* near 45° and its peaks past 70°, and that was the wrong answer for a reason * 45° and its peaks past 70°, and that is the wrong answer for a reason that is
* that is not about taste: the grain would then out-shout the geography, the * not about taste: the grain would out-shout the geography, the sun's N·L would
* sun's N·L would swing by ±0.6 across two texels, and a state whose whole * swing by ±0.6 across two texels, and a state whose whole subject is its
* subject is its landforms would read as crumpled foil. The mesh carries the * landforms would read as crumpled foil. The mesh carries the exaggeration; the
* exaggeration; the finish on it does not. * finish on it does not.
* *
* Measured on the field these three produce: mean slope 8.5°, peak 32°, and * Measured on the field these three produce: **mean slope 9.9°, peak 39.4°.**
* 79% of it under 12°. The three layers are one decade apart each — about 110 m * The three are about a factor of three apart each — roughly 55 m of swell, 20 m
* of swell, 30 m of gully, and the lattice floor at 3.4 m as grain — because a * of gully and 7 m of grain — because a single octave at any of those reads as a
* single octave at any of those reads as a pattern and three read as a surface. * pattern and three read as a surface. The `scale`s are all comfortably above 4
* for the reason `GROUND_DETAIL_TILE_METRES` records: below that,
* `tileableNoise`'s wrapped cross-fade is itself the biggest feature in the tile.
*/ */
const GROUND_DETAIL_LAYERS: readonly { scale: number; offset: number; metres: number }[] = [ const GROUND_DETAIL_LAYERS: readonly { scale: number; offset: number; metres: number }[] = [
{ scale: 2, offset: 5.7, metres: 27 }, { scale: 8, offset: 5.7, metres: 21 },
{ scale: 7, offset: 19.3, metres: 10.2 }, { scale: 22, offset: 19.3, metres: 8 },
{ scale: 23, offset: 41.1, metres: 3.3 }, { scale: 60, offset: 41.1, metres: 2.6 },
]; ];
/** /**
* Where the detail gives up, in metres of view distance. * Where the detail gives up, in **device pixels of one repeat**.
* *
* The same argument as `SEA_CALM_NEAR` and the same mechanism: past some * The same argument as `SEA_CALM_NEAR` and a different measurement, because the
* distance one pixel covers more of the map than the mip chain can average * first attempt at this used the sea's and it was wrong by a factor of nine —
* without the grain crawling as the camera moves, and long before that the map * measured, in a photograph of the Central Valley where the map was doing
* has stopped saying anything. From the table at `GROUND_DETAIL_TILE_METRES`, a * literally nothing and a five-fold amplitude produced a byte-identical PNG.
* 220 m tile is 5.7 px at 45 km and 2.3 px at 110 km, so it is legible at the
* near end of this window and mip-flattened noise at the far end.
* *
* It matters more here than it does for the sea, because **the board's own * The sea fades on `length( vViewPosition )` converted from real metres by
* budget poses live at the far end.** `california-one` is measured at whole- * `metresPerUnit`, which quietly assumes the camera's distance is a *plan*
* board framing, 400 km out, where a 220 m tile is 0.6 px; without this the * distance. On this board it is not: `verticalExaggeration` is 15, so a camera
* ground would be paying a fetch and a tangent frame at the one pose the * lifted to 0.6 of its stand-off sits `sqrt(1 + (0.6 x 15)²) = 9.06` stand-offs
* product is judged on, to perturb a normal by an amount that has already * away in scene units. A window written as "25 km to 70 km" was therefore
* averaged to zero. In real metres rather than board spans so that a phone * closing at about 2.8 km of stand-off and the detail was off everywhere a
* looking at San Francisco and a desktop looking at the state agree about what * viewer could actually go.
* "far" means. *
* The fix is to stop inferring the quantity and measure it. `fwidth` of the
* detail UV is exactly how much of one repeat a pixel covers, so its reciprocal
* is the repeat's width in pixels — independent of the board's scale, the
* exaggeration, the field of view, the viewport and the device pixel ratio, all
* five of which the old form was implicitly guessing at. Two thresholds then say
* what the study of stand-offs already concluded: **under 2 px the map is noise
* the mip chain has already flattened, over 8 px it is legible.** For a 440 m
* repeat that is the same window as "from about 110 km down to about 45 km",
* arrived at without any of the assumptions.
*
* It matters more here than it does for the water, because the board's own
* budget poses live at the far end: `california-one` is measured at whole-board
* framing, 400 km out, where a 440 m repeat is 1.3 px. Without this the ground
* would be paying a fetch and a tangent frame at the one pose the product is
* judged on, to perturb a normal by an amount that has already averaged to zero.
*
* Two derivative pairs, which is the whole cost, and the normal-map path is
* already taking three for `getTangentFrame`.
*/ */
const GROUND_DETAIL_NEAR_M = 25_000; const GROUND_DETAIL_NOISE_PX = 2;
const GROUND_DETAIL_FAR_M = 70_000; const GROUND_DETAIL_LEGIBLE_PX = 8;
/** /**
* A tiling tangent-space normal map for the land, as raw RGBA bytes. * A tiling tangent-space normal map for the land, as raw RGBA bytes.
@@ -283,9 +312,23 @@ export function groundDetailData(size: number = GROUND_DETAIL_SIZE): Uint8Array
return normalMapData(height, size, GROUND_DETAIL_TILE_METRES); return normalMapData(height, size, GROUND_DETAIL_TILE_METRES);
} }
/**
* The bytes, built once for the life of the page.
*
* Two materials want this map — the terrain and the shore plate under it — and
* the field is a pure function of the constants above, so building it twice is
* 50 ms of identical arithmetic. Only the *bytes* are shared: each material gets
* its own `DataTexture`, because `scene.ts` disposes a board by traversing it
* and disposing what it finds, and a `Texture` shared across boards would be
* disposed by the first board to leave. A `Uint8Array` has no GPU resource to
* free and is never written to after this line.
*/
let groundDetailBytes: Uint8Array | null = null;
function groundDetailTexture(): THREE.DataTexture { function groundDetailTexture(): THREE.DataTexture {
const size = GROUND_DETAIL_SIZE; const size = GROUND_DETAIL_SIZE;
const texture = new THREE.DataTexture(groundDetailData(size), size, size); groundDetailBytes ??= groundDetailData(size);
const texture = new THREE.DataTexture(groundDetailBytes, size, size);
texture.wrapS = THREE.RepeatWrapping; texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping;
texture.magFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter;
@@ -317,10 +360,11 @@ function groundDetailTexture(): THREE.DataTexture {
* renderer's idea of "phone" cannot drift from the stylesheet's. * renderer's idea of "phone" cannot drift from the stylesheet's.
* *
* What a handheld keeps is the half of this change that reads at every * What a handheld keeps is the half of this change that reads at every
* stand-off: the standard material, its specular and its environment term. What * distance: the standard material, its specular and its environment term. What
* it gives up is the half that only reads below 45 km — one texture fetch and * it gives up is the half that only reads inside the last decade of the descent
* `getTangentFrame`'s three derivative pairs per ground fragment, on a viewport * — one texture fetch and `getTangentFrame`'s three derivative pairs per ground
* that is 1.3 Mpx of them, at a whole-board pose where the tile is 0.6 px wide. * fragment, on a viewport that is 1.3 Mpx of them, at a whole-board pose where
* one repeat of the map is 1.3 px wide.
* *
* `typeof window` because `createTerrain` runs under `node --test` as well, * `typeof window` because `createTerrain` runs under `node --test` as well,
* where there is no device to profile and no fill rate to protect. * where there is no device to profile and no fill rate to protect.
@@ -330,8 +374,180 @@ function groundDetailWanted(): boolean {
return !deviceProfile().handheld; return !deviceProfile().handheld;
} }
/**
* The one material the ground is shaded by, built for the terrain and again for
* the shore plate under it.
*
* Two materials rather than one because the terrain is `vertexColors` and the
* plate is a flat `pal.shore`, and three compiles those to different programs
* whatever this returns. **One material per mesh either way**, so the draw call
* count is exactly what it was: 17 chunks, one zero-triangle caster and one
* plate, sharing two programs between them. The plate is here rather than left
* on its own flat colour because a textured terrain against a smooth plate makes
* the grid's stair-stepped rim *legible* — which is the one thing
* `createShorePlates` exists to hide — and because `WebGLRenderer` forwards
* `scene.environment` only to `isMeshStandardMaterial`, so a Lambert plate would
* also sit a fifth of the hemisphere's diffuse contribution darker than the
* ground it abuts, all the way around the coastline.
*/
function groundMaterial(
world: World,
detail: boolean,
paint: Pick<THREE.MeshStandardMaterialParameters, "color" | "vertexColors">,
): THREE.MeshStandardMaterial {
const material = new THREE.MeshStandardMaterial({
...paint,
side: THREE.DoubleSide,
roughness: GROUND_ROUGHNESS,
// Stated rather than defaulted: ground is a dielectric, and a metallic
// surface has no diffuse term at all, so a stray metalness here would take
// `groundColor`'s whole ramp off the board.
metalness: 0,
...(detail ? { normalMap: groundDetailTexture() } : {}),
});
/**
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
* heightfield.
*
* The depth pass then culls every face turned *toward* the sun — precisely
* the set of faces that were shadowing themselves — and keeps the faces
* turned away from it. The boundary between the two is the terminator, so the
* depth recorded along a ridge starts at the crest and runs down its far
* slope, and the valley floor beyond, which is still front-facing and
* therefore writes nothing, tests against it and lands in shadow. A lit slope
* has nothing in the map above it and cannot stipple.
*
* Without it, a constant `shadow.bias` has to cover a depth-per-texel that
* grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
* 0.73 on the Bay Area — and there is no single value that is free of acne at
* 40° and free of peter-panning at 8°. That is why this was left off through
* the previous round, and it is checked here by photographing the boards at
* a sun of 12° and of 2°.
*
* It survives the move to a standard material unchanged, and it has to: the
* depth pass does not use this material at all — `WebGLShadowMap` swaps in
* its own `MeshDepthMaterial` and copies across `shadowSide`, `alphaMap` and
* the displacement slots and nothing else — so the patch below is invisible
* to it and this flag is the only thing it reads.
*/
material.shadowSide = THREE.BackSide;
if (detail) {
/**
* Three edits to the standard shader. The ground has no `uv` attribute and
* no `tangent` attribute, and both of those are decisions rather than
* omissions.
*
* 1. **The detail UV is derived from world XZ in the vertex shader.** The
* alternative is a real `uv` attribute, and it was costed: 46,144
* vertices x 2 x 4 bytes is 369 KB against the 1.66 MB of position,
* colour and normal the ground already uploads — a 22% growth of the
* ground's vertex data to store a number that is `position.xz` times a
* constant. It would also have to be added as *one* attribute object
* assigned to all 18 geometries, or the shared-buffer arrangement in
* `createTerrain` quietly duplicates it seventeen times. The plan
* projection is in any case the natural parameterisation of a
* heightfield, which is the other half of why there is no attribute to
* interpolate. `position.xz` and not the world matrix's, deliberately:
* `scene.ts` scales this group in **Y** to move `verticalExaggeration`
* without rebuilding a board, and a UV taken off the plan is the one
* that does not slide when it does.
*
* 2. **The slope is scaled by the cosine of the drawn slope, which is the
* fix for the exaggeration trap.** `verticalExaggeration` is 15 on this
* board, so a real 10° hillside is *drawn* at 69° and a real 30° Sierra
* face at 83°. A plan-projected tile on a face at angle θ covers 1/cos θ
* of surface along the fall line — 2.8x on that hillside, 8.7x on that
* face — so the map's encoded slope describes bumps far shorter than the
* ones it is actually being stretched over, and the Sierra escarpment
* comes out as a smear of vertical stripes. Multiplying the tangent-space
* slope by cos θ restores the geometry exactly: the bumps get longer, so
* they get shallower by the same factor. It costs no fetch — the cosine
* is a dot product against world up, which `viewMatrix`' second column
* already is — and it is why there is no triplanar projection here
* tripling the sampler traffic to solve the same problem. The
* approximation in it, stated because it is real: the stretch is along
* the fall line only and this scales both tangent axes, so the detail is
* slightly over-flattened across a steep slope. At the angles the
* exaggeration produces that is a rounding error next to the stripes.
*
* 3. **It fades out once one repeat is worth a couple of pixels**, which is
* the sea's `SEA_CALM_NEAR` idea measured rather than inferred. See
* `GROUND_DETAIL_NOISE_PX` — that note also records the nine-fold error
* the distance form made on a board with a vertical exaggeration on it.
*
* A patch rather than a `ShaderMaterial`, for the reason `createWater`
* gives at length: the lighting this surface has to obey is a sun, a
* hemisphere, an ambient, a shadow, a PMREM environment and a fog, all
* owned elsewhere and all changing with the hour, and that is exactly what
* `MeshStandardMaterial` already implements correctly.
*/
const uniforms = {
/** Repeats of the detail map per scene unit, so the grain is the same
* size in metres on a 94 m board and on a 1,919 m one. */
uGroundTile: { value: world.metresPerUnit / GROUND_DETAIL_TILE_METRES },
/** The fade window, in device pixels of one repeat. See above. */
uGroundFade: {
value: new THREE.Vector2(GROUND_DETAIL_NOISE_PX, GROUND_DETAIL_LEGIBLE_PX),
},
};
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
uniform float uGroundTile;`,
)
// `uv_vertex` has already written `vNormalMapUv` from the `uv`
// attribute, which this geometry does not have — WebGL feeds a constant
// for a missing attribute, so every fragment would sample texel 0. This
// overwrites it with the plan coordinate, which is the only place the
// varying is ever set.
.replace(
"#include <uv_vertex>",
`#include <uv_vertex>
vNormalMapUv = position.xz * uGroundTile;`,
);
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
`#include <common>
uniform vec2 uGroundFade;`,
)
.replace(
"#include <normal_fragment_maps>",
`vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;
// \`viewMatrix\`' second column is world up in view space, and \`normal\` here is
// still the interpolated geometric normal — \`normal_fragment_begin\` has run and
// \`normal_fragment_maps\` is what this replaces. \`abs\` because the ground is
// DoubleSide and a back face arrives already flipped by \`faceDirection\`.
float groundFlat = saturate( abs( dot( normal, normalize( mat3( viewMatrix )[1] ) ) ) );
// One repeat of the map spans 1.0 in UV, so the reciprocal of the pixel's UV
// footprint is the repeat's width in pixels. The geometric mean of the two axes
// rather than the larger of them, floored at an eighth of the larger: that is
// what \`anisotropy = 8\` actually resolves, and the isotropic maximum would
// give the detail up across most of an oblique frame — which is most of a map
// pose — while the minimum would keep it on ground stretched to nothing.
vec2 groundStep = fwidth( vNormalMapUv );
float groundWide = max( groundStep.x, groundStep.y );
float groundFoot = max( sqrt( groundStep.x * groundStep.y ), groundWide / 8.0 );
float groundSeen = smoothstep( uGroundFade.x, uGroundFade.y, 1.0 / max( groundFoot, 1e-6 ) );
mapN.xy *= normalScale * groundFlat * groundSeen;
normal = normalize( tbn * mapN );`,
);
};
// Two materials that compile to different programs must not share a cache
// key, and `onBeforeCompile` is invisible to three's default key. Set only
// on the patched branch: an unpatched ground is an ordinary standard
// material and must go on sharing whatever key three gives it.
material.customProgramCacheKey = () => "tera:ground";
}
return material;
}
/** The smooth flat polygon under each landmass — the crisp coastline. */ /** The smooth flat polygon under each landmass — the crisp coastline. */
export function createShorePlates(world: World): THREE.Mesh { export function createShorePlates(world: World, options: TerrainOptions = {}): THREE.Mesh {
const pal = paletteFor(world); const pal = paletteFor(world);
const positions: number[] = []; const positions: number[] = [];
@@ -357,30 +573,26 @@ export function createShorePlates(world: World): THREE.Mesh {
geo.computeVertexNormals(); geo.computeVertexNormals();
/** /**
* Standard, and only because the terrain on top of it is. * The same ground material the relief above it wears, in a flat colour.
* *
* The plate carries no map and wants none — it is the landmass polygon lying * It used to be `MeshLambertMaterial({ color: pal.shore })`, and both halves
* flat at y=0, and the only part of it anyone ever sees is the rim of it * of `groundMaterial` matter to it. Without the standard class it would get no
* showing past the terrain grid's stair-stepped edge. What it cannot afford * `scene.environment` — `WebGLRenderer` forwards that only to
* is to be lit by a *different* set of terms from the ground it abuts. A * `isMeshStandardMaterial` — and would sit about a fifth of the hemisphere's
* `MeshLambertMaterial` gets no `scene.environment` at all — * diffuse contribution darker than the terrain, all the way around the
* `WebGLRenderer` forwards it only to `isMeshStandardMaterial` — so leaving * coastline: the one place on the board where two materials meet edge to edge
* this one Lambert while `createTerrain` went standard would have put a * in the same colour, where a step reads as a seam rather than as a different
* step of roughly a fifth of the hemisphere's diffuse contribution around * object. Without the detail map it would be *smooth* against a grained
* every coastline in the state, which is the one place on the board where * terrain, and that was photographed at San Francisco — the grid's
* two materials meet edge to edge in the same colour and a step reads as a * stair-stepped rim, which this plate exists to hide, came back as a legible
* seam rather than as a different object. * zigzag between a textured surface and a flat one. The plate is flat at y=0,
* so the plan projection the map is applied through is exact on it.
* *
* One mesh, one material, one draw call, exactly as before. * One mesh, one material, one draw call, exactly as before.
*/ */
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
geo, geo,
new THREE.MeshStandardMaterial({ groundMaterial(world, options.detail ?? groundDetailWanted(), { color: pal.shore }),
color: pal.shore,
roughness: GROUND_ROUGHNESS,
metalness: 0,
side: THREE.DoubleSide,
}),
); );
mesh.receiveShadow = true; mesh.receiveShadow = true;
/** /**
@@ -866,8 +1078,8 @@ export function createTerrain(world: World, options: TerrainOptions = {}): THREE
* could not receive. It receives it now, for no second pass, no new uniform * could not receive. It receives it now, for no second pass, no new uniform
* and no new draw call. * and no new draw call.
* *
* **The relief.** `GROUND_DETAIL_TILE_METRES` explains the tile; the patch * **The relief.** `GROUND_DETAIL_TILE_METRES` explains the repeat, and
* below explains the three edits it needs. `lodPatches` collapses flat ground * `groundMaterial` explains the three shader edits it needs. `lodPatches` collapses flat ground
* into patches up to eight 300 m cells across, so at a close pose the state is * into patches up to eight 300 m cells across, so at a close pose the state is
* kilometre-wide triangles with one normal each, and a tiling detail normal is * kilometre-wide triangles with one normal each, and a tiling detail normal is
* the cheapest thing in computer graphics that hides that. * the cheapest thing in computer graphics that hides that.
@@ -879,147 +1091,9 @@ export function createTerrain(world: World, options: TerrainOptions = {}): THREE
* that. A texture is not a draw call. A second mesh, a decal layer or a * that. A texture is not a draw call. A second mesh, a decal layer or a
* second pass would have been, which is why none of those is here. * second pass would have been, which is why none of those is here.
*/ */
const detail = options.detail ?? groundDetailWanted(); const material = groundMaterial(world, options.detail ?? groundDetailWanted(), {
const material = new THREE.MeshStandardMaterial({
vertexColors: true, vertexColors: true,
side: THREE.DoubleSide,
roughness: GROUND_ROUGHNESS,
// Stated rather than defaulted: ground is a dielectric, and a metallic
// surface has no diffuse term at all, so a stray metalness here would take
// `groundColor`'s whole ramp off the board.
metalness: 0,
...(detail ? { normalMap: groundDetailTexture() } : {}),
}); });
/**
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
* heightfield.
*
* The depth pass then culls every face turned *toward* the sun — precisely
* the set of faces that were shadowing themselves — and keeps the faces
* turned away from it. The boundary between the two is the terminator, so the
* depth recorded along a ridge starts at the crest and runs down its far
* slope, and the valley floor beyond, which is still front-facing and
* therefore writes nothing, tests against it and lands in shadow. A lit slope
* has nothing in the map above it and cannot stipple.
*
* Without it, a constant `shadow.bias` has to cover a depth-per-texel that
* grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
* 0.73 on the Bay Area — and there is no single value that is free of acne at
* 40° and free of peter-panning at 8°. That is why this was left off through
* the previous round, and it is checked here by photographing the boards at
* a sun of 12° and of 2°.
*
* It survives the move to a standard material unchanged, and it has to: the
* depth pass does not use this material at all — `WebGLShadowMap` swaps in
* its own `MeshDepthMaterial` and copies across `shadowSide`, `alphaMap` and
* the displacement slots and nothing else — so the patch below is invisible
* to it and this flag is the only thing it reads.
*/
material.shadowSide = THREE.BackSide;
if (detail) {
/**
* Three edits to the standard shader. The ground has no `uv` attribute and
* no `tangent` attribute, and both of those are decisions rather than
* omissions.
*
* 1. **The detail UV is derived from world XZ in the vertex shader.** The
* alternative is a real `uv` attribute, and it was costed: 46,144
* vertices x 2 x 4 bytes is 369 KB against the 1.66 MB of position,
* colour and normal the ground already uploads — a 22% growth of the
* ground's vertex data to store a number that is `position.xz` times a
* constant. It would also have to be added as *one* attribute object
* assigned to all 18 geometries, or the shared-buffer arrangement in
* `createTerrain` quietly duplicates it seventeen times. The plan
* projection is in any case the natural parameterisation of a
* heightfield, which is the other half of why there is no attribute to
* interpolate. `position.xz` and not the world matrix's, deliberately:
* `scene.ts` scales this group in **Y** to move `verticalExaggeration`
* without rebuilding a board, and a UV taken off the plan is the one
* that does not slide when it does.
*
* 2. **The slope is scaled by the cosine of the drawn slope, which is the
* fix for the exaggeration trap.** `verticalExaggeration` is 15 on this
* board, so a real 10° hillside is *drawn* at 69° and a real 30° Sierra
* face at 83°. A plan-projected tile on a face at angle θ covers 1/cos θ
* of surface along the fall line — 2.8x on that hillside, 8.7x on that
* face — so the map's encoded slope describes bumps far shorter than the
* ones it is actually being stretched over, and the Sierra escarpment
* comes out as a smear of vertical stripes. Multiplying the tangent-space
* slope by cos θ restores the geometry exactly: the bumps get longer, so
* they get shallower by the same factor. It costs no fetch — the cosine
* is a dot product against world up, which `viewMatrix`' second column
* already is — and it is why there is no triplanar projection here
* tripling the sampler traffic to solve the same problem. The
* approximation in it, stated because it is real: the stretch is along
* the fall line only and this scales both tangent axes, so the detail is
* slightly over-flattened across a steep slope. At the angles the
* exaggeration produces that is a rounding error next to the stripes.
*
* 3. **It fades out with distance**, exactly as the sea's does at
* `SEA_CALM_NEAR`. See `GROUND_DETAIL_NEAR_M` for why the far end of
* that window matters more here than it does for the water.
*
* A patch rather than a `ShaderMaterial`, for the reason `createWater`
* gives at length: the lighting this surface has to obey is a sun, a
* hemisphere, an ambient, a shadow, a PMREM environment and a fog, all
* owned elsewhere and all changing with the hour, and that is exactly what
* `MeshStandardMaterial` already implements correctly.
*/
const uniforms = {
/** Repeats of the detail map per scene unit, so the grain is the same
* size in metres on a 94 m board and on a 1,919 m one. */
uGroundTile: { value: world.metresPerUnit / GROUND_DETAIL_TILE_METRES },
uGroundFade: {
value: new THREE.Vector2(
GROUND_DETAIL_NEAR_M / world.metresPerUnit,
GROUND_DETAIL_FAR_M / world.metresPerUnit,
),
},
};
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
uniform float uGroundTile;`,
)
// `uv_vertex` has already written `vNormalMapUv` from the `uv`
// attribute, which this geometry does not have — WebGL feeds a constant
// for a missing attribute, so every fragment would sample texel 0. This
// overwrites it with the plan coordinate, which is the only place the
// varying is ever set.
.replace(
"#include <uv_vertex>",
`#include <uv_vertex>
vNormalMapUv = position.xz * uGroundTile;`,
);
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
`#include <common>
uniform vec2 uGroundFade;`,
)
.replace(
"#include <normal_fragment_maps>",
`vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;
// \`viewMatrix\`' second column is world up in view space, and \`normal\` here is
// still the interpolated geometric normal — \`normal_fragment_begin\` has run and
// \`normal_fragment_maps\` is what this replaces. \`abs\` because the ground is
// DoubleSide and a back face arrives already flipped by \`faceDirection\`.
float groundFlat = saturate( abs( dot( normal, normalize( mat3( viewMatrix )[1] ) ) ) );
float groundFar = smoothstep( uGroundFade.x, uGroundFade.y, length( vViewPosition ) );
mapN.xy *= normalScale * groundFlat * ( 1.0 - groundFar );
normal = normalize( tbn * mapN );`,
);
};
// Two materials that compile to different programs must not share a cache
// key, and `onBeforeCompile` is invisible to three's default key. Set only
// on the patched branch: an unpatched ground is an ordinary standard
// material and must go on sharing whatever key three gives it.
material.customProgramCacheKey = () => "tera:ground";
}
const group = new THREE.Group(); const group = new THREE.Group();
group.name = "terrain"; group.name = "terrain";
+57 -15
View File
@@ -276,6 +276,35 @@ test("the shore plate receives and does not cast", async () => {
assert.equal(plate.castShadow, false); assert.equal(plate.castShadow, false);
}); });
test("the shore plate wears the same surface as the ground it abuts", async () => {
const world = await board();
const plate = createShorePlates(world, { detail: true }).material as THREE.MeshStandardMaterial;
const ground = (createTerrain(world, { detail: true }).children[0] as THREE.Mesh)
.material as THREE.MeshStandardMaterial;
/*
* Two material objects — the plate is a flat `pal.shore` and the terrain is
* `vertexColors`, which three compiles to different programs whatever this
* file does — but every term that decides how they *light* has to agree,
* because the coastline is the one place on the board where two materials
* meet edge to edge in the same colour. A step there reads as a seam.
*
* Both halves were got wrong in turn and both were caught by a photograph.
* Lambert plate against a standard ground: `WebGLRenderer` forwards
* `scene.environment` only to `isMeshStandardMaterial`, so the rim sat about a
* fifth of the hemisphere's diffuse contribution darker than the ground.
* Mapless plate against a grained ground: the terrain grid's stair-stepped rim
* — which the plate exists to hide — came back as a legible zigzag between a
* textured surface and a smooth one at San Francisco.
*/
assert.ok(plate.isMeshStandardMaterial, "the coastline is lit by different terms again");
assert.equal(plate.roughness, ground.roughness);
assert.equal(plate.metalness, ground.metalness);
assert.ok(plate.normalMap, "the plate is smooth against grained ground, so the rim is legible");
assert.equal(plate.shadowSide, ground.shadowSide);
// Not the same *object*: it carries a colour where the ground carries a ramp.
assert.notEqual(plate, ground);
});
// ---- The land's surface ---------------------------------------------------- // ---- The land's surface ----------------------------------------------------
/* /*
@@ -380,26 +409,39 @@ test("the detail UV is built from the plan, because the ground has no uv attribu
// and 8.7x. Scaling the tangent-space slope by cos θ is what turns that back // and 8.7x. Scaling the tangent-space slope by cos θ is what turns that back
// into the same bumps rather than a smear of vertical stripes, and it is the // into the same bumps rather than a smear of vertical stripes, and it is the
// reason there is no triplanar projection here costing three fetches. // reason there is no triplanar projection here costing three fetches.
assert.match(shader.fragmentShader, /mapN\.xy \*= normalScale \* groundFlat \* \( 1\.0 - groundFar \);/); assert.match(shader.fragmentShader, /mapN\.xy \*= normalScale \* groundFlat \* groundSeen;/);
// `fwidth` of the detail UV, and not a view distance. The distance form was
// written first and was wrong by a factor of nine on this board: a camera
// lifted to 0.6 of its stand-off sits 9.06 stand-offs away in scene units once
// `verticalExaggeration` has multiplied the lift, so a window stated in real
// metres closed at a ninth of the stand-off it named and the map was switched
// off everywhere a viewer could go. It was caught by a photograph, not by a
// test, which is why there is now a test.
assert.match(shader.fragmentShader, /fwidth\( vNormalMapUv \)/);
assert.match(shader.fragmentShader, /normal = normalize\( tbn \* mapN \);/); assert.match(shader.fragmentShader, /normal = normalize\( tbn \* mapN \);/);
const tile = shader.uniforms.uGroundTile?.value as number; const tile = shader.uniforms.uGroundTile?.value as number;
// Repeats per scene unit. One repeat is `GROUND_DETAIL_TILE_METRES` of real // Repeats per scene unit. One repeat is `GROUND_DETAIL_TILE_METRES` of real
// ground on every board, which is what keeps the grain the same physical size // ground on every board, which is what keeps the grain the same physical size
// on a 94 m board and a 1,919 m one. // on a 94 m board and a 1,919 m one. 440 rather than 220, and the difference
// was a photograph: at 220 `tileableNoise`'s own wrapped cross-fade was the
// biggest feature in the tile, and the Central Valley came out as a regular
// grid of four-pointed stars, one per repeat.
assert.ok( assert.ok(
Math.abs(tile - world.metresPerUnit / 220) < 1e-9, Math.abs(tile - world.metresPerUnit / 440) < 1e-9,
`one repeat covers ${world.metresPerUnit / tile} m, not 220`, `one repeat covers ${world.metresPerUnit / tile} m, not 440`,
); );
const fade = shader.uniforms.uGroundFade?.value as THREE.Vector2; const fade = shader.uniforms.uGroundFade?.value as THREE.Vector2;
assert.ok(fade.x > 0 && fade.y > fade.x, "the detail never fades, so it aliases at the horizon"); /*
// Stated in metres of view distance and converted here, so the fade means the * Device pixels of one repeat, not scene units — so the window means the same
// same thing on every board. The board's own budget pose is 400 km out, where * thing on a 94 m board and a 1,919 m one, at any field of view, at any
// a 220 m tile is 0.6 device pixels — the fetch has to be worth nothing there. * viewport, at any device pixel ratio and under any vertical exaggeration. It
assert.ok( * is off under 2 px, where the mip chain has already flattened the map, and
Math.abs(fade.y * world.metresPerUnit - 70_000) < 1, * full over 8 px, where it is legible. The board's own budget pose is 400 km
`the detail survives to ${Math.round(fade.y * world.metresPerUnit)} m`, * out, where a 440 m repeat is 1.3 px: the fetch has to be worth nothing
); * there.
*/
assert.ok(fade.x >= 1 && fade.x < fade.y && fade.y <= 16, `the fade window is ${fade.x}..${fade.y} px`);
}); });
// ---- The ground's detail map ------------------------------------------------ // ---- The ground's detail map ------------------------------------------------
@@ -455,8 +497,8 @@ test("the ground detail map tiles: the wrap is no sharper than the interior", ()
seam = Math.max(seam, normalAt(data, size, size - 1, y).distanceTo(normalAt(data, size, 0, y))); seam = Math.max(seam, normalAt(data, size, size - 1, y).distanceTo(normalAt(data, size, 0, y)));
} }
/* /*
* California is 1,063 km across and this tile is 220 m, so the map is laid * California is 1,063 km across and this tile is 440 m, so the map is laid
* down about five thousand times along one edge of the board. A derivative * down about two and a half thousand times along one edge of the board. A derivative
* that does not wrap paints a grid over the entire state — which is the exact * that does not wrap paints a grid over the entire state — which is the exact
* failure `tileableNoise`'s four-way blend exists to prevent, reused here * failure `tileableNoise`'s four-way blend exists to prevent, reused here
* rather than reimplemented. * rather than reimplemented.
@@ -492,7 +534,7 @@ test("the ground detail map lights the same at 128 as at 256", () => {
Math.abs(a - b) / a < 0.12, Math.abs(a - b) / a < 0.12,
`mean slope is ${a} at 128 and ${b} at 256: the per-metre conversion is gone`, `mean slope is ${a} at 128 and ${b} at 256: the per-metre conversion is gone`,
); );
// And it is a real slope rather than a rounding error: about 8.5° mean on the // And it is a real slope rather than a rounding error: about 9.9° mean on the
// shipped field, deliberately far under the board's own 15x exaggeration so // shipped field, deliberately far under the board's own 15x exaggeration so
// the grain does not out-shout the landforms. // the grain does not out-shout the landforms.
assert.ok(a > 0.05 && a < 0.4, `the ground's mean slope is ${Math.atan(a) * 57.3}°`); assert.ok(a > 0.05 && a < 0.4, `the ground's mean slope is ${Math.atan(a) * 57.3}°`);