1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/scenekit.ts
T
karti 2ff11a385e fix: the sky meets the sea, and the places rail admits it scrolls
Two defects a photograph found and no gate could.

**The sky seam.** A phone held upright drew a hard horizontal step across the
top 10.5% of the frame — a flat duller slab above a warm gradient, meeting in
one pixel. Painting the sky dome red proved the slab was the dome itself and
everything below the line was sea; so this was never missing geometry, it was
two halves of one join disagreeing about a colour.

The sea is a flat plane, so however wide it is made the camera's own far plane
cuts it at an elevation *below* the horizontal — about 12 degrees in a plan
view of the state. atmosphere.ts has fogged it to exactly the horizon colour by
then, which is the entire mechanism by which water dissolves into sky instead
of ending in an edge. The dome at that same angle has to be the same number,
and it was not: the below-horizon darkening ramped to its full 0.82 over the
first 11.5 degrees. The sea arrived at the horizon colour and met a sky already
a fifth darker. Measured 155,107,99 against 127,88,81 across one pixel.

Holding the horizon's own colour for the first 30 degrees costs nothing the
darkening was for — a floor or an ocean covers that band whenever there is one,
and where there is not, matching is the whole point. Measured after: the worst
single-row step anywhere in the sky falls from 15/255 to 8, and the 8 is water
texture rather than a line.

render/skyHorizonSeam.test.ts parses the shader text, which is a weaker
instrument than a frame and the strongest one available with no GL context. It
asserts the one number the picture proved wrong — how far down the dome still
paints the horizon colour — and it caught a real off-by-a-hair while being
written: 0.42 is sin(24.8 deg), just inside the 25 deg the sea can reach. It
also guards the backtick-in-a-template-literal trap that has now broken this
same shader three times.

**The places rail.** Its cap is min(22rem, 42vh) and rows are variable height,
so it always landed mid-row, and a row sliced through its own text reads as a
button that failed to draw rather than as more list. markOverflow writes
data-overflow and the stylesheet fades whichever end has list behind it.

Every measurement in it is guarded, because ui/mount.ts is deliberately tested
with no DOM at all: no geometry must mean no attribute, not data-overflow=NaN.
A pixel of slack at each end, because fractional layout reports a fully-scrolled
list a fraction short and a fade that never switches off dims a row over
nothing. stylesheet.test.ts asserts the two files still speak the same four
words — nothing else connects them and the failure is silent.

1,664 + 295 tests, all gates, budget 779,789 tri / 150 draws / p95 16.7 ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 06:42:10 -07:00

999 lines
44 KiB
TypeScript

/**
* The per-scene half of the renderer: camera, controls, the light rig, camera
* flights and picking.
*
* Everything here is per-scene rather than per-stage, because the city and an
* office want different answers to all of it — different near/far planes,
* different orbit limits, a fixed interior rig against a driven daylight one.
* `Stage` keeps the renderer and the loop; a `SceneKit` is what a `StageScene`
* is built out of. See CONTRACT.md §1.
*
* The kit *applies* a `LightingState`; it never works one out. Whoever owns
* the sun — `Atmosphere` for a city, a fixed constant for an office — computes
* the state and hands it over, and nothing writes back. That is the one
* direction CONTRACT.md §4 asks for.
*
* It is also where a finger meets the map. `OrbitControls` gives one gesture
* vocabulary to both a mouse and a thumb, and the two want different answers —
* so the kit swaps a small input profile on every `pointerdown` according to
* `event.pointerType`. See `applyPointerProfile`. Nothing about the desktop
* changes; the touch values are only ever installed by a touch.
*
* The one thing that is *not* here is `touch-action`. `OrbitControls.connect()`
* sets `touchAction = "none"` on the element it is handed, and `index.html`
* also sets it on `#scene` in CSS. That duplication is deliberate: the CSS rule
* is what covers the second or two between first paint and this module
* existing, and a drag on the canvas in that window would otherwise scroll and
* rubber-band the page instead.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts";
/**
* How much slower one finger turns the camera than one mouse.
*
* `OrbitControls` maps a drag to `2π · delta / clientHeight` on **both** axes,
* and it has one `rotateSpeed` for both, so this is a compromise between them.
* Azimuth is forgiving: it wraps, and at 1.0 a 140px thumb arc on an 844px-tall
* phone swings the board 60°, which is fine. Polar is not: `maxPolarAngle`
* leaves about 85° of usable travel against a mapping that spends 360° over a
* screen height, so a tilt hits its clamp in the first 200 px and the camera
* feels like it is snapping rather than tilting. 0.7 stretches that to ~300 px
* and costs the azimuth a swing it can afford — the vertical axis is the
* binding constraint, and there is only one dial.
*/
const TOUCH_ROTATE_SCALE = 0.7;
/** How far a finger may wander and still be a tap, in CSS px. */
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 {
position: THREE.Vector3;
target: THREE.Vector3;
}
/**
* Picking, with the meaning left to the caller.
*
* `resolve` turns a raycast hit into whatever the caller considers picked — the
* kit never reads `userData` itself, because it has no idea what is in there.
*/
export interface PickOptions<T> {
/** A live array is fine; layers that rebuild theirs can pass a getter. */
targets: THREE.Object3D[] | (() => THREE.Object3D[]);
resolve(hit: THREE.Intersection): T | null;
/** Fires only on change, including the change back to `null`. */
onChange(picked: T | null): void;
}
export interface SceneKitOptions {
scene: THREE.Scene;
/** The element pointer coordinates are read against — the renderer's canvas. */
dom: HTMLElement;
fov?: number;
near?: number;
far?: number;
minDistance?: number;
maxDistance?: number;
maxPolarAngle?: number;
dampingFactor?: number;
/** Shadow-camera half-extent, in scene units. */
shadowExtent?: number;
shadowMapSize?: number;
shadowNear?: number;
shadowFar?: number;
shadowBias?: number;
/**
* What the shadow box is centred on, in scene units. Defaults to the origin,
* which is almost never where the thing being lit actually is.
*
* A `shadowExtent` says how *big* the box is; it says nothing about where.
* three centres a directional light's shadow camera on `light.target`, and a
* fresh `DirectionalLight` targets a brand-new `Object3D` sitting at the
* world origin — so without this every caller got a correctly-sized box in
* the wrong place, and the two consumers here both have their origin off to
* one side of what they want lit:
*
* - An office pack's origin is the **north-west corner of its slab**, not its
* middle. `lumbridge-hq` is 48 x 18 m against `shadowExtent: max(8, span *
* 0.7)` = ±33.6 m, so a box on the origin covered x ∈ [-33.6, 33.6] of a
* building occupying x ∈ [0, 48]: the eastern 14.4 m — call it a third of
* the floor plate — fell outside the frustum entirely and neither cast a
* shadow nor received one. Half the box was spent on the empty ground west
* of the building.
* - Scene space for a city is centred on `city.center`, and the comment on
* `boardRadius` in `scene.ts` already records that the Bay Area board runs
* forty kilometres down the peninsula from there. Same failure, one order
* of magnitude up.
*
* Pass the centre of what you want shadowed: `plan.bounds.center` for an
* office, the mid-point of the projected board for a city.
*/
shadowTarget?: THREE.Vector3;
/**
* How far along its direction the sun is placed, **from `shadowTarget`**. A
* `LightingState` carries a unit direction and no distance, because distance
* is a fact about the scale of the scene — 94 m per unit outdoors, 1 m per
* unit indoors — and not about where the sun is.
*/
sunDistance?: number;
/** Flight rate, in fractions of the flight per second. */
flightSpeed?: number;
/** Cursor while something is picked. */
hoverCursor?: string;
}
export interface SceneKit {
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
sun: THREE.DirectionalLight;
hemisphere: THREE.HemisphereLight;
ambient: THREE.AmbientLight;
applyLighting(state: LightingState): void;
/**
* Move the fog planes and nothing else. See `SceneHandle.setAerialFog`.
*
* A no-op before the first `applyLighting`, deliberately: the fog's *colour*
* is the rig's and this call does not carry one, so inventing a `THREE.Fog`
* here would have to invent a colour to put in it. The rig arrives within the
* frame either way — `createScene` applies one while it is still building —
* and it brings the real distances with it.
*/
setFogDistances(near: number, far: number): void;
/** Jump. Used for the opening pose, where a flight from nowhere is nonsense. */
setPose(pose: Pose): void;
flyTo(pose: Pose): void;
flying(): boolean;
setPicking<T>(options: PickOptions<T>): void;
/** Forget what is under the pointer and say so. */
resetPick(): void;
tick(dt: number): void;
dispose(): void;
}
export function createSceneKit(options: SceneKitOptions): SceneKit {
const { scene, dom } = options;
const sunDistance = options.sunDistance ?? 240;
const flightSpeed = options.flightSpeed ?? 0.65;
const hoverCursor = options.hoverCursor ?? "pointer";
const camera = new THREE.PerspectiveCamera(
options.fov ?? 42,
dom.clientWidth / Math.max(1, dom.clientHeight),
options.near ?? 0.1,
options.far ?? 900,
);
const controls = new OrbitControls(camera, dom);
controls.enableDamping = true;
const baseDamping = options.dampingFactor ?? 0.07;
controls.dampingFactor = baseDamping;
controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = options.minDistance ?? 12;
controls.maxDistance = options.maxDistance ?? 340;
// ---- Input --------------------------------------------------------------
/*
* The gesture map is three.js's default and it is already the right one:
* `touches = { ONE: ROTATE, TWO: DOLLY_PAN }`. One finger orbits; two fingers
* pinch and drag *at the same time*, which is how every map on a phone
* behaves and is why it is not split into separate two-finger modes here.
*
* Zoom needs nothing scaled to the board, and it is worth saying why, because
* `scene.ts` records what happened the last time a distance was treated as a
* constant. A pinch dollies by `(endSeparation / startSeparation) ^
* zoomSpeed` — a *ratio* — and the wheel is `0.95 ^ delta`, also a ratio. Both
* multiply the camera's current distance, so SoCal's 393-unit board and the
* Bay Area's 1003-unit one zoom at the same rate per finger-millimetre with
* no knowledge of either number. The only board-sized values in the gesture
* path are `minDistance` and `maxDistance`, which the caller already derives.
*/
/**
* A mouse and a thumb are given different values for the three settings where
* one answer cannot serve both, swapped in on `pointerdown` by `pointerType`.
*
* The alternative — pick the values once from a device probe — is wrong on
* every laptop with a touchscreen, where both inputs are live at once and the
* user switches between them mid-session. Keying off the event that is
* actually happening is both simpler and correct, and it means the desktop
* path is bit-for-bit what it was: the touch values do not exist until a
* touch installs them.
*
* - **`screenSpacePanning`** is three's default `true`, which pans along the
* camera's own up vector. On a map seen from above that lifts the target
* off the ground as you drag, and the board slides away underneath. For two
* fingers it goes to `false`: pan in the ground plane, so the board tracks
* the fingers. Left alone for the mouse, where right-drag pan is
* long-standing behaviour and someone would notice it change.
* - **`zoomToCursor`** goes on for touch so a pinch zooms toward the point
* between the fingers, which is the whole reason people pinch a particular
* neighbourhood. It moves `controls.target` as well as the camera, so the
* orbit centre drifts toward whatever was pinched — accepted deliberately,
* because on a map that drift *is* the interaction. The wheel keeps zooming
* to the centre of the view.
* - **`rotateSpeed`**: see `TOUCH_ROTATE_SCALE`.
*/
const mouseInput = {
rotateSpeed: controls.rotateSpeed,
screenSpacePanning: controls.screenSpacePanning,
zoomToCursor: controls.zoomToCursor,
};
function applyPointerProfile(pointerType: string) {
const touch = pointerType === "touch";
controls.rotateSpeed = mouseInput.rotateSpeed * (touch ? TOUCH_ROTATE_SCALE : 1);
controls.screenSpacePanning = touch ? false : mouseInput.screenSpacePanning;
controls.zoomToCursor = touch ? true : mouseInput.zoomToCursor;
}
/**
* A wheel arrives with no pointer, so it cannot announce its own type. Any
* wheel at all means a mouse or a trackpad is in the room, and without this a
* hybrid laptop that was last touched keeps the touch profile — and scrolls
* toward wherever the finger happened to be, once, for no visible reason.
*
* `OrbitControls` registered its own wheel handler first, so the notch that
* performs the reset is itself still anchored to the old point and only the
* next one is centred. One notch, on a machine that has both inputs and used
* both in the same breath; the fix for that costs finger-counting state and
* buys a frame.
*/
function onWheel() {
applyPointerProfile("mouse");
}
dom.addEventListener("wheel", onWheel, { passive: true });
/**
* iOS pinches the *page* as well as the map.
*
* `touch-action: none` stops Safari's double-tap zoom and its scroll, but
* WebKit's own `gesture*` events are not covered by it, and a two-finger
* pinch that begins on the canvas can still scale the whole document —
* leaving the UI enormous, half off-screen, and with no gesture left that
* undoes it. Refusing the three of them costs nothing anywhere else: no other
* engine implements the events at all.
*/
const preventGesture = (event: Event) => event.preventDefault();
dom.addEventListener("gesturestart", preventGesture);
dom.addEventListener("gesturechange", preventGesture);
dom.addEventListener("gestureend", preventGesture);
// ---- Light rig ----------------------------------------------------------
const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.castShadow = true;
// The default is the device's, not a constant: a phone gets a smaller map for
// the reasons written out in `stage.ts`. A caller that knows better — an
// office, at a hundredth of the city's scale — passes its own.
const mapSize = options.shadowMapSize ?? deviceProfile().shadowMapSize;
sun.shadow.mapSize.set(mapSize, mapSize);
sun.shadow.camera.near = options.shadowNear ?? 10;
sun.shadow.camera.far = options.shadowFar ?? 520;
const extent = options.shadowExtent ?? 170;
sun.shadow.camera.left = -extent;
sun.shadow.camera.right = extent;
sun.shadow.camera.top = extent;
sun.shadow.camera.bottom = -extent;
sun.shadow.bias = options.shadowBias ?? -0.0012;
/**
* Without this line, none of the six numbers above exist.
*
* `OrthographicCamera` bakes its frustum into a projection matrix in the
* constructor, and mutating `.left`/`.right`/`.near`/`.far` afterwards does
* nothing until the matrix is rebuilt. three.js rebuilds it for a *spot*
* light's shadow (`SpotLightShadow.updateMatrices` recomputes when the fov or
* far changes) and **not** for a directional light's — `LightShadow.
* updateMatrices` only does `lookAt` and `updateMatrixWorld`.
*
* So every caller here was configuring a shadow camera that was still
* three's default `(-5, 5, 5, -5, 0.5, 500)`: a ten-unit box. The city asks
* for `shadowExtent: boardSpan * 0.75`, which is ±752 units on the Bay Area
* board, and got ±5 — a shadow map covering a square about the size of one
* house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
*/
sun.shadow.camera.updateProjectionMatrix();
/**
* One update now, so a reader that recomputes for itself sees the right pose.
*
* This is **not** what makes the shadow correct — the paragraph above is: the
* target has to be *in the scene* so `Object3D.updateMatrixWorld`'s traversal
* reaches it, and `WebGLRenderer.render` runs that traversal before
* `shadowMap.render()` on every frame. That is the whole mechanism.
*
* What this line buys is narrower and worth being honest about. Nothing that
* reads `sun.shadow.camera` before the first render learns anything from it —
* three only writes that camera's pose inside `LightShadow.updateMatrices`,
* which runs during a render. It matters to a reader that recomputes from the
* target itself: a `DirectionalLightHelper`, or a manual
* `sun.shadow.updateMatrices(sun)` in a capture pass.
*/
const shadowTarget = new THREE.Vector3();
if (options.shadowTarget) shadowTarget.copy(options.shadowTarget);
sun.target.position.copy(shadowTarget);
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
scene.add(sun, sun.target, hemisphere, ambient);
// Belt and braces for anything that reads the shadow camera before the first
// render — a capture pass, a debug helper — where the renderer's own
// traversal has not happened yet. After that, the traversal owns it.
sun.target.updateMatrixWorld();
const sunDirection = new THREE.Vector3();
const dome = makeSkyDome();
let domeAttached = false;
function applyLighting(state: LightingState) {
const [dx, dy, dz] = state.sun.direction;
sunDirection.set(dx, dy, dz);
// A zero direction would put the sun inside the ground and black the scene
// out; leaving it where it was is the kinder failure.
if (sunDirection.lengthSq() > 0) {
/*
* `sunDistance` out from the **target**, not from the origin.
*
* A directional light's position is not physical — the shading only reads
* `position - target` as a direction — but the shadow camera *is* placed
* at it, and its `near`/`far` are measured from there along the view
* axis. Off the origin those two facts fight: `officeScene.ts` asks for
* `sunDistance: max(24, span * 1.4)`, so on a 12 m studio the sun sits
* 24 units from the origin while the slab centre it is aimed at can be
* 8 m away in some other direction — a light that is beside or behind the
* building rather than above it, with the near plane cutting into the
* geometry it is supposed to be shadowing.
*
* Anchoring to the target makes light-to-target exactly `sunDistance`
* whatever the direction, which is the invariant every caller's
* `shadowNear`/`shadowFar` was picked against.
*/
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)).add(shadowTarget);
}
sun.color.setHex(state.sun.color);
sun.intensity = state.sun.intensity;
hemisphere.color.setHex(state.hemisphere.sky);
hemisphere.groundColor.setHex(state.hemisphere.ground);
hemisphere.intensity = state.hemisphere.intensity;
ambient.color.setHex(state.ambient.color);
ambient.intensity = state.ambient.intensity;
// 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));
/**
* The moon, if this rig carries one. See `LightingMoon`.
*
* A drawn object and nothing else: no light is constructed here, no light
* is modified here, and the key after dark is already `state.sun`, which
* `atmosphere.ts` handed to the moon. Absent or `null` sets the visibility
* to zero and the shader's branch never runs, which is what an office rig
* and the hard-coded fallback both get.
*/
const moon = state.moon ?? null;
u.uMoonVisibility!.value = moon ? Math.min(1, Math.max(0, moon.visibility)) : 0;
if (moon) {
(u.uMoonDirection!.value as THREE.Vector3).fromArray(moon.direction).normalize();
(u.uMoonLimb!.value as THREE.Vector3).fromArray(moon.brightLimb).normalize();
u.uMoonRadius!.value = Math.max(1e-4, moon.angularRadius * MOON_ANGULAR_EXAGGERATION);
u.uMoonPhase!.value = Math.min(1, Math.max(0, moon.illuminated));
}
} else if (domeAttached) {
scene.remove(dome);
domeAttached = false;
}
if (!state.fog) {
scene.fog = null;
} else if (scene.fog instanceof THREE.Fog) {
scene.fog.color.setHex(state.fog.color);
scene.fog.near = state.fog.near;
scene.fog.far = state.fog.far;
} else {
scene.fog = new THREE.Fog(state.fog.color, state.fog.near, state.fog.far);
}
}
/**
* The camera-dependent half of `applyLighting`, on its own.
*
* Two assignments, no colour, no lights, no background. Everything else in
* `applyLighting` is a consequence of the sun and the sky, which do not change
* because somebody dragged the board — see `SceneHandle.setAerialFog` for the
* argument and `atmosphere.ts`'s `AerialFog` for why this may never grow a
* third parameter.
*/
function setFogDistances(near: number, far: number) {
if (!(scene.fog instanceof THREE.Fog)) return;
scene.fog.near = near;
scene.fog.far = far;
}
// ---- Camera flights -----------------------------------------------------
const from: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
const to: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
let flying = false;
let flightT = 0;
const motionQuery =
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
let reducedMotion = motionQuery?.matches ?? false;
function onMotionChange(event: MediaQueryListEvent) {
reducedMotion = event.matches;
// Mid-flight when the preference flips: land now rather than finish the arc.
if (reducedMotion && flying) setPose(to);
}
motionQuery?.addEventListener("change", onMotionChange);
function setPose(pose: Pose) {
flying = false;
camera.position.copy(pose.position);
controls.target.copy(pose.target);
controls.update();
}
/**
* A chapter flight is the largest motion this app makes: the whole field of
* view sweeps and rotates for a second and a half, unrequested by anyone who
* only clicked a name in a list. That is the case `prefers-reduced-motion`
* exists for, so under it the flight becomes a cut. `main.ts` already reached
* the same conclusion for a minimap seek and says so there.
*
* Damping is left alone, and the distinction is worth stating: damping only
* ever follows a finger or a mouse that is currently moving, and it settles
* in a few frames after it stops. It is the response to a gesture, not motion
* the interface started on its own.
*/
function flyTo(pose: Pose) {
if (reducedMotion) {
setPose(pose);
return;
}
from.position.copy(camera.position);
from.target.copy(controls.target);
to.position.copy(pose.position);
to.target.copy(pose.target);
flightT = 0;
flying = true;
}
// ---- Picking ------------------------------------------------------------
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let picking: PickOptions<unknown> | null = null;
let picked: unknown = null;
// The raycast runs at most once a frame, off the last pointer position,
// rather than once per `pointermove` — a fast drag across the canvas fires
// dozens of those between two frames and every one of them but the last is
// thrown away.
let pointerDirty = false;
function aimAt(clientX: number, clientY: number) {
const rect = dom.getBoundingClientRect();
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
pointerDirty = true;
}
// A moving finger is not hovering; see the tap block below.
function onPointerMove(event: PointerEvent) {
if (event.pointerType === "touch") return;
aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointermove", onPointerMove);
/**
* There is no hover on a touch screen, and pretending otherwise is how a map
* ends up flashing a detail card for every marker a thumb happens to sweep
* across on its way to turning the board. A finger only reports where it is
* *while it is pressed*, which is exactly when it is doing something else.
*
* So touch picks on a tap and nothing else: press, lift within `TAP_SLOP` and
* `TAP_MS`, and that point is picked. Anything longer or further is a gesture
* and picks nothing. The pick then survives the finger leaving the glass — a
* card raised by a tap has to stay up to be read — and is cleared by the next
* touch anywhere, which is what makes tapping empty water the way to dismiss
* it.
*
* 12 px of slop, not zero: a thumb pivots while it presses, and a tap that
* wandered a millimetre is still a tap. Past that the camera has visibly
* moved, and something that moved the map should not also have selected
* something on it.
*/
/** The pointer id of a candidate tap; -1 for none, -2 once a second finger lands. */
let tapPointer = -1;
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;
tapY = event.clientY;
tapAt = event.timeStamp;
}
dom.addEventListener("pointerdown", onPointerDown);
function onPointerUp(event: PointerEvent) {
if (event.pointerType !== "touch") return;
lastTouchAt = event.timeStamp;
const wasTap =
tapPointer === event.pointerId &&
event.timeStamp - tapAt <= TAP_MS &&
Math.hypot(event.clientX - tapX, event.clientY - tapY) <= TAP_SLOP;
tapPointer = -1;
if (wasTap) aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointerup", onPointerUp);
dom.addEventListener("pointercancel", onPointerUp);
function resetPick() {
pointerDirty = false;
if (picked === null) return;
const wasPicking = picking;
picked = null;
dom.style.cursor = "";
wasPicking?.onChange(null);
}
/*
* 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);
function repick() {
if (!picking || !pointerDirty) return;
pointerDirty = false;
const targets = typeof picking.targets === "function" ? picking.targets() : picking.targets;
const hit = targets.length === 0 ? undefined : raycastFirst(targets);
const next = hit ? picking.resolve(hit) : null;
if (next === picked) return;
picked = next;
dom.style.cursor = next ? hoverCursor : "";
picking.onChange(next);
}
function raycastFirst(targets: THREE.Object3D[]): THREE.Intersection | undefined {
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObjects(targets, false)[0];
}
return {
camera,
controls,
sun,
hemisphere,
ambient,
applyLighting,
setFogDistances,
setPose,
flyTo,
flying: () => flying,
setPicking(pick) {
picking = pick as PickOptions<unknown>;
},
resetPick,
tick(dt) {
/**
* `OrbitControls` damps per *frame*, not per second: every `update()`
* moves the camera `dampingFactor` of the way to where the input asked
* for. So the same 0.07 is a different feel on every refresh rate — twice
* as slow on a phone that has dropped to 30 fps, and 2.4x as fast on a
* 144 Hz monitor, which is why the settle on a laptop and the settle on a
* handset never matched.
*
* Re-deriving it from the frame time fixes both ends with the same line.
* At exactly 60 fps this returns `baseDamping` unchanged, so the desktop
* default it was tuned at is preserved to the digit; away from 60 it
* holds the wall-clock settle constant instead. `stage.ts` clamps `dt` to
* 50 ms, so the exponent cannot run away after a stall and snap the
* camera.
*/
controls.dampingFactor =
dt > 0 ? Math.min(1, 1 - (1 - baseDamping) ** (dt * 60)) : baseDamping;
if (flying) {
flightT = Math.min(1, flightT + dt * flightSpeed);
// easeInOutCubic — a flight that starts and lands gently
const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2;
camera.position.lerpVectors(from.position, to.position, e);
controls.target.lerpVectors(from.target, to.target, e);
if (flightT >= 1) flying = false;
// The pointer has not moved but the world under it has.
pointerDirty = true;
}
controls.update();
repick();
},
dispose() {
dom.removeEventListener("pointermove", onPointerMove);
dom.removeEventListener("pointerdown", onPointerDown);
dom.removeEventListener("pointerup", onPointerUp);
dom.removeEventListener("pointercancel", onPointerUp);
dom.removeEventListener("pointerleave", onPointerLeave);
dom.removeEventListener("wheel", onWheel);
dom.removeEventListener("gesturestart", preventGesture);
dom.removeEventListener("gesturechange", preventGesture);
dom.removeEventListener("gestureend", preventGesture);
motionQuery?.removeEventListener("change", onMotionChange);
dom.style.cursor = "";
picking = null;
controls.dispose();
// `sun.target` was added as a scene child in its own right, so removing
// the light does not take it with it — a kit torn down and rebuilt would
// otherwise leave one empty Object3D in the scene per cycle.
scene.remove(sun, sun.target, hemisphere, ambient);
sun.dispose();
hemisphere.dispose();
ambient.dispose();
if (domeAttached) scene.remove(dome);
domeAttached = false;
dome.geometry.dispose();
dome.material.dispose();
},
};
}
/**
* 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.
*/
const SUN_GLOW_FULL_INTENSITY = 1.9;
/**
* The moon's own colour, and how much of the dark side earthshine leaves visible.
*
* Not white. The full moon measures around 4100 K to the eye — a warm grey, and
* a pure-white disc against a deep blue night sky is the single most common tell
* of a synthetic sky. 0.045 for the earthshine is roughly what a young crescent
* over a cloudy earth actually shows, and it is what makes the *whole* disc
* findable at a thin phase instead of just the sliver.
*/
const MOON_COLOR = 0xf2ecdc;
const MOON_EARTHSHINE = 0.045;
/**
* How much larger than life the disc is drawn.
*
* The moon is half a degree across. At this camera's 42-degree vertical field
* over a 1000-pixel viewport that is nine pixels — smaller than the aeroplane
* glyphs, smaller than a satellite dot at some sizes, and indistinguishable from
* a stuck pixel. Drawn true, the four hundred lines of Meeus behind its position
* buy a picture of nothing.
*
* So it is exaggerated, and this is the same trade the rest of this codebase
* already makes and writes down: `clouds.ts` draws stratocumulus five to twelve
* kilometres wide because an honest cumulus is two pixels, `flights.ts` draws an
* aeroplane glyph hundreds of times its true span, and `atmosphere.ts` floors
* visibility at 4.5 km because honest fog is a white rectangle. A map is looked
* at from outside the sky it is depicting.
*
* 2.6 rather than more, because the *phase* is the information here and a phase
* is legible well before a disc becomes a cartoon: at 2.6 the disc is about
* 24 pixels of a 1000-pixel frame, a crescent is unmistakable, and nobody reads
* it as a second sun.
*/
const MOON_ANGULAR_EXAGGERATION = 2.6;
/**
* 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 },
uMoonDirection: { value: new THREE.Vector3(0, 1, 0) },
uMoonLimb: { value: new THREE.Vector3(1, 0, 0) },
uMoonRadius: { value: 0.012 },
uMoonPhase: { value: 1 },
/** 0 draws nothing, and is what an office and a fallback rig get. */
uMoonVisibility: { value: 0 },
uMoonColor: { value: new THREE.Color(MOON_COLOR) },
},
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;
uniform vec3 uMoonDirection;
uniform vec3 uMoonLimb;
uniform float uMoonRadius;
uniform float uMoonPhase;
uniform float uMoonVisibility;
uniform vec3 uMoonColor;
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, and what is painted there has one job:
* be *not brighter* than the horizon it sits under, for the office floors and
* ocean that normally cover it.
*
* The band immediately under the horizontal is the exception, and it is the
* only part of this a board ever shows. The sea is a flat plane, so however
* wide it is made the camera's own far plane cuts it off at an elevation some
* degrees *below* the horizontal — around 12 degrees in a plan view of the
* state. atmosphere.ts fogs it to exactly the horizon colour by the time it
* gets there, which is what makes the water dissolve into sky rather than end
* in an edge. So whatever the dome paints at that same angle is the other half
* of that join, and it has to be the same number.
*
* It was not: the darkening used to ramp in over the first 11.5 degrees, so
* the sea arrived at the horizon colour and met a sky already at 0.82 of it.
* That is a hard step of a fifth of the brightness, drawn all the way across
* the frame — 1.7% of a desktop window and **10.5% of a phone held upright**,
* where the camera pitches down far enough to put the whole band in shot.
*
* Holding the horizon's own colour for the first 30 degrees costs nothing that
* the darkening was for: a floor or an ocean covers that band whenever there
* is one, and where there is not, matching is the entire point.
*/
color = mix( color, uHorizon * 0.82, smoothstep( 0.50, 0.98, - height ) );
/*
* 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 );
/*
* The moon, drawn.
*
* Everything here is angular, which is what the dome preserves and the only
* reason this can live in the sky shader at all: direction is a unit vector
* and so is uMoonDirection, so the offset between them, projected onto the
* two axes of the visible disc, is the position on that disc in radians. Over
* half a degree the small-angle approximation is exact to eight decimal
* places, so no trigonometry is needed and none is done.
*
* uMoonLimb is perpendicular to the moon and points at the middle of the lit
* side — the sun's true direction with the moon's own component removed, which
* atmosphere.ts computes because it is the one module holding an unfloored
* sun. du is therefore measured along the phase and dv across it.
*/
if ( uMoonVisibility > 0.0 ) {
vec3 across = cross( uMoonDirection, uMoonLimb );
float du = dot( direction, uMoonLimb ) / uMoonRadius;
float dv = dot( direction, across ) / uMoonRadius;
float onThisSide = step( 0.0, dot( direction, uMoonDirection ) );
float rr = du * du + dv * dv;
/*
* The terminator is an ellipse, not a line, and the whole of it is one
* expression: the boundary sits at du = (1 - 2k) * sqrt(1 - dv^2), which
* is a straight line through the centre at half phase, the full limb at
* full, and nothing at new. Softened over a fifteenth of the disc because a
* hard step on an object sixty pixels across aliases into a staircase, and
* because the real terminator is a lit horizon rather than an edge.
*/
float boundary = ( 1.0 - 2.0 * uMoonPhase ) * sqrt( max( 0.0, 1.0 - dv * dv ) );
float lit = smoothstep( boundary - 0.07, boundary + 0.07, du );
/*
* Limb darkening, and earthshine.
*
* The moon is not a flat disc: brightness falls toward the edge as the
* surface turns away, which is what stops it reading as a sticker. And the
* dark side is not black — it is lit by a full earth, which is why the whole
* disc of a young crescent is visible on a clear night. Both are cheap and
* both are the difference between a moon and a circle.
*/
float curve = sqrt( max( 0.0, 1.0 - rr ) );
float shading = mix( ${MOON_EARTHSHINE.toFixed(3)}, 0.55 + 0.45 * curve, lit );
// The disc's own edge, feathered by the same amount as the terminator.
float disc = ( 1.0 - smoothstep( 0.86, 1.0, rr ) ) * onThisSide;
color += uMoonColor * uMoonVisibility * shading * disc;
/*
* A halo, because a moon with a hard edge and nothing around it reads as a
* decal. Kept to a twentieth of the disc's own brightness and to a few
* degrees of sky: the failure mode this file is downstream of is a night
* layer that lifts the whole frame, and a wide bloom is exactly that.
*/
float toMoon = max( dot( direction, uMoonDirection ), 0.0 );
color += uMoonColor * uMoonVisibility * pow( toMoon, 400.0 ) * 0.05;
}
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;
}