From 0d7eb28bb5ae6ed57653a5f8f024b2a6cbd914c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:49:15 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20the=20land=20is=20a=20surface,=20no?= =?UTF-8?q?t=20paint=20=E2=80=94=20standard=20material=20and=20a=20220=20m?= =?UTF-8?q?=20detail=20relief?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ground was `MeshLambertMaterial` with a vertex colour and no maps of any kind, next to a sea that has been a `MeshStandardMaterial` with a swell map on it since `createWater` was written. Two things came out of that gap. The louder one is invisible in the code and obvious in a picture: three forwards `scene.environment` only to `isMeshStandardMaterial`, so the whole PMREM sky `environmentRig.ts` builds — sun lobe, `SKY_RADIANCE_SHARE`, the ground bounce — was reaching the water, the ports and the vessels and not one photon of it was reaching the state. The land has been paying the counterweight for it the whole time: the day stops gave up about a tenth of `hemiIntensity` and a quarter of `ambientIntensity` to make room for an environment term the ground could not receive. The quieter one is the facets. `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 nothing on them. So: one `MeshStandardMaterial` at roughness 0.92, metalness 0, carrying a tiling detail normal map built from `textures.ts`' own `tileableNoise` / `sampleField` / `normalMapData` at a **220 m** repeat rather than the office's 2 m — which is sub-pixel at every stand-off this board can reach, orbit floor included. 220 m is legible from about 45 km down and mip-flat above about 110 km, and it is not 300 m because a tile the size of a terrain cell paints the same pattern on every cell and lands its repeat on the facet edges it exists to hide. Zero new draw calls, and that was the constraint: same 18 objects, same one shared material, same one attribute triple, same one program switch. `cost-at.mjs` reads 355,763 / 437 draws at the whole-board pose, unchanged. Three shader edits, none of which the material has a dial for: 1. The detail UV is `position.xz`, in the vertex shader. A real `uv` attribute would be 369 KB against the ground's current 1.66 MB and would have to be added as one object shared by all 18 geometries or the arrangement quietly duplicates it seventeen times. The plan projection is also the natural parameterisation of a heightfield. 2. The tangent-space slope is scaled by the cosine of the *drawn* slope. `verticalExaggeration` is 15 here, so a real 10° hillside draws at 69° and a real 30° Sierra face at 83°, and a plan-projected tile on a face at θ is stretched 1/cos θ along the fall line — 2.8x and 8.7x. Scaling by cos θ restores the same bumps instead of a smear of vertical stripes, for a dot product against world up rather than the three fetches triplanar would cost. 3. It fades out between 25 km and 70 km of view distance, as the sea's does at `SEA_CALM_NEAR`. That matters more here than for the water: the board's own budget pose is 400 km out, where a 220 m tile is 0.6 device pixels. The shore plate goes standard with it, mapless. It is the one place on the board where two materials meet edge to edge in the same colour, and a Lambert plate against a standard ground would have put a step of about a fifth of the hemisphere's diffuse contribution around every coastline in the state. A handheld gets the material and the environment and not the map — "off" is *no* map, the way `MaterialQuality`'s `low` rung is, because the cost is one fetch and `getTangentFrame`'s three derivative pairs per fragment and a smaller map costs exactly the same fetch. `tileableNoise`, `sampleField` and `normalMapData` are exported from `textures.ts` rather than reimplemented, and `normalMapData` takes the tile size it converts against instead of reading the office's constant. Same reason that file takes `fbm` from `engine/world.ts`: a second noise is a second thing to keep tiling, and a second Sobel is a second place to get the flipY reasoning wrong. Co-Authored-By: Claude Opus 5 (1M context) --- src/assets/textures.ts | 31 +- src/engine/terrain.ts | 411 +++++++++++++++++++++++++- src/test/render/seaAndTerrain.test.ts | 263 +++++++++++++++- src/test/render/terrainLod.test.ts | 14 +- 4 files changed, 707 insertions(+), 12 deletions(-) diff --git a/src/assets/textures.ts b/src/assets/textures.ts index 41f462d..735068a 100644 --- a/src/assets/textures.ts +++ b/src/assets/textures.ts @@ -102,8 +102,17 @@ const NOISE_RES = 64; * edge equal the right edge: each point is mixed with its wrapped neighbours * weighted by how close it is to them, which is exactly zero contribution in * the middle of the tile and a perfect match at the seam. + * + * 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 + * `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 + * land's map is as testable under `node --test` as the office's are. */ -function tileableNoise(res: number, scale: number, offset: number): Float32Array { +export function tileableNoise(res: number, scale: number, offset: number): Float32Array { const field = new Float32Array(res * res); for (let y = 0; y < res; y++) { const v = y / res; @@ -122,7 +131,7 @@ function tileableNoise(res: number, scale: number, offset: number): Float32Array } /** Bilinear read of a wrapped coarse field, in 0..1 texture space. */ -function sampleField(field: Float32Array, res: number, u: number, v: number): number { +export function sampleField(field: Float32Array, res: number, u: number, v: number): number { const fx = u * res; const fy = v * res; const x0 = Math.floor(fx); @@ -1075,17 +1084,27 @@ function heightField(recipe: HeightRecipe, size: number): Float32Array { * neighbouring rows is enough to bury it. * * The gradient is converted to a **slope** before it is encoded — `× size` puts - * it per unit UV, `/ TEXTURE_TILE_METRES` puts it per metre — which is why the - * same recipe drawn at 256 and at 512 gives the same steepness rather than the + * it per unit UV, `/ tileMetres` puts it per metre — which is why the same + * recipe drawn at 256 and at 512 gives the same steepness rather than the * 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 + * 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. */ -function normalMapData(height: Float32Array, size: number): Uint8Array { +export function normalMapData( + height: Float32Array, + size: number, + tileMetres: number = TEXTURE_TILE_METRES, +): Uint8Array { const data = new Uint8Array(size * size * 4); const at = (x: number, y: number): number => height[(((y % size) + size) % size) * size + (((x % size) + size) % size)] ?? 0; // Per-texel Sobel sums weight 4 either side across a 2-texel baseline. - const perUv = size / 8 / TEXTURE_TILE_METRES; + const perUv = size / 8 / tileMetres; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index 0516836..2ca0db2 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -17,6 +17,8 @@ import * as THREE from "three"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { normalMapData, sampleField, tileableNoise } from "../assets/textures.ts"; +import { deviceProfile } from "./stage.ts"; import type { ScenePalette } from "./types.ts"; import type { World } from "./world.ts"; @@ -117,6 +119,217 @@ function groundColor( ); } +// ---- The ground's surface -------------------------------------------------- + +/** + * How rough the land is, for the physically-shaded ground below. + * + * The land is not the sea and must not glint like it. At `SEA_*`'s 0.2 a GGX + * lobe is a few degrees wide and returns the sun as a hard streak; at 0.92 it + * is most of a hemisphere, which is what dry ground, scrub, tarmac and roof + * tile all are — the specular term contributes a low, wide sheen and nothing + * that reads as a highlight. What the number is really buying is not the + * specular at all but the *diffuse* environment term, which is what a Lambert + * material has no slot for; see the note on the material in `createTerrain`. + */ +const GROUND_ROUGHNESS = 0.92; + +/** + * How many metres one repeat of the ground's detail normal map covers, and the + * 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: + * + * | stand-off | m/px | a 2 m tile | a 220 m tile | + * |---|---|---|---| + * | 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 | + * + * **A 2 m tile is sub-pixel at every stand-off 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. + * + * **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. + * + * 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. + */ +const GROUND_DETAIL_TILE_METRES = 220; + +/** + * 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. + * + * 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. + */ +const GROUND_DETAIL_LATTICE = 64; +const GROUND_DETAIL_SIZE = 256; + +/** + * The height field the ground's normal map is differentiated from, in metres of + * **drawn** relief. + * + * Drawn, not real, and the distinction is the one trap in this whole file. + * `world.metres` multiplies every elevation by `verticalExaggeration` — 15 on + * 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. + * + * **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. + * + * 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. + */ +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 }, +]; + +/** + * Where the detail gives up, in metres of view distance. + * + * 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. + * + * 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. + */ +const GROUND_DETAIL_NEAR_M = 25_000; +const GROUND_DETAIL_FAR_M = 70_000; + +/** + * A tiling tangent-space normal map for the land, as raw RGBA bytes. + * + * The land's answer to `swellNormalData`, and built out of `textures.ts`' + * machinery rather than its own: `tileableNoise` for the four-way wrapped blend + * that makes the edges match, `sampleField` for the bilinear read, and + * `normalMapData` for the Sobel that turns metres into an encoded slope. The + * only thing this adds is the recipe and the tile size, which is the whole + * difference between a carpet and a hillside. + * + * No canvas, no `document`, no image: the field is arithmetic, so this function + * exists identically in a browser and under `node --test`, and + * `scripts/check-no-binaries.mjs` has nothing to find. + */ +export function groundDetailData(size: number = GROUND_DETAIL_SIZE): Uint8Array { + const height = new Float32Array(size * size); + for (const layer of GROUND_DETAIL_LAYERS) { + const field = tileableNoise(GROUND_DETAIL_LATTICE, layer.scale, layer.offset); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + // `/ 0.94` is `grain()`'s: four octaves of `fbm` land in 0..0.94. The + // half is what centres the layer, so the summed field has no drift in + // it and `metres` is a peak-to-trough rather than a ceiling. + const n = Math.min( + 1, + Math.max(0, sampleField(field, GROUND_DETAIL_LATTICE, x / size, y / size) / 0.94), + ); + height[y * size + x] = (height[y * size + x] as number) + (n - 0.5) * layer.metres; + } + } + } + return normalMapData(height, size, GROUND_DETAIL_TILE_METRES); +} + +function groundDetailTexture(): THREE.DataTexture { + const size = GROUND_DETAIL_SIZE; + const texture = new THREE.DataTexture(groundDetailData(size), size, size); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.magFilter = THREE.LinearFilter; + // Mipmapped for the reason `swellNormalTexture` states: the mip chain of a + // normal map converges on flat, so the ground a long way off stops perturbing + // its normal without anything having to decide when — and `GROUND_DETAIL_FAR_M` + // then finishes the job by taking the fetch's *result* to zero rather than + // leaving a residue of the coarsest mip on the horizon. + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.generateMipmaps = true; + // Eight, as the sea has: from a map pose the ground is seen at a grazing + // angle across most of the frame, and that is exactly where an isotropic + // filter picks a mip several levels too coarse and the detail vanishes + // halfway up the screen for no reason the viewer can see. + texture.anisotropy = 8; + texture.needsUpdate = true; + texture.name = "groundDetailNormal"; + return texture; +} + +/** + * Whether this device should pay for the ground's detail relief. + * + * The precedent is `MaterialQuality`'s `low` rung in `assets/materials.ts`, + * which is flat Lambert with no maps — a tier's "off" is *no map*, not a + * smaller one, because the cost being avoided is per fragment and a smaller map + * costs exactly the same fetch. `deviceProfile()` is the only device knob a + * city board reads and it is asked here rather than re-derived, so the + * 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. + * + * `typeof window` because `createTerrain` runs under `node --test` as well, + * where there is no device to profile and no fill rate to protect. + */ +function groundDetailWanted(): boolean { + if (typeof window === "undefined") return true; + return !deviceProfile().handheld; +} + /** The smooth flat polygon under each landmass — the crisp coastline. */ export function createShorePlates(world: World): THREE.Mesh { const pal = paletteFor(world); @@ -143,9 +356,31 @@ export function createShorePlates(world: World): THREE.Mesh { geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geo.computeVertexNormals(); + /** + * Standard, and only because the terrain on top of it is. + * + * 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. + * + * One mesh, one material, one draw call, exactly as before. + */ const mesh = new THREE.Mesh( geo, - new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }), + new THREE.MeshStandardMaterial({ + color: pal.shore, + roughness: GROUND_ROUGHNESS, + metalness: 0, + side: THREE.DoubleSide, + }), ); mesh.receiveShadow = true; /** @@ -454,7 +689,18 @@ function lodPatches( * the chunking below for why the split exists and why the caster is not part * of it. */ -export function createTerrain(world: World): THREE.Group { +export interface TerrainOptions { + /** + * Whether the ground carries its detail normal map. + * + * Defaults to `groundDetailWanted()`, which is "not on a phone". Passed + * explicitly by the tests, which want the map on a board that has no + * `window` to profile and want to be able to ask for it off. + */ + readonly detail?: boolean; +} + +export function createTerrain(world: World, options: TerrainOptions = {}): THREE.Group { const pal = paletteFor(world); // Which cells are drawn, and how big, is `lodPatches`' answer; this function // only turns a corner into a vertex. @@ -594,7 +840,56 @@ export function createTerrain(world: World): THREE.Group { normalAttribute = new THREE.BufferAttribute(grown, 3); } - const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }); + /** + * Standard rather than Lambert, and the land had the better half of the + * argument `createWater` already won. + * + * The sea below this is a `MeshStandardMaterial` with a swell map on it; the + * state beside it was `MeshLambertMaterial` with a vertex colour and **no + * maps of any kind** — no albedo, no normal, no roughness — which is most of + * why the board read as a painted relief model with a real ocean next to it. + * + * Two things change, and only one of them is the texture. + * + * **The environment.** `environmentRig.ts` builds a PMREM of the sky the + * atmosphere decided on — a sun lobe at radiance 1.2, `SKY_RADIANCE_SHARE` + * 0.12 of the dome, `GROUND_RADIANCE_SHARE` 0.09 bouncing back — and + * `scene.ts` applies it to every city scene. `WebGLRenderer` then forwards + * `scene.environment` to `material.isMeshStandardMaterial` **and to nothing + * else** (`WebGLRenderer.js`, twice, at the `materialProperties.environment` + * assignment). So until this line the whole sky probe was being delivered to + * the water, the guardrails, the ports and the vessels, and not one photon of + * it reached the land. That is not a subtle loss: `environmentRig.ts` records + * that the day stops in `atmosphere.ts` gave up about a tenth of + * `hemiIntensity` and a quarter of `ambientIntensity` to make room for the + * environment, so the ground has been paying the counterweight for a term it + * 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 + * 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. + * + * **What does not change: the draw call count, which has 22 spare of 460.** + * This is the same one material object handed to all 17 chunk meshes and to + * the caster, so it is the same 18 objects, the same one program switch and + * the same one vertex buffer — `terrainLod.test.ts` asserts every part of + * 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({ + 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. @@ -613,9 +908,119 @@ export function createTerrain(world: World): THREE.Group { * 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"; // ^ the group carries the name the mesh used to. `godmode.ts` matches its diff --git a/src/test/render/seaAndTerrain.test.ts b/src/test/render/seaAndTerrain.test.ts index 76871fc..c4ebdff 100644 --- a/src/test/render/seaAndTerrain.test.ts +++ b/src/test/render/seaAndTerrain.test.ts @@ -19,7 +19,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as THREE from "three"; -import { createShorePlates, createTerrain, createWater, swellNormalData } from "../../engine/terrain.ts"; +import { + createShorePlates, + createTerrain, + createWater, + groundDetailData, + swellNormalData, +} from "../../engine/terrain.ts"; import type { City } from "../../engine/types.ts"; import { World } from "../../engine/world.ts"; @@ -204,7 +210,13 @@ test("the relief casts, and from a decimated copy of itself", async () => { const terrain = createTerrain(world).children[0] as THREE.Mesh; assert.equal(terrain.castShadow, true, "the hills shadow nothing again"); assert.equal(terrain.receiveShadow, true); - const material = terrain.material as THREE.MeshLambertMaterial; + // Standard since the land got a surface response; the cast is stated because + // the previous one said `MeshLambertMaterial` and a compile-time cast is + // exactly the kind of assertion that goes on passing after it stops being + // true. `shadowSide` is unchanged by that move and has to be: the depth pass + // swaps in its own `MeshDepthMaterial` and copies this flag across, so it is + // the only thing about this material the shadow map ever reads. + const material = terrain.material as THREE.MeshStandardMaterial; // The acne cure. Without it a constant bias has to cover a depth-per-texel // that grows as 1/tan(sun elevation), and no single value is free of acne at // a high sun and free of peter-panning at a low one. @@ -263,3 +275,250 @@ test("the shore plate receives and does not cast", async () => { // whole coastline out across the water as a hard slab. assert.equal(plate.castShadow, false); }); + +// ---- The land's surface ---------------------------------------------------- + +/* + * Everything below is the land's half of the argument the sea won further up + * this file. The ground was `MeshLambertMaterial` with a vertex colour and no + * maps of any kind, next to a sea with a swell map on it — and, less visibly, + * `WebGLRenderer` forwards `scene.environment` only to `isMeshStandardMaterial`, + * so the whole PMREM sky `environmentRig.ts` builds was reaching the water and + * not the state. None of that was visible to the type checker, and the two + * assertions above it that were about the terrain's material both went through + * a compile-time cast that a class change passes straight through. + */ + +test("the land catches the sky, which a Lambert card cannot", async () => { + const world = await board(); + const terrain = createTerrain(world).children[0] as THREE.Mesh; + const material = terrain.material as THREE.MeshStandardMaterial; + /* + * `WebGLRenderer.js` reads + * `materialProperties.environment = material.isMeshStandardMaterial ? scene.environment : null` + * in both of the places it decides a program's lights — so this flag is + * literally the switch that lets the sky reach the ground. `scene.ts` applies + * the rig to every city scene already; the land was the one large surface in + * the frame not receiving it. + */ + assert.ok(material.isMeshStandardMaterial, "the state went back to being unlit paint"); + assert.equal(material.metalness, 0, "a metallic ground has no diffuse term at all"); + // Ground, not water: the sea is under 0.5 so the sun has a path across it, + // and the land must be well above that or it glints like wet tarmac at every + // hour. The upper bound is what stops someone "simplifying" this to 1.0, + // which switches the environment's specular lobe off again. + assert.ok( + material.roughness > 0.7 && material.roughness < 1, + `the land is at roughness ${material.roughness}`, + ); + assert.equal(material.vertexColors, true, "`groundColor`'s ramp is the board's palette"); + assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the class change"); +}); + +test("the ground's detail relief is on by default and can be switched off whole", async () => { + const world = await board(); + const on = createTerrain(world, { detail: true }).children[0] as THREE.Mesh; + const off = createTerrain(world, { detail: false }).children[0] as THREE.Mesh; + const lit = on.material as THREE.MeshStandardMaterial; + const flat = off.material as THREE.MeshStandardMaterial; + + assert.ok(lit.normalMap, "the ground lost the map that breaks up its 300 m facets"); + assert.equal(lit.normalMap.wrapS, THREE.RepeatWrapping, "one tile of ground, then a seam"); + assert.equal(lit.normalMap.wrapT, THREE.RepeatWrapping); + assert.equal(lit.normalMap.generateMipmaps, true); + // The mip chain of a normal map converges on flat, which is what stops the + // grain crawling in the distance without anything having to decide when. + assert.equal(lit.normalMap.minFilter, THREE.LinearMipmapLinearFilter); + assert.ok(lit.normalMap.anisotropy >= 8, "the ground is grazing across most of a map pose"); + + /* + * "Off" is *no map*, the way `MaterialQuality`'s `low` rung is no map — not a + * smaller one. The cost a handheld is being spared is per fragment: one + * texture fetch and `getTangentFrame`'s three derivative pairs on 1.3 Mpx of + * them. A quarter-size map costs exactly the same fetch. + */ + assert.equal(flat.normalMap, null, "a handheld is still paying for the map it cannot see"); + // And it keeps the half that reads at every stand-off. + assert.ok(flat.isMeshStandardMaterial, "the phone lost the sky as well as the grain"); + assert.equal(flat.shadowSide, THREE.BackSide); +}); + +test("the detail UV is built from the plan, because the ground has no uv attribute", async () => { + const world = await board(); + const terrain = createTerrain(world, { detail: true }).children[0] as THREE.Mesh; + /* + * The trap this exists for. `vNormalMapUv` is written by three's `uv_vertex` + * from the `uv` attribute, WebGL feeds a *constant* for an attribute the + * geometry does not supply, and the result is a ground where every fragment + * samples texel 0 — a map that costs its fetch and returns one value, with no + * error anywhere and nothing to see in a picture but a surface that is + * somehow still flat. + */ + assert.equal(terrain.geometry.getAttribute("uv"), undefined, "then the patch below is dead code"); + + const material = terrain.material as THREE.MeshStandardMaterial; + assert.equal( + material.customProgramCacheKey?.(), + "tera:ground", + "a patched program sharing three's default key gets someone else's shader", + ); + + // three's own chunk names, so a rename in an upgrade breaks this rather than + // silently dropping an edit — `String.replace` on a missing needle is a no-op. + const shader = { + uniforms: {} as Record, + vertexShader: "#include \nvoid main() {\n#include \n}", + fragmentShader: "#include \nvoid main() {\n#include \n}", + }; + material.onBeforeCompile(shader as never, null as never); + + assert.match(shader.vertexShader, /vNormalMapUv = position\.xz \* uGroundTile;/); + assert.match(shader.vertexShader, /uniform float uGroundTile;/); + // The cosine of the *drawn* slope. `verticalExaggeration` draws a real 10° + // hillside at 69° and a real 30° Sierra face at 83°, and a plan-projected + // tile on a face at angle θ is stretched 1/cos θ along the fall line — 2.8x + // 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, /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. + assert.ok( + Math.abs(tile - world.metresPerUnit / 220) < 1e-9, + `one repeat covers ${world.metresPerUnit / tile} m, not 220`, + ); + 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`, + ); +}); + +// ---- The ground's detail map ------------------------------------------------ + +/* + * The same six properties `textureMaps.test.ts` holds the office's relief maps + * to, applied to the one the land carries: it is arithmetic rather than a + * canvas, so it exists identically here and in a browser and every one of them + * is checkable under `node --test`. + */ + +test("the ground detail map is the same land on every reload, and is not flat", () => { + const a = groundDetailData(64); + const b = groundDetailData(64); + assert.deepEqual(a, b, "two boards would show two different Californias"); + let peak = 0; + for (let i = 0; i < a.length; i += 4) { + peak = Math.max(peak, Math.abs((a[i] as number) - 128), Math.abs((a[i + 1] as number) - 128)); + } + // `textureMaps.test.ts` puts it best: a flat normal map is a wasted texture + // unit. 8/128 is its threshold and this is the same one. + assert.ok(peak >= 8, `the ground's relief peaks at ${peak}/128, which is a flat card`); +}); + +test("every texel of the ground detail map decodes to a unit normal facing up", () => { + const size = 64; + const data = groundDetailData(size); + let worst = 0; + let lowest = 1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const n = normalAt(data, size, x, y); + worst = Math.max(worst, Math.abs(n.length() - 1)); + lowest = Math.min(lowest, n.z); + } + } + assert.ok(worst < 0.02, `a texel decoded to a normal of length ${1 + worst}`); + // A finish on a heightfield, not an overhang. `z` is the surface's own axis + // and ground grain that leans past about 45° stops being grain and starts + // competing with the geography the mesh is carrying. + assert.ok(lowest > 0.6, `a texel leaned to z=${lowest}, which is a cliff, not a field`); +}); + +test("the ground detail map tiles: the wrap is no sharper than the interior", () => { + const size = 64; + const data = groundDetailData(size); + let interior = 0; + let seam = 0; + for (let y = 0; y < size; y++) { + for (let x = 1; x < size - 1; x++) { + interior = Math.max(interior, normalAt(data, size, x, y).distanceTo(normalAt(data, size, x + 1, 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 + * down about five 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. + */ + assert.ok( + seam <= interior * 1.5, + `the wrap steps by ${seam} against an interior maximum of ${interior}`, + ); +}); + +test("the ground detail map lights the same at 128 as at 256", () => { + /* + * `normalMapData` converts gradient to slope — `x size` puts it per unit UV, + * `/ tileMetres` puts it per metre — and that is the only reason a resolution + * can be chosen for memory rather than for looks. Drop the division and the + * map gets twice as steep every time the size doubles, silently. + */ + const coarse = groundDetailData(128); + const fine = groundDetailData(256); + const slope = (data: Uint8Array, size: number): number => { + let sum = 0; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const n = normalAt(data, size, x, y); + sum += Math.hypot(n.x, n.y) / n.z; + } + } + return sum / (size * size); + }; + const a = slope(coarse, 128); + const b = slope(fine, 256); + assert.ok( + 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 + // 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}°`); +}); + +test("the ground grain runs in every direction rather than one", () => { + /* + * The swell's failure, restated for the land: a field whose slope is carried + * by one heading reads as corduroy, and on ground it reads as ploughing — + * which would be a very confident claim to make about the entire state. + */ + const size = 128; + const data = groundDetailData(size); + const buckets = new Array(12).fill(0) as number[]; + let total = 0; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const n = normalAt(data, size, x, y); + const slope = Math.hypot(n.x, n.y); + if (slope < 1e-4) continue; + const angle = (Math.atan2(n.y, n.x) + Math.PI * 2) % Math.PI; + const at = Math.min(11, Math.floor((angle / Math.PI) * 12)); + buckets[at] = (buckets[at] as number) + slope; + total += slope; + } + } + const dominant = Math.max(...buckets) / total; + assert.ok(dominant < 0.25, `${Math.round(dominant * 100)}% of the grain runs one way`); +}); diff --git a/src/test/render/terrainLod.test.ts b/src/test/render/terrainLod.test.ts index bc6e19c..6faa2b3 100644 --- a/src/test/render/terrainLod.test.ts +++ b/src/test/render/terrainLod.test.ts @@ -491,8 +491,20 @@ test("the chunks and the caster share one vertex buffer and one material", async const materials = new Set(group.children.map((c) => (c as THREE.Mesh).material)); assert.equal(materials.size, 1, "one material, or the split costs a program switch per chunk"); - const material = caster.material as THREE.MeshLambertMaterial; + // `MeshStandardMaterial` since the ground gained a surface response, and the + // cast is restated rather than left saying Lambert: the one shared material + // is now a patched standard one, and the point of this assertion is that all + // eighteen objects still hold the *same* one. + const material = caster.material as THREE.MeshStandardMaterial; assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the split"); + /* + * The detail map is on the shared material or it is on nothing. A per-chunk + * material would be seventeen program switches, and a caster with a cheaper + * material would be two — the `materials.size` assertion above is what + * forbids both, and this is the thing that would have tempted someone to + * break it. + */ + assert.ok(material.normalMap, "the shared ground material lost its detail relief"); }); test("the chunks are culled and the caster deliberately is not", async () => { From 76ca903cd2872df83d452266f421e431b46092d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:13:35 -0700 Subject: [PATCH 2/3] 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}°`); From 389245fd60dbfec13eb9889358af34bb92645769 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 20:15:21 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20two=20stale=20references=20in=20the?= =?UTF-8?q?=20ground=20material=20=E2=80=94=20the=20fade's=20name,=20and?= =?UTF-8?q?=20why=20two=20materials=20may=20share=20one=20cache=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- src/engine/terrain.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/engine/terrain.ts b/src/engine/terrain.ts index 9c83f97..73f92e0 100644 --- a/src/engine/terrain.ts +++ b/src/engine/terrain.ts @@ -334,9 +334,10 @@ function groundDetailTexture(): THREE.DataTexture { texture.magFilter = THREE.LinearFilter; // Mipmapped for the reason `swellNormalTexture` states: the mip chain of a // normal map converges on flat, so the ground a long way off stops perturbing - // its normal without anything having to decide when — and `GROUND_DETAIL_FAR_M` - // then finishes the job by taking the fetch's *result* to zero rather than - // leaving a residue of the coarsest mip on the horizon. + // its normal without anything having to decide when — and the fade at + // `GROUND_DETAIL_NOISE_PX` then finishes the job by taking the fetch's + // *result* to zero rather than leaving a residue of the coarsest mip on the + // horizon. texture.minFilter = THREE.LinearMipmapLinearFilter; texture.generateMipmaps = true; // Eight, as the sea has: from a map pose the ground is seen at a grazing @@ -537,10 +538,17 @@ 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. + /* + * `onBeforeCompile` is invisible to three's default key, so a patched + * material that does not declare one gets handed somebody else's program. + * + * The terrain and the shore plate both answer `"tera:ground"` and that is + * correct rather than a collision: `WebGLPrograms.getProgramCacheKey` + * appends this string to the *whole* parameter list, and `vertexColors` + * is in that list — so the two still hash apart, and two boards of the same + * kind still share. Set only on the patched branch: an unpatched ground is + * an ordinary standard material and must go on sharing three's own key. + */ material.customProgramCacheKey = () => "tera:ground"; } return material;