diff --git a/src/assets/textures.ts b/src/assets/textures.ts index 41f462d..ad8fc16 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 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 + * 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 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. */ -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..73f92e0 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,8 +119,443 @@ 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 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`: + * + * | 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 | 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 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. + * + * **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. + * + * **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 = 440; + +/** + * The detail map's resolution, and the lattice its height field is built on. + * + * 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, checked again below for this one. + */ +const GROUND_DETAIL_LATTICE = 128; +const GROUND_DETAIL_SIZE = 512; + +/** + * 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 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 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 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: 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 **device pixels of one repeat**. + * + * 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. + * + * 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_NOISE_PX = 2; +const GROUND_DETAIL_LEGIBLE_PX = 8; + +/** + * 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); +} + +/** + * 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; + groundDetailBytes ??= groundDetailData(size); + const texture = new THREE.DataTexture(groundDetailBytes, 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 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 + // 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 + * 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. + */ +function groundDetailWanted(): boolean { + if (typeof window === "undefined") return true; + 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 );`, + ); + }; + /* + * `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; +} + /** 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[] = []; @@ -143,9 +580,27 @@ export function createShorePlates(world: World): THREE.Mesh { geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geo.computeVertexNormals(); + /** + * The same ground material the relief above it wears, in a flat colour. + * + * 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.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }), + groundMaterial(world, options.detail ?? groundDetailWanted(), { color: pal.shore }), ); mesh.receiveShadow = true; /** @@ -454,7 +909,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,27 +1060,48 @@ export function createTerrain(world: World): THREE.Group { normalAttribute = new THREE.BufferAttribute(grown, 3); } - const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }); /** - * `shadowSide = BackSide` is the acne cure, and it is exactly right for a - * heightfield. + * Standard rather than Lambert, and the land had the better half of the + * argument `createWater` already won. * - * 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. + * 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. * - * 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°. + * 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 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. + * + * **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. */ - material.shadowSide = THREE.BackSide; + const material = groundMaterial(world, options.detail ?? groundDetailWanted(), { + vertexColors: true, + }); 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 76871fc..0a47363 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,292 @@ 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); }); + +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 ---------------------------------------------------- + +/* + * 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 \* 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. 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 / 440) < 1e-9, + `one repeat covers ${world.metresPerUnit / tile} m, not 440`, + ); + const fade = shader.uniforms.uGroundFade?.value as THREE.Vector2; + /* + * 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 ------------------------------------------------ + +/* + * 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 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. + */ + 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 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}°`); +}); + +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 () => {