Spaces: the inside of the world, and a sun that is actually where it should be
Ten agents wrote this in parallel against CONTRACT.md, which exists because the five design agents before them collided on fifteen blocking points — four files specified twice with incompatible contents, three separate backends for one box, and `Environment` exported twice meaning different things. What landed: a Stage owning only the renderer and the loop, with the city and an office as two scenes over it. They cannot share one — San Francisco is ~94 m per scene unit with 3.6x vertical exaggeration and an office is 1 unit = 1 m — and the city is paused rather than disposed on the way in, because rebuilding its 336,864-point heightfield costs about a second on the way back out. Offices are data. `src/offices/lumbridge-hq.ts` is fifteen rooms and seventy-six seats, and it is the file a self-hoster copies. Walls are a segment list with 1-D openings, so doors and windows are holes punched in a wall rather than placed objects, and the pass that splits a wall around its openings hands the walk-mode collider its segments for free. The sun is real. `solar.ts` is a NOAA/Meeus implementation with no imports at all — not even three.js — so time of day keeps working on a laptop in a field. Verified against known values: 75.45 degrees at the June solstice in SF, 28.79 at December, sunset at 03:15Z. The first screenshot after wiring it was a black rectangle, which turned out to be correct: it was midnight in San Francisco. Presence binds to a seat id and never to a coordinate. The pack knows where `eng-04` is; who is sitting in it is private data behind an API. Same shape as the marker rule, one level in. Two corrections to ARCHITECTURE.md are in here. Containment does not discharge ODbL — publishing OSM-derived coordinates is Public Use of a Derivative Database wherever the rows live, so the rule is about the geocoder (US Census, public domain) and not the storage. And a person at a desk is not a Marker; markers are geographic. One contract gap surfaced only in a screenshot: two agents read `height` on a viewpoint differently, so the establishing shot aimed at empty air fourteen metres above the roof. It now means what the same field means for a city. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,955 @@
|
||||
/**
|
||||
* The sky: what the sun and the weather mean for the light rig.
|
||||
*
|
||||
* `Atmosphere` is the **sole owner of lighting**. It takes an `Environment` — an
|
||||
* observation of the world, `{ time, sun, weather }` — and returns a
|
||||
* `LightingState`, which a `SceneKit` applies. One direction, no write-backs,
|
||||
* and nothing else in the engine is allowed to reach into the three lights. Two
|
||||
* modules both constructing and mutating the same `DirectionalLight` is the
|
||||
* failure this shape exists to prevent; see CONTRACT.md §4.
|
||||
*
|
||||
* Two things follow from that ownership and are worth stating up front:
|
||||
*
|
||||
* 1. **An office gets no Atmosphere at all.** An interior has walls, no
|
||||
* horizon and no weather: it wants `fog: null`, `sky: null` — which tells
|
||||
* `SceneKit` to leave `scene.background` alone entirely — and a fixed
|
||||
* interior rig of its own that never moves. Daylight through windows is a
|
||||
* later refinement and deliberately not a v1 coupling. Nothing in this
|
||||
* file is for an interior, and an office importing it is a mistake.
|
||||
* 2. **`apply` is pure.** It reads its argument, allocates one small object,
|
||||
* and touches nothing. Call it every frame or once a minute; the cost is
|
||||
* the same table lookup either way.
|
||||
*
|
||||
* The solar half is computed locally by `solar.ts` with no network, and the
|
||||
* weather half degrades to `null` — which this file reads as a clear day with
|
||||
* the local climatology still running. The whole engine has to work with no
|
||||
* account, no key and no network, and a sky that goes flat grey the moment the
|
||||
* wifi drops would fail that in the most visible way possible.
|
||||
*
|
||||
* Wiring one to a city, in full:
|
||||
*
|
||||
* ```ts
|
||||
* const atmosphere = createAtmosphere({
|
||||
* lng: city.center.lng,
|
||||
* // Degrees of latitude are ~111.32 km everywhere, so the city's own
|
||||
* // `latScale` is the scale conversion already.
|
||||
* metresPerUnit: 111_320 / city.latScale,
|
||||
* marineLayer: PACIFIC_MARINE_LAYER,
|
||||
* });
|
||||
* handle.setLighting(atmosphere.apply(observe(city.center.lat, city.center.lng, new Date())));
|
||||
* ```
|
||||
*
|
||||
* The marine layer is opt-in and off by default, because it is a fact about a
|
||||
* coast and this module has no idea which one it is looking at.
|
||||
*/
|
||||
|
||||
import { solarPosition, sunDirection, type SolarPosition } from "./solar.ts";
|
||||
import type { LightingState } from "./types.ts";
|
||||
|
||||
// ---- The observation ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sky conditions as a renderer can use them.
|
||||
*
|
||||
* This mirrors `WeatherCondition` in `src/server/wire.ts` member for member,
|
||||
* and is named differently on purpose. The engine must not import the wire —
|
||||
* `wire.ts` already imports `engine/types.ts`, and pointing the arrow back
|
||||
* would make the engine's type graph depend on the server's. A `WeatherBody`
|
||||
* off the wire is structurally assignable to `WeatherObservation` as it stands,
|
||||
* so the adapter is a pass-through and not a translation; if a member is ever
|
||||
* added on one side, add it on the other.
|
||||
*/
|
||||
export type SkyCondition =
|
||||
| "clear"
|
||||
| "partly-cloudy"
|
||||
| "cloudy"
|
||||
| "overcast"
|
||||
| "fog"
|
||||
| "rain"
|
||||
| "snow"
|
||||
| "thunderstorm";
|
||||
|
||||
/**
|
||||
* What the sky is doing, reduced to the four things that change a light rig.
|
||||
*
|
||||
* `null` fields mean *not reported*, never zero. The difference matters: a
|
||||
* station that does not measure visibility is not a station reporting perfect
|
||||
* visibility, and treating the two the same is how a foggy morning renders
|
||||
* clear.
|
||||
*/
|
||||
export interface WeatherObservation {
|
||||
/** 0..1. */
|
||||
cloudCover: number;
|
||||
/** 0..1, an intensity rather than a rate. */
|
||||
precipitation: number;
|
||||
visibilityKm: number | null;
|
||||
windKph: number | null;
|
||||
/** Degrees clockwise from true north, the direction the wind blows *from*. */
|
||||
windDirDeg: number | null;
|
||||
condition: SkyCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the light rig is a consequence of.
|
||||
*
|
||||
* An *observation*, emphatically not a state: this is what the world is doing,
|
||||
* and a `LightingState` is what that means for the three lights. The two were
|
||||
* once one type called `Environment` in two places meaning both things at once,
|
||||
* which is why the names are now this far apart.
|
||||
*/
|
||||
export interface Environment {
|
||||
time: Date;
|
||||
sun: SolarPosition;
|
||||
/** `null` when nobody was asked. A supported state, not an error. */
|
||||
weather: WeatherObservation | null;
|
||||
}
|
||||
|
||||
/** Build an `Environment` for a place and an instant, computing the sun locally. */
|
||||
export function observe(
|
||||
lat: number,
|
||||
lng: number,
|
||||
when: Date,
|
||||
weather: WeatherObservation | null = null,
|
||||
): Environment {
|
||||
return { time: when, sun: solarPosition(lat, lng, when), weather };
|
||||
}
|
||||
|
||||
// ---- Options --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coastal advection fog, as a season, a clock and a wind gate.
|
||||
*
|
||||
* Parameterised rather than hard-coded because the model is climatology, not
|
||||
* geography: the same three curves describe the eastern Pacific layer, the
|
||||
* Namibian one and the Peruvian one, with different numbers in them.
|
||||
*/
|
||||
export interface MarineLayerOptions {
|
||||
/** Day of year, 1-366, at which the season peaks. */
|
||||
peakDay: number;
|
||||
/** Half-width of the season in days. Outside it, `offSeason` applies. */
|
||||
seasonDays: number;
|
||||
/** Residual strength out of season, 0..1. */
|
||||
offSeason: number;
|
||||
/** Bearing the onshore wind blows *from*, degrees clockwise from true north. */
|
||||
onshoreBearing: number;
|
||||
/** How far off that bearing still counts as onshore, in degrees. */
|
||||
onshoreHalfWidth: number;
|
||||
/** Strength at the peak of the season, 0..1. */
|
||||
strength: number;
|
||||
/** Visibility inside the layer, in metres. */
|
||||
visibilityM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The eastern-Pacific summer layer: San Francisco's fog.
|
||||
*
|
||||
* This is the single most recognisable atmospheric fact about the city and it
|
||||
* is worth getting specifically right rather than approximating with generic
|
||||
* haze. The layer is cold air over the California Current, dragged inland
|
||||
* through the one sea-level gap in the coast range by the Central Valley's
|
||||
* afternoon heat low. That gives it a shape a renderer can actually use:
|
||||
*
|
||||
* - **A season.** It is a summer phenomenon, peaking in July, essentially
|
||||
* gone by the clear warm October that surprises every visitor who packed
|
||||
* for August. Winter fog in the Bay Area is a different animal — radiation
|
||||
* fog, mostly inland — and the small `offSeason` residual is all of it that
|
||||
* belongs here.
|
||||
* - **A clock.** In through the Gate in the late afternoon, thickest from
|
||||
* midnight to a couple of hours after sunrise, burning off through the late
|
||||
* morning and back again from about five.
|
||||
* - **A wind.** It is *advection* fog: it has to be blown in. A westerly at
|
||||
* 15-30 km/h is the engine of it; an offshore easterly kills it outright,
|
||||
* and a gale mixes it up into stratus instead.
|
||||
*
|
||||
* What is deliberately not modelled: the layer is shallow — a few hundred
|
||||
* metres — so downtown's towers and Twin Peaks stand in clear air above a white
|
||||
* floor, and the Sunset is buried while the Mission is in sunshine. Both facts
|
||||
* need height fog and a horizontal gradient; `THREE.Fog` is a single global
|
||||
* linear ramp and can express neither. Rather than fake it, the strength is
|
||||
* capped so that the city dims and flattens instead of disappearing.
|
||||
*/
|
||||
export const PACIFIC_MARINE_LAYER: MarineLayerOptions = {
|
||||
peakDay: 196, // 15 July
|
||||
seasonDays: 88, // roughly mid-April to mid-October
|
||||
offSeason: 0.1,
|
||||
onshoreBearing: 275, // just north of due west, straight in through the Gate
|
||||
onshoreHalfWidth: 75,
|
||||
strength: 1,
|
||||
visibilityM: 5000,
|
||||
};
|
||||
|
||||
export interface AtmosphereOptions {
|
||||
/**
|
||||
* Observer longitude, degrees east. Needed for apparent solar time, which is
|
||||
* what the marine layer's clock runs on — a fog that burns off at 11 a.m.
|
||||
* needs to know when 11 a.m. is, and we ship no timezone database.
|
||||
*/
|
||||
lng: number;
|
||||
/**
|
||||
* Metres in one scene unit. Visibility arrives in kilometres and fog
|
||||
* distances leave in scene units, and this is the only thing that knows the
|
||||
* exchange rate: ~94 m per unit for San Francisco, 1 m for an interior that
|
||||
* will never call this anyway.
|
||||
*/
|
||||
metresPerUnit: number;
|
||||
/**
|
||||
* The clear-noon sky this city wants. Every other stop in the table is a
|
||||
* consequence of where the sun is and is not a city's business to override.
|
||||
*/
|
||||
sky?: { top: number; horizon: number };
|
||||
/** Fog on a clear day, in scene units. */
|
||||
clearFog?: { near: number; far: number };
|
||||
/**
|
||||
* Visibility, in metres, that fog is never allowed to fall below.
|
||||
*
|
||||
* A deliberate lie. Real advection fog at the Golden Gate has visibility
|
||||
* under 400 m, and rendering that honestly produces a white rectangle with a
|
||||
* city somewhere inside it. What reads as fog is the *look* — no horizon, no
|
||||
* shadows, a dead sun, everything the same flat grey — at a range you can
|
||||
* still fly a camera through.
|
||||
*/
|
||||
minVisibilityM?: number;
|
||||
/**
|
||||
* Degrees. The light direction is never allowed below this elevation. `0`
|
||||
* turns the lift off; see `liftedElevation` for why it exists.
|
||||
*/
|
||||
shadowFloorDeg?: number;
|
||||
/** Coastal fog model, or nothing. Off unless a city asks for it. */
|
||||
marineLayer?: MarineLayerOptions | null;
|
||||
}
|
||||
|
||||
export interface Atmosphere {
|
||||
/** The rig this observation implies. Pure; the caller applies the result. */
|
||||
apply(env: Environment): LightingState;
|
||||
}
|
||||
|
||||
// ---- Constants ------------------------------------------------------------
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** Matches `cityDaylight()` in `scene.ts`, so switching to a live sun at noon does not jolt. */
|
||||
const DEFAULT_SKY_TOP = 0x8fb8d8;
|
||||
const DEFAULT_SKY_HORIZON = 0xd9e6ee;
|
||||
const DEFAULT_FOG_NEAR = 210;
|
||||
const DEFAULT_FOG_FAR = 460;
|
||||
const DEFAULT_MIN_VISIBILITY_M = 4500;
|
||||
const DEFAULT_SHADOW_FLOOR_DEG = 7;
|
||||
|
||||
/**
|
||||
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
|
||||
* 0.457 and looks right, so the ratio is held rather than the distance: a fog
|
||||
* that closes to 60 units with its near plane still at 210 is not fog, it is a
|
||||
* solid wall.
|
||||
*/
|
||||
const FOG_NEAR_RATIO = 0.45;
|
||||
|
||||
/**
|
||||
* Kilometres at and above which a reported visibility means "as far as anyone
|
||||
* bothered to look".
|
||||
*
|
||||
* This is the correction that stops every clear day rendering hazy. A METAR of
|
||||
* `10SM` is the *maximum value the report can carry*, not a measurement of ten
|
||||
* miles — the observer stopped counting. Taking it literally puts the fog plane
|
||||
* at 170 scene units, inside the camera's own orbit range, and washes out a sky
|
||||
* that is in fact unlimited. The blend from `VISIBILITY_HAZY_KM` upward also
|
||||
* keeps the transition smooth, because a cliff at exactly 16 km would make the
|
||||
* whole city snap between hazy and crisp on a one-decimal change upstream.
|
||||
*/
|
||||
const VISIBILITY_UNLIMITED_KM = 16;
|
||||
const VISIBILITY_HAZY_KM = 8;
|
||||
|
||||
/** Visibility assumed when a source says "fog" and reports no number. */
|
||||
const FOG_CONDITION_VISIBILITY_KM = 1.5;
|
||||
|
||||
// ---- The daylight table ---------------------------------------------------
|
||||
|
||||
interface Rig {
|
||||
skyTop: number;
|
||||
skyHorizon: number;
|
||||
sunColor: number;
|
||||
sunIntensity: number;
|
||||
hemiSky: number;
|
||||
hemiGround: number;
|
||||
hemiIntensity: number;
|
||||
ambientColor: number;
|
||||
ambientIntensity: number;
|
||||
}
|
||||
|
||||
interface Keyframe extends Rig {
|
||||
/** Solar elevation, in degrees, that this frame describes exactly. */
|
||||
elevation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sky at eight solar elevations, interpolated between.
|
||||
*
|
||||
* The stops sit on the twilight boundaries `solar.ts` already names — -18, -12,
|
||||
* -6, the refracted horizon, and up through golden hour into full day — so that
|
||||
* `daylightPhase` and this table agree about where a phase begins. A table
|
||||
* rather than a formula because the interesting part of a sunset is not
|
||||
* physical: the horizon band goes salmon while the zenith is still deep blue,
|
||||
* and the ratio between them is a thing you tune by looking, not by deriving.
|
||||
*
|
||||
* Two stops carry the city's own daylight colours (see `AtmosphereOptions.sky`),
|
||||
* so a city that declares a paler or bluer sky keeps it at noon and still gets
|
||||
* the same dusk as everywhere else — dusk is not regional in any way this
|
||||
* renderer can see.
|
||||
*/
|
||||
function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
return [
|
||||
{
|
||||
// Full night. The directional light is not the sun and is not the moon
|
||||
// either; it is a token sidelight standing in for moonlight and city
|
||||
// glow, because a scene lit by hemisphere alone has no silhouettes in it
|
||||
// and reads as a bug rather than as darkness.
|
||||
elevation: -18,
|
||||
skyTop: 0x05070f,
|
||||
skyHorizon: 0x0b1120,
|
||||
sunColor: 0x2e3c66,
|
||||
sunIntensity: 0.05,
|
||||
hemiSky: 0x121a30,
|
||||
hemiGround: 0x080a10,
|
||||
hemiIntensity: 0.25,
|
||||
ambientColor: 0x28304a,
|
||||
ambientIntensity: 0.1,
|
||||
},
|
||||
{
|
||||
elevation: -12,
|
||||
skyTop: 0x080d1e,
|
||||
skyHorizon: 0x141d38,
|
||||
sunColor: 0x3d4a76,
|
||||
sunIntensity: 0.07,
|
||||
hemiSky: 0x18223c,
|
||||
hemiGround: 0x0a0d16,
|
||||
hemiIntensity: 0.28,
|
||||
ambientColor: 0x2c3552,
|
||||
ambientIntensity: 0.11,
|
||||
},
|
||||
{
|
||||
elevation: -6,
|
||||
skyTop: 0x101a3a,
|
||||
skyHorizon: 0x2b3560,
|
||||
sunColor: 0x5b5d8e,
|
||||
sunIntensity: 0.12,
|
||||
hemiSky: 0x22304f,
|
||||
hemiGround: 0x121520,
|
||||
hemiIntensity: 0.35,
|
||||
ambientColor: 0x38406a,
|
||||
ambientIntensity: 0.14,
|
||||
},
|
||||
{
|
||||
// The sun on the horizon. Warm at the bottom, cold at the top, and the
|
||||
// widest colour spread the sky ever has.
|
||||
elevation: -0.4,
|
||||
skyTop: 0x2a4275,
|
||||
skyHorizon: 0x9a6a63,
|
||||
sunColor: 0xc2795c,
|
||||
sunIntensity: 0.45,
|
||||
hemiSky: 0x4a5f8c,
|
||||
hemiGround: 0x2a2a2c,
|
||||
hemiIntensity: 0.6,
|
||||
ambientColor: 0x6a6a80,
|
||||
ambientIntensity: 0.2,
|
||||
},
|
||||
{
|
||||
elevation: 3,
|
||||
skyTop: 0x4d76ac,
|
||||
skyHorizon: 0xdba078,
|
||||
sunColor: 0xff9c56,
|
||||
sunIntensity: 1.25,
|
||||
hemiSky: 0x86a6cc,
|
||||
hemiGround: 0x54503f,
|
||||
hemiIntensity: 0.85,
|
||||
ambientColor: 0xffd9b8,
|
||||
ambientIntensity: 0.24,
|
||||
},
|
||||
{
|
||||
elevation: 8,
|
||||
skyTop: 0x6b96c6,
|
||||
skyHorizon: 0xebc9a4,
|
||||
sunColor: 0xffc489,
|
||||
sunIntensity: 1.8,
|
||||
hemiSky: 0xb2cbe4,
|
||||
hemiGround: 0x6a6752,
|
||||
hemiIntensity: 0.98,
|
||||
ambientColor: 0xffe7cf,
|
||||
ambientIntensity: 0.28,
|
||||
},
|
||||
{
|
||||
// Ordinary daylight, and the one stop that reproduces `cityDaylight()`.
|
||||
elevation: 25,
|
||||
skyTop: dayTop,
|
||||
skyHorizon: dayHorizon,
|
||||
sunColor: 0xfff3e0,
|
||||
sunIntensity: 2.1,
|
||||
hemiSky: 0xdcecf7,
|
||||
hemiGround: 0x6b6f5e,
|
||||
hemiIntensity: 1.05,
|
||||
ambientColor: 0xffffff,
|
||||
ambientIntensity: 0.32,
|
||||
},
|
||||
{
|
||||
// A high sun. The zenith deepens — less air to scatter through overhead —
|
||||
// while the horizon whitens, so the gradient is at its steepest at noon.
|
||||
elevation: 65,
|
||||
skyTop: mixHex(dayTop, 0x2f6bb0, 0.35),
|
||||
skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2),
|
||||
sunColor: 0xfffdf6,
|
||||
sunIntensity: 2.35,
|
||||
hemiSky: 0xe6f2fb,
|
||||
hemiGround: 0x74786a,
|
||||
hemiIntensity: 1.1,
|
||||
ambientColor: 0xffffff,
|
||||
ambientIntensity: 0.3,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame for an elevation, blended between the two stops around it.
|
||||
*
|
||||
* Eased rather than linear. Straight lerp between table rows leaves a visible
|
||||
* crease every time the sun crosses a stop — the rate of change jumps, and on
|
||||
* a sky the eye reads that as a seam sliding down the screen. Smoothstep makes
|
||||
* the derivative zero at each stop, so the stops stop being findable.
|
||||
*/
|
||||
function sample(frames: readonly Keyframe[], elevation: number): Rig {
|
||||
const first = frames[0];
|
||||
const last = frames[frames.length - 1];
|
||||
if (!first || !last) throw new Error("atmosphere: empty keyframe table");
|
||||
if (elevation <= first.elevation) return first;
|
||||
if (elevation >= last.elevation) return last;
|
||||
|
||||
for (let i = 1; i < frames.length; i++) {
|
||||
const a = frames[i - 1];
|
||||
const b = frames[i];
|
||||
if (!a || !b) continue;
|
||||
if (elevation <= b.elevation) {
|
||||
const t = ease((elevation - a.elevation) / (b.elevation - a.elevation));
|
||||
return {
|
||||
skyTop: mixHex(a.skyTop, b.skyTop, t),
|
||||
skyHorizon: mixHex(a.skyHorizon, b.skyHorizon, t),
|
||||
sunColor: mixHex(a.sunColor, b.sunColor, t),
|
||||
sunIntensity: lerp(a.sunIntensity, b.sunIntensity, t),
|
||||
hemiSky: mixHex(a.hemiSky, b.hemiSky, t),
|
||||
hemiGround: mixHex(a.hemiGround, b.hemiGround, t),
|
||||
hemiIntensity: lerp(a.hemiIntensity, b.hemiIntensity, t),
|
||||
ambientColor: mixHex(a.ambientColor, b.ambientColor, t),
|
||||
ambientIntensity: lerp(a.ambientIntensity, b.ambientIntensity, t),
|
||||
};
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
// ---- The atmosphere -------------------------------------------------------
|
||||
|
||||
export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
|
||||
const { lng, metresPerUnit } = options;
|
||||
const table = keyframes(
|
||||
options.sky?.top ?? DEFAULT_SKY_TOP,
|
||||
options.sky?.horizon ?? DEFAULT_SKY_HORIZON,
|
||||
);
|
||||
const clearNear = options.clearFog?.near ?? DEFAULT_FOG_NEAR;
|
||||
const clearFar = options.clearFog?.far ?? DEFAULT_FOG_FAR;
|
||||
const floorFar = (options.minVisibilityM ?? DEFAULT_MIN_VISIBILITY_M) / metresPerUnit;
|
||||
const shadowFloor = options.shadowFloorDeg ?? DEFAULT_SHADOW_FLOOR_DEG;
|
||||
const marineOptions = options.marineLayer ?? null;
|
||||
|
||||
function apply(env: Environment): LightingState {
|
||||
const elevation = env.sun.elevation;
|
||||
// Copied, because `sample` hands back a table row unchanged when the
|
||||
// elevation is off either end of it and everything below mutates in place.
|
||||
const rig = { ...sample(table, elevation) };
|
||||
|
||||
// How much of the light is daylight at all. Used to keep the weather from
|
||||
// brightening the night: overcast at noon is grey, overcast at 2 a.m. is
|
||||
// still black, and a modifier that does not know the difference will
|
||||
// cheerfully raise the small hours to a uniform slate.
|
||||
const day = smoothstep(-6, 6, elevation);
|
||||
|
||||
const weather = env.weather;
|
||||
const cloud = clamp(weather?.cloudCover ?? 0, 0, 1);
|
||||
const precipitation = clamp(weather?.precipitation ?? 0, 0, 1);
|
||||
const condition = weather?.condition ?? "clear";
|
||||
|
||||
applyCloud(rig, cloud, day);
|
||||
applyPrecipitation(rig, precipitation, condition, day);
|
||||
|
||||
// An observation always beats the climatology: `null` lets the model run
|
||||
// free, which is what gives an offline San Francisco its summer fog, but a
|
||||
// station reporting sunshine ends the argument. See `observedObscuration`.
|
||||
const observed = weather ? observedObscuration(weather) : null;
|
||||
const modelled = marineOptions ? marineStrength(marineOptions, env, lng) : 0;
|
||||
const obscuration =
|
||||
observed === null ? modelled : observed === 0 ? 0 : Math.max(observed, modelled);
|
||||
|
||||
let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit);
|
||||
if (weather === null || weather.visibilityKm === null) {
|
||||
// Rain shortens the view; a source that measured visibility has already
|
||||
// said so, and applying both would count it twice.
|
||||
fogFar *= 1 - 0.45 * precipitation;
|
||||
}
|
||||
if (marineOptions && obscuration > 0) {
|
||||
const inside = marineOptions.visibilityM / metresPerUnit;
|
||||
fogFar = Math.min(fogFar, lerp(fogFar, inside, obscuration));
|
||||
}
|
||||
fogFar = Math.max(fogFar, floorFar);
|
||||
|
||||
const fogColor = applyObscuration(rig, obscuration, day, condition);
|
||||
|
||||
// A clear day keeps the near plane it was given; anything shorter holds the
|
||||
// ratio instead, because fog that starts where the clear day's did and ends
|
||||
// sixty units out is not fog, it is a wall.
|
||||
const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9);
|
||||
|
||||
return {
|
||||
sun: {
|
||||
direction: lightDirection(env.sun, shadowFloor),
|
||||
color: rig.sunColor,
|
||||
intensity: Math.max(0, rig.sunIntensity),
|
||||
},
|
||||
hemisphere: {
|
||||
sky: rig.hemiSky,
|
||||
ground: rig.hemiGround,
|
||||
intensity: Math.max(0, rig.hemiIntensity),
|
||||
},
|
||||
ambient: { color: rig.ambientColor, intensity: Math.max(0, rig.ambientIntensity) },
|
||||
sky: { top: rig.skyTop, horizon: rig.skyHorizon },
|
||||
fog: { color: fogColor, near, far: fogFar },
|
||||
};
|
||||
}
|
||||
|
||||
return { apply };
|
||||
}
|
||||
|
||||
// ---- The sun's direction, and the shadow camera ---------------------------
|
||||
|
||||
/**
|
||||
* A unit vector toward the sun, never allowed below `floorDeg`.
|
||||
*
|
||||
* This is the whole of the atmosphere's shadow handling, and it is a lift on
|
||||
* the light direction rather than a change to the shadow camera because a
|
||||
* `LightingState` deliberately carries no shadow fields — the extents belong to
|
||||
* the scene, which knows its own scale, and letting the sun reach into them
|
||||
* would be exactly the write-back CONTRACT.md §4 forbids. So the rule is
|
||||
* inverted: never ask for a direction the shadow camera cannot serve.
|
||||
*
|
||||
* What goes wrong without it, at `SceneKit`'s defaults — a 2048 map over a
|
||||
* 340-unit box, so one texel is ~0.17 units or about 16 m of San Francisco:
|
||||
*
|
||||
* - Depth across a texel grows as 1/tan(elevation). At 20° a texel spans 0.46
|
||||
* units of depth; at 3° it spans 3.2. A single fixed `shadow.bias` tuned at
|
||||
* one of those is acne or peter-panning at the other, and there is no value
|
||||
* that is right at both.
|
||||
* - A shadow three hundred metres long and two metres wide is a shape a 16 m
|
||||
* texel cannot represent at all. It comes out as a dashed line that crawls
|
||||
* when the camera moves.
|
||||
* - Below the horizon the direction points *up* from underneath, lighting the
|
||||
* undersides of everything and leaving the roofs black. That is not a
|
||||
* subtle artefact; it is a scene that looks inside out.
|
||||
*
|
||||
* The visible cost is that shadows stop lengthening a few degrees before
|
||||
* sunset. Against a sunset in which they shimmer, dash and then invert, that is
|
||||
* a cheap trade — and by the time the lift is doing real work the sun's
|
||||
* intensity is already down near a tenth, so there is very little shadow left
|
||||
* to be wrong about. A scene that genuinely wants grazing shadows widens its
|
||||
* own `shadowExtent`/`shadowMapSize` and passes `shadowFloorDeg: 0`.
|
||||
*/
|
||||
function lightDirection(sun: SolarPosition, floorDeg: number): [number, number, number] {
|
||||
const lifted = floorDeg > 0 ? Math.max(sun.elevation, floorDeg) : sun.elevation;
|
||||
const dir = sunDirection({ ...sun, elevation: lifted });
|
||||
return [dir.x, dir.y, dir.z];
|
||||
}
|
||||
|
||||
// ---- Weather --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cloud flattens and desaturates, and it does both by taking the sun away.
|
||||
*
|
||||
* Overcast is not "the same scene, dimmer": the directional component collapses
|
||||
* and what is left is a uniform dome, so shadows vanish, the sky's gradient
|
||||
* closes up and every colour loses its warmth because it is being lit by grey.
|
||||
* Raising ambient while cutting the sun is what reproduces that — the total
|
||||
* falls, but the ratio falls much further, which is the part the eye reads.
|
||||
*/
|
||||
function applyCloud(rig: Rig, cloud: number, day: number): void {
|
||||
if (cloud <= 0) return;
|
||||
|
||||
rig.sunIntensity *= 1 - 0.78 * cloud;
|
||||
rig.sunColor = mixHex(rig.sunColor, 0xf2f4f6, 0.6 * cloud);
|
||||
|
||||
// The zenith comes down to meet the horizon: an overcast sky has almost no
|
||||
// gradient left in it, which is why an overcast photograph has no top.
|
||||
rig.skyTop = desaturate(mixHex(rig.skyTop, rig.skyHorizon, 0.55 * cloud), 0.7 * cloud);
|
||||
rig.skyHorizon = desaturate(rig.skyHorizon, 0.6 * cloud);
|
||||
const dim = 1 - 0.16 * cloud * day;
|
||||
rig.skyTop = scale(rig.skyTop, dim);
|
||||
rig.skyHorizon = scale(rig.skyHorizon, dim);
|
||||
|
||||
rig.hemiSky = desaturate(rig.hemiSky, 0.55 * cloud);
|
||||
rig.hemiIntensity *= 1 + 0.18 * cloud * day;
|
||||
rig.ambientColor = desaturate(rig.ambientColor, 0.7 * cloud);
|
||||
rig.ambientIntensity *= 1 + 0.55 * cloud * day;
|
||||
}
|
||||
|
||||
function applyPrecipitation(
|
||||
rig: Rig,
|
||||
precipitation: number,
|
||||
condition: SkyCondition,
|
||||
day: number,
|
||||
): void {
|
||||
if (condition === "snow") {
|
||||
// Snow is the largest reflector a scene ever acquires: the bounce light off
|
||||
// the ground stops being dirt-coloured and starts being sky-coloured, which
|
||||
// is most of why a snowy day looks the way it does from below.
|
||||
rig.hemiGround = mixHex(rig.hemiGround, 0xe9eef2, 0.75);
|
||||
rig.hemiIntensity *= 1 + 0.2 * day;
|
||||
rig.ambientIntensity *= 1 + 0.15 * day;
|
||||
}
|
||||
if (precipitation <= 0 && condition !== "thunderstorm") return;
|
||||
|
||||
const heavy = condition === "thunderstorm" ? Math.max(0.75, precipitation) : precipitation;
|
||||
const dim = 1 - 0.3 * heavy * day;
|
||||
rig.sunIntensity *= 1 - 0.4 * heavy;
|
||||
rig.hemiIntensity *= dim;
|
||||
rig.ambientIntensity *= dim;
|
||||
rig.skyTop = scale(desaturate(rig.skyTop, 0.4 * heavy), dim);
|
||||
rig.skyHorizon = scale(desaturate(rig.skyHorizon, 0.4 * heavy), dim);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fog distance from a reported visibility, in scene units.
|
||||
*
|
||||
* `THREE.Fog` is linear and fully opaque at `far`, and meteorological
|
||||
* visibility is the range at which contrast is essentially gone, so the two are
|
||||
* the same number by definition — once the "10 miles means we stopped counting"
|
||||
* problem above is dealt with.
|
||||
*/
|
||||
function visibilityFar(
|
||||
weather: WeatherObservation | null,
|
||||
condition: SkyCondition,
|
||||
clearFar: number,
|
||||
metresPerUnit: number,
|
||||
): number {
|
||||
let km = weather?.visibilityKm ?? null;
|
||||
if (km === null && condition === "fog") km = FOG_CONDITION_VISIBILITY_KM;
|
||||
if (km === null) return clearFar;
|
||||
|
||||
const observed = (km * 1000) / metresPerUnit;
|
||||
return lerp(observed, clearFar, smoothstep(VISIBILITY_HAZY_KM, VISIBILITY_UNLIMITED_KM, km));
|
||||
}
|
||||
|
||||
// ---- The marine layer -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* How thoroughly the air itself is in the way, 0..1, as reported.
|
||||
*
|
||||
* Kept separate from the marine layer, and generic, because obscuration is not
|
||||
* a San Francisco phenomenon even though the model of where it comes from is:
|
||||
* a city with no `marineLayer` configured must still render a reported fog as
|
||||
* fog — flat, shadowless, no horizon — rather than as a blue sky with the far
|
||||
* shore mysteriously missing. The layer's job is to *supply* this number when
|
||||
* nobody was asked; this is what to do with one once it exists.
|
||||
*/
|
||||
function observedObscuration(weather: WeatherObservation): number {
|
||||
if (weather.condition === "fog") return 1;
|
||||
const visibility = weather.visibilityKm;
|
||||
// A continuous ramp rather than a threshold: 10 km is where a distant hill
|
||||
// starts losing its edges and half a kilometre is where everything has gone,
|
||||
// and a step anywhere between them would make the sky snap on a rounding
|
||||
// difference upstream.
|
||||
if (visibility !== null && visibility < 10) return clamp(1 - (visibility - 0.5) / 9.5, 0, 1);
|
||||
// High stratus: the same air mass a few hundred metres up. Locally this is
|
||||
// the overcast that gets called May grey and June gloom, and it flattens the
|
||||
// light the same way without ever touching the ground.
|
||||
if (weather.cloudCover > 0.8 && (visibility === null || visibility < 12)) return 0.35;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function marineStrength(layer: MarineLayerOptions, env: Environment, lng: number): number {
|
||||
const season = seasonFactor(layer, dayOfYear(env.time));
|
||||
const diurnal = diurnalFactor(solarHours(env.time, lng, env.sun.equationOfTime));
|
||||
const wind = windGate(layer, env.weather);
|
||||
return clamp(layer.strength * season * diurnal * wind, 0, 1);
|
||||
}
|
||||
|
||||
/** A smooth bump centred on the season's peak, on a residual floor. */
|
||||
function seasonFactor(layer: MarineLayerOptions, doy: number): number {
|
||||
const d = Math.abs(wrapSigned(doy - layer.peakDay, 365.25));
|
||||
if (d >= layer.seasonDays) return layer.offSeason;
|
||||
const bump = 0.5 * (1 + Math.cos((Math.PI * d) / layer.seasonDays));
|
||||
return layer.offSeason + (1 - layer.offSeason) * bump;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's shape: thickest before dawn, burnt off through the late morning,
|
||||
* back in from mid-afternoon.
|
||||
*
|
||||
* The hours are **apparent solar**, not civil, which is both physically right —
|
||||
* burn-off is the sun doing work, and it starts when the sun does — and the
|
||||
* only clock available offline. It does mean the curve reads early against a
|
||||
* wristwatch: San Francisco's solar noon is around 13:07 PDT, so the 11.0 here
|
||||
* where the layer is thinnest is a little after midday on the clock, and the
|
||||
* 16.0 where it starts coming back is around five.
|
||||
*/
|
||||
const DIURNAL: readonly (readonly [number, number])[] = [
|
||||
[0, 0.95],
|
||||
[5, 1],
|
||||
[7, 0.95],
|
||||
[9, 0.6],
|
||||
[11, 0.2],
|
||||
[14, 0.12],
|
||||
[16, 0.4],
|
||||
[18, 0.8],
|
||||
[20, 0.92],
|
||||
[24, 0.95],
|
||||
];
|
||||
|
||||
function diurnalFactor(hours: number): number {
|
||||
const h = mod(hours, 24);
|
||||
for (let i = 1; i < DIURNAL.length; i++) {
|
||||
const a = DIURNAL[i - 1];
|
||||
const b = DIURNAL[i];
|
||||
if (!a || !b) continue;
|
||||
if (h <= b[0]) return lerp(a[1], b[1], ease((h - a[0]) / (b[0] - a[0])));
|
||||
}
|
||||
return 0.95;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advection fog has to be blown in, so the wind is a gate and not a garnish.
|
||||
*
|
||||
* This is also the only place wind touches the rig at all, and that is
|
||||
* deliberate: wind moves clouds and shreds fog, and this renderer has no cloud
|
||||
* layer for it to move. Wiring it to anything else — a brightness, a colour —
|
||||
* would be decoration dressed as physics.
|
||||
*
|
||||
* Missing wind data returns 1. An unreported wind is not a calm.
|
||||
*/
|
||||
function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null): number {
|
||||
const speed = weather?.windKph ?? null;
|
||||
const from = weather?.windDirDeg ?? null;
|
||||
if (speed === null && from === null) return 1;
|
||||
|
||||
let gate = 1;
|
||||
if (from !== null) {
|
||||
const off = Math.abs(wrapSigned(from - layer.onshoreBearing, 360));
|
||||
gate *=
|
||||
off >= layer.onshoreHalfWidth
|
||||
? 0.1
|
||||
: 0.1 + 0.9 * (0.5 * (1 + Math.cos((Math.PI * off) / layer.onshoreHalfWidth)));
|
||||
}
|
||||
if (speed !== null) {
|
||||
// Calm: the layer sits offshore and never arrives. Gale: mechanical mixing
|
||||
// lifts it clear of the ground into stratus, which is why the foggiest days
|
||||
// are breezy rather than windy.
|
||||
if (speed < 3) gate *= 0.55;
|
||||
else if (speed < 8) gate *= 0.55 + 0.45 * ((speed - 3) / 5);
|
||||
else if (speed > 35) gate *= Math.max(0.25, 1 - (speed - 35) / 35);
|
||||
}
|
||||
return gate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold obscuration into the rig and return the fog colour.
|
||||
*
|
||||
* Everything converges: the zenith, the horizon and the fog all end up the same
|
||||
* flat grey-white, which is what kills the horizon line — there is no boundary
|
||||
* left between sky and distance. The sun goes out but the total light does not,
|
||||
* because fog is a diffuser and not a lid; it is bright, shadowless and
|
||||
* directionless, and getting that combination right is the difference between
|
||||
* fog and dusk.
|
||||
*
|
||||
* At zero the fog colour is the horizon colour exactly, which is both what
|
||||
* `cityDaylight()` commits to and the physically honest answer — distant haze
|
||||
* is lit by the sky it sits in front of, so it goes warm at sunset along with
|
||||
* everything else rather than staying a neutral grey.
|
||||
*/
|
||||
function applyObscuration(
|
||||
rig: Rig,
|
||||
obscuration: number,
|
||||
day: number,
|
||||
condition: SkyCondition,
|
||||
): number {
|
||||
let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day);
|
||||
if (condition === "snow") thick = mixHex(thick, 0xeef2f5, 0.4);
|
||||
const fogColor = mixHex(rig.skyHorizon, thick, obscuration);
|
||||
if (obscuration <= 0) return fogColor;
|
||||
|
||||
rig.skyTop = mixHex(rig.skyTop, fogColor, 0.85 * obscuration);
|
||||
rig.skyHorizon = mixHex(rig.skyHorizon, fogColor, 0.92 * obscuration);
|
||||
|
||||
rig.sunIntensity *= 1 - 0.88 * obscuration;
|
||||
rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration);
|
||||
|
||||
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration);
|
||||
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration);
|
||||
rig.hemiIntensity *= 1 + 0.12 * obscuration * day;
|
||||
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration);
|
||||
rig.ambientIntensity *= 1 + 0.45 * obscuration * day;
|
||||
|
||||
return fogColor;
|
||||
}
|
||||
|
||||
// ---- Time -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apparent solar hours since local midnight.
|
||||
*
|
||||
* `solar.ts` computes this same quantity on its way to an azimuth and does not
|
||||
* export it. Three lines here is cheaper than widening `SolarPosition` with a
|
||||
* field only the marine layer reads — and the equation of time, which is the
|
||||
* hard part, does come across on the observation.
|
||||
*/
|
||||
function solarHours(when: Date, lng: number, equationOfTime: number): number {
|
||||
const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440);
|
||||
return mod(utcMinutes + equationOfTime + 4 * lng, 1440) / 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day of the year, 1-366, in UTC. The seasonal curve is nearly three months
|
||||
* wide, so which side of midnight the local day falls on is not a difference it
|
||||
* can express.
|
||||
*/
|
||||
function dayOfYear(when: Date): number {
|
||||
const start = Date.UTC(when.getUTCFullYear(), 0, 1);
|
||||
return Math.floor((when.getTime() - start) / MS_PER_DAY) + 1;
|
||||
}
|
||||
|
||||
// ---- Colour ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Blend two colours the way light blends, not the way bytes do.
|
||||
*
|
||||
* sRGB is a display encoding, and lerping in it sends the midpoint between a
|
||||
* twilight blue and a sunset orange through a dead brown-grey that neither
|
||||
* colour has any of. Squaring into approximately linear light, mixing there and
|
||||
* taking the square root back is the cheapest fix that removes it, and at dusk
|
||||
* — when almost every colour in the table is being interpolated at once — the
|
||||
* difference is the whole mood of the frame.
|
||||
*/
|
||||
function mixHex(a: number, b: number, t: number): number {
|
||||
const k = clamp(t, 0, 1);
|
||||
const [ar, ag, ab] = linear(a);
|
||||
const [br, bg, bb] = linear(b);
|
||||
return encode(lerp(ar, br, k), lerp(ag, bg, k), lerp(ab, bb, k));
|
||||
}
|
||||
|
||||
/** Pull a colour toward its own brightness. `t = 1` is grey. */
|
||||
function desaturate(hex: number, t: number): number {
|
||||
const k = clamp(t, 0, 1);
|
||||
const [r, g, b] = linear(hex);
|
||||
// Rec. 709 luminance, on linear values, which is the only place it means
|
||||
// anything.
|
||||
const y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return encode(lerp(r, y, k), lerp(g, y, k), lerp(b, y, k));
|
||||
}
|
||||
|
||||
/** Multiply a colour's light, not its bytes. */
|
||||
function scale(hex: number, factor: number): number {
|
||||
const [r, g, b] = linear(hex);
|
||||
const f = Math.max(0, factor);
|
||||
return encode(r * f, g * f, b * f);
|
||||
}
|
||||
|
||||
function linear(hex: number): [number, number, number] {
|
||||
const r = ((hex >> 16) & 0xff) / 255;
|
||||
const g = ((hex >> 8) & 0xff) / 255;
|
||||
const b = (hex & 0xff) / 255;
|
||||
return [r * r, g * g, b * b];
|
||||
}
|
||||
|
||||
function encode(r: number, g: number, b: number): number {
|
||||
const to = (v: number) => Math.round(clamp(Math.sqrt(Math.max(0, v)), 0, 1) * 255);
|
||||
return (to(r) << 16) | (to(g) << 8) | to(b);
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return x < lo ? lo : x > hi ? hi : x;
|
||||
}
|
||||
|
||||
/** Hermite ease over a span, flat at both ends. */
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
if (edge1 === edge0) return x < edge0 ? 0 : 1;
|
||||
return ease((x - edge0) / (edge1 - edge0));
|
||||
}
|
||||
|
||||
function ease(t: number): number {
|
||||
const k = clamp(t, 0, 1);
|
||||
return k * k * (3 - 2 * k);
|
||||
}
|
||||
|
||||
/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */
|
||||
function mod(x: number, n: number): number {
|
||||
return ((x % n) + n) % n;
|
||||
}
|
||||
|
||||
/** The shortest signed distance around a cycle: -180..180 for degrees. */
|
||||
function wrapSigned(x: number, period: number): number {
|
||||
return mod(x + period / 2, period) - period / 2;
|
||||
}
|
||||
|
||||
// ---- Sanity checks --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Values this file actually produces, so the numbers above can be argued with
|
||||
* rather than only read.
|
||||
*
|
||||
* All of these are San Francisco — 37.7749 N, 122.4194 W, `metresPerUnit` of
|
||||
* 94.34, `marineLayer: PACIFIC_MARINE_LAYER` — with fog distances in scene
|
||||
* units, where the clear-day baseline is 210/460.
|
||||
*
|
||||
* **With no weather at all, which is the offline case:**
|
||||
*
|
||||
* - **Solar noon, 21 June** (75.6°): sun 2.03, fog 179/397. The layer's
|
||||
* diurnal curve is near its minimum and its season factor near its
|
||||
* maximum, which nets out as the faint July haze that softens the far side
|
||||
* of the bay without touching the light.
|
||||
* - **08:00 PDT, 21 June** (23.4°): sun 0.61, hemisphere 1.15, ambient 0.43,
|
||||
* fog 60/133. A high sun almost entirely extinguished, the sky and the fog
|
||||
* converged on one grey, and more fill light than at noon. That is the
|
||||
* single most San Franciscan frame this engine can produce, and the fact
|
||||
* that the *fill goes up* as the *sun goes down* is the whole trick.
|
||||
* - **08:00 PDT, 21 October** (6.1°): sun 1.49, fog 190/421, horizon
|
||||
* #e6bd98. The same hour, four months later, and the model has to give
|
||||
* back a clear golden morning or it is not a model of anything — October is
|
||||
* the month San Francisco is warm and cloudless and every visitor is
|
||||
* surprised by it.
|
||||
* - **03:00 PDT** (-23.7°): sun 0.01, hemisphere 0.25, ambient 0.10, and the
|
||||
* light direction's `y` pinned at 0.122, which is sin 7° — the shadow
|
||||
* floor, keeping the token night sidelight from shining up through the
|
||||
* ground.
|
||||
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
|
||||
* the palette's own. Out of season, the layer is not there.
|
||||
*
|
||||
* **With weather, at solar noon on 21 June:**
|
||||
*
|
||||
* - `cloudCover: 0.05, visibilityKm: 16` — sun 2.26, fog 210/460 exactly.
|
||||
* The observation says clear and the climatology is overruled; 16 km is
|
||||
* read as "unlimited" rather than as 170 units of haze, which is the `10SM`
|
||||
* correction doing its job.
|
||||
* - `cloudCover: 1, visibilityKm: 14, condition: "overcast"` — sun 0.52,
|
||||
* ambient 0.31 → 0.46, fog 185/411. Bright and shadowless, not dusk.
|
||||
* - `condition: "fog"`, no visibility number — sun 0.06, hemisphere 1.45,
|
||||
* ambient 0.67, fog 21/48. 48 units is the 4500 m floor: fog this thick is
|
||||
* capped deliberately, because the honest number renders a white rectangle.
|
||||
* - `visibilityKm: 6` under light cloud — sun 1.13, fog 27/59. Haze, not fog.
|
||||
*
|
||||
* **Without a `marineLayer` at all**, a reported fog still renders as fog —
|
||||
* sun 0.06, one flat grey, no horizon — and the same city with no weather
|
||||
* renders the clear baseline exactly. The layer decides where obscuration
|
||||
* *comes from* when nobody was asked; it is not what makes obscuration look
|
||||
* like anything.
|
||||
*
|
||||
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
|
||||
* and nothing non-finite anywhere, which is the polar-night path through
|
||||
* `solar.ts` arriving here intact.
|
||||
*/
|
||||
+118
-154
@@ -1,21 +1,39 @@
|
||||
/**
|
||||
* The scene: lights, sky, layers, camera flights, render loop.
|
||||
* 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`; 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 its ~1.0 s
|
||||
* heightfield on the way back. See CONTRACT.md §1.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { createBlocks, createLandmarks } from "./blocks.ts";
|
||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||||
import { createStage, type Stage, type StageScene } from "./stage.ts";
|
||||
import { createBridges, createRoads } from "./structures.ts";
|
||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type { Chapter, City, FlightSource, Marker, MarkerPalette } from "./types.ts";
|
||||
import type {
|
||||
Chapter,
|
||||
City,
|
||||
FlightSource,
|
||||
LightingState,
|
||||
Marker,
|
||||
MarkerPalette,
|
||||
ScenePalette,
|
||||
} from "./types.ts";
|
||||
import { World } from "./world.ts";
|
||||
|
||||
export interface SceneOptions {
|
||||
@@ -24,11 +42,27 @@ export interface SceneOptions {
|
||||
flights?: FlightSource;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export interface SceneHandle {
|
||||
world: World;
|
||||
chapters: Chapter[];
|
||||
/**
|
||||
* The renderer and the loop. 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.
|
||||
*/
|
||||
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;
|
||||
flyTo(chapterId: string): void;
|
||||
current(): string;
|
||||
onChapterChange(fn: (id: string) => void): void;
|
||||
@@ -41,47 +75,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
||||
const world = new World(city);
|
||||
const pal = paletteFor(world);
|
||||
|
||||
const stage = createStage(canvas);
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = makeSkyTexture(pal.skyTop, pal.skyHorizon);
|
||||
scene.fog = new THREE.Fog(pal.skyHorizon, 210, 460);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
42,
|
||||
canvas.clientWidth / Math.max(1, canvas.clientHeight),
|
||||
0.1,
|
||||
900,
|
||||
);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.07;
|
||||
controls.maxPolarAngle = Math.PI / 2.12; // never dip under the ground plane
|
||||
controls.minDistance = 12;
|
||||
controls.maxDistance = 340;
|
||||
|
||||
// Late-afternoon sun from the west, which throws the hills' shadows east
|
||||
// across the flats.
|
||||
const sun = new THREE.DirectionalLight(0xfff3e0, 2.1);
|
||||
sun.position.set(-150, 170, 70);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
sun.shadow.camera.near = 10;
|
||||
sun.shadow.camera.far = 520;
|
||||
const extent = 170;
|
||||
sun.shadow.camera.left = -extent;
|
||||
sun.shadow.camera.right = extent;
|
||||
sun.shadow.camera.top = extent;
|
||||
sun.shadow.camera.bottom = -extent;
|
||||
sun.shadow.bias = -0.0012;
|
||||
scene.add(sun);
|
||||
scene.add(new THREE.HemisphereLight(0xdcecf7, 0x6b6f5e, 1.05));
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.32));
|
||||
const kit = createSceneKit({
|
||||
scene,
|
||||
dom: stage.renderer.domElement,
|
||||
fov: 42,
|
||||
near: 0.1,
|
||||
far: 900,
|
||||
minDistance: 12,
|
||||
maxDistance: 340,
|
||||
shadowExtent: 170,
|
||||
shadowFar: 520,
|
||||
});
|
||||
kit.applyLighting(options.lighting ?? cityDaylight(pal));
|
||||
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
@@ -101,26 +109,21 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
||||
scene.add(flightLayer.group);
|
||||
}
|
||||
|
||||
// ---- Camera flights -----------------------------------------------------
|
||||
// ---- 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`);
|
||||
|
||||
const desiredTarget = new THREE.Vector3();
|
||||
const desiredPosition = new THREE.Vector3();
|
||||
const flightFrom = { pos: new THREE.Vector3(), target: new THREE.Vector3() };
|
||||
let flying = false;
|
||||
let flightT = 0;
|
||||
let currentChapter = first.id;
|
||||
const chapterListeners: ((id: string) => void)[] = [];
|
||||
|
||||
function chapterPose(ch: Chapter) {
|
||||
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),
|
||||
pos: new THREE.Vector3(
|
||||
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,
|
||||
@@ -131,96 +134,67 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
||||
function flyTo(chapterId: string) {
|
||||
const ch = chapterById[chapterId];
|
||||
if (!ch) return;
|
||||
const pose = chapterPose(ch);
|
||||
flightFrom.pos.copy(camera.position);
|
||||
flightFrom.target.copy(controls.target);
|
||||
desiredPosition.copy(pose.pos);
|
||||
desiredTarget.copy(pose.target);
|
||||
flightT = 0;
|
||||
flying = true;
|
||||
kit.flyTo(chapterPose(ch));
|
||||
if (currentChapter !== chapterId) {
|
||||
currentChapter = chapterId;
|
||||
for (const fn of chapterListeners) fn(chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const pose = chapterPose(first);
|
||||
camera.position.copy(pose.pos);
|
||||
controls.target.copy(pose.target);
|
||||
controls.update();
|
||||
}
|
||||
kit.setPose(chapterPose(first));
|
||||
|
||||
// ---- Picking ------------------------------------------------------------
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
let hovered: Marker | null = null;
|
||||
// `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),
|
||||
});
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hit = raycaster.intersectObjects(markerLayer.pickables, false)[0];
|
||||
const marker = (hit?.object.userData.marker as Marker | undefined) ?? null;
|
||||
if (marker !== hovered) {
|
||||
hovered = marker;
|
||||
canvas.style.cursor = marker ? "pointer" : "";
|
||||
options.onMarkerPick?.(marker);
|
||||
}
|
||||
}
|
||||
canvas.addEventListener("pointermove", onPointerMove);
|
||||
// ---- The scene, as the stage sees it ------------------------------------
|
||||
|
||||
// ---- Loop ---------------------------------------------------------------
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let raf = 0;
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
raf = requestAnimationFrame(tick);
|
||||
const dt = Math.min(clock.getDelta(), 0.05);
|
||||
resize();
|
||||
|
||||
if (flying) {
|
||||
flightT = Math.min(1, flightT + dt * 0.65);
|
||||
// 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(flightFrom.pos, desiredPosition, e);
|
||||
controls.target.lerpVectors(flightFrom.target, desiredTarget, e);
|
||||
if (flightT >= 1) flying = false;
|
||||
}
|
||||
|
||||
if (options.flights && flightLayer) {
|
||||
flightTimer -= dt;
|
||||
if (flightTimer <= 0) {
|
||||
flightTimer = options.flights.interval;
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
||||
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) {
|
||||
kit.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
tick();
|
||||
|
||||
const onWindowResize = () => resize();
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
},
|
||||
dispose() {
|
||||
options.flights?.dispose?.();
|
||||
flightLayer?.dispose();
|
||||
markerLayer.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),
|
||||
flyTo,
|
||||
current: () => currentChapter,
|
||||
onChapterChange(fn) {
|
||||
@@ -230,38 +204,28 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
|
||||
markerLayer.setMarkers(markers);
|
||||
},
|
||||
dispose() {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
canvas.removeEventListener("pointermove", onPointerMove);
|
||||
options.flights?.dispose?.();
|
||||
flightLayer?.dispose();
|
||||
markerLayer.dispose();
|
||||
controls.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();
|
||||
});
|
||||
renderer.dispose();
|
||||
// Stage first, so nothing ticks a half-disposed scene.
|
||||
stage.dispose();
|
||||
stageScene.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 2;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D canvas context unavailable");
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, 256);
|
||||
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
|
||||
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 2, 256);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
/**
|
||||
* 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): 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 },
|
||||
fog: { color: palette.skyHorizon, near: 210, far: 460 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import type { LightingState } from "./types.ts";
|
||||
|
||||
/** 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;
|
||||
/**
|
||||
* How far along its direction the sun is placed. 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;
|
||||
/** 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;
|
||||
controls.dampingFactor = options.dampingFactor ?? 0.07;
|
||||
controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
|
||||
controls.minDistance = options.minDistance ?? 12;
|
||||
controls.maxDistance = options.maxDistance ?? 340;
|
||||
|
||||
// ---- Light rig ----------------------------------------------------------
|
||||
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1);
|
||||
sun.castShadow = true;
|
||||
const mapSize = options.shadowMapSize ?? 2048;
|
||||
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;
|
||||
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
|
||||
scene.add(sun, hemisphere, ambient);
|
||||
|
||||
const sunDirection = new THREE.Vector3();
|
||||
let sky: THREE.Texture | null = null;
|
||||
let skyTop = -1;
|
||||
let skyHorizon = -1;
|
||||
|
||||
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) {
|
||||
sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance));
|
||||
}
|
||||
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 `scene.background` alone entirely, which is what an
|
||||
// office wants: it has walls, and whatever is behind them is not sky.
|
||||
if (state.sky && (state.sky.top !== skyTop || state.sky.horizon !== skyHorizon)) {
|
||||
sky?.dispose();
|
||||
sky = makeSkyTexture(state.sky.top, state.sky.horizon);
|
||||
skyTop = state.sky.top;
|
||||
skyHorizon = state.sky.horizon;
|
||||
scene.background = sky;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
|
||||
function setPose(pose: Pose) {
|
||||
flying = false;
|
||||
camera.position.copy(pose.position);
|
||||
controls.target.copy(pose.target);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function flyTo(pose: Pose) {
|
||||
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 onPointerMove(event: PointerEvent) {
|
||||
const rect = dom.getBoundingClientRect();
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
pointerDirty = true;
|
||||
}
|
||||
dom.addEventListener("pointermove", onPointerMove);
|
||||
|
||||
function resetPick() {
|
||||
pointerDirty = false;
|
||||
if (picked === null) return;
|
||||
const wasPicking = picking;
|
||||
picked = null;
|
||||
dom.style.cursor = "";
|
||||
wasPicking?.onChange(null);
|
||||
}
|
||||
dom.addEventListener("pointerleave", resetPick);
|
||||
|
||||
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,
|
||||
setPose,
|
||||
flyTo,
|
||||
flying: () => flying,
|
||||
setPicking(pick) {
|
||||
picking = pick as PickOptions<unknown>;
|
||||
},
|
||||
resetPick,
|
||||
tick(dt) {
|
||||
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("pointerleave", resetPick);
|
||||
dom.style.cursor = "";
|
||||
picking = null;
|
||||
controls.dispose();
|
||||
scene.remove(sun, hemisphere, ambient);
|
||||
sun.dispose();
|
||||
hemisphere.dispose();
|
||||
ambient.dispose();
|
||||
sky?.dispose();
|
||||
if (scene.background === sky) scene.background = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-pixel-wide vertical gradient. Cheap, and a `Scene.background` texture
|
||||
* is stretched to fill regardless, so the width buys nothing.
|
||||
*/
|
||||
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 2;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D canvas context unavailable");
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, 256);
|
||||
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
|
||||
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 2, 256);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Where the sun actually is, computed rather than fetched.
|
||||
*
|
||||
* This is the NOAA Solar Calculator's algorithm, which is Jean Meeus,
|
||||
* *Astronomical Algorithms* (2nd ed., 1998) — chapter 22 for nutation and
|
||||
* obliquity, 25 for the sun's coordinates, 28 for the equation of time — at the
|
||||
* low-precision truncation NOAA uses. It is good to roughly 0.01°, a minute of
|
||||
* arc, for dates between about 1800 and 2100. That is a fifth of the sun's own
|
||||
* half-degree disc, so nothing a renderer does with it can see the error.
|
||||
* Sunrise and sunset land within a minute or so at temperate latitudes and
|
||||
* degrade towards the poles, where the sun crosses the horizon at a shallow
|
||||
* enough angle that a minute of arc is a long time.
|
||||
*
|
||||
* There is a perfectly good web service for this and we do not use it. The
|
||||
* whole engine has to work with no account, no key and no network, and a
|
||||
* time-of-day that silently stops moving when the wifi drops is worse than one
|
||||
* that was never real. Two hundred lines of trigonometry buys that outright.
|
||||
*
|
||||
* Nothing here imports three.js: this is arithmetic, and `atmosphere.ts` is
|
||||
* what turns it into light.
|
||||
*/
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const RAD = 180 / Math.PI;
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Zenith angle counted as sunrise. 90° puts the sun's centre on the horizon;
|
||||
* the extra 0.833° is its 16' semidiameter plus the 34' of refraction that
|
||||
* lifts the upper limb into view before it geometrically arrives.
|
||||
*/
|
||||
const SUNRISE_ZENITH = 90.833;
|
||||
|
||||
export interface SolarPosition {
|
||||
/** Degrees clockwise from true north: 90 is due east, 180 due south. */
|
||||
azimuth: number;
|
||||
/** Degrees above the horizon, with atmospheric refraction applied. */
|
||||
elevation: number;
|
||||
/** The sun's declination in degrees — its latitude on the celestial sphere. */
|
||||
declination: number;
|
||||
/** Apparent solar time minus mean solar time, in minutes. Roughly ±16. */
|
||||
equationOfTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard twilight bands, plus the one photographers named.
|
||||
*
|
||||
* The boundaries are applied to the refraction-corrected elevation this module
|
||||
* returns, so `daylightPhase` and `sunTimes` agree about where the horizon is
|
||||
* to within a few seconds rather than the ~90 s they would disagree by if the
|
||||
* horizon sat at a flat zero.
|
||||
*/
|
||||
export type DaylightPhase = "night" | "astronomical" | "nautical" | "civil" | "golden" | "day";
|
||||
|
||||
// ---- The sun's coordinates ------------------------------------------------
|
||||
|
||||
interface SolarTerms {
|
||||
declination: number;
|
||||
equationOfTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Unix epoch is JD 2440587.5, and `Date` is already a count of
|
||||
* milliseconds, so the Gregorian calendar arithmetic of Meeus chapter 7 never
|
||||
* has to happen here.
|
||||
*/
|
||||
function julianDay(when: Date): number {
|
||||
return when.getTime() / MS_PER_DAY + 2_440_587.5;
|
||||
}
|
||||
|
||||
/** Julian centuries since J2000.0, the argument every series below is in. */
|
||||
function julianCentury(jd: number): number {
|
||||
return (jd - 2_451_545) / 36_525;
|
||||
}
|
||||
|
||||
function solarTerms(t: number): SolarTerms {
|
||||
const meanLongitude = mod(280.46646 + t * (36_000.76983 + t * 0.0003032), 360);
|
||||
const meanAnomaly = 357.52911 + t * (35_999.05029 - 0.0001537 * t);
|
||||
const eccentricity = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
|
||||
|
||||
// Equation of centre: the correction from the fictitious mean sun, which
|
||||
// moves uniformly, to the real one, which does not because the orbit is an
|
||||
// ellipse. Three sine terms are plenty at this precision.
|
||||
const centre =
|
||||
Math.sin(meanAnomaly * DEG) * (1.914602 - t * (0.004817 + 0.000014 * t)) +
|
||||
Math.sin(2 * meanAnomaly * DEG) * (0.019993 - 0.000101 * t) +
|
||||
Math.sin(3 * meanAnomaly * DEG) * 0.000289;
|
||||
const trueLongitude = meanLongitude + centre;
|
||||
|
||||
// Nutation and aberration, both driven by the moon's ascending node, folded
|
||||
// into the one term Meeus gives for them together.
|
||||
const omega = 125.04 - 1934.136 * t;
|
||||
const apparentLongitude = trueLongitude - 0.00569 - 0.00478 * Math.sin(omega * DEG);
|
||||
|
||||
const meanObliquity = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60;
|
||||
const obliquity = meanObliquity + 0.00256 * Math.cos(omega * DEG);
|
||||
|
||||
const declination =
|
||||
Math.asin(Math.sin(obliquity * DEG) * Math.sin(apparentLongitude * DEG)) * RAD;
|
||||
|
||||
// Equation of time, Meeus 28.3. `y` is tan²(ε/2); the series is in the mean
|
||||
// longitude and anomaly, not the true ones, which is easy to get wrong.
|
||||
const y = Math.tan((obliquity / 2) * DEG) ** 2;
|
||||
const equationOfTime =
|
||||
4 *
|
||||
RAD *
|
||||
(y * Math.sin(2 * meanLongitude * DEG) -
|
||||
2 * eccentricity * Math.sin(meanAnomaly * DEG) +
|
||||
4 * eccentricity * y * Math.sin(meanAnomaly * DEG) * Math.cos(2 * meanLongitude * DEG) -
|
||||
0.5 * y * y * Math.sin(4 * meanLongitude * DEG) -
|
||||
1.25 * eccentricity ** 2 * Math.sin(2 * meanAnomaly * DEG));
|
||||
|
||||
return { declination, equationOfTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Atmospheric refraction in degrees, to be added to the geometric elevation.
|
||||
*
|
||||
* The atmosphere bends light down over the horizon, so a low sun appears
|
||||
* higher than it is — by more than its own diameter at the horizon itself,
|
||||
* which is why a sunset you can see has already happened. NOAA's piecewise fit
|
||||
* in arcseconds; it assumes ordinary sea-level pressure and temperature, and
|
||||
* the last branch is a fiction that keeps the curve continuous below the
|
||||
* horizon rather than a claim about anything observable.
|
||||
*/
|
||||
function refraction(elevation: number): number {
|
||||
if (elevation > 85) return 0;
|
||||
const te = Math.tan(elevation * DEG);
|
||||
let arcseconds: number;
|
||||
if (elevation > 5) {
|
||||
arcseconds = 58.1 / te - 0.07 / te ** 3 + 0.000086 / te ** 5;
|
||||
} else if (elevation > -0.575) {
|
||||
arcseconds =
|
||||
1735 +
|
||||
elevation * (-518.2 + elevation * (103.4 + elevation * (-12.79 + elevation * 0.711)));
|
||||
} else {
|
||||
arcseconds = -20.772 / te;
|
||||
}
|
||||
return arcseconds / 3600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azimuth and elevation for an observer at `lat`/`lng`, in degrees, at an
|
||||
* instant. Longitude is positive east, which is the sign convention the rest
|
||||
* of the engine uses and the opposite of NOAA's own spreadsheet.
|
||||
*/
|
||||
export function solarPosition(lat: number, lng: number, when: Date): SolarPosition {
|
||||
const { declination, equationOfTime } = solarTerms(julianCentury(julianDay(when)));
|
||||
|
||||
// Apparent solar time: minutes of UTC, carried east four minutes per degree
|
||||
// of longitude, then bent from mean to apparent by the equation of time.
|
||||
const utcMinutes = mod(when.getTime() / MS_PER_MINUTE, 1440);
|
||||
const trueSolarTime = mod(utcMinutes + equationOfTime + 4 * lng, 1440);
|
||||
const hourAngle = trueSolarTime / 4 - 180;
|
||||
|
||||
const latRad = lat * DEG;
|
||||
const decRad = declination * DEG;
|
||||
const cosZenith = clamp(
|
||||
Math.sin(latRad) * Math.sin(decRad) +
|
||||
Math.cos(latRad) * Math.cos(decRad) * Math.cos(hourAngle * DEG),
|
||||
-1,
|
||||
1,
|
||||
);
|
||||
const zenith = Math.acos(cosZenith) * RAD;
|
||||
const geometric = 90 - zenith;
|
||||
|
||||
const sinZenith = Math.sin(zenith * DEG);
|
||||
const cosLat = Math.cos(latRad);
|
||||
let azimuth: number;
|
||||
if (Math.abs(sinZenith) < 1e-9 || Math.abs(cosLat) < 1e-9) {
|
||||
// The sun within a hair of the zenith, or the observer standing on a pole.
|
||||
// Azimuth is genuinely undefined at both, and at the first it also does not
|
||||
// matter — the light is coming straight down. Fall back to the hour angle,
|
||||
// which is continuous and gets the meridian crossing right.
|
||||
azimuth = mod(hourAngle + 180, 360);
|
||||
} else {
|
||||
const cosAzimuth = clamp(
|
||||
(Math.sin(latRad) * cosZenith - Math.sin(decRad)) / (cosLat * sinZenith),
|
||||
-1,
|
||||
1,
|
||||
);
|
||||
const a = Math.acos(cosAzimuth) * RAD;
|
||||
// `acos` cannot tell morning from afternoon; the hour angle can.
|
||||
azimuth = hourAngle > 0 ? mod(a + 180, 360) : mod(540 - a, 360);
|
||||
}
|
||||
|
||||
return {
|
||||
azimuth,
|
||||
elevation: geometric + refraction(geometric),
|
||||
declination,
|
||||
equationOfTime,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Rise, set and noon ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sunrise, sunset and solar noon for the local solar day containing `when`.
|
||||
*
|
||||
* `sunrise` and `sunset` are `null` through a polar summer or winter, when the
|
||||
* sun does not cross the horizon at all. That is a legitimate answer, not an
|
||||
* error, and a caller rendering Tromsø in January should get a long blue day
|
||||
* rather than an exception.
|
||||
*/
|
||||
export function sunTimes(
|
||||
lat: number,
|
||||
lng: number,
|
||||
when: Date,
|
||||
): { sunrise: Date | null; sunset: Date | null; solarNoon: Date } {
|
||||
const dayStart = solarDayStart(lng, when);
|
||||
|
||||
// Solar noon is local mean noon pulled back by the equation of time. Two
|
||||
// passes because the equation of time itself wants evaluating at the answer,
|
||||
// and it moves slowly enough that two is convergence.
|
||||
let solarNoonMs = dayStart + MS_PER_DAY / 2;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const terms = solarTerms(julianCentury(julianDay(new Date(solarNoonMs))));
|
||||
solarNoonMs = dayStart + MS_PER_DAY / 2 - terms.equationOfTime * MS_PER_MINUTE;
|
||||
}
|
||||
|
||||
return {
|
||||
sunrise: refineEvent(lat, solarNoonMs, -1),
|
||||
sunset: refineEvent(lat, solarNoonMs, 1),
|
||||
solarNoon: new Date(solarNoonMs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local *mean solar* midnight, as a UTC timestamp — four minutes of day per
|
||||
* degree of longitude.
|
||||
*
|
||||
* Using the solar day rather than the UTC calendar day is what makes a caller
|
||||
* in San Francisco at 23:00 local get tonight's sunset instead of tomorrow's,
|
||||
* and it needs no timezone database, which is just as well: we could not ship
|
||||
* one and still claim to work offline forever.
|
||||
*/
|
||||
function solarDayStart(lng: number, when: Date): number {
|
||||
const offset = lng * 4 * MS_PER_MINUTE;
|
||||
const local = when.getTime() + offset;
|
||||
return Math.floor(local / MS_PER_DAY) * MS_PER_DAY - offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hour angle at which the sun reaches `SUNRISE_ZENITH`, in degrees, or
|
||||
* `null` if it never does. The cosine leaving [-1, 1] is exactly the polar
|
||||
* day/night case; at the poles themselves the denominator also collapses, so
|
||||
* the finiteness check has to come first.
|
||||
*/
|
||||
function sunriseHourAngle(lat: number, declination: number): number | null {
|
||||
const latRad = lat * DEG;
|
||||
const decRad = declination * DEG;
|
||||
const c =
|
||||
Math.cos(SUNRISE_ZENITH * DEG) / (Math.cos(latRad) * Math.cos(decRad)) -
|
||||
Math.tan(latRad) * Math.tan(decRad);
|
||||
if (!Number.isFinite(c) || c > 1 || c < -1) return null;
|
||||
return Math.acos(c) * RAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk an event in from solar noon. The declination is evaluated at the event's
|
||||
* own approximate time rather than at noon, which is worth several seconds at
|
||||
* the solstices and much more than that at high latitude.
|
||||
*/
|
||||
function refineEvent(lat: number, solarNoonMs: number, sign: 1 | -1): Date | null {
|
||||
let ms = solarNoonMs;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const { declination } = solarTerms(julianCentury(julianDay(new Date(ms))));
|
||||
const hourAngle = sunriseHourAngle(lat, declination);
|
||||
if (hourAngle === null) return null;
|
||||
ms = solarNoonMs + sign * hourAngle * 4 * MS_PER_MINUTE;
|
||||
}
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
// ---- Consumption ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The elevation `solarPosition` reports at the instant `sunTimes` calls
|
||||
* sunrise: the sun's centre is 0.833° below the true horizon and refraction is
|
||||
* lifting it back by about 0.41° of that. Deriving it rather than writing
|
||||
* -0.42 down keeps the two functions agreeing if `SUNRISE_ZENITH` ever moves.
|
||||
*/
|
||||
const HORIZON = 90 - SUNRISE_ZENITH + refraction(90 - SUNRISE_ZENITH);
|
||||
|
||||
/**
|
||||
* Which band of light we are in, from an elevation in degrees.
|
||||
*
|
||||
* Astronomical, nautical and civil twilight are defined on the *geometric*
|
||||
* elevation, but refraction is under a tenth of a degree by 6° down, so
|
||||
* applying them to the refracted value costs nothing and saves the caller
|
||||
* carrying two elevations around.
|
||||
*/
|
||||
export function daylightPhase(elevation: number): DaylightPhase {
|
||||
if (elevation >= 6) return "day";
|
||||
if (elevation >= HORIZON) return "golden";
|
||||
if (elevation >= -6) return "civil";
|
||||
if (elevation >= -12) return "nautical";
|
||||
if (elevation >= -18) return "astronomical";
|
||||
return "night";
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit vector pointing *from the scene towards the sun*, in the engine's
|
||||
* axes: `x` east, `z` south, `y` up — the same convention `World.project`
|
||||
* establishes, where north is `-z`.
|
||||
*
|
||||
* This is the direction to put a light in, not the direction light travels;
|
||||
* negate it for the latter. A `THREE.DirectionalLight` wants
|
||||
* `light.position.copy(dir).multiplyScalar(distance)` with its target at the
|
||||
* origin, because three.js reads a directional light's direction off the vector
|
||||
* between the two.
|
||||
*/
|
||||
export function sunDirection(pos: SolarPosition): { x: number; y: number; z: number } {
|
||||
const el = pos.elevation * DEG;
|
||||
const az = pos.azimuth * DEG;
|
||||
const horizontal = Math.cos(el);
|
||||
return {
|
||||
x: horizontal * Math.sin(az),
|
||||
// Azimuth is measured from north and north is -z, so the northward
|
||||
// component is negated on the way in. A midday sun in the northern
|
||||
// hemisphere sits due south at azimuth 180, which lands on +z.
|
||||
z: -horizontal * Math.cos(az),
|
||||
y: Math.sin(el),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
/** `%` keeps the sign of the dividend, which is wrong for angles and clocks. */
|
||||
function mod(x: number, n: number): number {
|
||||
return ((x % n) + n) % n;
|
||||
}
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return x < lo ? lo : x > hi ? hi : x;
|
||||
}
|
||||
|
||||
// ---- Sanity checks --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Values a reviewer can check against an almanac without running anything, or
|
||||
* against a reference implementation without trusting this one.
|
||||
*
|
||||
* San Francisco is 37.7749° N, 122.4194° W. Its noon sun is always due south,
|
||||
* because the latitude is north of the tropic, so the noon azimuth is 180.00
|
||||
* every day of the year. The noon elevation is 90° minus the latitude plus the
|
||||
* declination:
|
||||
*
|
||||
* June solstice 90 - 37.7749 + 23.44 = 75.67° (returned: 75.67)
|
||||
* equinox 90 - 37.7749 + 0 = 52.23° (returned: 52.30, the
|
||||
* extra being the declination not being exactly zero at
|
||||
* solar noon on the day the equinox happens to fall)
|
||||
* December solstice 90 - 37.7749 - 23.44 = 28.79° (returned: 28.82)
|
||||
*
|
||||
* The returned figures run a hundredth of a degree over the geometric ones at
|
||||
* noon and about four tenths over at the horizon; that gap is the refraction
|
||||
* term, and it is the whole reason a sunset you can watch has already happened.
|
||||
*
|
||||
* The equation of time reaches about -14.2 min around 11 February and +16.5 min
|
||||
* around 3 November, and passes through zero near 15 April, 13 June, 1
|
||||
* September and 25 December. Solar noon in San Francisco therefore lands near
|
||||
* 20:10 UTC in mid-April and near 19:53 UTC in early November — a seventeen
|
||||
* minute swing in when noon is, from a clock that never moves.
|
||||
*
|
||||
* `sunTimes` for San Francisco at the June solstice: sunrise 12:48 UTC, sunset
|
||||
* 03:35 UTC the following day — 05:48 and 20:35 Pacific — a day 14 h 47 m long.
|
||||
* At the December solstice: 15:21 and 00:54 UTC, which is 07:21 and 16:54
|
||||
* Pacific and 9 h 33 m of daylight.
|
||||
*
|
||||
* Tromsø at 69.65° N returns `null` for both events from 18 May to 26 July and
|
||||
* again from 28 November to 15 January, which is the midnight sun and the polar
|
||||
* night to the day. `solarNoon` is still returned in every one of those cases,
|
||||
* and the poles themselves return nulls rather than a NaN or a throw.
|
||||
*
|
||||
* `sunDirection` of an azimuth of 180 and an elevation of 0 is `{x: 0, y: 0,
|
||||
* z: 1}` — due south is +z. Azimuth 90 gives +x, east; azimuth 0 gives -z.
|
||||
*/
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The stage: one renderer, one loop, one canvas, and a scene you can swap.
|
||||
*
|
||||
* Stage owns *only* the WebGL renderer, the RAF loop and resize. It has no
|
||||
* camera, no lights and no picking — those are per-scene and live in
|
||||
* `SceneKit`, because a city and an office cannot share a `THREE.Scene` at all:
|
||||
* SF's `latScale` puts one scene unit at ~94 m with 3.6x vertical
|
||||
* exaggeration, and an office renders at 1 unit = 1 m.
|
||||
*
|
||||
* The swap **retains and pauses** the outgoing scene rather than disposing it.
|
||||
* That is a measured choice, not a preference: SF's heightfield is 484 x 696
|
||||
* lattice points and `world.ts` records a ~1.0 s build, so throwing the city
|
||||
* away every time somebody steps into an office means paying a second of
|
||||
* rebuild on the way back out. Stage therefore disposes nothing it did not
|
||||
* create — whoever built a `StageScene` disposes it, when they actually mean
|
||||
* to be rid of it. See CONTRACT.md §1.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
|
||||
export interface StageScene {
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
controls: OrbitControls;
|
||||
onEnter?(): void;
|
||||
onExit?(): void;
|
||||
tick(dt: number, elapsed: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
setScene(s: StageScene): void;
|
||||
current(): StageScene | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface StageOptions {
|
||||
antialias?: boolean;
|
||||
/** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */
|
||||
maxPixelRatio?: number;
|
||||
shadows?: boolean;
|
||||
}
|
||||
|
||||
export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage {
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2));
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||
if (options.shadows ?? true) {
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
}
|
||||
|
||||
let currentScene: StageScene | null = null;
|
||||
|
||||
// Elapsed time is tracked per scene rather than per stage. A scene that sat
|
||||
// paused for forty seconds should come back where it left off, not jump
|
||||
// forty seconds into whatever it animates.
|
||||
const elapsedByScene = new WeakMap<StageScene, number>();
|
||||
|
||||
// Compared against CSS pixels, because `canvas.width` is in device pixels and
|
||||
// differs from `clientWidth` on every retina display — checking it would call
|
||||
// `setSize` on every single frame.
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
|
||||
function applyViewport(target: StageScene) {
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
target.camera.aspect = w / h;
|
||||
target.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
if (w === lastWidth && h === lastHeight) return;
|
||||
lastWidth = w;
|
||||
lastHeight = h;
|
||||
renderer.setSize(w, h, false);
|
||||
if (currentScene) applyViewport(currentScene);
|
||||
}
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let raf = 0;
|
||||
|
||||
function tick() {
|
||||
raf = requestAnimationFrame(tick);
|
||||
// Clamped, so a backgrounded tab returning does not advance every animation
|
||||
// by however long it was gone.
|
||||
const dt = Math.min(clock.getDelta(), 0.05);
|
||||
resize();
|
||||
const active = currentScene;
|
||||
if (!active) return;
|
||||
const elapsed = (elapsedByScene.get(active) ?? 0) + dt;
|
||||
elapsedByScene.set(active, elapsed);
|
||||
active.tick(dt, elapsed);
|
||||
renderer.render(active.scene, active.camera);
|
||||
}
|
||||
tick();
|
||||
|
||||
const onWindowResize = () => resize();
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
|
||||
return {
|
||||
renderer,
|
||||
setScene(s) {
|
||||
if (s === currentScene) return;
|
||||
currentScene?.onExit?.();
|
||||
currentScene = s;
|
||||
// The incoming camera may never have seen this canvas, and the canvas may
|
||||
// have been resized while the scene was paused.
|
||||
applyViewport(s);
|
||||
s.onEnter?.();
|
||||
},
|
||||
current: () => currentScene,
|
||||
dispose() {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
currentScene = null;
|
||||
renderer.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
+70
-8
@@ -85,12 +85,29 @@ export interface Road {
|
||||
kind: "street" | "freeway";
|
||||
}
|
||||
|
||||
/** A camera destination, and a sentence about why it is on the map. */
|
||||
export interface Chapter {
|
||||
/**
|
||||
* A named destination, as the interface knows it: what the legend prints and
|
||||
* what `flyTo` is keyed on.
|
||||
*
|
||||
* Split out of `Chapter` because an office has exactly the same idea — a short
|
||||
* list of places you can jump to — but positions them in metres, not in
|
||||
* latitude and longitude. Only this half of a chapter is shared; the pose is
|
||||
* not. `number` and `description` are optional here and required on `Chapter`,
|
||||
* because a city's chapters are a numbered tour with a sentence each and an
|
||||
* office's views are usually just "Reception" and "The desk bay".
|
||||
*/
|
||||
export interface View {
|
||||
id: string;
|
||||
number: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
number?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A camera destination, and a sentence about why it is on the map. */
|
||||
export interface Chapter extends View {
|
||||
number: string;
|
||||
description: string;
|
||||
focus: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
@@ -98,7 +115,6 @@ export interface Chapter {
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,24 +195,70 @@ export interface ScenePalette {
|
||||
parkHigh: number;
|
||||
}
|
||||
|
||||
// ---- Lighting -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Everything the light rig needs, as plain numbers.
|
||||
*
|
||||
* This is a *state*, not an observation: `Environment` — `{ time, sun, weather }`
|
||||
* — is what the world is doing, and a `LightingState` is what that means for the
|
||||
* rig. `Atmosphere` owns the conversion and is the only thing allowed to make
|
||||
* one; a scene applies it and never writes back. Two modules both constructing
|
||||
* and mutating the same three lights is the failure this shape exists to
|
||||
* prevent. See CONTRACT.md §4.
|
||||
*
|
||||
* Colours are `0xrrggbb`, matching `ScenePalette` and three.js.
|
||||
*/
|
||||
export interface LightingState {
|
||||
sun: {
|
||||
/**
|
||||
* Unit vector from the scene toward the sun. Distance is deliberately
|
||||
* absent: how far away to place the light is a fact about the scale of the
|
||||
* scene, and the sun does not know whether it is shining on 94 m per unit
|
||||
* or on 1 m per unit.
|
||||
*/
|
||||
direction: [number, number, number];
|
||||
color: number;
|
||||
intensity: number;
|
||||
};
|
||||
hemisphere: { sky: number; ground: number; intensity: number };
|
||||
ambient: { color: number; intensity: number };
|
||||
/**
|
||||
* Background gradient, or `null` to leave the background alone — which is
|
||||
* what an interior wants, since it has walls and no horizon.
|
||||
*/
|
||||
sky: { top: number; horizon: number } | null;
|
||||
/** `null` for no fog at all. An office gets none. */
|
||||
fog: { color: number; near: number; far: number } | null;
|
||||
}
|
||||
|
||||
// ---- Markers --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A thing on the map.
|
||||
* A thing worth pointing at, minus where it is.
|
||||
*
|
||||
* `colorKey` is deliberately opaque to the engine — it indexes into a palette
|
||||
* the caller supplies. The engine will not learn what "rejected" means.
|
||||
*
|
||||
* This is the half that survives a change of coordinate system: a pin on a city
|
||||
* at 37.79 N, -122.40 E and a pin on a desk 4.2 m along the east wall are the
|
||||
* same kind of thing to everything downstream of the geometry, so an office can
|
||||
* carry its own positions and still hand a `Pin` to the same detail card.
|
||||
*/
|
||||
export interface Marker {
|
||||
export interface Pin {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
colorKey: string;
|
||||
/** Optional href for the detail card. */
|
||||
url?: string;
|
||||
/** Optional one-liner for the detail card. */
|
||||
blurb?: string;
|
||||
}
|
||||
|
||||
/** A `Pin` placed on a city, in degrees. */
|
||||
export interface Marker extends Pin {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/**
|
||||
* False when the position is a placeholder rather than a real address.
|
||||
* Rendered distinctly, because inventing a location on a map whose premise
|
||||
|
||||
Reference in New Issue
Block a user