1
0

merge: the ground gets a real material

The land was MeshLambertMaterial with vertex colours and no maps at all,
while the sea beside it was a MeshStandardMaterial with an animated
procedural normal map. Closes that gap: a standard material with
roughness and a tiling detail normal generated from seeded noise, zero
draw calls and zero triangles.

Two versions of this did nothing and were caught only by looking at the
pictures — the first faded out at 2.8 km because it copied the sea's
camera-distance form, which assumes a plan measurement and is 9x wrong
under 15x exaggeration; the second tiled the Central Valley with a
regular lattice of four-pointed stars.
This commit is contained in:
2026-08-24 21:02:58 -07:00
4 changed files with 848 additions and 29 deletions
+25 -6
View File
@@ -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++) {
+507 -20
View File
@@ -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.MeshStandardMaterialParameters, "color" | "vertexColors">,
): THREE.MeshStandardMaterial {
const material = new THREE.MeshStandardMaterial({
...paint,
side: THREE.DoubleSide,
roughness: GROUND_ROUGHNESS,
// Stated rather than defaulted: ground is a dielectric, and a metallic
// surface has no diffuse term at all, so a stray metalness here would take
// `groundColor`'s whole ramp off the board.
metalness: 0,
...(detail ? { normalMap: groundDetailTexture() } : {}),
});
/**
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
* heightfield.
*
* The depth pass then culls every face turned *toward* the sun — precisely
* the set of faces that were shadowing themselves — and keeps the faces
* turned away from it. The boundary between the two is the terminator, so the
* depth recorded along a ridge starts at the crest and runs down its far
* slope, and the valley floor beyond, which is still front-facing and
* therefore writes nothing, tests against it and lands in shadow. A lit slope
* has nothing in the map above it and cannot stipple.
*
* Without it, a constant `shadow.bias` has to cover a depth-per-texel that
* grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
* 0.73 on the Bay Area — and there is no single value that is free of acne at
* 40° and free of peter-panning at 8°. That is why this was left off through
* the previous round, and it is checked here by photographing the boards at
* a sun of 12° and of 2°.
*
* It survives the move to a standard material unchanged, and it has to: the
* depth pass does not use this material at all — `WebGLShadowMap` swaps in
* its own `MeshDepthMaterial` and copies across `shadowSide`, `alphaMap` and
* the displacement slots and nothing else — so the patch below is invisible
* to it and this flag is the only thing it reads.
*/
material.shadowSide = THREE.BackSide;
if (detail) {
/**
* Three edits to the standard shader. The ground has no `uv` attribute and
* no `tangent` attribute, and both of those are decisions rather than
* omissions.
*
* 1. **The detail UV is derived from world XZ in the vertex shader.** The
* alternative is a real `uv` attribute, and it was costed: 46,144
* vertices x 2 x 4 bytes is 369 KB against the 1.66 MB of position,
* colour and normal the ground already uploads — a 22% growth of the
* ground's vertex data to store a number that is `position.xz` times a
* constant. It would also have to be added as *one* attribute object
* assigned to all 18 geometries, or the shared-buffer arrangement in
* `createTerrain` quietly duplicates it seventeen times. The plan
* projection is in any case the natural parameterisation of a
* heightfield, which is the other half of why there is no attribute to
* interpolate. `position.xz` and not the world matrix's, deliberately:
* `scene.ts` scales this group in **Y** to move `verticalExaggeration`
* without rebuilding a board, and a UV taken off the plan is the one
* that does not slide when it does.
*
* 2. **The slope is scaled by the cosine of the drawn slope, which is the
* fix for the exaggeration trap.** `verticalExaggeration` is 15 on this
* board, so a real 10° hillside is *drawn* at 69° and a real 30° Sierra
* face at 83°. A plan-projected tile on a face at angle θ covers 1/cos θ
* of surface along the fall line — 2.8x on that hillside, 8.7x on that
* face — so the map's encoded slope describes bumps far shorter than the
* ones it is actually being stretched over, and the Sierra escarpment
* comes out as a smear of vertical stripes. Multiplying the tangent-space
* slope by cos θ restores the geometry exactly: the bumps get longer, so
* they get shallower by the same factor. It costs no fetch — the cosine
* is a dot product against world up, which `viewMatrix`' second column
* already is — and it is why there is no triplanar projection here
* tripling the sampler traffic to solve the same problem. The
* approximation in it, stated because it is real: the stretch is along
* the fall line only and this scales both tangent axes, so the detail is
* slightly over-flattened across a steep slope. At the angles the
* exaggeration produces that is a rounding error next to the stripes.
*
* 3. **It fades out once one repeat is worth a couple of pixels**, which is
* the sea's `SEA_CALM_NEAR` idea measured rather than inferred. See
* `GROUND_DETAIL_NOISE_PX` — that note also records the nine-fold error
* the distance form made on a board with a vertical exaggeration on it.
*
* A patch rather than a `ShaderMaterial`, for the reason `createWater`
* gives at length: the lighting this surface has to obey is a sun, a
* hemisphere, an ambient, a shadow, a PMREM environment and a fog, all
* owned elsewhere and all changing with the hour, and that is exactly what
* `MeshStandardMaterial` already implements correctly.
*/
const uniforms = {
/** Repeats of the detail map per scene unit, so the grain is the same
* size in metres on a 94 m board and on a 1,919 m one. */
uGroundTile: { value: world.metresPerUnit / GROUND_DETAIL_TILE_METRES },
/** The fade window, in device pixels of one repeat. See above. */
uGroundFade: {
value: new THREE.Vector2(GROUND_DETAIL_NOISE_PX, GROUND_DETAIL_LEGIBLE_PX),
},
};
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
uniform float uGroundTile;`,
)
// `uv_vertex` has already written `vNormalMapUv` from the `uv`
// attribute, which this geometry does not have — WebGL feeds a constant
// for a missing attribute, so every fragment would sample texel 0. This
// overwrites it with the plan coordinate, which is the only place the
// varying is ever set.
.replace(
"#include <uv_vertex>",
`#include <uv_vertex>
vNormalMapUv = position.xz * uGroundTile;`,
);
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
`#include <common>
uniform vec2 uGroundFade;`,
)
.replace(
"#include <normal_fragment_maps>",
`vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;
// \`viewMatrix\`' second column is world up in view space, and \`normal\` here is
// still the interpolated geometric normal — \`normal_fragment_begin\` has run and
// \`normal_fragment_maps\` is what this replaces. \`abs\` because the ground is
// DoubleSide and a back face arrives already flipped by \`faceDirection\`.
float groundFlat = saturate( abs( dot( normal, normalize( mat3( viewMatrix )[1] ) ) ) );
// One repeat of the map spans 1.0 in UV, so the reciprocal of the pixel's UV
// footprint is the repeat's width in pixels. The geometric mean of the two axes
// rather than the larger of them, floored at an eighth of the larger: that is
// what \`anisotropy = 8\` actually resolves, and the isotropic maximum would
// give the detail up across most of an oblique frame — which is most of a map
// pose — while the minimum would keep it on ground stretched to nothing.
vec2 groundStep = fwidth( vNormalMapUv );
float groundWide = max( groundStep.x, groundStep.y );
float groundFoot = max( sqrt( groundStep.x * groundStep.y ), groundWide / 8.0 );
float groundSeen = smoothstep( uGroundFade.x, uGroundFade.y, 1.0 / max( groundFoot, 1e-6 ) );
mapN.xy *= normalScale * groundFlat * groundSeen;
normal = normalize( tbn * mapN );`,
);
};
/*
* `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";
+303 -2
View File
@@ -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<string, { value: unknown }>,
vertexShader: "#include <common>\nvoid main() {\n#include <uv_vertex>\n}",
fragmentShader: "#include <common>\nvoid main() {\n#include <normal_fragment_maps>\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`);
});
+13 -1
View File
@@ -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 () => {