/** * 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 { /** 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(options: PickOptions): 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 | 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; }, 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 { 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; }