1
0

feat: give the boards a horizon, a sea that reflects, and a state worth flying over

The wide shot, which is what an anonymous visitor actually lands on.

**The sea was `MeshLambertMaterial`** — a material with no specular term at all,
by construction — on a board where water is half the frame. It is now a
low-roughness dielectric that reads `scene.environment`, with a runtime-generated
tiling swell normal map sampled twice per fragment at two scales and two
headings, so the sun breaks into a moving glitter path instead of a mirror point.
An `onBeforeCompile` patch takes the body colour toward the deep value looking
straight down and leaves it to the reflection at grazing, and walks roughness up
past 1.6 board spans so the far water cannot shimmer.

The swell spectrum is 1/k^2 and not 1/k because the first attempt was
photographed: at 1/k every component carries the same slope, the shortest wave
wins, and the sea renders as hard diagonal corduroy. A test holds it now.

**The board no longer ends in a diamond.** The sea plane went from 1.8 board
spans to 18, past the fog's far plane from anywhere the orbit reaches, and the
sky is a world-space dome rather than a screen-space gradient. That gradient was
wrong in a way dusk made obvious: the sunset band was painted along the *bottom*
of the picture, under the board, while the true horizon at the top of frame stayed
zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the
seam was a symptom of it.

**Terrain casts shadows.** Left off before because double-sided terrain against a
~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot
at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is
a stride-2 decimation appended to the same index buffer and swapped in by
`onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of
the depth cost, and indistinguishable from the full-resolution caster in a
side-by-side crop. Stride 1 was measured at +65,566 triangles and would have
missed the budget by ~47,000, so it was not shipped.

**California reads as California.** It was a beige kite: the eastern edge one
ruled line for five degrees of latitude, the south closing in a diagonal V, the
whole south-east a featureless tan wedge. Now the coast runs to the Mexican
border with San Diego on it, the eastern edge follows the Colorado and the Nevada
diagonal, and the south-east is the Basin and Range — forty parallel desert ridges
throwing shadows east, Death Valley as a white pan between the Panamints and the
Black Mountains, the Salton Sea the one cool value for two hundred kilometres.
The opening pose is retuned to the bigger board; the old 452/392 stand-off left a
slab of empty ocean where the state should be.

**The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to
find one at all — indistinguishable from a dead pixel, on a board whose entire
claim is that the sky is live. They are airliners now, with planform and trail,
and clicking one raises its card for a signed-out visitor.

**The Model X is off the wall.** It stood at floor level outside a studio 188 m up
a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen
from `site.elevation`, which the pack already carries — not from an office id,
which is the bug class this repo already hit once when a door marker gated on
`id === "sf"` and would have pinned the Los Angeles building to San Francisco.

Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground
on the Bay Area and SoCal boards. And one test asserted an exact source line for
the water material, so the better multi-line implementation failed it — it now
asserts the property (dielectric, metalness 0, low roughness) rather than the
author's first guess at formatting.

Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000
triangles — 2.8% of triangle headroom left, which is the number the next person
should check first. No budget was raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 23:43:14 -07:00
parent 655848746d
commit 8fb85cd2e5
30 changed files with 4781 additions and 257 deletions
+262
View File
@@ -0,0 +1,262 @@
/**
* The two things `engine/terrain.ts` now does that a picture found and a test
* can keep: the sea has a surface, and the relief casts a shadow it can afford.
*
* Everything in this round was invisible to the type checker and to every
* existing test. What a test *can* hold is the handful of facts underneath the
* picture — that the sea reaches past the fog rather than stopping in a hard
* diamond, that its swell map tiles and is not one hard diagonal rib, that the
* terrain casts from a decimated copy of itself rather than from the mesh you
* are looking at. Each of those is a number, each was got wrong at least once
* on the way here, and each would go back to being wrong silently.
*
* The board below is synthetic and tiny — twenty cells a side — because none of
* these facts are about California. A real pack would make the file slow and
* would couple a render test to a city's coastline.
*/
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 type { City } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";
const BOARD: City = {
id: "test-board",
name: "Test Board",
center: { lat: 37, lng: -122 },
bounds: { minLat: 36.5, maxLat: 37.5, minLng: -122.5, maxLng: -121.5 },
latScale: 100,
verticalExaggeration: 2,
cellLat: 0.05,
cellLng: 0.05,
coastFalloff: 0.02,
// One square island with a hill on it: enough land for a terrain grid, and
// enough water around it for the sea to be the thing under everything.
landmasses: [
[
[36.7, -122.3],
[37.3, -122.3],
[37.3, -121.7],
[36.7, -121.7],
],
],
parks: [],
inlandWater: [],
hills: [{ name: "Test Hill", lat: 37, lng: -122, elevation: 400, radius: 0.15 }],
districts: [],
landmarks: [],
bridges: [],
roads: [],
chapters: [],
};
async function board(): Promise<World> {
const world = new World(BOARD);
assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
return world;
}
function boardSpan(world: World): number {
const [westX, northZ] = world.project(BOARD.bounds.maxLat, BOARD.bounds.minLng);
const [eastX, southZ] = world.project(BOARD.bounds.minLat, BOARD.bounds.maxLng);
return Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
}
// ---- The swell map ---------------------------------------------------------
/** Decode one texel back to the tangent-space normal it stands for. */
function normalAt(data: Uint8Array, size: number, x: number, y: number): THREE.Vector3 {
const i = (((y + size) % size) * size + ((x + size) % size)) * 4;
return new THREE.Vector3(
((data[i] as number) / 255) * 2 - 1,
((data[i + 1] as number) / 255) * 2 - 1,
((data[i + 2] as number) / 255) * 2 - 1,
);
}
test("the swell map is the same sea on every reload", () => {
const a = swellNormalData(64);
const b = swellNormalData(64);
assert.deepEqual(a, b, "two boards would show two different oceans");
// And it is a sea rather than a flat card: `Math.random` removed would pass
// the equality above just as happily as a seeded field does.
const flat = [...a].every((_, i) => i % 4 === 2 || i % 4 === 3 || a[i] === 128);
assert.equal(flat, false, "the swell map has no swell in it");
});
test("every texel of the swell map decodes to a unit normal facing up", () => {
const size = 64;
const data = swellNormalData(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);
}
}
// One byte of quantisation is 1/255 per channel, so a little over that is the
// whole tolerance a correctly encoded map needs.
assert.ok(worst < 0.02, `a texel decoded to a normal of length ${1 + worst}`);
// A tangent-space normal map for a surface, not for an overhang: z is the
// surface's own axis and nothing may lean past horizontal.
assert.ok(lowest > 0.5, `a texel leaned to z=${lowest}, which is a cliff, not a wave`);
});
test("the swell map tiles: the wrap is no sharper than the interior", () => {
const size = 64;
const data = swellNormalData(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)));
}
// The sea is drawn as hundreds of copies of this map side by side, so a
// derivative that does not wrap paints a visible grid across the whole ocean.
assert.ok(
seam <= interior * 1.5,
`the wrap steps by ${seam} against an interior maximum of ${interior}`,
);
});
test("the swell runs in every direction rather than one", () => {
/*
* The failure this holds is a photographed one. With amplitude falling as
* 1/k every component of the sum carries the *same* slope — slope is
* amplitude times wave number — the shortest wave wins on sheer count of
* edges, and the ocean renders as one hard diagonal rib that reads as
* corduroy rather than water. At 1/k² the slope falls as 1/k and the eight
* headings stay spread.
*/
const size = 64;
const data = swellNormalData(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;
// Folded to a half turn: a crest and its trough are one direction.
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 swell runs one way`);
});
// ---- The sea ---------------------------------------------------------------
test("the sea reaches far enough out to fade instead of ending", () => {
return board().then((world) => {
const sea = createWater(world).children.find((child) => child.name === "sea") as
| THREE.Mesh<THREE.PlaneGeometry>
| undefined;
assert.ok(sea, "there is no sea in the water group");
/*
* Where the number comes from: `main.ts` gives the atmosphere a clear-day
* fog closing at 3.9 board spans, and `scene.ts` lets the orbit retreat to
* 2.0 spans from the middle of the board. So the furthest a fully-fogged
* horizon can be from the origin is about 5.9 spans, and a sea that stops
* anywhere nearer than that shows the viewer its own edge — which is
* exactly what the 1.8-span plane this replaced did, as a hard diamond with
* the state floating on it.
*/
assert.ok(
sea.geometry.parameters.width / boardSpan(world) >= 12,
`the sea is only ${sea.geometry.parameters.width / boardSpan(world)} board spans across`,
);
});
});
test("the sea has a specular response, which a Lambert card cannot", () => {
return board().then((world) => {
const sea = createWater(world).children.find((child) => child.name === "sea") as THREE.Mesh;
const material = sea.material as THREE.MeshStandardMaterial;
// `MeshLambertMaterial` has no specular term at all, by construction, which
// is the whole reason the Pacific used to render as one flat blue value at
// every hour and from every angle.
assert.ok(material.isMeshStandardMaterial, "the sea went back to being unlit paint");
assert.equal(material.metalness, 0, "water is a dielectric");
assert.ok(material.roughness > 0 && material.roughness < 0.5, "the sun would have no path");
assert.ok(material.normalMap, "a mirror-flat plane has a specular point, not a glitter path");
assert.ok(material.normalScale.x > 0, "the swell is switched off");
});
});
// ---- The terrain's shadow --------------------------------------------------
test("the relief casts, and from a decimated copy of itself", async () => {
const world = await board();
const terrain = createTerrain(world);
assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
assert.equal(terrain.receiveShadow, true);
const material = terrain.material as THREE.MeshLambertMaterial;
// 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.
assert.equal(material.shadowSide, THREE.BackSide);
const index = terrain.geometry.getIndex();
assert.ok(index, "the terrain lost its index");
const seen = terrain.geometry.drawRange.count;
const cast = index.count - seen;
assert.ok(seen > 0 && cast > 0, `nothing to draw: ${seen} seen, ${cast} cast`);
assert.equal(terrain.geometry.drawRange.start, 0, "the colour pass would skip the near edge");
/*
* A quarter, give or take the edge cells a stride of 2 cannot cover. The
* ratio is the whole reason this exists: submitting the visible surface to
* the depth pass draws every triangle on the board a second time, and
* `renderer.info` counts it — 65,566 triangles on California against about
* 28,000 of headroom in the board's budget.
*/
assert.ok(cast / seen > 0.1 && cast / seen < 0.45, `the caster is ${cast / seen} of the surface`);
});
test("the shadow draw range swings onto the caster and back", async () => {
const world = await board();
const terrain = createTerrain(world);
const index = terrain.geometry.getIndex();
assert.ok(index);
const seen = terrain.geometry.drawRange.count;
// three fires these either side of the one `renderBufferDirect` the depth
// pass makes for this mesh, and the depth pass runs before the colour pass —
// so this pair is the whole mechanism that keeps the caster out of the
// picture without keeping it out of the shadow map.
const nothing = null as never;
terrain.onBeforeShadow(
nothing, nothing, nothing, nothing,
terrain.geometry, terrain.material as THREE.Material, nothing,
);
assert.equal(terrain.geometry.drawRange.start, seen, "the depth pass is still drawing the surface");
assert.equal(terrain.geometry.drawRange.count, index.count - seen);
terrain.onAfterShadow(
nothing, nothing, nothing, nothing,
terrain.geometry, terrain.material as THREE.Material, nothing,
);
assert.equal(terrain.geometry.drawRange.start, 0, "the caster leaked into the colour pass");
assert.equal(terrain.geometry.drawRange.count, seen);
});
test("the shore plate receives and does not cast", async () => {
const world = await board();
const plate = createShorePlates(world);
assert.equal(plate.receiveShadow, true);
// It is the landmass polygon lying flat six hundredths of a unit above the
// sea. A caster that thin has no volume: at a low sun it would throw the
// whole coastline out across the water as a hard slab.
assert.equal(plate.castShadow, false);
});
+3 -2
View File
@@ -203,8 +203,9 @@ test("the sun brightens monotonically as it rises", () => {
});
test("the sky colours were left alone, because they are not tone mapped", () => {
// Three marks the background mesh `toneMapped = false` for an sRGB-transfer
// texture and mixes fog after the tone map from an already-encoded uniform.
// The sky is a world-space `ShaderMaterial` dome marked `toneMapped = false`,
// so it writes its components straight out, and fog mixes after the tone map
// from an already-encoded uniform.
// So the one thing the re-tune must NOT have touched is the sky, and the noon
// stop still reproduces the city's own declared daylight colours.
const noon = rig(25);