1
0

feat: the land is a surface, not paint — standard material and a 220 m detail relief

The ground was `MeshLambertMaterial` with a vertex colour and no maps of any
kind, next to a sea that has been a `MeshStandardMaterial` with a swell map on
it since `createWater` was written. Two things came out of that gap.

The louder one is invisible in the code and obvious in a picture: three forwards
`scene.environment` only to `isMeshStandardMaterial`, so the whole PMREM sky
`environmentRig.ts` builds — sun lobe, `SKY_RADIANCE_SHARE`, the ground bounce —
was reaching the water, the ports and the vessels and not one photon of it was
reaching the state. The land has been paying the counterweight for it the whole
time: the day stops gave up about a tenth of `hemiIntensity` and a quarter of
`ambientIntensity` to make room for an environment term the ground could not
receive.

The quieter one is the facets. `lodPatches` collapses flat ground into patches
up to eight 300 m cells across, so at a close pose the state is kilometre-wide
triangles with one normal each, and nothing on them.

So: one `MeshStandardMaterial` at roughness 0.92, metalness 0, carrying a tiling
detail normal map built from `textures.ts`' own `tileableNoise` / `sampleField` /
`normalMapData` at a **220 m** repeat rather than the office's 2 m — which is
sub-pixel at every stand-off this board can reach, orbit floor included. 220 m is
legible from about 45 km down and mip-flat above about 110 km, and it is not
300 m because a tile the size of a terrain cell paints the same pattern on every
cell and lands its repeat on the facet edges it exists to hide.

Zero new draw calls, and that was the constraint: same 18 objects, same one
shared material, same one attribute triple, same one program switch.
`cost-at.mjs` reads 355,763 / 437 draws at the whole-board pose, unchanged.

Three shader edits, none of which the material has a dial for:

  1. The detail UV is `position.xz`, in the vertex shader. A real `uv` attribute
     would be 369 KB against the ground's current 1.66 MB and would have to be
     added as one object shared by all 18 geometries or the arrangement quietly
     duplicates it seventeen times. The plan projection is also the natural
     parameterisation of a heightfield.
  2. The tangent-space slope is scaled by the cosine of the *drawn* slope.
     `verticalExaggeration` is 15 here, so a real 10° hillside draws at 69° and a
     real 30° Sierra face at 83°, and a plan-projected tile on a face at θ is
     stretched 1/cos θ along the fall line — 2.8x and 8.7x. Scaling by cos θ
     restores the same bumps instead of a smear of vertical stripes, for a dot
     product against world up rather than the three fetches triplanar would cost.
  3. It fades out between 25 km and 70 km of view distance, as the sea's does at
     `SEA_CALM_NEAR`. That matters more here than for the water: the board's own
     budget pose is 400 km out, where a 220 m tile is 0.6 device pixels.

The shore plate goes standard with it, mapless. It is the one place on the board
where two materials meet edge to edge in the same colour, and a Lambert plate
against a standard ground would have put a step of about a fifth of the
hemisphere's diffuse contribution around every coastline in the state.

A handheld gets the material and the environment and not the map — "off" is *no*
map, the way `MaterialQuality`'s `low` rung is, because the cost is one fetch and
`getTangentFrame`'s three derivative pairs per fragment and a smaller map costs
exactly the same fetch.

`tileableNoise`, `sampleField` and `normalMapData` are exported from
`textures.ts` rather than reimplemented, and `normalMapData` takes the tile size
it converts against instead of reading the office's constant. Same reason that
file takes `fbm` from `engine/world.ts`: a second noise is a second thing to keep
tiling, and a second Sobel is a second place to get the flipY reasoning wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-24 19:49:15 -07:00
parent 1049f942b8
commit 0d7eb28bb5
4 changed files with 707 additions and 12 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 220 m repeat, for the same reason this file takes `fbm` from
* `engine/world.ts` rather than writing its own: a second noise is a second
* thing to keep tiling, and a second Sobel is a second place for the
* flipY/perUv reasoning to be got wrong. Nothing here touches the DOM, so the
* land's map is as testable under `node --test` as the office's are.
*/
function tileableNoise(res: number, scale: number, offset: number): Float32Array {
export function tileableNoise(res: number, scale: number, offset: number): Float32Array {
const field = new Float32Array(res * res);
for (let y = 0; y < res; y++) {
const v = y / res;
@@ -122,7 +131,7 @@ function tileableNoise(res: number, scale: number, offset: number): Float32Array
}
/** Bilinear read of a wrapped coarse field, in 0..1 texture space. */
function sampleField(field: Float32Array, res: number, u: number, v: number): number {
export function sampleField(field: Float32Array, res: number, u: number, v: number): number {
const fx = u * res;
const fy = v * res;
const x0 = Math.floor(fx);
@@ -1075,17 +1084,27 @@ function heightField(recipe: HeightRecipe, size: number): Float32Array {
* neighbouring rows is enough to bury it.
*
* The gradient is converted to a **slope** before it is encoded — `× size` puts
* it per unit UV, `/ TEXTURE_TILE_METRES` puts it per metre — which is why the
* same recipe drawn at 256 and at 512 gives the same steepness rather than the
* it per unit UV, `/ tileMetres` puts it per metre — which is why the same
* recipe drawn at 256 and at 512 gives the same steepness rather than the
* higher-resolution floor looking twice as smooth.
*
* `tileMetres` is a parameter rather than the constant it used to read, because
* the ground's detail map repeats every 220 m and the office's finishes every
* 2 m, and the whole point of the conversion is that the number is the tile's
* own. Every caller in this file passes `TEXTURE_TILE_METRES`, which is what
* the default preserves.
*/
function normalMapData(height: Float32Array, size: number): Uint8Array {
export function normalMapData(
height: Float32Array,
size: number,
tileMetres: number = TEXTURE_TILE_METRES,
): Uint8Array {
const data = new Uint8Array(size * size * 4);
const at = (x: number, y: number): number =>
height[(((y % size) + size) % size) * size + (((x % size) + size) % size)] ?? 0;
// Per-texel Sobel sums weight 4 either side across a 2-texel baseline.
const perUv = size / 8 / TEXTURE_TILE_METRES;
const perUv = size / 8 / tileMetres;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
+408 -3
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,6 +119,217 @@ function groundColor(
);
}
// ---- The ground's surface --------------------------------------------------
/**
* How rough the land is, for the physically-shaded ground below.
*
* The land is not the sea and must not glint like it. At `SEA_*`'s 0.2 a GGX
* lobe is a few degrees wide and returns the sun as a hard streak; at 0.92 it
* is most of a hemisphere, which is what dry ground, scrub, tarmac and roof
* tile all are — the specular term contributes a low, wide sheen and nothing
* that reads as a highlight. What the number is really buying is not the
* specular at all but the *diffuse* environment term, which is what a Lambert
* material has no slot for; see the note on the material in `createTerrain`.
*/
const GROUND_ROUGHNESS = 0.92;
/**
* How many metres one repeat of the ground's detail normal map covers, and the
* whole reason it is not `TEXTURE_TILE_METRES`.
*
* The office's constant is 2 m, which is correct for a carpet and meaningless
* here. Work out what a tile is worth in pixels before choosing one. The
* desktop budget viewport is 1440 x 900 at a 42° *vertical* field of view, so
* looking straight down from a stand-off of `d` metres one device pixel covers
* `d · 2·tan(21°) / 900 = d / 1173` metres of ground, and an oblique look
* divides that by `sin(pitch)`. Against the poses this board can actually be
* put in:
*
* | stand-off | m/px | a 2 m tile | a 220 m tile |
* |---|---|---|---|
* | 400 km — the whole state, and where the budget measures | 341 | 0.006 px | 0.6 px |
* | 110 km — Los Angeles wide | 94 | 0.02 px | 2.3 px |
* | 45 km — the Bay rung | 38.4 | 0.05 px | 5.7 px |
* | 7.7 km | 6.6 | 0.3 px | 33 px |
* | 2.5 km | 2.13 | 0.9 px | 103 px |
* | 1.9 km — `ORBIT_MIN_STANDOFF_M`, the floor | 1.62 | 1.2 px | 136 px |
*
* **A 2 m tile is sub-pixel at every stand-off the board can reach**, right
* down to the orbit floor, so reusing the office's number would have bought a
* texture fetch per ground fragment in exchange for a map the mip chain has
* already flattened. 220 m is legible from about 45 km down and is noise above
* about 110 km, which is one decade of the board's three — and it is the decade
* where the ground looks worst, because the terrain's 300 m cells are collapsed
* by `lodPatches` into flat patches up to eight cells across.
*
* **Not 300 m, deliberately.** The obvious choice is one repeat per terrain
* cell, and it is the one number that must be avoided: the lattice is very
* nearly axis-aligned, so a tile the size of a cell paints the *same* pattern
* on every cell and the repeat lands exactly on the facet edges it exists to
* hide. 220 beats against them instead.
*
* Magnification is never the failure mode. At `GROUND_DETAIL_SIZE` a texel is
* 0.86 m, which first exceeds one device pixel at a stand-off of about a
* kilometre — below the orbit floor. Inside the reachable range the map is
* always minified, so the mip chain decides everything and the magnifier
* decides nothing.
*/
const GROUND_DETAIL_TILE_METRES = 220;
/**
* The detail map's resolution, and the lattice its height field is built on.
*
* 64 is `textures.ts`' own `NOISE_RES` and is used here for the reason that
* file records: the bilinear upsample off a coarse lattice is what makes the
* field cheap, and `normalMapData`'s Sobel is what buries the lattice in the
* derivative. Sixty-four samples across a 220 m tile puts the finest thing in
* the height field at 3.4 m, so 256 texels lands four of them across it and 512
* would be storing the interpolation between them. That is also 262 KB rather
* than 1.05 MB, against a board whose whole resident geometry is about 40 MB.
*
* The Sobel converts gradient to slope per metre, so the two resolutions light
* identically — `textureMaps.test.ts` pins that property for the office's maps
* and it is the same arithmetic here.
*/
const GROUND_DETAIL_LATTICE = 64;
const GROUND_DETAIL_SIZE = 256;
/**
* The height field the ground's normal map is differentiated from, in metres of
* **drawn** relief.
*
* Drawn, not real, and the distinction is the one trap in this whole file.
* `world.metres` multiplies every elevation by `verticalExaggeration` — 15 on
* the merged California board — while a horizontal metre is divided by
* `metresPerUnit` alone. A normal map encodes a slope in scene units per scene
* unit, so the numbers below are already on the drawn side of that
* multiplication: the ±5.9 m of relief these three layers sum to stands for
* about ±0.4 m of real ground, which is a 1% grade over 220 m and is about
* right for field, scrub and alluvium.
*
* **They are deliberately not exaggerated to match the geography.** Carrying
* the real relief through the board's own 15× would put the map's mean slope
* near 45° and its peaks past 70°, and that was the wrong answer for a reason
* that is not about taste: the grain would then out-shout the geography, the
* sun's N·L would swing by ±0.6 across two texels, and a state whose whole
* subject is its landforms would read as crumpled foil. The mesh carries the
* exaggeration; the finish on it does not.
*
* Measured on the field these three produce: mean slope 8.5°, peak 32°, and
* 79% of it under 12°. The three layers are one decade apart each — about 110 m
* of swell, 30 m of gully, and the lattice floor at 3.4 m as grain — because a
* single octave at any of those reads as a pattern and three read as a surface.
*/
const GROUND_DETAIL_LAYERS: readonly { scale: number; offset: number; metres: number }[] = [
{ scale: 2, offset: 5.7, metres: 27 },
{ scale: 7, offset: 19.3, metres: 10.2 },
{ scale: 23, offset: 41.1, metres: 3.3 },
];
/**
* Where the detail gives up, in metres of view distance.
*
* The same argument as `SEA_CALM_NEAR` and the same mechanism: past some
* distance one pixel covers more of the map than the mip chain can average
* without the grain crawling as the camera moves, and long before that the map
* has stopped saying anything. From the table at `GROUND_DETAIL_TILE_METRES`, a
* 220 m tile is 5.7 px at 45 km and 2.3 px at 110 km, so it is legible at the
* near end of this window and mip-flattened noise at the far end.
*
* It matters more here than it does for the sea, because **the board's own
* budget poses live at the far end.** `california-one` is measured at whole-
* board framing, 400 km out, where a 220 m tile is 0.6 px; without this the
* ground would be paying a fetch and a tangent frame at the one pose the
* product is judged on, to perturb a normal by an amount that has already
* averaged to zero. In real metres rather than board spans so that a phone
* looking at San Francisco and a desktop looking at the state agree about what
* "far" means.
*/
const GROUND_DETAIL_NEAR_M = 25_000;
const GROUND_DETAIL_FAR_M = 70_000;
/**
* A tiling tangent-space normal map for the land, as raw RGBA bytes.
*
* The land's answer to `swellNormalData`, and built out of `textures.ts`'
* machinery rather than its own: `tileableNoise` for the four-way wrapped blend
* that makes the edges match, `sampleField` for the bilinear read, and
* `normalMapData` for the Sobel that turns metres into an encoded slope. The
* only thing this adds is the recipe and the tile size, which is the whole
* difference between a carpet and a hillside.
*
* No canvas, no `document`, no image: the field is arithmetic, so this function
* exists identically in a browser and under `node --test`, and
* `scripts/check-no-binaries.mjs` has nothing to find.
*/
export function groundDetailData(size: number = GROUND_DETAIL_SIZE): Uint8Array {
const height = new Float32Array(size * size);
for (const layer of GROUND_DETAIL_LAYERS) {
const field = tileableNoise(GROUND_DETAIL_LATTICE, layer.scale, layer.offset);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// `/ 0.94` is `grain()`'s: four octaves of `fbm` land in 0..0.94. The
// half is what centres the layer, so the summed field has no drift in
// it and `metres` is a peak-to-trough rather than a ceiling.
const n = Math.min(
1,
Math.max(0, sampleField(field, GROUND_DETAIL_LATTICE, x / size, y / size) / 0.94),
);
height[y * size + x] = (height[y * size + x] as number) + (n - 0.5) * layer.metres;
}
}
}
return normalMapData(height, size, GROUND_DETAIL_TILE_METRES);
}
function groundDetailTexture(): THREE.DataTexture {
const size = GROUND_DETAIL_SIZE;
const texture = new THREE.DataTexture(groundDetailData(size), size, size);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.magFilter = THREE.LinearFilter;
// Mipmapped for the reason `swellNormalTexture` states: the mip chain of a
// normal map converges on flat, so the ground a long way off stops perturbing
// its normal without anything having to decide when — and `GROUND_DETAIL_FAR_M`
// then finishes the job by taking the fetch's *result* to zero rather than
// leaving a residue of the coarsest mip on the horizon.
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true;
// Eight, as the sea has: from a map pose the ground is seen at a grazing
// angle across most of the frame, and that is exactly where an isotropic
// filter picks a mip several levels too coarse and the detail vanishes
// halfway up the screen for no reason the viewer can see.
texture.anisotropy = 8;
texture.needsUpdate = true;
texture.name = "groundDetailNormal";
return texture;
}
/**
* Whether this device should pay for the ground's detail relief.
*
* The precedent is `MaterialQuality`'s `low` rung in `assets/materials.ts`,
* which is flat Lambert with no maps — a tier's "off" is *no map*, not a
* smaller one, because the cost being avoided is per fragment and a smaller map
* costs exactly the same fetch. `deviceProfile()` is the only device knob a
* city board reads and it is asked here rather than re-derived, so the
* renderer's idea of "phone" cannot drift from the stylesheet's.
*
* What a handheld keeps is the half of this change that reads at every
* stand-off: the standard material, its specular and its environment term. What
* it gives up is the half that only reads below 45 km — one texture fetch and
* `getTangentFrame`'s three derivative pairs per ground fragment, on a viewport
* that is 1.3 Mpx of them, at a whole-board pose where the tile is 0.6 px wide.
*
* `typeof window` because `createTerrain` runs under `node --test` as well,
* where there is no device to profile and no fill rate to protect.
*/
function groundDetailWanted(): boolean {
if (typeof window === "undefined") return true;
return !deviceProfile().handheld;
}
/** The smooth flat polygon under each landmass — the crisp coastline. */
export function createShorePlates(world: World): THREE.Mesh {
const pal = paletteFor(world);
@@ -143,9 +356,31 @@ export function createShorePlates(world: World): THREE.Mesh {
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.computeVertexNormals();
/**
* Standard, and only because the terrain on top of it is.
*
* The plate carries no map and wants none — it is the landmass polygon lying
* flat at y=0, and the only part of it anyone ever sees is the rim of it
* showing past the terrain grid's stair-stepped edge. What it cannot afford
* is to be lit by a *different* set of terms from the ground it abuts. A
* `MeshLambertMaterial` gets no `scene.environment` at all —
* `WebGLRenderer` forwards it only to `isMeshStandardMaterial` — so leaving
* this one Lambert while `createTerrain` went standard would have put a
* step of roughly a fifth of the hemisphere's diffuse contribution around
* every coastline in the state, which is the one place on the board where
* two materials meet edge to edge in the same colour and a step reads as a
* seam rather than as a different object.
*
* One mesh, one material, one draw call, exactly as before.
*/
const mesh = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }),
new THREE.MeshStandardMaterial({
color: pal.shore,
roughness: GROUND_ROUGHNESS,
metalness: 0,
side: THREE.DoubleSide,
}),
);
mesh.receiveShadow = true;
/**
@@ -454,7 +689,18 @@ function lodPatches(
* the chunking below for why the split exists and why the caster is not part
* of it.
*/
export function createTerrain(world: World): THREE.Group {
export interface TerrainOptions {
/**
* Whether the ground carries its detail normal map.
*
* Defaults to `groundDetailWanted()`, which is "not on a phone". Passed
* explicitly by the tests, which want the map on a board that has no
* `window` to profile and want to be able to ask for it off.
*/
readonly detail?: boolean;
}
export function createTerrain(world: World, options: TerrainOptions = {}): THREE.Group {
const pal = paletteFor(world);
// Which cells are drawn, and how big, is `lodPatches`' answer; this function
// only turns a corner into a vertex.
@@ -594,7 +840,56 @@ export function createTerrain(world: World): THREE.Group {
normalAttribute = new THREE.BufferAttribute(grown, 3);
}
const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
/**
* Standard rather than Lambert, and the land had the better half of the
* argument `createWater` already won.
*
* The sea below this is a `MeshStandardMaterial` with a swell map on it; the
* state beside it was `MeshLambertMaterial` with a vertex colour and **no
* maps of any kind** — no albedo, no normal, no roughness — which is most of
* why the board read as a painted relief model with a real ocean next to it.
*
* Two things change, and only one of them is the texture.
*
* **The environment.** `environmentRig.ts` builds a PMREM of the sky the
* atmosphere decided on — a sun lobe at radiance 1.2, `SKY_RADIANCE_SHARE`
* 0.12 of the dome, `GROUND_RADIANCE_SHARE` 0.09 bouncing back — and
* `scene.ts` applies it to every city scene. `WebGLRenderer` then forwards
* `scene.environment` to `material.isMeshStandardMaterial` **and to nothing
* else** (`WebGLRenderer.js`, twice, at the `materialProperties.environment`
* assignment). So until this line the whole sky probe was being delivered to
* the water, the guardrails, the ports and the vessels, and not one photon of
* it reached the land. That is not a subtle loss: `environmentRig.ts` records
* that the day stops in `atmosphere.ts` gave up about a tenth of
* `hemiIntensity` and a quarter of `ambientIntensity` to make room for the
* environment, so the ground has been paying the counterweight for a term it
* could not receive. It receives it now, for no second pass, no new uniform
* and no new draw call.
*
* **The relief.** `GROUND_DETAIL_TILE_METRES` explains the tile; the patch
* below explains the three edits it needs. `lodPatches` collapses flat ground
* into patches up to eight 300 m cells across, so at a close pose the state is
* kilometre-wide triangles with one normal each, and a tiling detail normal is
* the cheapest thing in computer graphics that hides that.
*
* **What does not change: the draw call count, which has 22 spare of 460.**
* This is the same one material object handed to all 17 chunk meshes and to
* the caster, so it is the same 18 objects, the same one program switch and
* the same one vertex buffer — `terrainLod.test.ts` asserts every part of
* that. A texture is not a draw call. A second mesh, a decal layer or a
* second pass would have been, which is why none of those is here.
*/
const detail = options.detail ?? groundDetailWanted();
const material = new THREE.MeshStandardMaterial({
vertexColors: true,
side: THREE.DoubleSide,
roughness: GROUND_ROUGHNESS,
// Stated rather than defaulted: ground is a dielectric, and a metallic
// surface has no diffuse term at all, so a stray metalness here would take
// `groundColor`'s whole ramp off the board.
metalness: 0,
...(detail ? { normalMap: groundDetailTexture() } : {}),
});
/**
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
* heightfield.
@@ -613,9 +908,119 @@ export function createTerrain(world: World): THREE.Group {
* 40° and free of peter-panning at 8°. That is why this was left off through
* the previous round, and it is checked here by photographing the boards at
* a sun of 12° and of 2°.
*
* It survives the move to a standard material unchanged, and it has to: the
* depth pass does not use this material at all — `WebGLShadowMap` swaps in
* its own `MeshDepthMaterial` and copies across `shadowSide`, `alphaMap` and
* the displacement slots and nothing else — so the patch below is invisible
* to it and this flag is the only thing it reads.
*/
material.shadowSide = THREE.BackSide;
if (detail) {
/**
* Three edits to the standard shader. The ground has no `uv` attribute and
* no `tangent` attribute, and both of those are decisions rather than
* omissions.
*
* 1. **The detail UV is derived from world XZ in the vertex shader.** The
* alternative is a real `uv` attribute, and it was costed: 46,144
* vertices x 2 x 4 bytes is 369 KB against the 1.66 MB of position,
* colour and normal the ground already uploads — a 22% growth of the
* ground's vertex data to store a number that is `position.xz` times a
* constant. It would also have to be added as *one* attribute object
* assigned to all 18 geometries, or the shared-buffer arrangement in
* `createTerrain` quietly duplicates it seventeen times. The plan
* projection is in any case the natural parameterisation of a
* heightfield, which is the other half of why there is no attribute to
* interpolate. `position.xz` and not the world matrix's, deliberately:
* `scene.ts` scales this group in **Y** to move `verticalExaggeration`
* without rebuilding a board, and a UV taken off the plan is the one
* that does not slide when it does.
*
* 2. **The slope is scaled by the cosine of the drawn slope, which is the
* fix for the exaggeration trap.** `verticalExaggeration` is 15 on this
* board, so a real 10° hillside is *drawn* at 69° and a real 30° Sierra
* face at 83°. A plan-projected tile on a face at angle θ covers 1/cos θ
* of surface along the fall line — 2.8x on that hillside, 8.7x on that
* face — so the map's encoded slope describes bumps far shorter than the
* ones it is actually being stretched over, and the Sierra escarpment
* comes out as a smear of vertical stripes. Multiplying the tangent-space
* slope by cos θ restores the geometry exactly: the bumps get longer, so
* they get shallower by the same factor. It costs no fetch — the cosine
* is a dot product against world up, which `viewMatrix`' second column
* already is — and it is why there is no triplanar projection here
* tripling the sampler traffic to solve the same problem. The
* approximation in it, stated because it is real: the stretch is along
* the fall line only and this scales both tangent axes, so the detail is
* slightly over-flattened across a steep slope. At the angles the
* exaggeration produces that is a rounding error next to the stripes.
*
* 3. **It fades out with distance**, exactly as the sea's does at
* `SEA_CALM_NEAR`. See `GROUND_DETAIL_NEAR_M` for why the far end of
* that window matters more here than it does for the water.
*
* A patch rather than a `ShaderMaterial`, for the reason `createWater`
* gives at length: the lighting this surface has to obey is a sun, a
* hemisphere, an ambient, a shadow, a PMREM environment and a fog, all
* owned elsewhere and all changing with the hour, and that is exactly what
* `MeshStandardMaterial` already implements correctly.
*/
const uniforms = {
/** Repeats of the detail map per scene unit, so the grain is the same
* size in metres on a 94 m board and on a 1,919 m one. */
uGroundTile: { value: world.metresPerUnit / GROUND_DETAIL_TILE_METRES },
uGroundFade: {
value: new THREE.Vector2(
GROUND_DETAIL_NEAR_M / world.metresPerUnit,
GROUND_DETAIL_FAR_M / world.metresPerUnit,
),
},
};
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms);
shader.vertexShader = shader.vertexShader
.replace(
"#include <common>",
`#include <common>
uniform float uGroundTile;`,
)
// `uv_vertex` has already written `vNormalMapUv` from the `uv`
// attribute, which this geometry does not have — WebGL feeds a constant
// for a missing attribute, so every fragment would sample texel 0. This
// overwrites it with the plan coordinate, which is the only place the
// varying is ever set.
.replace(
"#include <uv_vertex>",
`#include <uv_vertex>
vNormalMapUv = position.xz * uGroundTile;`,
);
shader.fragmentShader = shader.fragmentShader
.replace(
"#include <common>",
`#include <common>
uniform vec2 uGroundFade;`,
)
.replace(
"#include <normal_fragment_maps>",
`vec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;
// \`viewMatrix\`' second column is world up in view space, and \`normal\` here is
// still the interpolated geometric normal — \`normal_fragment_begin\` has run and
// \`normal_fragment_maps\` is what this replaces. \`abs\` because the ground is
// DoubleSide and a back face arrives already flipped by \`faceDirection\`.
float groundFlat = saturate( abs( dot( normal, normalize( mat3( viewMatrix )[1] ) ) ) );
float groundFar = smoothstep( uGroundFade.x, uGroundFade.y, length( vViewPosition ) );
mapN.xy *= normalScale * groundFlat * ( 1.0 - groundFar );
normal = normalize( tbn * mapN );`,
);
};
// Two materials that compile to different programs must not share a cache
// key, and `onBeforeCompile` is invisible to three's default key. Set only
// on the patched branch: an unpatched ground is an ordinary standard
// material and must go on sharing whatever key three gives it.
material.customProgramCacheKey = () => "tera:ground";
}
const group = new THREE.Group();
group.name = "terrain";
// ^ the group carries the name the mesh used to. `godmode.ts` matches its
+261 -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,250 @@ test("the shore plate receives and does not cast", async () => {
// whole coastline out across the water as a hard slab.
assert.equal(plate.castShadow, false);
});
// ---- The land's surface ----------------------------------------------------
/*
* Everything below is the land's half of the argument the sea won further up
* this file. The ground was `MeshLambertMaterial` with a vertex colour and no
* maps of any kind, next to a sea with a swell map on it and, less visibly,
* `WebGLRenderer` forwards `scene.environment` only to `isMeshStandardMaterial`,
* so the whole PMREM sky `environmentRig.ts` builds was reaching the water and
* not the state. None of that was visible to the type checker, and the two
* assertions above it that were about the terrain's material both went through
* a compile-time cast that a class change passes straight through.
*/
test("the land catches the sky, which a Lambert card cannot", async () => {
const world = await board();
const terrain = createTerrain(world).children[0] as THREE.Mesh;
const material = terrain.material as THREE.MeshStandardMaterial;
/*
* `WebGLRenderer.js` reads
* `materialProperties.environment = material.isMeshStandardMaterial ? scene.environment : null`
* in both of the places it decides a program's lights so this flag is
* literally the switch that lets the sky reach the ground. `scene.ts` applies
* the rig to every city scene already; the land was the one large surface in
* the frame not receiving it.
*/
assert.ok(material.isMeshStandardMaterial, "the state went back to being unlit paint");
assert.equal(material.metalness, 0, "a metallic ground has no diffuse term at all");
// Ground, not water: the sea is under 0.5 so the sun has a path across it,
// and the land must be well above that or it glints like wet tarmac at every
// hour. The upper bound is what stops someone "simplifying" this to 1.0,
// which switches the environment's specular lobe off again.
assert.ok(
material.roughness > 0.7 && material.roughness < 1,
`the land is at roughness ${material.roughness}`,
);
assert.equal(material.vertexColors, true, "`groundColor`'s ramp is the board's palette");
assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the class change");
});
test("the ground's detail relief is on by default and can be switched off whole", async () => {
const world = await board();
const on = createTerrain(world, { detail: true }).children[0] as THREE.Mesh;
const off = createTerrain(world, { detail: false }).children[0] as THREE.Mesh;
const lit = on.material as THREE.MeshStandardMaterial;
const flat = off.material as THREE.MeshStandardMaterial;
assert.ok(lit.normalMap, "the ground lost the map that breaks up its 300 m facets");
assert.equal(lit.normalMap.wrapS, THREE.RepeatWrapping, "one tile of ground, then a seam");
assert.equal(lit.normalMap.wrapT, THREE.RepeatWrapping);
assert.equal(lit.normalMap.generateMipmaps, true);
// The mip chain of a normal map converges on flat, which is what stops the
// grain crawling in the distance without anything having to decide when.
assert.equal(lit.normalMap.minFilter, THREE.LinearMipmapLinearFilter);
assert.ok(lit.normalMap.anisotropy >= 8, "the ground is grazing across most of a map pose");
/*
* "Off" is *no map*, the way `MaterialQuality`'s `low` rung is no map not a
* smaller one. The cost a handheld is being spared is per fragment: one
* texture fetch and `getTangentFrame`'s three derivative pairs on 1.3 Mpx of
* them. A quarter-size map costs exactly the same fetch.
*/
assert.equal(flat.normalMap, null, "a handheld is still paying for the map it cannot see");
// And it keeps the half that reads at every stand-off.
assert.ok(flat.isMeshStandardMaterial, "the phone lost the sky as well as the grain");
assert.equal(flat.shadowSide, THREE.BackSide);
});
test("the detail UV is built from the plan, because the ground has no uv attribute", async () => {
const world = await board();
const terrain = createTerrain(world, { detail: true }).children[0] as THREE.Mesh;
/*
* The trap this exists for. `vNormalMapUv` is written by three's `uv_vertex`
* from the `uv` attribute, WebGL feeds a *constant* for an attribute the
* geometry does not supply, and the result is a ground where every fragment
* samples texel 0 a map that costs its fetch and returns one value, with no
* error anywhere and nothing to see in a picture but a surface that is
* somehow still flat.
*/
assert.equal(terrain.geometry.getAttribute("uv"), undefined, "then the patch below is dead code");
const material = terrain.material as THREE.MeshStandardMaterial;
assert.equal(
material.customProgramCacheKey?.(),
"tera:ground",
"a patched program sharing three's default key gets someone else's shader",
);
// three's own chunk names, so a rename in an upgrade breaks this rather than
// silently dropping an edit — `String.replace` on a missing needle is a no-op.
const shader = {
uniforms: {} as Record<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 \* \( 1\.0 - groundFar \);/);
assert.match(shader.fragmentShader, /normal = normalize\( tbn \* mapN \);/);
const tile = shader.uniforms.uGroundTile?.value as number;
// Repeats per scene unit. One repeat is `GROUND_DETAIL_TILE_METRES` of real
// ground on every board, which is what keeps the grain the same physical size
// on a 94 m board and a 1,919 m one.
assert.ok(
Math.abs(tile - world.metresPerUnit / 220) < 1e-9,
`one repeat covers ${world.metresPerUnit / tile} m, not 220`,
);
const fade = shader.uniforms.uGroundFade?.value as THREE.Vector2;
assert.ok(fade.x > 0 && fade.y > fade.x, "the detail never fades, so it aliases at the horizon");
// Stated in metres of view distance and converted here, so the fade means the
// same thing on every board. The board's own budget pose is 400 km out, where
// a 220 m tile is 0.6 device pixels — the fetch has to be worth nothing there.
assert.ok(
Math.abs(fade.y * world.metresPerUnit - 70_000) < 1,
`the detail survives to ${Math.round(fade.y * world.metresPerUnit)} m`,
);
});
// ---- The ground's detail map ------------------------------------------------
/*
* The same six properties `textureMaps.test.ts` holds the office's relief maps
* to, applied to the one the land carries: it is arithmetic rather than a
* canvas, so it exists identically here and in a browser and every one of them
* is checkable under `node --test`.
*/
test("the ground detail map is the same land on every reload, and is not flat", () => {
const a = groundDetailData(64);
const b = groundDetailData(64);
assert.deepEqual(a, b, "two boards would show two different Californias");
let peak = 0;
for (let i = 0; i < a.length; i += 4) {
peak = Math.max(peak, Math.abs((a[i] as number) - 128), Math.abs((a[i + 1] as number) - 128));
}
// `textureMaps.test.ts` puts it best: a flat normal map is a wasted texture
// unit. 8/128 is its threshold and this is the same one.
assert.ok(peak >= 8, `the ground's relief peaks at ${peak}/128, which is a flat card`);
});
test("every texel of the ground detail map decodes to a unit normal facing up", () => {
const size = 64;
const data = groundDetailData(size);
let worst = 0;
let lowest = 1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const n = normalAt(data, size, x, y);
worst = Math.max(worst, Math.abs(n.length() - 1));
lowest = Math.min(lowest, n.z);
}
}
assert.ok(worst < 0.02, `a texel decoded to a normal of length ${1 + worst}`);
// A finish on a heightfield, not an overhang. `z` is the surface's own axis
// and ground grain that leans past about 45° stops being grain and starts
// competing with the geography the mesh is carrying.
assert.ok(lowest > 0.6, `a texel leaned to z=${lowest}, which is a cliff, not a field`);
});
test("the ground detail map tiles: the wrap is no sharper than the interior", () => {
const size = 64;
const data = groundDetailData(size);
let interior = 0;
let seam = 0;
for (let y = 0; y < size; y++) {
for (let x = 1; x < size - 1; x++) {
interior = Math.max(interior, normalAt(data, size, x, y).distanceTo(normalAt(data, size, x + 1, y)));
}
seam = Math.max(seam, normalAt(data, size, size - 1, y).distanceTo(normalAt(data, size, 0, y)));
}
/*
* California is 1,063 km across and this tile is 220 m, so the map is laid
* down about five thousand times along one edge of the board. A derivative
* that does not wrap paints a grid over the entire state which is the exact
* failure `tileableNoise`'s four-way blend exists to prevent, reused here
* rather than reimplemented.
*/
assert.ok(
seam <= interior * 1.5,
`the wrap steps by ${seam} against an interior maximum of ${interior}`,
);
});
test("the ground detail map lights the same at 128 as at 256", () => {
/*
* `normalMapData` converts gradient to slope `x size` puts it per unit UV,
* `/ tileMetres` puts it per metre and that is the only reason a resolution
* can be chosen for memory rather than for looks. Drop the division and the
* map gets twice as steep every time the size doubles, silently.
*/
const coarse = groundDetailData(128);
const fine = groundDetailData(256);
const slope = (data: Uint8Array, size: number): number => {
let sum = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const n = normalAt(data, size, x, y);
sum += Math.hypot(n.x, n.y) / n.z;
}
}
return sum / (size * size);
};
const a = slope(coarse, 128);
const b = slope(fine, 256);
assert.ok(
Math.abs(a - b) / a < 0.12,
`mean slope is ${a} at 128 and ${b} at 256: the per-metre conversion is gone`,
);
// And it is a real slope rather than a rounding error: about 8.5° mean on the
// shipped field, deliberately far under the board's own 15x exaggeration so
// the grain does not out-shout the landforms.
assert.ok(a > 0.05 && a < 0.4, `the ground's mean slope is ${Math.atan(a) * 57.3}°`);
});
test("the ground grain runs in every direction rather than one", () => {
/*
* The swell's failure, restated for the land: a field whose slope is carried
* by one heading reads as corduroy, and on ground it reads as ploughing
* which would be a very confident claim to make about the entire state.
*/
const size = 128;
const data = groundDetailData(size);
const buckets = new Array(12).fill(0) as number[];
let total = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const n = normalAt(data, size, x, y);
const slope = Math.hypot(n.x, n.y);
if (slope < 1e-4) continue;
const angle = (Math.atan2(n.y, n.x) + Math.PI * 2) % Math.PI;
const at = Math.min(11, Math.floor((angle / Math.PI) * 12));
buckets[at] = (buckets[at] as number) + slope;
total += slope;
}
}
const dominant = Math.max(...buckets) / total;
assert.ok(dominant < 0.25, `${Math.round(dominant * 100)}% of the grain runs one way`);
});
+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 () => {