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/scene.ts
T

653 lines
28 KiB
TypeScript

/**
* The city scene: layers, chapter flights, markers, and the handle the app
* drives it all through.
*
* `createScene` owns a canvas and a `City` and nothing else. It knows nothing
* about React, about any API, or about what the markers mean — the caller hands
* it data and gets back a small imperative handle. That boundary is what lets
* one renderer serve a private map coloured by pipeline state and a public one
* coloured by sector without either being a fork.
*
* The renderer and the loop live in `Stage`, which is **handed in and not
* built here**: one renderer serves the canvas for the life of the page, and a
* city is a thing that is put on it and taken off again. Building a Stage per
* city is what this function used to do, and `stage.ts` records what it cost —
* every switch orphaned a 2048² shadow map on the GL context, because
* `WebGLRenderer.dispose()` frees none of a renderer's own textures.
*
* The camera, lights, flights and picking live in a `SceneKit`. What is left
* here — and it is the only thing that ought to be here — is the city itself:
* which layers go in the scene, where a chapter puts the camera, and what a
* pick means. An office builds the same two pieces with its own answers and
* swaps in on the same `Stage`, which keeps this city alive and paused rather
* than rebuilding it on the way back.
* For the Bay Area that is about 2.2 s of layer construction, of which roughly
* 730 ms is the heightfield — and the heightfield is now the only part of it
* that happens off the main thread, so a rebuild would be 2.2 s of *frozen*
* page rather than 2.2 s of busy one. See CONTRACT.md §1.
*/
import * as THREE from "three";
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { solarPosition, sunDirection } from "./solar.ts";
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
import {
createSatelliteLayer,
type SatelliteCatalogue,
type SatelliteLayer,
} from "./satellites.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import {
createRoadTrafficLayer,
type RoadTrafficLayer,
type RoadTrafficOptions,
type VehicleCameraMode,
} from "./roadTraffic.ts";
import type { Stage, StageScene } from "./stage.ts";
import type {
VehicleActionSnapshot,
VehicleControllerState,
} from "../transport/vehicleController.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
Chapter,
City,
FlightSource,
LightingState,
Marker,
MarkerPalette,
ScenePalette,
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type { ActorActionSnapshot, ActorControllerSnapshot } from "../actors/controller.ts";
export interface SceneOptions {
city: City;
markerPalette?: MarkerPalette;
/**
* Markers available at construction time.
*
* Building glyphs in this first set reserve their footprints in the
* anonymous block scatter. Later `setMarkers()` calls remain cheap and do
* not rebuild a city, so callers should put stable destinations here and use
* updates for genuinely live marker feeds.
*/
markers?: Marker[];
/** Optional deterministic road traffic for a state/corridor-scale board. */
roadTraffic?: RoadTrafficOptions;
/** Optional possessed procedural actor for play mode; inactive by default. */
actor?: SceneActorOptions;
/** Geographic spawn anchor for that actor; defaults to the board centre. */
actorAnchor?: { lat: number; lng: number };
flights?: FlightSource;
/**
* Element sets to propagate, if this deployment has any.
*
* A catalogue rather than a source, and the asymmetry with `flights` is the
* point: a `FlightSource` is polled because there is no closed form for where
* aircraft are, and a `SatelliteCatalogue` is *evaluated* because a TLE is
* exactly that closed form. Nothing here is ever fetched on a timer.
*/
satellites?: SatelliteCatalogue;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
/**
* Opening light rig. Comes from an `Atmosphere` when there is one; without
* one the city gets `cityDaylight()`, because a scene that renders black
* until somebody wires up the sun is not a scene that boots with no config.
*/
lighting?: LightingState;
/**
* Fires while the heightfield builds, several times a second. The caller
* decides what to say about it; the engine only reports a phase and a
* fraction. See `FieldProgress`.
*/
onProgress?: (progress: FieldProgress) => void;
/**
* Abandons the build. `createScene` then resolves to `null` having allocated
* no geometry and having touched the stage not at all — the point of aborting
* is that the next city gets the machine to itself, and a half-built scene
* parked on the stage defeats that.
*/
signal?: AbortSignal;
}
export interface SceneHandle {
world: World;
chapters: Chapter[];
/**
* The stage this city was built on, which it uses and does not own. An office
* is swapped in with `stage.setScene(officeScene)` and this city back in the
* same way; the one that steps out is paused, not thrown away.
*
* `dispose()` below takes the city off it and leaves it running. Whoever
* built the stage disposes it, and on this page nobody does — it lives as
* long as the canvas does.
*/
stage: Stage;
/** This city, as the thing `stage.setScene` takes. */
stageScene: StageScene;
/** Applies a rig computed elsewhere. The scene never works one out itself. */
setLighting(state: LightingState): void;
/**
* Solar elevation in degrees, for the layers that need the sun's position
* rather than the rig it implies. `LightingState` deliberately carries no
* elevation, so the number has to arrive separately.
*/
setSolarElevation(degrees: number): void;
/**
* How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`.
*
* Separate from `setLighting` for the same reason `setSolarElevation` is: a
* `LightingState` deliberately carries no cover, so the number has to arrive
* on its own rather than be reverse-engineered out of a rig.
*/
setCloudCover(fraction: number): void;
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
setWind(kph: number | null, fromDeg: number | null): void;
/**
* Freeze the satellite sky at an instant, or pass `null` to follow the wall
* clock. Exactly the shape of `main.ts`'s own time override, deliberately.
*
* Separate from `setSolarElevation` even though both follow the same clock,
* because they need different things from it: the night-lights want a scalar
* the caller has already worked out, and SGP4 wants the date itself. Handing
* the layer an elevation would mean it could not propagate, and handing the
* lights a `Date` would mean two modules computing the sun.
*
* No-op on a deployment with no catalogue.
*/
setSkyInstant(when: Date | null): void;
/** Draw the satellite layer, or do not. No-op with no catalogue. */
setSatellitesVisible(visible: boolean): void;
/**
* How many objects were above the horizon at the end of the last complete
* propagation pass, and how many element sets this build could read at all.
* Both zero without a catalogue. For the godmode readout; nothing renders it.
*/
satelliteCounts(): { visible: number; total: number };
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
/** Current playable corridor state, or null on a city-scale board. */
vehicleState(): Readonly<VehicleControllerState> | null;
setVehicleCamera(mode: VehicleCameraMode): void;
vehicleCamera(): VehicleCameraMode | null;
setActorActions(actions: Partial<ActorActionSnapshot>): void;
actorState(): Readonly<ActorControllerSnapshot> | null;
setActorActive(active: boolean): void;
actorActive(): boolean;
setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void;
}
/**
* Build a city on a stage. Resolves to `null` if the build was abandoned.
*
* ## Why this is async, and why the handle is not
*
* `SceneHandle` is unchanged: every method on it is synchronous and every field
* on it is real by the time you hold one. Only getting one takes a moment.
*
* The alternative was tried on paper and is worse. Handing back a handle
* immediately means handing back a handle whose `stageScene` holds an empty
* scene, whose `flyTo` cannot know where the ground is, and whose `world`
* answers `groundAt` by building the heightfield on the main thread — the exact
* block this change exists to remove, reintroduced by the first caller who
* forgets to wait. Every one of those methods would need a "not yet" branch and
* a queue, and the queue would be the real API.
*
* So the wait is where the wait actually is. The cost is that it ripples: the
* app has to `await mountCity`, and `createMinimap` has to be constructed after
* this resolves rather than alongside it. That is a handful of `await`s in
* `main.ts` against an engine that cannot lie about whether its ground exists.
*/
export async function createScene(
stage: Stage,
options: SceneOptions,
): Promise<SceneHandle | null> {
const { city } = options;
const world = new World(city);
// First, so an abandoned build has nothing to tear down: everything below
// this line allocates, and a city that is no longer wanted should not have
// built a single buffer.
const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress });
if (!ready) return null;
const pal = paletteFor(world);
const scene = new THREE.Scene();
/**
* Every camera limit is derived from how big this city's board actually is.
*
* These were constants tuned for San Francisco — `maxDistance: 340`,
* `far: 900`, a 170-unit shadow box. That silently made board size a fixed
* property of the engine rather than of a city: expanding the pack from San
* Francisco to the whole Bay Area took the board from 230 units across to
* 1003, and the camera physically could not retreat far enough to frame it.
* You got a close-up of the peninsula with everything else off-screen, and
* nothing in the types said why.
*
* A city pack now chooses its own `latScale` freely and the camera follows.
*/
const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
/**
* How far the board reaches **from the scene origin**, which is not the same
* as how big it is.
*
* Scene space is centred on `city.center` — the city — and the Bay Area board
* runs forty kilometres down the peninsula from there, so the origin is
* nowhere near the middle of it. The furthest corner is 0.94 spans out where
* the half-diagonal is only 0.65, and anything sized off the half-diagonal is
* therefore too small by half.
*
* The satellite dome is centred on the origin, because that is where its look
* angles were computed for, so this is the radius it has to clear.
*/
const boardRadius = Math.max(
Math.hypot(westX, northZ),
Math.hypot(eastX, northZ),
Math.hypot(westX, southZ),
Math.hypot(eastX, southZ),
);
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
const kit = createSceneKit({
scene,
dom: stage.renderer.domElement,
fov: 42,
near: 0.1,
/**
* Everything, at the worst pose, plus room.
*
* The furthest thing from the camera is the back of the satellite dome seen
* from a chapter at the far end of the board: `maxChapterOffset` (0.94
* spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9.
* At 3.0 the sky was being clipped from any off-centre chapter, which is
* most of them.
*
* It also now sits at the fog's far plane, which is the honest statement of
* the invariant: nothing should be cut off before it has fully faded out.
*/
far: boardSpan * 4,
minDistance: orbitMinDistance,
/**
* Far enough out to be **above the satellites**, which is what the extra
* half-span buys.
*
* The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the
* camera was always inside the constellation and could only ever look up
* through it. At 2.0 the far end of the zoom is outside it looking down, with
* the whole dome in frame at this field of view and the board still filling
* about two thirds of the screen.
*
* The far plane already covers it: the furthest thing from the camera is then
* the back of the dome at 2.7 spans, against `far` at 3.0.
*/
maxDistance: boardSpan * 2.0,
shadowExtent: boardSpan * 0.75,
/**
* The middle of the board, which is **not** the origin.
*
* Scene space is centred on `city.center` — the city — and the Bay Area
* board runs forty kilometres down the peninsula from there, so a shadow
* box centred on the origin spends half itself on empty ocean and leaves the
* far end of the peninsula outside the frustum entirely. `shadowExtent`
* sizes the box and says nothing about where it is; this says where.
*/
shadowTarget: new THREE.Vector3((westX + eastX) / 2, 0, (northZ + southZ) / 2),
shadowFar: boardSpan * 2.2,
});
// Held, because the cloud layer needs the same opening rig the kit just got —
// and it must be the same object, not a second call to `cityDaylight`, or the
// two disagree for the one frame before the app's first `setLighting`.
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
kit.applyLighting(opening);
scene.add(createWater(world));
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(createRoads(world));
const buildingReservations: BuildingReservation[] = [];
for (const marker of options.markers ?? []) {
const glyph = marker.glyph;
if (marker.located === false || glyph?.kind !== "building") continue;
const [x, z] = world.project(marker.lat, marker.lng);
// Five metres of breathing room keeps an anonymous wall from sitting
// exactly on the authored façade after both reservation circles touch.
const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
buildingReservations.push({ x, z, radius });
}
const blocks = createBlocks(world, buildingReservations);
scene.add(blocks);
scene.add(createLandmarks(world, buildingReservations));
scene.add(createBridges(world));
/**
* The city switching itself on after sunset. Built after `blocks` because it
* patches the material that `createBlocks` made — order is load-bearing.
*/
const nightLights: NightLights = createNightLights({ world, blocks });
scene.add(nightLights.group);
const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
clouds.setLighting(opening);
scene.add(clouds.group);
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
markerLayer.setMarkers(options.markers ?? []);
scene.add(markerLayer.group);
const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
: null;
if (roadTraffic) scene.add(roadTraffic.group);
const actorAnchor = options.actorAnchor ?? city.center;
const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
const sceneActor = options.actor
? createSceneActor({
...options.actor,
sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit,
visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ??
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
sceneOrigin: options.actor.sceneOrigin ?? {
x: actorX,
y: world.groundAt(actorAnchor.lat, actorAnchor.lng),
z: actorZ,
},
})
: null;
if (sceneActor) scene.add(sceneActor.root);
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
if (options.flights) {
flightLayer = createFlightLayer(world);
scene.add(flightLayer.group);
}
let satelliteLayer: SatelliteLayer | null = null;
let starlinkMeshes: StarlinkMeshLayer | null = null;
if (options.satellites) {
satelliteLayer = createSatelliteLayer(boardRadius);
scene.add(satelliteLayer.group);
// Board radius, the same unit `createSatelliteLayer` takes above — the
// mesh layer applies the dome factor itself. It used to take the *dome*
// radius while its sibling took the *board* radius, with nothing in the
// types to tell them apart, which is precisely the confusion that has
// already produced one real bug in this file.
starlinkMeshes = createStarlinkMeshLayer({ boardRadius });
scene.add(starlinkMeshes.group);
}
/**
* The instant the sky is drawn for, or `null` for the wall clock.
*
* Satellites are the one layer whose content is a function of *absolute* time
* rather than of elapsed time, so `tick(dt)` cannot serve them: godmode scrubs
* the clock to an arbitrary date and the sky has to follow it there.
*
* It holds the **override** rather than a resolved `Date`, which is the same
* shape `main.ts` keeps its own clock in and is load-bearing here. A resolved
* instant would have to be pushed in on a timer, and the only timer available
* is `updateSun`'s — which runs about once a second, so the sky would advance
* in one-second jumps while everything around it moved smoothly. Holding the
* override means an unscrubbed scene reads the clock afresh every frame and a
* scrubbed one is frozen exactly where it was put.
*/
let skyOverride: Date | null = null;
// ---- Chapters -----------------------------------------------------------
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
const first = city.chapters[0];
if (!first) throw new Error(`City "${city.id}" declares no chapters`);
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
return {
target: new THREE.Vector3(x, groundY, z),
position: new THREE.Vector3(
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
groundY + ch.focus.height,
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
),
};
}
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
// Named viewpoints are observe/vehicle destinations. Possessing an actor
// is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else.
sceneActor?.setActive(false);
kit.controls.minDistance = orbitMinDistance;
if (kit.camera.near !== 0.1) {
kit.camera.near = 0.1;
kit.camera.updateProjectionMatrix();
}
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
roadTraffic.setFollowing(true);
} else {
roadTraffic?.setFollowing(false);
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
if (currentChapter !== chapterId) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
}
}
kit.setPose(chapterPose(first));
// ---- Picking ------------------------------------------------------------
// `pickables` is mutated in place by the layer, so the array itself is the
// live target list.
kit.setPicking<Marker>({
targets: markerLayer.pickables,
resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
onChange: (marker) => options.onMarkerPick?.(marker),
});
// ---- The scene, as the stage sees it ------------------------------------
const stageScene: StageScene = {
scene,
camera: kit.camera,
controls: kit.controls,
// Leaving for an office should retire the hover with it; coming back to a
// stale detail card for something the pointer is nowhere near reads as a
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
const actorPlaying = sceneActor?.active() ?? false;
kit.controls.enabled = !actorPlaying;
kit.tick(dt);
sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
roadTraffic?.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
}
}
// Every frame and on no timer of its own. The catalogue's sweep is
// time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
// often makes it walk the catalogue sooner, never makes it cost more.
if (options.satellites && satelliteLayer) {
/**
* One instant, one sweep, shared.
*
* `fixes()` advances the catalogue's rolling propagation, so calling it
* twice in a frame spends twice the budget for no new information — and
* two different `when`s would put the near-field satellites' sun
* attitude on a different clock from the sky they are in.
*
* The sun comes from `solar.ts` directly rather than from the rig,
* because `atmosphere.ts` floors the light direction at
* `shadowFloorDeg` to keep the shadow camera usable. That floor pins the
* sun above the horizon, and a sun ten degrees *down* is precisely the
* dusk geometry that lights a Starlink pass.
*/
const when = skyOverride ?? new Date();
const fixes = options.satellites.fixes(when);
satelliteLayer.update(fixes);
starlinkMeshes?.update(
fixes,
kit.camera,
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
);
}
},
dispose() {
options.flights?.dispose?.();
flightLayer?.dispose();
satelliteLayer?.dispose();
starlinkMeshes?.dispose();
// Before the `scene.traverse` sweep below, and required rather than tidy:
// the sweep reaches geometries and materials, and a `ShaderMaterial`'s
// uniform textures are neither — the cloud texture is a canvas this layer
// drew and only it can free.
clouds.dispose();
nightLights.dispose();
markerLayer.dispose();
roadTraffic?.dispose();
kit.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const mat = mesh.material;
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
else if (mat) (mat as THREE.Material).dispose();
});
},
};
stage.setScene(stageScene);
return {
world,
chapters: city.chapters,
stage,
stageScene,
setLighting: (state) => {
kit.applyLighting(state);
clouds.setLighting(state);
},
setCloudCover: (fraction) => clouds.setCover(fraction),
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
setSkyInstant: (when) => {
skyOverride = when;
},
setSatellitesVisible: (visible) => satelliteLayer?.setVisible(visible),
satelliteCounts: () => ({
visible: options.satellites?.visibleCount ?? 0,
total: options.satellites?.size ?? 0,
}),
flyTo,
current: () => currentChapter,
onChapterChange(fn) {
chapterListeners.push(fn);
},
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setActorActions: (actions) => { sceneActor?.setActions(actions); },
actorState: () => sceneActor?.state() ?? null,
setActorActive(active) {
sceneActor?.setActive(active);
if (active) roadTraffic?.setFollowing(false);
// State boards compress one real metre to a few hundredths of a scene
// unit. Their possessed actor and chase camera are therefore closer than
// the map camera's 0.1 near plane; lower it only for play mode so the
// procedural rig is not clipped away, then restore the depth precision.
kit.camera.near = active ? 0.001 : 0.1;
kit.controls.minDistance = active ? 0.001 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
},
actorActive: () => sceneActor?.active() ?? false,
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
dispose() {
sceneActor?.dispose();
/**
* Off the stage, then released — and the stage itself is left running.
*
* The order is load-bearing and it used to be the other way round, with
* a comment saying "Stage first, so nothing ticks a half-disposed scene".
* The concern was real and the remedy was the bug: `stage.dispose()`
* calls `renderer.dispose()`, which replaces the `properties` WeakMap,
* and `stageScene.dispose()` then walks the scene disposing materials
* that the renderer no longer has an entry for. `three` reads
* `properties.get(material).programs`, finds `undefined`, and quietly
* skips `gl.deleteProgram` for every one of them — so disposing in that
* order freed nothing it was written to free.
*
* `setScene(null)` answers the ticking concern on its own and answers it
* better: the loop drops this scene on the very next frame, and the
* renderer keeps its bookkeeping so the disposals below actually land.
*/
if (stage.current() === stageScene) stage.setScene(null);
stageScene.dispose();
},
};
}
/**
* The committed default rig: a late-afternoon sun from the west, which throws
* the hills' shadows east across the flats.
*
* Not an `Atmosphere` and not a substitute for one — it computes nothing from
* time or weather, it is a constant with the city's own sky colours poured in.
* It exists so the engine renders with no server, no clock and no config, which
* is the acceptance test the whole repo is held to.
*/
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
return {
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 },
ambient: { color: 0xffffff, intensity: 0.32 },
sky: { top: palette.skyTop, horizon: palette.skyHorizon },
// Scaled to the board for the same reason the camera is, and it was the same
// bug: 210/460 were tuned when San Francisco's 230-unit board was the only
// one, and on the Bay Area's 1003 units they close the fog well inside the
// city. The camera sits about 0.6 spans out on a whole-board view, so a fog
// that starts nearer than that is behind the viewer's own shoulder.
//
// The default keeps the original numbers exactly, for a caller that has a
// palette but no world.
fog: {
color: palette.skyHorizon,
near: boardSpan * 0.91,
far: boardSpan * 2,
},
};
}