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
+225 -33
View File
@@ -51,6 +51,20 @@ const TOUCH_ROTATE_SCALE = 0.7;
const TAP_SLOP = 12;
/** How long a finger may rest and still be a tap, in ms. */
const TAP_MS = 400;
/**
* How long after a touch a `pointerType: "mouse"` event is assumed to be the
* browser's compatibility replay of that touch rather than a real mouse.
*
* Chrome finishes a tap by re-dispatching it as mouse events for pages written
* before pointer events existed, and the tail of that replay is a
* `pointerout`/`pointerleave` pair whose `pointerType` is `"mouse"`. Measured on
* the deployed build it lands about 32 ms after the tap; 800 ms is far enough
* out to cover a loaded phone and still shorter than any deliberate reach for a
* trackpad. Being wrong in this direction costs a hybrid laptop one stale card
* until the mouse moves again; being wrong in the other direction means no
* detail card can ever be read on a phone at all.
*/
const COMPAT_MOUSE_MS = 800;
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose {
@@ -319,9 +333,8 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
sun.target.updateMatrixWorld();
const sunDirection = new THREE.Vector3();
let sky: THREE.Texture | null = null;
let skyTop = -1;
let skyHorizon = -1;
const dome = makeSkyDome();
let domeAttached = false;
function applyLighting(state: LightingState) {
const [dx, dy, dz] = state.sun.direction;
@@ -358,14 +371,34 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
ambient.color.setHex(state.ambient.color);
ambient.intensity = state.ambient.intensity;
// A null sky leaves `scene.background` alone entirely, which is what an
// office wants: it has walls, and whatever is behind them is not sky.
if (state.sky && (state.sky.top !== skyTop || state.sky.horizon !== skyHorizon)) {
sky?.dispose();
sky = makeSkyTexture(state.sky.top, state.sky.horizon);
skyTop = state.sky.top;
skyHorizon = state.sky.horizon;
scene.background = sky;
// A null sky leaves the background alone entirely, which is what an office
// wants: it has walls, and whatever is behind them is not sky.
if (state.sky) {
if (!domeAttached) {
scene.add(dome);
domeAttached = true;
}
const u = dome.material.uniforms;
(u.uTop!.value as THREE.Color).setHex(state.sky.top, THREE.LinearSRGBColorSpace);
(u.uHorizon!.value as THREE.Color).setHex(state.sky.horizon, THREE.LinearSRGBColorSpace);
(u.uSunColor!.value as THREE.Color).setHex(state.sun.color, THREE.LinearSRGBColorSpace);
(u.uSunDirection!.value as THREE.Vector3).set(dx, dy, dz).normalize();
/**
* The glow follows the *key*, not the direction, and that is what keeps
* it off the night sky.
*
* `atmosphere.ts` floors the sun's direction at `shadowFloorDeg` — seven
* degrees — so that the shadow camera stays usable, which means the
* vector in a `LightingState` never actually sets. Taken literally it
* would park a sunrise on the horizon all night, at the azimuth the sun
* went down at. The intensity is the honest signal: it collapses through
* dusk and what is left at 2 a.m. is the moon's, so scaling by it gives a
* glow that fades out with the daylight it belongs to.
*/
u.uSunGlow!.value = Math.min(1, Math.max(0, state.sun.intensity / SUN_GLOW_FULL_INTENSITY));
} else if (domeAttached) {
scene.remove(dome);
domeAttached = false;
}
if (!state.fog) {
@@ -481,10 +514,13 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
let tapX = 0;
let tapY = 0;
let tapAt = 0;
/** When the glass was last touched, in `event.timeStamp` units. */
let lastTouchAt = Number.NEGATIVE_INFINITY;
function onPointerDown(event: PointerEvent) {
applyPointerProfile(event.pointerType);
if (event.pointerType !== "touch") return;
lastTouchAt = event.timeStamp;
resetPick();
tapPointer = tapPointer === -1 ? event.pointerId : -2;
tapX = event.clientX;
@@ -495,6 +531,7 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
function onPointerUp(event: PointerEvent) {
if (event.pointerType !== "touch") return;
lastTouchAt = event.timeStamp;
const wasTap =
tapPointer === event.pointerId &&
event.timeStamp - tapAt <= TAP_MS &&
@@ -514,11 +551,28 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
wasPicking?.onChange(null);
}
// Not for touch. A finger lifting fires `pointerleave` immediately after
// `pointerup`, so honouring it here would wipe the pick a tap had just made,
// in the same frame, every time.
/*
* Not for touch, and not for the compatibility mouse either.
*
* A finger lifting fires `pointerleave` immediately after `pointerup`, so
* honouring that would wipe the pick a tap had just made, in the same frame,
* every time. That much was anticipated. What was not is the *second* leave:
* Chrome replays a finished tap as legacy mouse events, and the recorded tail
* of a real tap on the canvas is
*
* pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
* mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
*
* — so the last event of a tap is a `pointerleave` claiming to be a mouse,
* about 32 ms later. Filtering on `pointerType` alone let that one through,
* which called `resetPick()` and fired `onChange(null)`: the card was written
* to the page and blanked before a thumb had left the glass, and no detail
* card of any kind could be read on a phone. It is a clock that tells these
* apart, not a type.
*/
function onPointerLeave(event: PointerEvent) {
if (event.pointerType === "touch") return;
if (event.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
resetPick();
}
dom.addEventListener("pointerleave", onPointerLeave);
@@ -606,29 +660,167 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
sun.dispose();
hemisphere.dispose();
ambient.dispose();
sky?.dispose();
if (scene.background === sky) scene.background = null;
if (domeAttached) scene.remove(dome);
domeAttached = false;
dome.geometry.dispose();
dome.material.dispose();
},
};
}
/**
* A two-pixel-wide vertical gradient. Cheap, and a `Scene.background` texture
* is stretched to fill regardless, so the width buys nothing.
* The sun intensity at which the sky's glow around it is at full strength.
*
* `atmosphere.ts`'s day stops sit at 2.6, so this is reached a little before
* noon and held; everything below it — the whole of dusk and all of the
* night — scales down from there. See the note at the call site for why the
* intensity and not the direction is what the glow is allowed to read.
*/
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
const canvas = document.createElement("canvas");
canvas.width = 2;
canvas.height = 256;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas context unavailable");
const grad = ctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 2, 256);
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.LinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
const SUN_GLOW_FULL_INTENSITY = 1.9;
/**
* The sky, as a mesh in the world rather than a gradient on the screen.
*
* ## What was wrong with the gradient
*
* `Scene.background` with a plain 2D texture is drawn by three onto a
* screen-filling quad: the top of the *viewport* is `skyTop` and the bottom of
* the viewport is `skyHorizon`, whatever the camera happens to be doing. That
* is not a sky, it is a wash, and on a map board — where the camera is almost
* always tilted down and the true horizon sits high in the frame — it fails in
* a way you can name from a screenshot:
*
* - At dusk the warm band appeared along the **bottom** of the picture, under
* the board, while the actual horizon at the top of the frame stayed the
* deep blue of the zenith. The sunset was rendered upside down.
* - The world's far edge is faded out by `THREE.Fog` into `fog.color`, which
* `atmosphere.ts` makes the horizon colour exactly so the two meet. They
* could not meet, because the horizon colour was not at the horizon, so
* there was a visible seam wherever the ground ran out — and
* `interiors/daylight.ts` documents pinning its own horizon stop to the fog
* colour to hide it, which is the symptom stated in the source.
*
* ## Why a dome and not an equirectangular background
*
* three renders `Scene.background` in world space only for a `CubeTexture` or a
* PMREM (`CubeUVReflectionMapping`); a 2D texture tagged
* `EquirectangularReflectionMapping` still takes the screen-space plane path.
* Getting a world-oriented sky out of the background slot therefore means
* running a `PMREMGenerator` over a gradient on every colour change, which is
* the sharpest thing in the frame put through a blur chain built to destroy
* detail. A dome is one draw call, a thousand triangles, and it can also do the
* two things a gradient texture cannot: put the glow **around the sun** rather
* than uniformly around the compass, and keep the horizon band tight.
*
* ## How it sits in the scene
*
* `depthTest: false` with `renderOrder` far negative, which is exactly how
* three's own background box works: it is drawn first, writes no depth, and
* every other object in the scene paints over it. That makes the radius
* irrelevant — nothing is ever compared against it — so the sphere is a unit
* one, recentred on the camera in `onBeforeRender`, and can never be clipped by
* a near or far plane however large the board is.
*
* ## Colour, and why nothing is converted
*
* The components are written straight out with no tone mapping and no output
* transform, which reproduces exactly what the old texture path did: an
* sRGB-tagged background is decoded on sample and re-encoded on write, and
* three sets `toneMapped = false` for it. So `LightingState.sky` is displayed
* as the number the atmosphere table wrote, which is what
* `render/toneMapping.test.ts` asserts about those columns. Hence
* `setHex(hex, LinearSRGBColorSpace)` at the call site: it loads the byte
* values without a colour-space conversion, because the shader is not
* performing one either.
*/
function makeSkyDome(): THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial> {
const material = new THREE.ShaderMaterial({
uniforms: {
uTop: { value: new THREE.Color(0x8fb8d8) },
uHorizon: { value: new THREE.Color(0xd9e6ee) },
uSunDirection: { value: new THREE.Vector3(0, 1, 0) },
uSunColor: { value: new THREE.Color(0xffffff) },
uSunGlow: { value: 0 },
},
vertexShader: `
varying vec3 vDirection;
void main() {
// The dome is only ever translated, never rotated or scaled, so a unit
// sphere's own vertex position is already the world direction it stands for.
vDirection = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
fragmentShader: `
uniform vec3 uTop;
uniform vec3 uHorizon;
uniform vec3 uSunDirection;
uniform vec3 uSunColor;
uniform float uSunGlow;
varying vec3 vDirection;
void main() {
vec3 direction = normalize( vDirection );
float height = direction.y;
/*
* The exponent is what makes this read as air rather than as a ramp. A
* linear zenith-to-horizon blend spends half its colour change in the top
* forty-five degrees of sky, where there is nothing to see; a real sky does
* almost all of it in the first fifteen degrees above the horizon, which is
* also the only part of it a map board ever has in frame.
*/
vec3 color = mix( uHorizon, uTop, pow( clamp( height, 0.0, 1.0 ), 0.42 ) );
// Below the horizon there is no sky. The ocean covers this in the city and
// the ground plane covers it in a sited office, so what it has to be is
// simply *not brighter* than the horizon it sits under.
color = mix( color, uHorizon * 0.82, clamp( - height * 5.0, 0.0, 1.0 ) );
/*
* Two glows, one warm quarter of sky.
*
* The first is round and centred on the sun: the aureole, tight enough to
* say where the sun is without drawing a disc — a hard white disc at map
* scale reads as a rendering artefact, and the environment map already
* carries a real sun lobe for anything reflective to catch.
*
* The second hugs the horizon and falls off with the *azimuth* to the sun,
* which is the half of a sunset the atmosphere's colour table cannot
* express: its keyframes are one horizon colour for the whole compass, so
* without this the sky behind the viewer is as orange as the sky the sun is
* setting into.
*/
float toSun = max( dot( direction, uSunDirection ), 0.0 );
float aureole = pow( toSun, 5.0 ) * 0.22 + pow( toSun, 90.0 ) * 0.30;
vec2 flat0 = normalize( vec2( direction.x, direction.z ) + 1e-5 );
vec2 flatSun = normalize( vec2( uSunDirection.x, uSunDirection.z ) + 1e-5 );
float azimuth = max( dot( flat0, flatSun ), 0.0 );
float band = exp( - abs( height ) * 6.0 ) * pow( azimuth, 2.5 ) * 0.18;
color += uSunColor * uSunGlow * ( aureole + band );
gl_FragColor = vec4( color, 1.0 );
}
`,
side: THREE.BackSide,
depthTest: false,
depthWrite: false,
fog: false,
toneMapped: false,
});
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), material);
mesh.name = "sky";
// First in the opaque list, before anything that could occlude it.
mesh.renderOrder = -1000;
// The pose is written below, after culling would have run, so culling must
// not be allowed to run: the bounding sphere three would test is the one at
// the origin, which is nowhere near where this is drawn.
mesh.frustumCulled = false;
mesh.matrixAutoUpdate = false;
mesh.onBeforeRender = (_renderer, _scene, camera) => {
mesh.matrixWorld.copyPosition(camera.matrixWorld);
};
return mesh;
}