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
+250 -7
View File
@@ -17,7 +17,7 @@
*/
import * as THREE from "three";
import { airlinerGeometry } from "./aircraftGeometry.ts";
import { AIRLINER_LENGTH, airlinerGeometry } from "./aircraftGeometry.ts";
import type { Aircraft, City, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
@@ -618,16 +618,23 @@ export function aircraftDetail(
export interface FlightLayer {
group: THREE.Group;
/**
* The aircraft meshes currently in the sky, as a **live** array, each
* carrying `userData.aircraftId`.
* What a pointer can hit, as a **live** array, each entry carrying
* `userData.aircraftId`.
*
* Here rather than on the caller because only this layer knows which mesh is
* Here rather than on the caller because only this layer knows which object is
* which track: the map from id to mesh is private and the group's child order
* is an artefact of when each aircraft appeared. It is the same shape
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
* pick is resolved from the object that was hit, and something has to say
* what the object stands for.
*
* What a ray meets is a **sphere** around the aeroplane rather than its
* triangles — see `raycastGlyph` — because a seventeen-pixel glyph with
* two-pixel wings is a game of marksmanship rather than an interface, and
* under a fingertip it is not even that. The array itself holds one object per
* *drawn* aircraft, and only while it is drawn: a track kept through a dropped
* refresh stops being clickable at the moment it stops being visible.
*
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
* position is broadcast unencrypted to anybody with a receiver, so the card
* it opens is available to an anonymous visitor and the picking that reaches
@@ -741,6 +748,69 @@ const MAX_SPAN = 30;
*/
const JUMP_UNITS_PER_SECOND = 8;
/**
* How much of the screen an aeroplane is never allowed to fall below.
*
* A fraction of the viewport's **height**, and the single number that decides
* whether the live thing in this sky is visible at all.
*
* The arithmetic is unforgiving and it is worth writing down, because every
* board in this repo lost it. A glyph `AIRLINER_LENGTH` long at distance `d`
* covers `length / (2·d·tan(fov/2))` of the frame; at the 42° field of view
* `scene.ts` uses that is `length / (0.767·d)`. The camera sits at up to two
* board spans out, and a span is 230 units over San Francisco, 393 over the
* Southland and about 580 across California — so a 0.42-unit aeroplane on the
* default board came to **0.0005 of the frame, which is two thirds of one
* pixel**. That is not a small aeroplane, it is a dead pixel, and a person who
* has just arrived reads it as one: the feed was live, the callsigns were real,
* and the whole layer was indistinguishable from a smudge on the monitor.
*
* So the glyph is given a floor in *apparent* size and grows with distance to
* hold it. 0.016 is about seventeen pixels of aeroplane on a 1080-tall window
* and eleven on a phone — the size a flight-tracker icon is drawn at, which is
* the reference this is aiming for and not an accident. Below about 0.012 the
* wings stop resolving and it degenerates into the cross it used to be; much
* above 0.02 and a dozen of them start to look like a squadron flying formation
* over a state, which is the cartoon this is trying not to be.
*
* Two things this deliberately is *not*:
*
* - It is not a size in metres. `aircraftGeometry.ts` already argues that
* traffic here is a map symbol drawn in 3-D — 40 m over San Francisco, 164 m
* over the Southland, the same 0.42 units on both — and a floor in screen
* space is the same claim taken to its conclusion. What a person needs from
* an aeroplane on a map is its position and its heading, and neither of
* those is legible at half a pixel however truthful the span is.
* - It is not applied unconditionally. The scale is `max(1, …)`, so the
* authored geometry wins whenever the camera is close enough for it to be
* legible on its own — about 34 units, or a chapter's worth of standoff.
* Zooming in therefore *shrinks* an aeroplane back to the size the file that
* drew it intended, rather than leaving a state-sized airliner parked over a
* downtown.
*/
const GLYPH_MIN_SCREEN_FRACTION = 0.016;
/**
* The radius of the sphere a pointer actually has to hit, in glyph lengths.
*
* The aeroplane's *triangles* are not the hit target and must not be. Even at
* the floor above it is seventeen pixels of aeroplane, which is four pixels of
* fuselage and a pair of wings a couple of pixels thick — a raycast against
* those is a test of mouse marksmanship, and on a touch screen, where the tap
* lands under a fingertip eight millimetres across, it is not winnable at all.
* The owner's ask was that clicking a plane works for a stranger, and a target
* you have to aim at does not.
*
* One glyph length gives a sphere two aeroplane-lengths across — about
* thirty-four pixels at the legibility floor, which is a comfortable tap and is
* still small enough that the pointer has to be *on* the aeroplane rather than
* merely in the same part of the sky. Two aircraft whose spheres overlap still
* resolve to the nearer one: `Raycaster` sorts its hits by distance, so what
* wins is the aeroplane in front rather than the one that happened to be created
* first.
*/
const PICK_RADIUS_GLYPHS = 1;
/**
* Altitude, as colour.
*
@@ -842,6 +912,17 @@ interface TrailSample {
interface Track {
mesh: THREE.Mesh;
/**
* Whether the aeroplane is currently in `pickables`.
*
* Tracked rather than derived because the answer changes on a rule the array
* cannot see — a track that has gone stale is still held and still has a mesh,
* and must stop being clickable the moment it stops being drawn. `Raycaster`
* has not consulted `visible` since r119, so an aeroplane hidden by `tick` is
* still a hit until somebody takes it out of the list, and a card raised on an
* aeroplane nobody can see is a card about an aeroplane that is not there.
*/
picking: boolean;
/** Observations, oldest first. The last is where the aircraft is heading. */
samples: TrailSample[];
/** Seconds the current leg should take: the measured gap between the last two. */
@@ -911,6 +992,22 @@ export function createFlightLayer(world: World): FlightLayer {
const scratch = new THREE.Color();
/**
* The camera the layer was last drawn for, or `null` before the first frame.
*
* The layer has no camera of its own and CONTRACT §1 is why it must not
* acquire one: the camera belongs to the scene's `SceneKit`, and a layer that
* took a second reference would have to be told when the scene swapped. It is
* read off `onBeforeRender` instead, which is handed the camera actually being
* rendered for — so an office looking at the same layer through a different
* camera would size the glyphs for *that* view without anything having to be
* wired up.
*
* Before the first frame there is no answer and the glyphs stay at their
* authored size, which is the size they were before any of this existed.
*/
let viewer: THREE.PerspectiveCamera | null = null;
/**
* One material per altitude band, built on demand.
*
@@ -966,7 +1063,18 @@ export function createFlightLayer(world: World): FlightLayer {
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
// that would rather drive the layer itself can call it and nothing here
// double-counts.
trailLine.onBeforeRender = () => tick();
// It also carries the camera in, which is the only reason this layer knows how
// far away it is being looked at from — see `viewer`. Like every other write
// `tick` makes, the scale it computes here lands on the *next* frame: world
// matrices were resolved before any `onBeforeRender` ran. That is how this
// layer's position and attitude have always worked, it is one frame at 60 Hz,
// and the alternative is a camera reference this file is not entitled to hold.
trailLine.onBeforeRender = (_renderer, _scene, camera) => {
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
viewer = camera as THREE.PerspectiveCamera;
}
tick();
};
group.add(trailLine);
// ---- Observations -------------------------------------------------------
@@ -990,10 +1098,14 @@ export function createFlightLayer(world: World): FlightLayer {
// The id, on the object, so a raycast hit resolves to an aeroplane
// without this layer having to expose its private track table.
mesh.userData.aircraftId = a.id;
// The pointer aims at the aeroplane and hits a sphere around it. See
// `raycastGlyph`.
mesh.raycast = raycastGlyph;
group.add(mesh);
pickables.push(mesh);
track = {
mesh,
picking: true,
samples: [],
span: MIN_SPAN,
pitch: 0,
@@ -1112,15 +1224,38 @@ export function createFlightLayer(world: World): FlightLayer {
}
if (track.missingSince === 0) track.missingSince = now;
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
setPickable(track, false);
// The geometry and the material are shared by every aircraft in the sky
// and belong to the layer, which frees them once in `dispose`.
group.remove(track.mesh);
const at = pickables.indexOf(track.mesh);
if (at >= 0) pickables.splice(at, 1);
tracks.delete(id);
}
tick();
}
/**
* Put a track into the pick list, or take it out.
*
* Idempotent, and the flag is what makes it cheap: a hundred aircraft holding
* station would otherwise walk the array with `indexOf` on every frame to
* discover that nothing had changed.
*
* `pickables` is spliced rather than rebuilt because `scene.ts` hands the
* array itself to the picker as a live target list — see `FlightLayer` — so
* the identity of the array has to survive.
*/
function setPickable(track: Track, on: boolean) {
if (track.picking === on) return;
track.picking = on;
if (on) {
pickables.push(track.mesh);
return;
}
const at = pickables.indexOf(track.mesh);
if (at >= 0) pickables.splice(at, 1);
}
/**
* Whether a feed has handed back the same position it did last time.
*
@@ -1176,12 +1311,34 @@ export function createFlightLayer(world: World): FlightLayer {
*/
track.stale = track.missingSince !== 0 && now - to.at > track.span;
track.mesh.visible = !track.stale;
// An aeroplane that is no longer drawn must no longer be clickable, and
// saying so is not optional: `Raycaster` does not consult `visible`, so a
// hidden track left in the list goes on opening its card from a patch of
// empty sky for the rest of the grace period.
setPickable(track, !track.stale);
if (track.stale) continue;
track.head.lerpVectors(from.position, to.position, alpha);
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
track.mesh.position.copy(track.head);
/**
* Big enough to be an aeroplane from wherever this is being watched.
*
* Per aircraft rather than once for the layer, because the board is deep:
* on the California corridor an arrival over Los Angeles and one over the
* Bay are hundreds of units apart along the view axis, and a single scale
* taken from the camera's orbit distance would leave the far one half the
* size of the near one — which reads as depth on a photograph and as an
* inconsistency on a map, where two aeroplanes at the same altitude are
* the same aeroplane.
*
* Uniform, so nothing about the shape changes: this is the glyph the
* geometry file drew, held at a legible size, and not a stretched one.
*/
if (viewer !== null) {
track.mesh.scale.setScalar(glyphScale(viewer.position.distanceTo(track.head), viewer.fov));
}
// A heading of 0 is north, and north is -z, so an aircraft whose nose is
// modelled along +z has to be turned all the way round before the compass
// and the scene agree. The previous mapping was a bare negation of the
@@ -1351,6 +1508,92 @@ export function createFlightLayer(world: World): FlightLayer {
/**
* How much to enlarge an aeroplane so that it is still an aeroplane from here.
*
* Pure arithmetic on two numbers the camera already knows, extracted so it can
* be tested without a WebGL context — the defect it exists to fix is a *visual*
* one and can only be confirmed with a picture, but the ratio it turns on is
* exactly the kind of thing that regresses silently under a refactor.
*
* `2·distance·tan(fov/2)` is the world-space height of the frustum at that
* distance — the ruler the frame is measured with — so the glyph's share of the
* screen is its length over that. Solving for the length that hits
* `GLYPH_MIN_SCREEN_FRACTION` and dividing by the length the geometry was
* authored at gives the scale, and `Math.max(1, …)` is the floor rather than a
* fit: an aeroplane close enough to read at its authored size keeps it.
*
* Degenerate inputs return 1 rather than throwing. A camera at zero distance
* from an aircraft is the chase view, a camera with no field of view is a
* caller in the middle of setting one up, and neither is a reason for the sky
* to disappear.
*/
export function glyphScale(distance: number, fovDegrees: number): number {
if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1;
if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1;
const frustumHeight = 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
return Math.max(1, (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH);
}
/**
* Scratch for `raycastGlyph`. Module-level and reused: a raycast runs once a
* frame against every aeroplane in the sky, and four hundred of them allocating
* a `Sphere` and a `Vector3` apiece is a garbage-collection pause a pointer can
* feel.
*/
const pickSphere = new THREE.Sphere();
const pickPoint = new THREE.Vector3();
const pickScale = new THREE.Vector3();
/**
* What a pointer hits when it aims at an aeroplane: a sphere, not the aeroplane.
*
* Assigned onto each aircraft mesh in place of `Mesh.raycast`, which is the
* extension point three.js publishes for exactly this — `Object3D.raycast` is
* documented as the method a subclass or an instance supplies to say how it
* meets a ray, and `Points` and `Line` already answer it with a threshold
* instead of with geometry for the same reason this does.
*
* The reason is `PICK_RADIUS_GLYPHS`: at any camera distance where this layer is
* worth looking at, the aeroplane's own triangles are a few pixels of fuselage
* and a wing two pixels thick, and a pointer test against those is a game of
* marksmanship rather than an interface. A sphere at the glyph's own scale is
* the target a person thinks they are aiming at.
*
* Doing it here rather than with a second invisible object in the scene is worth
* the unusual assignment. A proxy mesh would be another `Object3D` per aircraft
* — four hundred more nodes to walk and four hundred more world matrices to
* compose every frame, for something that is never drawn — and it would have to
* be kept in step with the aeroplane's position and scale by hand. This costs
* one sphere test, allocates nothing, and cannot drift out of step because there
* is nothing to keep in step with.
*
* The scale is read off `matrixWorld` rather than off `this.scale` on purpose:
* the layer's group is at the identity today, and a raycast that silently starts
* lying if somebody ever moves or scales it is precisely the class of bug this
* file's comments keep recording.
*/
function raycastGlyph(
this: THREE.Mesh,
raycaster: THREE.Raycaster,
intersects: THREE.Intersection[],
): void {
pickSphere.center.setFromMatrixPosition(this.matrixWorld);
pickScale.setFromMatrixScale(this.matrixWorld);
pickSphere.radius = PICK_RADIUS_GLYPHS * AIRLINER_LENGTH * pickScale.x;
// `intersectSphere` answers with the near hit, or with the far one when the
// ray starts inside — so a camera flying through the sphere still picks the
// aeroplane it is inside rather than nothing at all.
if (raycaster.ray.intersectSphere(pickSphere, pickPoint) === null) return;
const distance = raycaster.ray.origin.distanceTo(pickPoint);
// The near/far clamp is the caller's contract and `Mesh.raycast` honours it;
// an aeroplane behind the camera must not be pickable through the back of it.
if (distance < raycaster.near || distance > raycaster.far) return;
// Cloned rather than shared: the caller keeps the intersection, and every hit
// in a frame would otherwise be handed the same point object.
intersects.push({ distance, point: pickPoint.clone(), object: this });
}
/**
* The climb angle of a leg, from the real numbers rather than the scene's.
*