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.
|
||||
*/
|
||||
Reference in New Issue
Block a user