From 76ca903cd2872df83d452266f421e431b46092d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:13:35 -0700 Subject: [PATCH] fix: the ground's detail was switched off everywhere, and the map was a grid of stars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/assets/textures.ts | 4 +- src/engine/terrain.ts | 550 +++++++++++++++----------- src/test/render/seaAndTerrain.test.ts | 72 +++- 3 files changed, 371 insertions(+), 255 deletions(-) diff --git a/src/assets/textures.ts b/src/assets/textures.ts index 735068a..ad8fc16 100644 --- a/src/assets/textures.ts +++ b/src/assets/textures.ts @@ -106,7 +106,7 @@ const NOISE_RES = 64; * 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 * 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 * 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 @@ -1089,7 +1089,7 @@ function heightField(recipe: HeightRecipe, size: number): Float32Array { * higher-resolution floor looking twice as smooth. * * `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 * own. Every caller in this file passes `TEXTURE_TILE_METRES`, which is what * the default preserves. diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index 2ca0db2..9c83f97 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -139,61 +139,72 @@ const GROUND_ROUGHNESS = 0.92; * whole reason it is not `TEXTURE_TILE_METRES`. * * 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 - * desktop budget viewport is 1440 x 900 at a 42° *vertical* field of view, so - * looking straight down from a stand-off of `d` metres one device pixel covers - * `d · 2·tan(21°) / 900 = d / 1173` metres of ground, and an oblique look - * divides that by `sin(pitch)`. Against the poses this board can actually be - * put in: + * 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 at + * a camera-to-ground distance of `D` scene units one device pixel covers + * `D · 2·tan(21°) / 900` scene units — 1.64 m of California's 1,919 m per unit. + * Against the distances this board can actually be put at, quoted the way + * `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 | - * | 110 km — Los Angeles wide | 94 | 0.02 px | 2.3 px | - * | 45 km — the Bay rung | 38.4 | 0.05 px | 5.7 px | - * | 7.7 km | 6.6 | 0.3 px | 33 px | - * | 2.5 km | 2.13 | 0.9 px | 103 px | - * | 1.9 km — `ORBIT_MIN_STANDOFF_M`, the floor | 1.62 | 1.2 px | 136 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 | 4.7 px | + * | 45 km — the Bay rung | 38.4 | 0.05 px | 11 px | + * | 7.7 km | 6.6 | 0.3 px | 67 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 | 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 * 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 - * 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. + * already flattened. * - * **Not 300 m, deliberately.** The obvious choice is one repeat per terrain - * cell, and it is the one number that must be avoided: the lattice is very - * nearly axis-aligned, so a tile the size of a cell paints the *same* pattern - * on every cell and the repeat lands exactly on the facet edges it exists to - * hide. 220 beats against them instead. + * **440 m, and it was 220 m first, and the difference was a photograph.** The + * arithmetic says a repeat wants to be a few hundred metres and stops there; it + * cannot tell you that at 220 m, with `tileableNoise` at a `scale` of 2, the + * blend's own four-way cross-fade is the largest thing in the tile and the flat + * 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 - * 0.86 m, which first exceeds one device pixel at a stand-off of about a - * kilometre — below the orbit floor. Inside the reachable range the map is - * always minified, so the mip chain decides everything and the magnifier - * decides nothing. + * **Not 300 m, and not 600.** The obvious choice is one repeat per terrain cell, + * and it is the one number to avoid: the lattice is very nearly axis-aligned, so + * a repeat the size of a cell paints the *same* pattern on every cell and lands + * its seam exactly on the facet edges it exists to hide. 440 beats against them. */ -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. * - * 64 is `textures.ts`' own `NOISE_RES` and is used here for the reason that - * file records: the bilinear upsample off a coarse lattice is what makes the - * field cheap, and `normalMapData`'s Sobel is what buries the lattice in the - * derivative. Sixty-four samples across a 220 m tile puts the finest thing in - * the height field at 3.4 m, so 256 texels lands four of them across it and 512 - * would be storing the interpolation between them. That is also 262 KB rather - * than 1.05 MB, against a board whose whole resident geometry is about 40 MB. + * Four texels per lattice cell, which is the ratio that matters. `textures.ts` + * records why the noise is evaluated on a coarse lattice and bilinearly + * upsampled — four `fbm` calls per pixel at 512² is about sixteen million + * `Math.sin`es — and why `normalMapData` uses a Sobel rather than a two-tap + * difference: the upsample reproduces the lattice as a faint grid of creases in + * the derivative, and averaging three rows buries it. 128 over a 440 m repeat + * 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 * 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_SIZE = 256; +const GROUND_DETAIL_LATTICE = 128; +const GROUND_DETAIL_SIZE = 512; /** * 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 * `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 - * multiplication: the ±5.9 m of relief these three layers sum to stands for - * about ±0.4 m of real ground, which is a 1% grade over 220 m and is about - * right for field, scrub and alluvium. + * multiplication: the ±5 m of relief these three layers sum to stands for about + * ±0.35 m of real ground, which is a sub-1% grade over 440 m and is about right + * for field, scrub and alluvium. * - * **They are deliberately not exaggerated to match the geography.** Carrying - * the real relief through the board's own 15× would put the map's mean slope - * near 45° and its peaks past 70°, and that was the wrong answer for a reason - * that is not about taste: the grain would then out-shout the geography, the - * sun's N·L would swing by ±0.6 across two texels, and a state whose whole - * subject is its landforms would read as crumpled foil. The mesh carries the - * exaggeration; the finish on it does not. + * **They are deliberately not exaggerated to match the geography.** Carrying the + * real relief through the board's own 15× would put the map's mean slope near + * 45° and its peaks past 70°, and that is the wrong answer for a reason that is + * not about taste: the grain would out-shout the geography, the sun's N·L would + * swing by ±0.6 across two texels, and a state whose whole subject is its + * landforms would read as crumpled foil. The mesh carries the exaggeration; the + * finish on it does not. * - * Measured on the field these three produce: mean slope 8.5°, peak 32°, and - * 79% of it under 12°. The three layers are one decade apart each — about 110 m - * of swell, 30 m of gully, and the lattice floor at 3.4 m as grain — because a - * single octave at any of those reads as a pattern and three read as a surface. + * Measured on the field these three produce: **mean slope 9.9°, peak 39.4°.** + * The three are about a factor of three apart each — roughly 55 m of swell, 20 m + * of gully and 7 m of grain — because a single octave at any of those reads as a + * 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 }[] = [ - { scale: 2, offset: 5.7, metres: 27 }, - { scale: 7, offset: 19.3, metres: 10.2 }, - { scale: 23, offset: 41.1, metres: 3.3 }, + { scale: 8, offset: 5.7, metres: 21 }, + { scale: 22, offset: 19.3, metres: 8 }, + { 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 - * distance one pixel covers more of the map than the mip chain can average - * without the grain crawling as the camera moves, and long before that the map - * has stopped saying anything. From the table at `GROUND_DETAIL_TILE_METRES`, a - * 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. + * The same argument as `SEA_CALM_NEAR` and a different measurement, because the + * first attempt at this used the sea's and it was wrong by a factor of nine — + * measured, in a photograph of the Central Valley where the map was doing + * literally nothing and a five-fold amplitude produced a byte-identical PNG. * - * It matters more here than it does for the sea, 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 220 m tile is 0.6 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. In real metres rather than board spans so that a phone - * looking at San Francisco and a desktop looking at the state agree about what - * "far" means. + * The sea fades on `length( vViewPosition )` converted from real metres by + * `metresPerUnit`, which quietly assumes the camera's distance is a *plan* + * distance. 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. A window written as "25 km to 70 km" was therefore + * closing at about 2.8 km of stand-off and the detail was off everywhere a + * viewer could actually go. + * + * 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_FAR_M = 70_000; +const GROUND_DETAIL_NOISE_PX = 2; +const GROUND_DETAIL_LEGIBLE_PX = 8; /** * 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); } +/** + * 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 { 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.wrapT = THREE.RepeatWrapping; texture.magFilter = THREE.LinearFilter; @@ -317,10 +360,11 @@ function groundDetailTexture(): THREE.DataTexture { * 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 - * stand-off: 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 - * `getTangentFrame`'s three derivative pairs per ground fragment, on a viewport - * that is 1.3 Mpx of them, at a whole-board pose where the tile is 0.6 px wide. + * distance: the standard material, its specular and its environment term. What + * it gives up is the half that only reads inside the last decade of the descent + * — one texture fetch and `getTangentFrame`'s three derivative pairs per ground + * 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, * where there is no device to profile and no fill rate to protect. @@ -330,8 +374,180 @@ function groundDetailWanted(): boolean { 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.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 ", + `#include +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 ", + `#include +vNormalMapUv = position.xz * uGroundTile;`, + ); + shader.fragmentShader = shader.fragmentShader + .replace( + "#include ", + `#include +uniform vec2 uGroundFade;`, + ) + .replace( + "#include ", + `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. */ -export function createShorePlates(world: World): THREE.Mesh { +export function createShorePlates(world: World, options: TerrainOptions = {}): THREE.Mesh { const pal = paletteFor(world); const positions: number[] = []; @@ -357,30 +573,26 @@ export function createShorePlates(world: World): THREE.Mesh { 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 - * flat at y=0, and the only part of it anyone ever sees is the rim of it - * showing past the terrain grid's stair-stepped edge. What it cannot afford - * is to be lit by a *different* set of terms from the ground it abuts. A - * `MeshLambertMaterial` gets no `scene.environment` at all — - * `WebGLRenderer` forwards it only to `isMeshStandardMaterial` — so leaving - * this one Lambert while `createTerrain` went standard would have put a - * step of roughly a fifth of the hemisphere's diffuse contribution around - * every coastline in the state, which is the one place on the board where - * two materials meet edge to edge in the same colour and a step reads as a - * seam rather than as a different object. + * It used to be `MeshLambertMaterial({ color: pal.shore })`, and both halves + * of `groundMaterial` matter to it. Without the standard class it would get no + * `scene.environment` — `WebGLRenderer` forwards that only to + * `isMeshStandardMaterial` — and would sit about a fifth of the hemisphere's + * diffuse contribution darker than the terrain, all the way around the + * coastline: the one place on the board where two materials meet edge to edge + * in the same colour, where a step reads as a seam rather than as a different + * object. Without the detail map it would be *smooth* against a grained + * terrain, and that was photographed at San Francisco — the grid's + * stair-stepped rim, which this plate exists to hide, came back as a legible + * 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. */ const mesh = new THREE.Mesh( geo, - new THREE.MeshStandardMaterial({ - color: pal.shore, - roughness: GROUND_ROUGHNESS, - metalness: 0, - side: THREE.DoubleSide, - }), + groundMaterial(world, options.detail ?? groundDetailWanted(), { color: pal.shore }), ); 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 * and no new draw call. * - * **The relief.** `GROUND_DETAIL_TILE_METRES` explains the tile; the patch - * below explains the three edits it needs. `lodPatches` collapses flat ground + * **The relief.** `GROUND_DETAIL_TILE_METRES` explains the repeat, and + * `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 * kilometre-wide triangles with one normal each, and a tiling detail normal is * 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 * second pass would have been, which is why none of those is here. */ - const detail = options.detail ?? groundDetailWanted(); - const material = new THREE.MeshStandardMaterial({ + const material = groundMaterial(world, options.detail ?? groundDetailWanted(), { 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 ", - `#include -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 ", - `#include -vNormalMapUv = position.xz * uGroundTile;`, - ); - shader.fragmentShader = shader.fragmentShader - .replace( - "#include ", - `#include -uniform vec2 uGroundFade;`, - ) - .replace( - "#include ", - `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(); group.name = "terrain"; diff --git a/src/test/render/seaAndTerrain.test.ts b/src/test/render/seaAndTerrain.test.ts index c4ebdff..0a47363 100644 --- a/src/test/render/seaAndTerrain.test.ts +++ b/src/test/render/seaAndTerrain.test.ts @@ -276,6 +276,35 @@ test("the shore plate receives and does not cast", async () => { 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 ---------------------------------------------------- /* @@ -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 // 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. - 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 \);/); const tile = shader.uniforms.uGroundTile?.value as number; // 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 - // 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( - Math.abs(tile - world.metresPerUnit / 220) < 1e-9, - `one repeat covers ${world.metresPerUnit / tile} m, not 220`, + Math.abs(tile - world.metresPerUnit / 440) < 1e-9, + `one repeat covers ${world.metresPerUnit / tile} m, not 440`, ); 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 - // same thing on every board. The board's own budget pose is 400 km out, where - // a 220 m tile is 0.6 device pixels — the fetch has to be worth nothing there. - assert.ok( - Math.abs(fade.y * world.metresPerUnit - 70_000) < 1, - `the detail survives to ${Math.round(fade.y * world.metresPerUnit)} m`, - ); + /* + * Device pixels of one repeat, not scene units — so the window means the same + * thing on a 94 m board and a 1,919 m one, at any field of view, at any + * viewport, at any device pixel ratio and under any vertical exaggeration. It + * is off under 2 px, where the mip chain has already flattened the map, and + * full over 8 px, where it is legible. The board's own budget pose is 400 km + * 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 ------------------------------------------------ @@ -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))); } /* - * California is 1,063 km across and this tile is 220 m, so the map is laid - * down about five thousand times along one edge of the board. A derivative + * California is 1,063 km across and this tile is 440 m, so the map is laid + * 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 * failure `tileableNoise`'s four-way blend exists to prevent, reused here * 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, `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 // 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}°`);