feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+84
-26
@@ -556,12 +556,31 @@ const NIGHT_FLOOR_HORIZON = 0x16203a;
|
||||
* a roof is lighter than a wall; and the keyframe table's token sidelight
|
||||
* survives at full strength on a moonless night (see `applyNight`) so the hills
|
||||
* still have a lit side and a dark one.
|
||||
*
|
||||
* ### Why these two numbers moved when tone mapping arrived
|
||||
*
|
||||
* They went up by a third, and the ratios between all five did not change,
|
||||
* which is the point. `stage.ts` now runs ACES filmic instead of a bare
|
||||
* `saturate()`, and ACES has a toe: it is steeper than a plain sRGB encode
|
||||
* everywhere below about linear 0.1, which is the entire range a moonless night
|
||||
* occupies. A ground reading that displayed at 0.212 under the old renderer
|
||||
* came out at 0.174 under the new one for the same physical light — an 18% loss
|
||||
* concentrated exactly where this file has the least to give.
|
||||
*
|
||||
* A third more linear light puts it back at 0.240 and leaves the *shape* of the
|
||||
* night alone, because every one of the five terms was scaled by the same
|
||||
* factor. That is deliberate: the comment above spends four paragraphs on the
|
||||
* ratios between them, and a re-tune that fixed the brightness by flattening the
|
||||
* sky-to-ground gradient would have thrown away the argument to keep the number.
|
||||
* The city's own lit windows keep their four-to-five-times lead as well — they
|
||||
* are emissive, they were clipping at 1.0 before, and under a shoulder they now
|
||||
* separate rather than all rendering as the same white.
|
||||
*/
|
||||
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
|
||||
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
|
||||
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
|
||||
const NIGHT_FLOOR_HEMI_INTENSITY = 1.05;
|
||||
const NIGHT_FLOOR_AMBIENT = 0x47557f;
|
||||
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
|
||||
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.3;
|
||||
|
||||
/**
|
||||
* The same sky, and the same fill, with a full moon in it.
|
||||
@@ -636,6 +655,45 @@ interface Keyframe extends Rig {
|
||||
* 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.
|
||||
*
|
||||
* ### The intensities are tuned against a tone curve, and it is ACES
|
||||
*
|
||||
* Read the three intensity columns as a set, because they were re-tuned as one
|
||||
* when `stage.ts` stopped rendering through `NoToneMapping`. The old numbers
|
||||
* were not wrong — they were correct for a renderer that hard-clipped at linear
|
||||
* 1.0, and being correct for that is exactly what makes them wrong now.
|
||||
*
|
||||
* Two things changed, in opposite directions, and both are visible in the table.
|
||||
*
|
||||
* **Below the horizon the fills went up by about a third.** ACES's toe is
|
||||
* steeper than a plain sRGB encode everywhere under about linear 0.1, which is
|
||||
* the whole of the range a night frame lives in. See `NIGHT_FLOOR_HEMI_SKY` for
|
||||
* the arithmetic; the three night stops move with the floor because they sit
|
||||
* just under it and it is the floor that binds.
|
||||
*
|
||||
* **Above the horizon the key went up and the fill came down.** That is not a
|
||||
* brightness change, it is a contrast change, and it is the whole reason to have
|
||||
* done this. Under a clipping renderer a sunlit 0.7-albedo wall and a sunlit
|
||||
* 0.95-albedo wall were both exactly white, so the only way to make a daylit
|
||||
* scene read as *lit* was to pour in fill until the shadows came up to meet the
|
||||
* blown highlights — which is a description of a flat picture. With a shoulder
|
||||
* over the top, a sun at 2.6 puts a lit face at about 0.88 display and its own
|
||||
* shaded face at about 0.52, and the difference between them is the modelling
|
||||
* that was missing. So `hemiIntensity` loses roughly a tenth and
|
||||
* `ambientIntensity` roughly a quarter at the two day stops.
|
||||
*
|
||||
* The fill can afford that for a second reason: it is no longer the only
|
||||
* indirect light in the scene. `environmentRig.ts` derives a real sky
|
||||
* environment from this same `LightingState` and puts it on `Scene.environment`,
|
||||
* so the diffuse bounce the hemisphere light was standing in for now arrives
|
||||
* from something with a direction and a horizon in it. Trimming here and adding
|
||||
* there is one move, not two.
|
||||
*
|
||||
* The sky columns are untouched, and that is not an oversight. Three marks the
|
||||
* background mesh `toneMapped = false` for an sRGB-transfer texture and mixes
|
||||
* fog after the tone map from an already-encoded uniform, so `skyTop`,
|
||||
* `skyHorizon` and the fog colour are displayed exactly as written here. Only
|
||||
* the lit geometry moved, so only the light was re-tuned.
|
||||
*/
|
||||
function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
return [
|
||||
@@ -660,36 +718,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: 0x05070f,
|
||||
skyHorizon: 0x0b1120,
|
||||
sunColor: 0x44558a,
|
||||
sunIntensity: 0.16,
|
||||
sunIntensity: 0.22,
|
||||
hemiSky: 0x2f447e,
|
||||
hemiGround: 0x1b2234,
|
||||
hemiIntensity: 0.55,
|
||||
hemiIntensity: 0.74,
|
||||
ambientColor: 0x414e78,
|
||||
ambientIntensity: 0.16,
|
||||
ambientIntensity: 0.22,
|
||||
},
|
||||
{
|
||||
elevation: -12,
|
||||
skyTop: 0x080d1e,
|
||||
skyHorizon: 0x141d38,
|
||||
sunColor: 0x51629b,
|
||||
sunIntensity: 0.19,
|
||||
sunIntensity: 0.26,
|
||||
hemiSky: 0x32477d,
|
||||
hemiGround: 0x1d2437,
|
||||
hemiIntensity: 0.57,
|
||||
hemiIntensity: 0.77,
|
||||
ambientColor: 0x424f7a,
|
||||
ambientIntensity: 0.17,
|
||||
ambientIntensity: 0.23,
|
||||
},
|
||||
{
|
||||
elevation: -6,
|
||||
skyTop: 0x101a3a,
|
||||
skyHorizon: 0x2b3560,
|
||||
sunColor: 0x66699a,
|
||||
sunIntensity: 0.26,
|
||||
sunIntensity: 0.35,
|
||||
hemiSky: 0x3c558c,
|
||||
hemiGround: 0x23293c,
|
||||
hemiIntensity: 0.6,
|
||||
hemiIntensity: 0.81,
|
||||
ambientColor: 0x485389,
|
||||
ambientIntensity: 0.19,
|
||||
ambientIntensity: 0.26,
|
||||
},
|
||||
{
|
||||
// The sun on the horizon. Warm at the bottom, cold at the top, and the
|
||||
@@ -698,36 +756,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: 0x2a4275,
|
||||
skyHorizon: 0x9a6a63,
|
||||
sunColor: 0xc2795c,
|
||||
sunIntensity: 0.45,
|
||||
sunIntensity: 0.58,
|
||||
hemiSky: 0x4a5f8c,
|
||||
hemiGround: 0x2a2a2c,
|
||||
hemiIntensity: 0.6,
|
||||
hemiIntensity: 0.72,
|
||||
ambientColor: 0x6a6a80,
|
||||
ambientIntensity: 0.2,
|
||||
ambientIntensity: 0.25,
|
||||
},
|
||||
{
|
||||
elevation: 3,
|
||||
skyTop: 0x4d76ac,
|
||||
skyHorizon: 0xdba078,
|
||||
sunColor: 0xff9c56,
|
||||
sunIntensity: 1.25,
|
||||
sunIntensity: 1.45,
|
||||
hemiSky: 0x86a6cc,
|
||||
hemiGround: 0x54503f,
|
||||
hemiIntensity: 0.85,
|
||||
hemiIntensity: 0.9,
|
||||
ambientColor: 0xffd9b8,
|
||||
ambientIntensity: 0.24,
|
||||
ambientIntensity: 0.26,
|
||||
},
|
||||
{
|
||||
elevation: 8,
|
||||
skyTop: 0x6b96c6,
|
||||
skyHorizon: 0xebc9a4,
|
||||
sunColor: 0xffc489,
|
||||
sunIntensity: 1.8,
|
||||
sunIntensity: 2.0,
|
||||
hemiSky: 0xb2cbe4,
|
||||
hemiGround: 0x6a6752,
|
||||
hemiIntensity: 0.98,
|
||||
hemiIntensity: 0.95,
|
||||
ambientColor: 0xffe7cf,
|
||||
ambientIntensity: 0.28,
|
||||
ambientIntensity: 0.26,
|
||||
},
|
||||
{
|
||||
// Ordinary daylight, and the one stop that reproduces `cityDaylight()`.
|
||||
@@ -735,12 +793,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: dayTop,
|
||||
skyHorizon: dayHorizon,
|
||||
sunColor: 0xfff3e0,
|
||||
sunIntensity: 2.1,
|
||||
sunIntensity: 2.35,
|
||||
hemiSky: 0xdcecf7,
|
||||
hemiGround: 0x6b6f5e,
|
||||
hemiIntensity: 1.05,
|
||||
hemiIntensity: 0.92,
|
||||
ambientColor: 0xffffff,
|
||||
ambientIntensity: 0.32,
|
||||
ambientIntensity: 0.24,
|
||||
},
|
||||
{
|
||||
// A high sun. The zenith deepens — less air to scatter through overhead —
|
||||
@@ -749,12 +807,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
skyTop: mixHex(dayTop, 0x2f6bb0, 0.35),
|
||||
skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2),
|
||||
sunColor: 0xfffdf6,
|
||||
sunIntensity: 2.35,
|
||||
sunIntensity: 2.6,
|
||||
hemiSky: 0xe6f2fb,
|
||||
hemiGround: 0x74786a,
|
||||
hemiIntensity: 1.1,
|
||||
hemiIntensity: 0.95,
|
||||
ambientColor: 0xffffff,
|
||||
ambientIntensity: 0.3,
|
||||
ambientIntensity: 0.22,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* The environment map: what every reflective surface in the world is looking at.
|
||||
*
|
||||
* ## What this fixes
|
||||
*
|
||||
* Before this file existed there was no `Scene.environment` anywhere in `src/`,
|
||||
* and that single absence is the whole explanation for the most common
|
||||
* complaint about how this product looks. A `MeshStandardMaterial` with
|
||||
* `metalness > 0` has, by construction, almost no diffuse term — metal does not
|
||||
* scatter, it *reflects* — so with nothing to reflect it renders as a flat dark
|
||||
* grey wash and reads as painted plastic. The library has eleven such roles:
|
||||
* `metalTrim` at 0.85, `chairBase` at 0.75, `glazingFrame` and `deskFrame` at
|
||||
* 0.7 and 0.65, `partitionFrame`, `deviceMesh`, and the five Model X materials.
|
||||
* All of them were being asked to look like metal with a black room around them.
|
||||
*
|
||||
* The codebase already documents this against itself in two places, which is
|
||||
* how you know it is not a matter of taste. `office/optimus.ts:62` abandoned a
|
||||
* whole material role over it, and `vehicles/modelX.ts:96` fakes an `emissive`
|
||||
* term on the car paint to stand in for the sky bounce that was missing. Both
|
||||
* are workarounds for this file not existing.
|
||||
*
|
||||
* ## What it is allowed to do
|
||||
*
|
||||
* **It constructs no light. Not one, of any type.** CONTRACT.md §4 makes
|
||||
* `Atmosphere` the sole light owner and gives the data exactly one direction to
|
||||
* flow: an `Environment` is observed, `atmosphere.apply()` turns it into a
|
||||
* `LightingState`, a scene applies that, and nothing writes back. This rig sits
|
||||
* at the end of that same one-way street — it is handed the `LightingState`
|
||||
* that has *already been decided* and derives an environment from it. It never
|
||||
* decides anything about the light itself, so there is no second owner and
|
||||
* nothing to keep in sync.
|
||||
*
|
||||
* That constraint is also why there is no `RoomEnvironment` import here.
|
||||
* Three's version is a perfectly good office environment and the office path
|
||||
* below is recognisably descended from it, but it is a fixed room: it does not
|
||||
* know the hour, the weather, or which way the building faces, so an office at
|
||||
* 4 p.m. in August would reflect the same neutral studio light as one at
|
||||
* midnight in January. Deriving the room from `LightingState` instead costs
|
||||
* about forty lines and means the reflections move with the sun like everything
|
||||
* else in the scene does.
|
||||
*
|
||||
* ## Procedural, like everything else
|
||||
*
|
||||
* No `.hdr`, no `.exr`, no cubemap faces on disk. The city environment is a
|
||||
* `DataTexture` filled in a double loop from the same sky colours the
|
||||
* background gradient uses, and the office environment is nine untextured
|
||||
* quads. `scripts/check-no-binaries.mjs` stays satisfied for the same reason
|
||||
* `textures.ts` keeps it satisfied — the art is the code (CONTRACT.md §3).
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { LightingState } from "./types.ts";
|
||||
|
||||
/** Which of the two worlds is being reflected. */
|
||||
export type EnvironmentKind = "city" | "office";
|
||||
|
||||
export interface EnvironmentRig {
|
||||
apply(scene: THREE.Scene, lighting: LightingState, kind: "city" | "office"): void;
|
||||
/**
|
||||
* Forget a scene that is being torn down.
|
||||
*
|
||||
* `apply` records every scene it has written to, so that a rebuilt
|
||||
* environment can be pushed to all of them at once rather than only to the
|
||||
* one that happened to ask. That ledger is a strong reference, and a page
|
||||
* that switches city three times disposes three scenes the rig would
|
||||
* otherwise hold forever — the whole graph, because `createScene`'s dispose
|
||||
* frees geometries and materials without clearing its children. This is the
|
||||
* matching call, and a disposing scene is the only correct caller: it nulls
|
||||
* `scene.environment` and drops the entry. The cached PMREM targets are
|
||||
* shared across every scene of that kind and are **not** freed here; that is
|
||||
* `dispose()`'s job, and it belongs to whoever owns the Stage.
|
||||
*/
|
||||
release(scene: THREE.Scene): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equirectangular source resolution, in texels.
|
||||
*
|
||||
* Small on purpose. PMREM takes a cube face of `width / 4`, so 256 gives a
|
||||
* 64² cubemap, and the thing being encoded is a vertical gradient with one
|
||||
* bright lobe in it — there is no detail here that 512 would preserve and 256
|
||||
* would lose. What that buys is the right to rebuild often: the whole cost of
|
||||
* a sky change is 32k texels of CPU fill, a 128 KB upload and a handful of
|
||||
* quarter-megapixel GPU passes, which is affordable several times a second and
|
||||
* therefore affordable during a time-lapse.
|
||||
*/
|
||||
const EQUIRECT_WIDTH = 256;
|
||||
const EQUIRECT_HEIGHT = 128;
|
||||
|
||||
/**
|
||||
* How much of the sky's brightness the environment carries, against the
|
||||
* hemisphere light that was already carrying all of it.
|
||||
*
|
||||
* This number exists because the environment map and the hemisphere light are
|
||||
* two descriptions of the same physical thing — light arriving from the sky —
|
||||
* and applying both at full strength counts it twice. Three's
|
||||
* `getIBLIrradiance` returns `PI * radiance`, and `BRDF_Lambert` divides by PI,
|
||||
* so an environment of uniform radiance R contributes exactly `albedo * R` to a
|
||||
* diffuse surface, against `albedo * intensity * colour / PI` from the
|
||||
* hemisphere. At 0.12 the environment lands at roughly a fifth of the
|
||||
* hemisphere's diffuse contribution, and the day stops in `atmosphere.ts` gave
|
||||
* up about a tenth of `hemiIntensity` and a quarter of `ambientIntensity` to
|
||||
* make room for it. Those two edits are one decision and were made together.
|
||||
*
|
||||
* The reason it is a minority share rather than a replacement: a PMREM
|
||||
* irradiance probe has no shadow term and no local occlusion, so raising it
|
||||
* until it *is* the sky fill would light the inside of a closed room as
|
||||
* brightly as the roof of it. The hemisphere light has the same flaw, but it
|
||||
* is the flaw the rest of the world is already tuned against.
|
||||
*/
|
||||
const SKY_RADIANCE_SHARE = 0.12;
|
||||
|
||||
/**
|
||||
* The sun's own disc, as a radiance and a tightness.
|
||||
*
|
||||
* Kept narrow and kept modest. A cosine power of 400 is a lobe about four
|
||||
* degrees across, which contributes almost nothing to the irradiance integral —
|
||||
* so the directional light `Atmosphere` already owns is not double-counted —
|
||||
* while giving every smooth metal and every pane of glass a specular highlight
|
||||
* with a *direction* in it. That highlight is most of what separates "this is
|
||||
* metal" from "this is grey", and it is the one thing a uniform ambient can
|
||||
* never supply.
|
||||
*/
|
||||
const SUN_LOBE_RADIANCE = 1.2;
|
||||
const SUN_LOBE_TIGHTNESS = 400;
|
||||
const SUN_GLOW_RADIANCE = 0.22;
|
||||
const SUN_GLOW_TIGHTNESS = 9;
|
||||
|
||||
/**
|
||||
* How much of the sky bounces back off the ground.
|
||||
*
|
||||
* The lower half of the sphere is not black — it is the city, or the floor, lit
|
||||
* by the same sun. `LightingState.hemisphere.ground` is the number
|
||||
* `Atmosphere` already publishes for exactly this and it is reused rather than
|
||||
* re-derived, so a hazy afternoon greys the underside of a car and the top of
|
||||
* it together.
|
||||
*/
|
||||
const GROUND_RADIANCE_SHARE = 0.09;
|
||||
|
||||
/**
|
||||
* The office room, in metres. A generous meeting room rather than a studio,
|
||||
* because the reflection of a room reads as the *proportions* of a room and a
|
||||
* cube reads as a lift.
|
||||
*/
|
||||
const ROOM_WIDTH = 9;
|
||||
const ROOM_DEPTH = 7;
|
||||
const ROOM_HEIGHT = 3.2;
|
||||
|
||||
export function createEnvironmentRig(renderer: THREE.WebGLRenderer): EnvironmentRig {
|
||||
/**
|
||||
* Everything below is built on first use and not before.
|
||||
*
|
||||
* A `PMREMGenerator` compiles three shader programs the moment it is asked to
|
||||
* do anything, and a page that opens on a city board should not pay for the
|
||||
* office's blur chain until somebody walks into an office. `createStage`
|
||||
* builds one rig for the life of the page (see the note in `stage.ts` about
|
||||
* why there is exactly one renderer), so "first use" here means once.
|
||||
*/
|
||||
let pmrem: THREE.PMREMGenerator | null = null;
|
||||
let equirect: THREE.DataTexture | null = null;
|
||||
let room: RoomProbe | null = null;
|
||||
|
||||
/** One cached PMREM target per kind, with the key it was built from. */
|
||||
const built = new Map<EnvironmentKind, { key: string; target: THREE.WebGLRenderTarget }>();
|
||||
|
||||
/**
|
||||
* Every scene this rig has written an environment onto, and which kind it
|
||||
* was given.
|
||||
*
|
||||
* The kind is carried rather than just the scene because a page can hold both
|
||||
* at once — CONTRACT.md §1 keeps the city alive and paused while an office is
|
||||
* on screen — and when one kind rebuilds, the scenes that need the new texture
|
||||
* are the ones on *that* kind. Handing a sunset city sky to the office
|
||||
* standing beside it would light the room through a wall.
|
||||
*/
|
||||
const applied = new Map<THREE.Scene, EnvironmentKind>();
|
||||
|
||||
let disposed = false;
|
||||
|
||||
function generator(): THREE.PMREMGenerator {
|
||||
if (!pmrem) pmrem = new THREE.PMREMGenerator(renderer);
|
||||
return pmrem;
|
||||
}
|
||||
|
||||
function build(kind: EnvironmentKind, lighting: LightingState): THREE.WebGLRenderTarget {
|
||||
if (kind === "office") {
|
||||
if (!room) room = createRoomProbe();
|
||||
room.tune(lighting);
|
||||
// A little blur at capture time. The room is nine flat quads and a hard
|
||||
// edge between two of them would show up as a visible seam in the
|
||||
// reflection on a polished desk; four hundredths of a radian is under a
|
||||
// pixel of the cube face and is enough to take that edge off.
|
||||
return generator().fromScene(room.scene, 0.04, 0.1, 40);
|
||||
}
|
||||
equirect = fillSkyEquirect(equirect, lighting);
|
||||
return generator().fromEquirectangular(equirect);
|
||||
}
|
||||
|
||||
return {
|
||||
apply(scene, lighting, kind) {
|
||||
if (disposed) return;
|
||||
const key = environmentKey(kind, lighting);
|
||||
const current = built.get(kind);
|
||||
|
||||
if (!current || current.key !== key) {
|
||||
let target: THREE.WebGLRenderTarget;
|
||||
try {
|
||||
target = build(kind, lighting);
|
||||
} catch (error) {
|
||||
/*
|
||||
* Degrade rather than take the frame down.
|
||||
*
|
||||
* Everything in here runs against a live GL context, and the two ways
|
||||
* that goes wrong in the field are a lost context and a driver that
|
||||
* refuses a half-float render target. Neither is a reason for a city
|
||||
* to stop drawing: without an environment the world looks the way it
|
||||
* looked before this file was written, which is worse and still a
|
||||
* world. The warning is deliberately not swallowed silently — a
|
||||
* missing environment is very hard to diagnose from the picture
|
||||
* alone, because "everything is slightly duller" does not look like
|
||||
* an error.
|
||||
*/
|
||||
console.warn("environmentRig: could not build an environment map", error);
|
||||
return;
|
||||
}
|
||||
current?.target.dispose();
|
||||
built.set(kind, { key, target });
|
||||
// The previous texture has just been freed, and every scene that was
|
||||
// holding it is now pointing at a disposed target — not only the scene
|
||||
// that happened to ask for the rebuild. Scenes on the other kind are
|
||||
// left strictly alone.
|
||||
for (const [other, otherKind] of applied) {
|
||||
if (otherKind === kind) other.environment = target.texture;
|
||||
}
|
||||
}
|
||||
|
||||
const target = built.get(kind);
|
||||
if (!target) return;
|
||||
scene.environment = target.target.texture;
|
||||
// Stated rather than left at its default, because the brightness of the
|
||||
// environment is decided by the radiances above and a stray intensity
|
||||
// here would silently override all of that reasoning.
|
||||
scene.environmentIntensity = 1;
|
||||
applied.set(scene, kind);
|
||||
},
|
||||
|
||||
release(scene) {
|
||||
if (!applied.delete(scene)) return;
|
||||
scene.environment = null;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
disposed = true;
|
||||
for (const scene of applied.keys()) {
|
||||
scene.environment = null;
|
||||
}
|
||||
applied.clear();
|
||||
for (const entry of built.values()) entry.target.dispose();
|
||||
built.clear();
|
||||
equirect?.dispose();
|
||||
equirect = null;
|
||||
room?.dispose();
|
||||
room = null;
|
||||
// `PMREMGenerator.dispose()` frees its own blur materials and ping-pong
|
||||
// target. It does not touch the targets it handed out, which is why they
|
||||
// are disposed above first.
|
||||
pmrem?.dispose();
|
||||
pmrem = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Rebuild key ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A coarse fingerprint of the lighting, used to decide whether to rebuild.
|
||||
*
|
||||
* Coarse is the entire point. `Atmosphere` interpolates its keyframe table
|
||||
* continuously, so on a running clock every field of a `LightingState` changes
|
||||
* by a fraction every single frame — an exact key would rebuild the environment
|
||||
* sixty times a second and none of those rebuilds would be visible. Colours are
|
||||
* reduced to five bits a channel and the sun direction to twelfths of a unit
|
||||
* vector, which is roughly a five-degree bucket, so a real dawn still rebuilds
|
||||
* often enough to track and a static afternoon rebuilds once.
|
||||
*/
|
||||
function environmentKey(kind: EnvironmentKind, l: LightingState): string {
|
||||
const d = l.sun.direction;
|
||||
return [
|
||||
kind,
|
||||
quantiseColor(l.sky?.top ?? l.hemisphere.sky),
|
||||
quantiseColor(l.sky?.horizon ?? l.hemisphere.ground),
|
||||
quantiseColor(l.hemisphere.sky),
|
||||
quantiseColor(l.hemisphere.ground),
|
||||
Math.round(l.hemisphere.intensity * 20),
|
||||
quantiseColor(l.ambient.color),
|
||||
Math.round(l.ambient.intensity * 20),
|
||||
quantiseColor(l.sun.color),
|
||||
Math.round(l.sun.intensity * 20),
|
||||
Math.round(d[0] * 12),
|
||||
Math.round(d[1] * 12),
|
||||
Math.round(d[2] * 12),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/** 24-bit colour down to 15, which is finer than the eye reads off a gradient. */
|
||||
function quantiseColor(hex: number): number {
|
||||
return (((hex >> 19) & 0x1f) << 10) | (((hex >> 11) & 0x1f) << 5) | ((hex >> 3) & 0x1f);
|
||||
}
|
||||
|
||||
// ---- The city sky ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fill (or refill) the equirectangular sky.
|
||||
*
|
||||
* Written into an existing buffer where there is one. A `DataTexture` is 128 KB
|
||||
* of `Uint16Array` and the rebuild happens on a timer nobody controls, so
|
||||
* allocating a new one each time hands the garbage collector a steady drip of
|
||||
* medium-sized buffers for no reason — and the GPU-side texture object would be
|
||||
* recreated with it, which is the expensive half.
|
||||
*
|
||||
* The sphere is built in three parts, all of them derived from the
|
||||
* `LightingState` and none of them invented here:
|
||||
*
|
||||
* - **Above the horizon**, the same top-to-horizon gradient `scenekit.ts`
|
||||
* paints on the background, so the environment and the visible sky are the
|
||||
* same sky. It is blended on `sin(elevation)` raised to a power rather than
|
||||
* linearly on the angle, because that is what puts the pale band near the
|
||||
* horizon where the eye expects it.
|
||||
* - **Below the horizon**, the hemisphere light's ground colour. The lower
|
||||
* half of a real environment is the terrain, and leaving it black is the
|
||||
* single most common way an environment map makes a car look like a toy.
|
||||
* - **The sun**, as a narrow lobe plus a wide glow, at the direction
|
||||
* `Atmosphere` already computed. Half-float storage is what makes this
|
||||
* possible at all: the lobe sits several times above 1.0 and an 8-bit
|
||||
* texture would clip it to the same white as the sky beside it, which is
|
||||
* the exact failure `stage.ts` just removed from the main render path.
|
||||
*/
|
||||
function fillSkyEquirect(existing: THREE.DataTexture | null, l: LightingState): THREE.DataTexture {
|
||||
const width = EQUIRECT_WIDTH;
|
||||
const height = EQUIRECT_HEIGHT;
|
||||
const texture =
|
||||
existing ??
|
||||
new THREE.DataTexture(
|
||||
new Uint16Array(width * height * 4),
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
THREE.HalfFloatType,
|
||||
);
|
||||
const data = texture.image.data as Uint16Array;
|
||||
|
||||
const top = linearOf(l.sky?.top ?? l.hemisphere.sky);
|
||||
const horizon = linearOf(l.sky?.horizon ?? l.hemisphere.ground);
|
||||
const ground = linearOf(l.hemisphere.ground);
|
||||
|
||||
const skyScale = SKY_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
|
||||
const groundScale = GROUND_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
|
||||
const sun = linearOf(l.sun.color);
|
||||
const sunScale = Math.max(0, l.sun.intensity);
|
||||
const [sx, sy, sz] = l.sun.direction;
|
||||
|
||||
const half = THREE.DataUtils.toHalfFloat;
|
||||
|
||||
for (let j = 0; j < height; j++) {
|
||||
// Row 0 is v = 0. Three's `equirectUv` puts v = 0 at `dir.y = -1`, and a
|
||||
// `DataTexture` does not flip, so row 0 is straight down.
|
||||
const v = (j + 0.5) / height;
|
||||
const phi = (v - 0.5) * Math.PI;
|
||||
const sinPhi = Math.sin(phi);
|
||||
const cosPhi = Math.cos(phi);
|
||||
|
||||
// The vertical blend, before the sun is added. Above the horizon it walks
|
||||
// the sky gradient; below it fades the ground colour down as it goes under,
|
||||
// so there is no hard band at the equator to show up in a mirror.
|
||||
let baseR: number;
|
||||
let baseG: number;
|
||||
let baseB: number;
|
||||
if (sinPhi >= 0) {
|
||||
const t = Math.pow(sinPhi, 0.55);
|
||||
baseR = (horizon[0] + (top[0] - horizon[0]) * t) * skyScale;
|
||||
baseG = (horizon[1] + (top[1] - horizon[1]) * t) * skyScale;
|
||||
baseB = (horizon[2] + (top[2] - horizon[2]) * t) * skyScale;
|
||||
} else {
|
||||
const t = Math.pow(-sinPhi, 0.7);
|
||||
const dim = 1 - 0.55 * t;
|
||||
baseR = (horizon[0] * (1 - t) * skyScale + ground[0] * t * groundScale) * dim;
|
||||
baseG = (horizon[1] * (1 - t) * skyScale + ground[1] * t * groundScale) * dim;
|
||||
baseB = (horizon[2] * (1 - t) * skyScale + ground[2] * t * groundScale) * dim;
|
||||
}
|
||||
|
||||
for (let i = 0; i < width; i++) {
|
||||
const u = (i + 0.5) / width;
|
||||
const theta = (u - 0.5) * Math.PI * 2;
|
||||
const dx = cosPhi * Math.cos(theta);
|
||||
const dy = sinPhi;
|
||||
const dz = cosPhi * Math.sin(theta);
|
||||
|
||||
const cos = dx * sx + dy * sy + dz * sz;
|
||||
let solar = 0;
|
||||
if (cos > 0) {
|
||||
solar =
|
||||
SUN_LOBE_RADIANCE * Math.pow(cos, SUN_LOBE_TIGHTNESS) +
|
||||
SUN_GLOW_RADIANCE * Math.pow(cos, SUN_GLOW_TIGHTNESS);
|
||||
solar *= sunScale;
|
||||
}
|
||||
|
||||
const o = (j * width + i) * 4;
|
||||
data[o] = half(baseR + sun[0] * solar);
|
||||
data[o + 1] = half(baseG + sun[1] * solar);
|
||||
data[o + 2] = half(baseB + sun[2] * solar);
|
||||
data[o + 3] = half(1);
|
||||
}
|
||||
}
|
||||
|
||||
texture.mapping = THREE.EquirectangularReflectionMapping;
|
||||
// Half-float data is already linear radiance; naming a transfer function here
|
||||
// would apply an sRGB decode to numbers that were never encoded.
|
||||
texture.colorSpace = THREE.NoColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// ---- The office room ------------------------------------------------------
|
||||
|
||||
interface RoomProbe {
|
||||
scene: THREE.Scene;
|
||||
tune(l: LightingState): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nine quads that reflect like a room.
|
||||
*
|
||||
* The list is short and every entry earns its place in a reflection: a bright
|
||||
* ceiling and two brighter light panels (which is what puts the long vertical
|
||||
* highlight down the edge of a monitor bezel), a floor darker than the walls, a
|
||||
* window wall carrying the sun's own colour, and a warm end wall so the room
|
||||
* has a direction to it and a chrome chair leg is not the same colour all the
|
||||
* way round.
|
||||
*
|
||||
* Built once and re-coloured, not rebuilt. `tune` only assigns to
|
||||
* `material.color`, so the geometry, the materials and their shader programs
|
||||
* survive every change of hour.
|
||||
*/
|
||||
function createRoomProbe(): RoomProbe {
|
||||
const scene = new THREE.Scene();
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
const materials: THREE.MeshBasicMaterial[] = [];
|
||||
|
||||
function quad(
|
||||
w: number,
|
||||
h: number,
|
||||
position: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
): THREE.MeshBasicMaterial {
|
||||
const geometry = new THREE.PlaneGeometry(w, h);
|
||||
// `DoubleSide` so the probe camera at the origin sees every quad whichever
|
||||
// way it was authored — an inside-out wall in an environment reads as a
|
||||
// hole, and a hole reads as a black stripe across everything shiny.
|
||||
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.position.set(position[0], position[1], position[2]);
|
||||
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
|
||||
scene.add(mesh);
|
||||
geometries.push(geometry);
|
||||
materials.push(material);
|
||||
return material;
|
||||
}
|
||||
|
||||
const HALF_W = ROOM_WIDTH / 2;
|
||||
const HALF_D = ROOM_DEPTH / 2;
|
||||
const HALF_H = ROOM_HEIGHT / 2;
|
||||
|
||||
const ceiling = quad(ROOM_WIDTH, ROOM_DEPTH, [0, HALF_H, 0], [Math.PI / 2, 0, 0]);
|
||||
const floor = quad(ROOM_WIDTH, ROOM_DEPTH, [0, -HALF_H, 0], [-Math.PI / 2, 0, 0]);
|
||||
const back = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, -HALF_D], [0, 0, 0]);
|
||||
const front = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, HALF_D], [0, Math.PI, 0]);
|
||||
const left = quad(ROOM_DEPTH, ROOM_HEIGHT, [-HALF_W, 0, 0], [0, Math.PI / 2, 0]);
|
||||
const window = quad(ROOM_DEPTH, ROOM_HEIGHT, [HALF_W, 0, 0], [0, -Math.PI / 2, 0]);
|
||||
const panelA = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, -1.4], [Math.PI / 2, 0, 0]);
|
||||
const panelB = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, 1.4], [Math.PI / 2, 0, 0]);
|
||||
const accent = quad(ROOM_WIDTH * 0.9, 0.35, [0, -0.6, -HALF_D + 0.01], [0, 0, 0]);
|
||||
|
||||
return {
|
||||
scene,
|
||||
tune(l) {
|
||||
const sky = linearOf(l.hemisphere.sky);
|
||||
const groundC = linearOf(l.hemisphere.ground);
|
||||
const ambient = linearOf(l.ambient.color);
|
||||
const sun = linearOf(l.sun.color);
|
||||
|
||||
// An interior probe is normalised against the *fill*, not against the
|
||||
// sun: a room's own surfaces are what a desk reflects, and they are lit
|
||||
// by whatever is getting inside. Reading `hemisphere.intensity` keeps a
|
||||
// night office reflecting a dim room and a noon office a bright one
|
||||
// without this file forming its own opinion about either.
|
||||
const fill = Math.max(0.05, l.hemisphere.intensity) * SKY_RADIANCE_SHARE;
|
||||
const solar = Math.max(0, l.sun.intensity) * SKY_RADIANCE_SHARE;
|
||||
|
||||
setLinear(ceiling, ambient, fill * 1.5);
|
||||
setLinear(floor, groundC, fill * 0.8);
|
||||
setLinear(back, ambient, fill * 1.1);
|
||||
setLinear(front, ambient, fill * 1.0);
|
||||
setLinear(left, ambient, fill * 1.2);
|
||||
// The window is the only surface that knows what time it is, and it is
|
||||
// the one that gives a monitor bezel a bright edge on the daylight side.
|
||||
setLinear(window, sun, solar * 2.2 + fill * 0.6);
|
||||
setLinear(panelA, sky, fill * 7);
|
||||
setLinear(panelB, sky, fill * 7);
|
||||
setLinear(accent, groundC, fill * 1.6);
|
||||
},
|
||||
dispose() {
|
||||
for (const g of geometries) g.dispose();
|
||||
for (const m of materials) m.dispose();
|
||||
geometries.length = 0;
|
||||
materials.length = 0;
|
||||
scene.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Colour ---------------------------------------------------------------
|
||||
|
||||
const SCRATCH = new THREE.Color();
|
||||
|
||||
/**
|
||||
* An authored `0xrrggbb` as linear-light RGB.
|
||||
*
|
||||
* Named rather than inlined because getting it wrong is invisible until it is
|
||||
* everywhere: `LightingState` colours are sRGB by the definition in `types.ts`,
|
||||
* and everything on this page is radiance, which is linear. Multiplying an
|
||||
* un-decoded 0.5 by an intensity is off by more than a factor of two at the
|
||||
* dark end of the range, and the symptom is a night sky that reflects like an
|
||||
* overcast noon.
|
||||
*/
|
||||
function linearOf(hex: number): [number, number, number] {
|
||||
SCRATCH.setHex(hex, THREE.SRGBColorSpace);
|
||||
return [SCRATCH.r, SCRATCH.g, SCRATCH.b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a material's colour from linear radiance, which may exceed 1.
|
||||
*
|
||||
* `Color.setHex` and the `{ color }` constructor argument both go through an
|
||||
* sRGB decode and both saturate at 1.0, so neither can express a light panel
|
||||
* seven times brighter than the wall beside it. `setRGB` in the working colour
|
||||
* space can, and a light panel that cannot be brighter than the wall is not a
|
||||
* light panel.
|
||||
*/
|
||||
function setLinear(
|
||||
material: THREE.MeshBasicMaterial,
|
||||
color: readonly [number, number, number],
|
||||
scale: number,
|
||||
): void {
|
||||
material.color.setRGB(color[0] * scale, color[1] * scale, color[2] * scale);
|
||||
}
|
||||
+202
-6
@@ -2,12 +2,14 @@
|
||||
* Aircraft over the city.
|
||||
*
|
||||
* The engine takes a `FlightSource` rather than talking to any particular
|
||||
* service, because the obvious one cannot ship here. FlightRadar24's terms
|
||||
* forbid scraping and forbid redistributing their data, so an Apache-2.0 repo
|
||||
* containing an FR24 client would be publishing instructions for breaking a
|
||||
* ToS and shipping data it has no right to relicense. Commercial sources are
|
||||
* adapters in a private deployment; this file holds what we can actually give
|
||||
* away. See ARCHITECTURE.md §4.
|
||||
* service, because the obvious one cannot ship here. FlightRadar24's terms do
|
||||
* not permit scraping and do not permit redistributing their data. An
|
||||
* Apache-2.0 repo shipping such a client would not merely be breaking a ToS —
|
||||
* it would be publishing instructions for doing so, alongside data it has no
|
||||
* right to relicense. Commercial sources are adapters in a private deployment;
|
||||
* this file holds what we can actually give away. See ARCHITECTURE.md §4, and
|
||||
* `server/src/flights/licence.ts` for the allowlist that keeps the open lane
|
||||
* open in practice rather than in principle.
|
||||
*
|
||||
* `SimulatedFlights` is the default and is genuinely enough for the map — what
|
||||
* a city view wants is convincing motion in the right corridors, not a
|
||||
@@ -345,6 +347,16 @@ const ADSB_HOLD_SECONDS = 60;
|
||||
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
|
||||
* first-party data, nothing to comply with.
|
||||
*
|
||||
* **`endpoint` is not free-form, even though its type is `string`.** The
|
||||
* allowlist of feeds this project will fetch, and the credit line each of them
|
||||
* is owed, live in `server/src/flights/licence.ts`, which is where the API's
|
||||
* `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing
|
||||
* a feed for itself is not republishing it and so is not the exposure that gate
|
||||
* exists for — but a self-hoster who constructs this class with some other
|
||||
* endpoint is choosing terms nobody here has read, and this is the sentence
|
||||
* that says so. Nothing in this repo constructs it: the shipped path is
|
||||
* `HttpFlights` against our own API.
|
||||
*
|
||||
* The region is required and has no default. It used to default to a point in
|
||||
* San Francisco, which is a fine centre for one of the two cities in this build
|
||||
* and a five-hundred-kilometre error for the other — and a wrong default is
|
||||
@@ -445,10 +457,183 @@ interface RawAircraft {
|
||||
track?: number;
|
||||
}
|
||||
|
||||
// ---- Detail ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One aircraft, described well enough to put on a card somebody clicked.
|
||||
*
|
||||
* The demo this project leads with is a signed-out visitor clicking a dart over
|
||||
* a city they recognise and being told what it is, so this type is written for
|
||||
* **anon** and carries nothing an account would be needed for. Everything in it
|
||||
* is either broadcast unencrypted by the aircraft itself — ADS-B is receivable
|
||||
* with a forty-dollar dongle — or arithmetic on top of that. There is no route,
|
||||
* no registration and no operator here, because the open feeds do not carry
|
||||
* them and inventing them would be the same class of lie `synthetic` exists to
|
||||
* prevent. `owner-decisions.md` reserves those for an openly-licensed registry
|
||||
* we have not wired.
|
||||
*
|
||||
* Two fields are about the *provenance* rather than the aeroplane, and they are
|
||||
* the reason this is a type and not an object literal built in the UI:
|
||||
* `observed` says whether anybody actually saw this, and `attribution` carries
|
||||
* whatever the feed asks to be credited with **at the point the data is
|
||||
* displayed**, which is what an ODbL notice is for. A card is a display. A
|
||||
* corner label on the other side of the screen is not obviously one.
|
||||
*/
|
||||
export interface AircraftDetail {
|
||||
/** The source's own id. The ICAO address for a live feed; a route name for the simulator. */
|
||||
id: string;
|
||||
/** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */
|
||||
callsign: string | null;
|
||||
/**
|
||||
* The transponder's 24-bit ICAO address, lowercase hex, or `null`.
|
||||
*
|
||||
* `null` rather than a guess for anything that does not look like one — the
|
||||
* simulator's ids are route names and a `~`-prefixed id on a real feed is a
|
||||
* non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely
|
||||
* not an ICAO24 and must not be presented as one. Somebody can paste this
|
||||
* into a registry lookup, so a wrong one sends them to another aircraft.
|
||||
*/
|
||||
icao24: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Barometric altitude, metres — the unit the wire and the engine both use. */
|
||||
altitudeM: number;
|
||||
/** The same altitude in feet, which is the unit aviation is actually read in. */
|
||||
altitudeFt: number;
|
||||
/** Degrees clockwise from true north. */
|
||||
headingDeg: number;
|
||||
/** The heading as a 16-point compass name, for a card a human reads. */
|
||||
headingCompass: string;
|
||||
/** Nautical miles from the board's centre, or `null` when no centre was given. */
|
||||
distanceNm: number | null;
|
||||
/**
|
||||
* Did somebody observe this, or did this repo invent it?
|
||||
*
|
||||
* The same statement `TrafficSource.live()` makes about the whole feed, made
|
||||
* about one aircraft, and it must travel with the aircraft: a card is read on
|
||||
* its own, away from any corner label, and a fabricated flight number
|
||||
* presented in the same frame as a real one is the confusion the `live` flag
|
||||
* exists to prevent.
|
||||
*/
|
||||
observed: boolean;
|
||||
/** Credit lines owed for this aircraft, to be shown on the card itself. */
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An ICAO 24-bit address as the feeds write it: six hex digits, lowercase.
|
||||
*
|
||||
* Anchored, so `sim-BA286` fails and `~abc123` — the anonymous-address form
|
||||
* both community feeds emit for targets whose real address is not known — fails
|
||||
* too, which is the point. See `AircraftDetail.icao24`.
|
||||
*/
|
||||
const ICAO24 = /^[0-9a-f]{6}$/;
|
||||
|
||||
/** The sixteen names, in the order the compass runs. */
|
||||
const COMPASS = [
|
||||
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
|
||||
];
|
||||
|
||||
/**
|
||||
* A bearing as a compass point.
|
||||
*
|
||||
* Sixteen points rather than eight because the difference between "north-east"
|
||||
* and "east-north-east" is the difference between two departure corridors, and
|
||||
* rather than thirty-two because nobody reads "NNE by N" off a card. Negative
|
||||
* and out-of-range degrees are wrapped rather than refused: a heading is an
|
||||
* angle and every angle names a direction.
|
||||
*/
|
||||
export function compassPoint(degrees: number): string {
|
||||
if (!Number.isFinite(degrees)) return "—";
|
||||
const wrapped = ((degrees % 360) + 360) % 360;
|
||||
return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
|
||||
}
|
||||
|
||||
/** Metres to feet. The wire carries metres; aviation is read in feet. */
|
||||
const FEET_PER_METRE = 3.280_84;
|
||||
|
||||
export interface AircraftDetailOptions {
|
||||
/**
|
||||
* The transponder address, when the caller was told one separately.
|
||||
*
|
||||
* `HttpFlights` is: `WireAircraft.icao24` is a field on the body and
|
||||
* `Aircraft` has nowhere to put it, so the adapter keeps the wire record
|
||||
* beside the position and hands it back here. Absent, the id is tested
|
||||
* against `ICAO24` — which is right for every feed that keys on the hex, and
|
||||
* correctly declines for the simulator.
|
||||
*/
|
||||
icao24?: string | null;
|
||||
/** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */
|
||||
observed?: boolean;
|
||||
/** Credit lines the feed asks for, shown on the card. */
|
||||
attribution?: readonly string[];
|
||||
/** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */
|
||||
from?: Place;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an `Aircraft` into something a panel can render, without the panel
|
||||
* knowing where aircraft come from.
|
||||
*
|
||||
* Pure, total and free of I/O, so the interface layer can call it on a click
|
||||
* without awaiting anything, and so it can be tested without a network. It
|
||||
* invents nothing: every field is a restatement, a unit conversion or a `null`.
|
||||
*/
|
||||
export function aircraftDetail(
|
||||
aircraft: Aircraft,
|
||||
options: AircraftDetailOptions = {},
|
||||
): AircraftDetail {
|
||||
const callsign = aircraft.callsign?.trim();
|
||||
const declared = options.icao24?.trim().toLowerCase();
|
||||
const fromId = aircraft.id.trim().toLowerCase();
|
||||
const icao24 =
|
||||
declared !== undefined && ICAO24.test(declared)
|
||||
? declared
|
||||
: ICAO24.test(fromId)
|
||||
? fromId
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: aircraft.id,
|
||||
callsign: callsign === undefined || callsign === "" ? null : callsign,
|
||||
icao24,
|
||||
lat: aircraft.lat,
|
||||
lng: aircraft.lng,
|
||||
altitudeM: aircraft.altitude,
|
||||
altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE),
|
||||
headingDeg: aircraft.heading,
|
||||
headingCompass: compassPoint(aircraft.heading),
|
||||
distanceNm:
|
||||
options.from === undefined
|
||||
? null
|
||||
: Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10,
|
||||
observed: options.observed === true,
|
||||
attribution: [...(options.attribution ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Rendering ------------------------------------------------------------
|
||||
|
||||
export interface FlightLayer {
|
||||
group: THREE.Group;
|
||||
/**
|
||||
* The aircraft meshes currently in the sky, as a **live** array, each
|
||||
* carrying `userData.aircraftId`.
|
||||
*
|
||||
* Here rather than on the caller because only this layer knows which mesh is
|
||||
* which track: the map from id to mesh is private and the group's child order
|
||||
* is an artefact of when each aircraft appeared. It is the same shape
|
||||
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
|
||||
* pick is resolved from the object that was hit, and something has to say
|
||||
* what the object stands for.
|
||||
*
|
||||
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
|
||||
* position is broadcast unencrypted to anybody with a receiver, so the card
|
||||
* it opens is available to an anonymous visitor and the picking that reaches
|
||||
* it must be too.
|
||||
*/
|
||||
pickables: THREE.Object3D[];
|
||||
/**
|
||||
* Hand over a fresh observation. Called on the source's own timer, which is
|
||||
* once a second for the simulator and once every several seconds for a real
|
||||
@@ -716,6 +901,9 @@ interface Track {
|
||||
export function createFlightLayer(world: World): FlightLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "flights";
|
||||
// Mutated in place as tracks appear and expire, so `setPicking` can hold the
|
||||
// array itself as its target list rather than re-reading it every pointer move.
|
||||
const pickables: THREE.Object3D[] = [];
|
||||
|
||||
const geo = airlinerGeometry();
|
||||
const materials = new Map<number, THREE.MeshLambertMaterial>();
|
||||
@@ -799,7 +987,11 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
// Yaw then pitch, because the heading is about the world's vertical and
|
||||
// the climb angle is about the aircraft's own wing.
|
||||
mesh.rotation.order = "YXZ";
|
||||
// The id, on the object, so a raycast hit resolves to an aeroplane
|
||||
// without this layer having to expose its private track table.
|
||||
mesh.userData.aircraftId = a.id;
|
||||
group.add(mesh);
|
||||
pickables.push(mesh);
|
||||
track = {
|
||||
mesh,
|
||||
samples: [],
|
||||
@@ -921,6 +1113,8 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
if (track.missingSince === 0) track.missingSince = now;
|
||||
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
|
||||
group.remove(track.mesh);
|
||||
const at = pickables.indexOf(track.mesh);
|
||||
if (at >= 0) pickables.splice(at, 1);
|
||||
tracks.delete(id);
|
||||
}
|
||||
|
||||
@@ -1139,6 +1333,7 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
|
||||
return {
|
||||
group,
|
||||
pickables,
|
||||
update,
|
||||
tick,
|
||||
dispose() {
|
||||
@@ -1148,6 +1343,7 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
trailGeo.dispose();
|
||||
trailMat.dispose();
|
||||
tracks.clear();
|
||||
pickables.length = 0;
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* The apron outside the front door, and the car standing on it.
|
||||
*
|
||||
* Every shipped pack authors one `ExteriorArrival` — a marked stall on the
|
||||
* ground outside the building, in the pack's own plan frame. This layer turns
|
||||
* that one anchor into a piece of the world: a paved pad, a painted bay, a kerb,
|
||||
* a charge post, and a Model X parked in it whose lamps and cabin reflect a live
|
||||
* {@link VehicleTelemetryState}.
|
||||
*
|
||||
* ### Why the car stands here rather than on the board
|
||||
*
|
||||
* There has been a Model X in this product since the freeway corridor shipped,
|
||||
* and it has only ever existed as traffic: forty instanced glyphs at 0.18 scale
|
||||
* on a board where one scene unit is 94 metres, seen from four kilometres up.
|
||||
* That is a map symbol. This is the same asset at 1 unit = 1 m, three metres
|
||||
* from a doorway you can walk through, which is the first time anything in the
|
||||
* product has asked it to be a car — and it is why `assets/vehicles/modelX.ts`
|
||||
* was rebuilt with arches, glass openings and a real tyre.
|
||||
*
|
||||
* ### What this layer owns and what it borrows
|
||||
*
|
||||
* It owns geometry and exactly one material (the cabin glow, whose emissive
|
||||
* strength varies continuously and therefore cannot be a shared registry
|
||||
* material). Everything else is borrowed: surface roles come from the
|
||||
* `MaterialRegistry`, indicator colours come from `materials.tinted`, and the
|
||||
* kerbside planter is built through the `AssetRegistry` so a self-hoster who has
|
||||
* re-skinned `tera:planter.trough` gets their trough out here too.
|
||||
*
|
||||
* `dispose()` frees what this layer made and deliberately leaves the registries
|
||||
* alone. Disposing a shared `polishedConcrete` here would blank the floor of the
|
||||
* office the apron stands outside of.
|
||||
*
|
||||
* ### It constructs no light
|
||||
*
|
||||
* CONTRACT §4: `Atmosphere` is the sole light owner. A charge lamp, a marker
|
||||
* lamp and a lit cabin are all *emissive materials*, exactly as
|
||||
* `interiors/luminaires.ts` makes a ceiling fitting glow without becoming one.
|
||||
* Nothing in this file is a light source, and the release gate's grep for the
|
||||
* five three.js light constructors returns nothing over it on purpose.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { AssetRegistry } from "../assets/kit.ts";
|
||||
import { createAssetContext } from "../assets/kit.ts";
|
||||
import type { MaterialRegistry } from "../assets/materials.ts";
|
||||
import { MeshBin, parts } from "../assets/parts.ts";
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
MODEL_X_PAINTS,
|
||||
buildModelX,
|
||||
disposeModelX,
|
||||
type ModelXDetail,
|
||||
} from "../assets/vehicles/index.ts";
|
||||
import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts";
|
||||
import {
|
||||
apronKindFor,
|
||||
apronMetrics,
|
||||
exteriorVehicleAppearance,
|
||||
lampTint,
|
||||
parkPose,
|
||||
type ApronMetrics,
|
||||
type ExteriorVehicleAppearance,
|
||||
} from "../transport/exteriorVehicle.ts";
|
||||
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
|
||||
|
||||
export interface OfficeExteriorOptions {
|
||||
site: OfficeSite;
|
||||
arrival: ExteriorArrival;
|
||||
assets: AssetRegistry;
|
||||
materials: MaterialRegistry;
|
||||
/** Seeded per office, so the same studio has the same car outside it forever. */
|
||||
rand: () => number;
|
||||
/**
|
||||
* Which Model X to build.
|
||||
*
|
||||
* `corridor` is the right answer for a parked car and is what a caller should
|
||||
* pass unless the camera is close enough to read a door shutline: 3,192
|
||||
* triangles across 18 draw calls, against 13,080 and 25 for `follow`. The
|
||||
* difference a viewer can see at three metres is the wing mirrors, the glass
|
||||
* frames and the brake calipers; at ten it is nothing at all.
|
||||
*/
|
||||
detail: ModelXDetail;
|
||||
}
|
||||
|
||||
export interface OfficeExterior {
|
||||
object: THREE.Object3D;
|
||||
/** Reflect one telemetry observation. Cheap, idempotent, safe every frame. */
|
||||
apply(telemetry: VehicleTelemetryState): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the charge flap sits on the vehicle's left rear quarter, in the asset's
|
||||
* own frame.
|
||||
*
|
||||
* These are not invented: they are the surface the bodyshell loft actually
|
||||
* produces there, measured by casting a ray across the flank at that height
|
||||
* (`x = −1.0004` on both LODs), plus 3.6 mm so the flap stands proud the way a
|
||||
* real one does rather than z-fighting with the paint. Aft of the rear arch and
|
||||
* below the shoulder line, which is where a charge port goes on every car that
|
||||
* has one.
|
||||
*/
|
||||
const CHARGE_PORT = { x: -1.004, y: 0.86, z: 1.95 } as const;
|
||||
|
||||
/** Radius of the charge-flap ring, metres. */
|
||||
const CHARGE_PORT_RADIUS = 0.055;
|
||||
|
||||
/**
|
||||
* The cabin glow slab, in the vehicle's frame: a dome light under the roof.
|
||||
*
|
||||
* Deliberately a thin horizontal plate rather than a filled cabin volume. The
|
||||
* greenhouse is real glass with an interior behind it, so a lit box would be
|
||||
* seen *through* the seats; a plate at head height reads as the light being on
|
||||
* and disappears when it is not.
|
||||
*/
|
||||
const CABIN_GLOW = { width: 1.24, thickness: 0.02, depth: 2.05, y: 1.3, z: -0.05 } as const;
|
||||
|
||||
/** How hard the cabin plate is driven at `cabinGlow === 1`. */
|
||||
const CABIN_GLOW_MAX_INTENSITY = 1.6;
|
||||
|
||||
/**
|
||||
* Build the apron and the vehicle at a pack's arrival anchor.
|
||||
*
|
||||
* The returned object sits at the plan's origin with an identity transform, and
|
||||
* everything inside it is positioned in the pack's own metres — so a caller adds
|
||||
* it to the level group and nothing has to agree about a frame. That is also
|
||||
* what makes the anchor assertable: the Model X's world position is
|
||||
* `arrival.position` plus a bounded parking jitter, and nothing else.
|
||||
*/
|
||||
export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExterior {
|
||||
const { site, arrival, assets, materials, rand, detail } = options;
|
||||
|
||||
const root = new THREE.Group();
|
||||
root.name = "office-exterior";
|
||||
root.userData.kind = "office-exterior";
|
||||
root.userData.arrivalKind = arrival.kind;
|
||||
if (arrival.label) root.userData.label = arrival.label;
|
||||
if (site.label) root.userData.siteLabel = site.label;
|
||||
|
||||
const kind = apronKindFor(site.elevation);
|
||||
const metrics = apronMetrics(
|
||||
{ length: MODEL_X_METRICS.length, width: MODEL_X_METRICS.width },
|
||||
kind,
|
||||
);
|
||||
|
||||
// Geometries this layer minted and must free. Registry materials are not in
|
||||
// here and must not be: they belong to the office this apron stands outside.
|
||||
const ownedGeometries: THREE.BufferGeometry[] = [];
|
||||
const ownedMaterials: THREE.Material[] = [];
|
||||
|
||||
// ---- The apron ----------------------------------------------------------
|
||||
|
||||
const apron = new THREE.Group();
|
||||
apron.name = "office-exterior:apron";
|
||||
apron.position.set(arrival.position.x, 0, arrival.position.z);
|
||||
// `Yaw` is `object.rotation.y` with no conversion (interiors/types.ts), which
|
||||
// is why nothing in this file converts an angle.
|
||||
apron.rotation.y = arrival.rotation;
|
||||
root.add(apron);
|
||||
|
||||
const pavingColor = kind === "street" ? 0x6f7370 : 0x8d908a;
|
||||
const paving = materials.tinted("polishedConcrete", pavingColor);
|
||||
const kerbMaterial = materials.get("skirting");
|
||||
const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd);
|
||||
const postShell = materials.get("deviceShell");
|
||||
const postCap = materials.get("metalTrim");
|
||||
|
||||
// Two bins rather than one, split on whether the piece casts a shadow. A
|
||||
// `MeshBin` sets the flags per build, and a flat slab lying on the ground has
|
||||
// nothing to cast onto while a 1.3 m post very much does.
|
||||
const flat = new MeshBin();
|
||||
const upright = new MeshBin();
|
||||
const top = metrics.padThickness;
|
||||
|
||||
// The slab. `metricQuad` under the top face rather than a scaled `quad`,
|
||||
// because the paving carries a texture and its relief now carries a normal
|
||||
// map: a unit quad scaled to six metres smears one 2 m tile across the whole
|
||||
// pad, colour and relief together.
|
||||
flat.box(paving, {
|
||||
y: 0,
|
||||
size: [metrics.padWidth, metrics.padThickness, metrics.padDepth],
|
||||
});
|
||||
flat.add(parts.metricQuad(metrics.padWidth, metrics.padDepth), paving, { y: top + 0.001 });
|
||||
|
||||
// A kerb upstand around the pad, with a dropped crossing at the open end.
|
||||
//
|
||||
// Two decisions in one shape, and both are worth naming. The upstand runs all
|
||||
// the way round rather than along one edge because the exterior layer is
|
||||
// handed a stall and a site and *neither of them says which side the street
|
||||
// is on*: `mateo-court`'s bay runs east along Mateo Street with the façade to
|
||||
// its left, `frontier-valley`'s faces an apron with taxiway on three sides,
|
||||
// and `lumbridge-hq`'s has no street at all. A carriageway laid on a guessed
|
||||
// side would be wrong half the time; a kerbed island is right every time.
|
||||
//
|
||||
// The gap at +Z is the crossing the car drove in over. Without it the kerb
|
||||
// closes the bay on all four sides and the car reads as having been craned
|
||||
// into a planter, which is a small thing that reliably breaks the illusion.
|
||||
const openingWidth = metrics.stallWidth + 0.4;
|
||||
const returnWidth = Math.max(0.2, (metrics.padWidth - openingWidth) / 2);
|
||||
const returnX = openingWidth / 2 + returnWidth / 2;
|
||||
const frontZ = metrics.padDepth / 2 - metrics.kerbDepth / 2;
|
||||
const kerbRuns: [number, number, number, number][] = [
|
||||
// The head of the bay, and the two long flanks.
|
||||
[0, -metrics.padDepth / 2 + metrics.kerbDepth / 2, metrics.padWidth, metrics.kerbDepth],
|
||||
[-metrics.padWidth / 2 + metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
|
||||
[metrics.padWidth / 2 - metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
|
||||
// The two returns either side of the crossing.
|
||||
[-returnX, frontZ, returnWidth, metrics.kerbDepth],
|
||||
[returnX, frontZ, returnWidth, metrics.kerbDepth],
|
||||
];
|
||||
for (const [x, z, w, d] of kerbRuns) {
|
||||
upright.box(kerbMaterial, { x, y: top, z, size: [w, metrics.kerbHeight, d] });
|
||||
}
|
||||
|
||||
// The painted bay: two flanks and a head. Three strips and not a rectangle
|
||||
// outline, because a bay is open at the end you drive in through, and the
|
||||
// open end is what tells a viewer which way the car came in.
|
||||
const lineY = top + 0.004;
|
||||
const halfW = metrics.stallWidth / 2;
|
||||
const halfL = metrics.stallLength / 2;
|
||||
flat.box(lineMaterial, {
|
||||
x: -halfW, y: lineY, z: 0,
|
||||
size: [metrics.lineWidth, 0.004, metrics.stallLength],
|
||||
});
|
||||
flat.box(lineMaterial, {
|
||||
x: halfW, y: lineY, z: 0,
|
||||
size: [metrics.lineWidth, 0.004, metrics.stallLength],
|
||||
});
|
||||
flat.box(lineMaterial, {
|
||||
x: 0, y: lineY, z: -halfL,
|
||||
size: [metrics.stallWidth, 0.004, metrics.lineWidth],
|
||||
});
|
||||
|
||||
// The charge post. A moulded column with a metal cap and a recessed face; the
|
||||
// lamps and the charge bar are separate meshes because they change.
|
||||
upright.box(postShell, {
|
||||
x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
|
||||
size: [metrics.postWidth, metrics.postHeight, metrics.postDepth],
|
||||
});
|
||||
upright.box(postCap, {
|
||||
x: metrics.postOffsetX, y: top + metrics.postHeight, z: metrics.postOffsetZ,
|
||||
size: [metrics.postWidth + 0.03, 0.035, metrics.postDepth + 0.03],
|
||||
});
|
||||
upright.box(postCap, {
|
||||
x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
|
||||
size: [metrics.postWidth + 0.06, 0.05, metrics.postDepth + 0.06],
|
||||
});
|
||||
|
||||
for (const group of [
|
||||
flat.build("office-exterior:paving", { castShadow: false }),
|
||||
upright.build("office-exterior:furniture", { castShadow: true }),
|
||||
]) {
|
||||
for (const child of group.children) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
|
||||
}
|
||||
apron.add(group);
|
||||
}
|
||||
|
||||
// ---- Kerbside dressing, through the asset registry ----------------------
|
||||
//
|
||||
// Built rather than modelled inline so that a self-hoster who registered
|
||||
// `acme:planter.trough` with `overrides: "tera:planter.trough"` gets their
|
||||
// planter out here as well as inside. Gated on `has()` because a stripped
|
||||
// registry is a legitimate configuration and a placeholder box on the kerb is
|
||||
// worse than an empty kerb.
|
||||
const PLANTER_ID = "tera:planter.trough";
|
||||
if (assets.has(PLANTER_ID)) {
|
||||
const planter = assets.build(
|
||||
PLANTER_ID,
|
||||
createAssetContext({ materials, registry: assets, rand }),
|
||||
);
|
||||
const footprint = assets.footprintOf(PLANTER_ID);
|
||||
// Turned side-on so its length runs along the bay rather than across it,
|
||||
// and set just inside the kerb on the side away from the charge post.
|
||||
planter.rotation.y = Math.PI / 2;
|
||||
planter.position.set(halfW + footprint.depth / 2 + 0.18, top, 0);
|
||||
planter.name = "office-exterior:planter";
|
||||
apron.add(planter);
|
||||
planter.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh;
|
||||
if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Indicators ---------------------------------------------------------
|
||||
//
|
||||
// One geometry shared by three lamps, and a material per lamp swapped on the
|
||||
// way through `materials.tinted`. See `LAMP_INTENSITY_STEPS` for why the
|
||||
// brightness is quantised rather than continuous.
|
||||
const lampGeometry = new THREE.BoxGeometry(
|
||||
metrics.lampSize, metrics.lampSize, metrics.lampSize,
|
||||
);
|
||||
ownedGeometries.push(lampGeometry);
|
||||
|
||||
function makeLamp(name: string, index: number): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(lampGeometry, materials.tinted("deviceIndicator", 0x000000));
|
||||
mesh.name = `office-exterior:${name}`;
|
||||
mesh.position.set(
|
||||
metrics.postOffsetX,
|
||||
top + metrics.lampHeight - index * (metrics.lampSize + 0.04),
|
||||
metrics.postOffsetZ + metrics.postDepth / 2,
|
||||
);
|
||||
apron.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
const chargeLamp = makeLamp("lamp-charge", 0);
|
||||
const climateLamp = makeLamp("lamp-climate", 1);
|
||||
const lockLamp = makeLamp("lamp-lock", 2);
|
||||
|
||||
// The charge bar. Scaling a mesh is free; minting a material per percent is
|
||||
// not, so the *quantity* is the scale and the colour is fixed.
|
||||
const barGeometry = new THREE.BoxGeometry(0.045, 1, 0.012).translate(0, 0.5, 0);
|
||||
ownedGeometries.push(barGeometry);
|
||||
const chargeBar = new THREE.Mesh(
|
||||
barGeometry,
|
||||
materials.tinted("deviceIndicator", 0x46d07a),
|
||||
);
|
||||
chargeBar.name = "office-exterior:charge-bar";
|
||||
chargeBar.position.set(
|
||||
metrics.postOffsetX,
|
||||
top + 0.24,
|
||||
metrics.postOffsetZ + metrics.postDepth / 2,
|
||||
);
|
||||
apron.add(chargeBar);
|
||||
|
||||
// ---- The vehicle --------------------------------------------------------
|
||||
|
||||
const pose = parkPose(arrival, rand);
|
||||
const vehicle = new THREE.Group();
|
||||
vehicle.name = "office-exterior:vehicle";
|
||||
vehicle.position.set(pose.x, top, pose.z);
|
||||
vehicle.rotation.y = pose.yaw;
|
||||
root.add(vehicle);
|
||||
|
||||
const paintIndex = Math.min(
|
||||
MODEL_X_PAINTS.length - 1,
|
||||
Math.max(0, Math.floor(rand() * MODEL_X_PAINTS.length)),
|
||||
);
|
||||
const rig = buildModelX({ detail, paint: MODEL_X_PAINTS[paintIndex] ?? 0x465157 });
|
||||
rig.root.name = "office-exterior:model-x";
|
||||
vehicle.add(rig.root);
|
||||
|
||||
const portGeometry = new THREE.CircleGeometry(CHARGE_PORT_RADIUS, 20);
|
||||
ownedGeometries.push(portGeometry);
|
||||
const chargePort = new THREE.Mesh(
|
||||
portGeometry,
|
||||
materials.tinted("deviceIndicator", 0x000000),
|
||||
);
|
||||
chargePort.name = "office-exterior:charge-port";
|
||||
chargePort.position.set(CHARGE_PORT.x, CHARGE_PORT.y, CHARGE_PORT.z);
|
||||
// A `CircleGeometry` faces +Z; the flap is on the vehicle's left, so it turns
|
||||
// to face −X.
|
||||
chargePort.rotation.y = -Math.PI / 2;
|
||||
vehicle.add(chargePort);
|
||||
|
||||
const cabinGeometry = new THREE.BoxGeometry(
|
||||
CABIN_GLOW.width, CABIN_GLOW.thickness, CABIN_GLOW.depth,
|
||||
);
|
||||
ownedGeometries.push(cabinGeometry);
|
||||
// The one material this layer owns. It cannot come from the registry because
|
||||
// its `emissiveIntensity` is a continuous function of telemetry, and a shared
|
||||
// material is shared: dimming this one would dim every indicator in the
|
||||
// office with it.
|
||||
const cabinMaterial = new THREE.MeshStandardMaterial({
|
||||
name: "office-exterior.cabin-glow",
|
||||
color: 0xffe9c8,
|
||||
emissive: 0xffe4bc,
|
||||
emissiveIntensity: 0,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
// The plate lives inside a closed glass volume and is only ever seen through
|
||||
// it. Writing depth would let it punch a hole in the tinted glass in front
|
||||
// of it, which reads as a rectangular window cut in the roof.
|
||||
depthWrite: false,
|
||||
});
|
||||
ownedMaterials.push(cabinMaterial);
|
||||
const cabinGlow = new THREE.Mesh(cabinGeometry, cabinMaterial);
|
||||
cabinGlow.name = "office-exterior:cabin-glow";
|
||||
cabinGlow.position.set(0, CABIN_GLOW.y, CABIN_GLOW.z);
|
||||
cabinGlow.castShadow = false;
|
||||
cabinGlow.receiveShadow = false;
|
||||
cabinGlow.visible = false;
|
||||
vehicle.add(cabinGlow);
|
||||
|
||||
// ---- The cable ----------------------------------------------------------
|
||||
//
|
||||
// Built in the root's frame rather than either child's, because its two ends
|
||||
// live in different frames: the socket is on the post (apron frame) and the
|
||||
// flap is on the car (vehicle frame, which carries the parking jitter). A
|
||||
// curve between two world points is the only version of this that stays
|
||||
// attached when the car parks 90 mm off the line.
|
||||
// Both children have just been positioned and nothing has rendered yet, so
|
||||
// their world matrices are stale until this runs.
|
||||
root.updateMatrixWorld(true);
|
||||
const socket = apron.localToWorld(
|
||||
new THREE.Vector3(
|
||||
metrics.postOffsetX,
|
||||
top + 0.72,
|
||||
metrics.postOffsetZ + metrics.postDepth / 2,
|
||||
),
|
||||
);
|
||||
const flap = vehicle.localToWorld(
|
||||
new THREE.Vector3(CHARGE_PORT.x - 0.02, CHARGE_PORT.y, CHARGE_PORT.z),
|
||||
);
|
||||
const sag = socket.clone().add(flap).multiplyScalar(0.5);
|
||||
// A charging cable hangs. Half a metre of droop over a two-metre span is what
|
||||
// a heavy DC lead actually does, and it is the difference between a cable and
|
||||
// a stick.
|
||||
sag.y = Math.min(socket.y, flap.y) - 0.42;
|
||||
const cableGeometry = new THREE.TubeGeometry(
|
||||
new THREE.QuadraticBezierCurve3(socket, sag, flap),
|
||||
12,
|
||||
0.021,
|
||||
6,
|
||||
false,
|
||||
);
|
||||
ownedGeometries.push(cableGeometry);
|
||||
const cable = new THREE.Mesh(cableGeometry, postShell);
|
||||
cable.name = "office-exterior:cable";
|
||||
cable.castShadow = true;
|
||||
cable.receiveShadow = true;
|
||||
cable.visible = false;
|
||||
root.add(cable);
|
||||
|
||||
// ---- Telemetry ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The last appearance applied, as a string.
|
||||
*
|
||||
* `apply` is safe to call every frame and a caller should not have to know
|
||||
* that. Everything downstream of a change is cheap except the registry tint
|
||||
* lookups, and those are a `Map.get` and a string concat each — small, but
|
||||
* three of them sixty times a second for a car that has not changed state in
|
||||
* an hour is work nobody asked for.
|
||||
*/
|
||||
let lastSignature = "";
|
||||
|
||||
function signatureOf(look: ExteriorVehicleAppearance, plugged: boolean): string {
|
||||
return [
|
||||
lampTint(look.charge), lampTint(look.climate), lampTint(look.lock),
|
||||
look.cabinGlow.toFixed(3), look.chargeFraction.toFixed(3), plugged ? 1 : 0,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function apply(telemetry: VehicleTelemetryState): void {
|
||||
const look = exteriorVehicleAppearance(telemetry);
|
||||
const signature = signatureOf(look, telemetry.pluggedIn);
|
||||
if (signature === lastSignature) return;
|
||||
lastSignature = signature;
|
||||
|
||||
chargeLamp.material = materials.tinted("deviceIndicator", lampTint(look.charge));
|
||||
climateLamp.material = materials.tinted("deviceIndicator", lampTint(look.climate));
|
||||
lockLamp.material = materials.tinted("deviceIndicator", lampTint(look.lock));
|
||||
chargePort.material = materials.tinted("deviceIndicator", lampTint(look.charge));
|
||||
|
||||
// A zero-height bar is a degenerate mesh rather than an absent one, so the
|
||||
// floor of the scale is a millimetre and visibility carries the rest.
|
||||
chargeBar.scale.y = Math.max(0.001, look.chargeFraction * 0.62);
|
||||
chargeBar.visible = look.chargeFraction > 0.005;
|
||||
|
||||
cabinMaterial.emissiveIntensity = look.cabinGlow * CABIN_GLOW_MAX_INTENSITY;
|
||||
cabinGlow.visible = look.cabinGlow > 0.01;
|
||||
|
||||
cable.visible = telemetry.pluggedIn;
|
||||
}
|
||||
|
||||
return {
|
||||
object: root,
|
||||
apply,
|
||||
dispose() {
|
||||
// The rig owns its own materials (nothing external was handed in), so it
|
||||
// frees them; `disposeModelX` already defaults to exactly that when
|
||||
// `ownsMaterials` is true, and it is stated here rather than implied.
|
||||
disposeModelX(rig, { disposeMaterials: true });
|
||||
for (const geometry of ownedGeometries) geometry.dispose();
|
||||
ownedGeometries.length = 0;
|
||||
for (const material of ownedMaterials) material.dispose();
|
||||
ownedMaterials.length = 0;
|
||||
root.clear();
|
||||
apron.clear();
|
||||
vehicle.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-exported so a caller can size a bay without reaching into `transport/`. */
|
||||
export type { ApronMetrics };
|
||||
@@ -35,7 +35,15 @@ export interface RoadTrafficOptions {
|
||||
seed?: number;
|
||||
/** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
|
||||
scale?: number;
|
||||
/** Route-distance compression for playable corridor travel. Defaults to 900. */
|
||||
/**
|
||||
* Route-distance compression for playable corridor travel. Defaults to 900.
|
||||
*
|
||||
* The counterpart is `METRE_SCALE_VEHICLE_OPTIONS` in
|
||||
* `transport/exteriorVehicle.ts`, which is this same controller at 1 for the
|
||||
* apron outside a studio. One state machine, one dial; see the note on
|
||||
* `VehicleControllerOptions.travelScale` for what the dial does and does not
|
||||
* touch.
|
||||
*/
|
||||
travelScale?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,20 @@ export interface SatelliteLayer {
|
||||
group: THREE.Group;
|
||||
/** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
|
||||
update(fixes: SatelliteFix[]): void;
|
||||
/**
|
||||
* How dark the sky is, 0..1 — `nightFactor(sun.elevation)` from
|
||||
* `atmosphere.ts`, and nothing else.
|
||||
*
|
||||
* This layer draws additively, which is correct at night and catastrophic in
|
||||
* daylight: at 15:55 with the sun at +44° every dot was adding to an already
|
||||
* bright sky and clipping to a hard white square, which is what a first-time
|
||||
* visitor to the California board saw scattered across the frame before
|
||||
* anything else registered. A satellite in daylight is not visible to the
|
||||
* naked eye, so the honest alpha is zero — and the fade is the *same* curve
|
||||
* `nightlights.ts` switches the city on with, so the sky does not empty at a
|
||||
* different dusk from the one the windows light up at.
|
||||
*/
|
||||
setSkyDarkness(darkness: number): void;
|
||||
/**
|
||||
* Whether the layer draws at all. The catalogue keeps propagating either way —
|
||||
* see `setVisible` for why that is deliberate rather than wasteful.
|
||||
@@ -481,6 +495,24 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
|
||||
const scratchVec = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* 1 until somebody says otherwise, so a caller that never calls
|
||||
* `setSkyDarkness` gets exactly the behaviour this layer had before it
|
||||
* existed. A silent regression to an invisible sky would be worse than the
|
||||
* defect being fixed.
|
||||
*/
|
||||
let skyDarkness = 1;
|
||||
/** Whether the godmode switch wants this layer at all. Two questions, two flags. */
|
||||
let wanted = true;
|
||||
|
||||
/** The hour outranks the switch: a god at noon still gets no white squares. */
|
||||
function applyVisibility(): void {
|
||||
// Below a fiftieth the dots contribute nothing a screen can show, and
|
||||
// skipping the draw entirely is what makes the daytime cost of this layer
|
||||
// zero rather than merely invisible.
|
||||
group.visible = wanted && skyDarkness > 0.02;
|
||||
}
|
||||
|
||||
function update(fixes: SatelliteFix[]): void {
|
||||
let n = 0;
|
||||
for (const fix of fixes) {
|
||||
@@ -500,10 +532,11 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
colors[n * 4] = scratch.r;
|
||||
colors[n * 4 + 1] = scratch.g;
|
||||
colors[n * 4 + 2] = scratch.b;
|
||||
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit);
|
||||
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit) * skyDarkness;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
applyVisibility();
|
||||
geo.setDrawRange(0, n);
|
||||
positionAttr.needsUpdate = true;
|
||||
colorAttr.needsUpdate = true;
|
||||
@@ -512,6 +545,10 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
setSkyDarkness(darkness) {
|
||||
skyDarkness = Math.min(1, Math.max(0, darkness));
|
||||
applyVisibility();
|
||||
},
|
||||
/**
|
||||
* Hiding the layer stops it drawing and does **not** stop the catalogue
|
||||
* propagating, which is the right way round: turning the sky back on should
|
||||
@@ -521,7 +558,8 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
* re-entry is worth more than reclaiming it.
|
||||
*/
|
||||
setVisible(visible: boolean) {
|
||||
group.visible = visible;
|
||||
wanted = visible;
|
||||
applyVisibility();
|
||||
},
|
||||
dispose() {
|
||||
geo.dispose();
|
||||
|
||||
+125
-12
@@ -34,6 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||
import { solarPosition, sunDirection } from "./solar.ts";
|
||||
import { nightFactor } from "./atmosphere.ts";
|
||||
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
|
||||
import {
|
||||
createSatelliteLayer,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
type SatelliteLayer,
|
||||
} from "./satellites.ts";
|
||||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||||
import type { EnvironmentRig } from "./environmentRig.ts";
|
||||
import {
|
||||
createRoadTrafficLayer,
|
||||
type RoadTrafficLayer,
|
||||
@@ -55,6 +57,7 @@ import type {
|
||||
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type {
|
||||
Aircraft,
|
||||
Chapter,
|
||||
City,
|
||||
FlightSource,
|
||||
@@ -84,6 +87,11 @@ import { cityControlOwnership, type CityControlMode } from "../play/controlMode.
|
||||
|
||||
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
||||
|
||||
/** What the pointer is over: an authored place, or an observed aeroplane. */
|
||||
type Pick =
|
||||
| { kind: "marker"; marker: Marker }
|
||||
| { kind: "aircraft"; aircraft: Aircraft };
|
||||
|
||||
export interface SceneOptions {
|
||||
city: City;
|
||||
markerPalette?: MarkerPalette;
|
||||
@@ -118,6 +126,34 @@ export interface SceneOptions {
|
||||
satellites?: SatelliteCatalogue;
|
||||
/** Fires on hover/click of a marker head. */
|
||||
onMarkerPick?: (marker: Marker | null) => void;
|
||||
/**
|
||||
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
|
||||
*
|
||||
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
|
||||
* picks by hover, so a click handler upstairs reads whatever the last hover
|
||||
* resolved. What comes back is the engine's own `Aircraft` — a position and a
|
||||
* callsign — and nothing about where it came from, because that is a question
|
||||
* about the deployment and `adapters/http.ts` is the layer that can answer it.
|
||||
*
|
||||
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
|
||||
* with a receiver, so there is nothing here an account could grant; see
|
||||
* `owner-decisions.md` and the note on `TrafficSource.detail`.
|
||||
*/
|
||||
onAircraftPick?: (aircraft: Aircraft | null) => void;
|
||||
/**
|
||||
* The shared environment map, when the page has one.
|
||||
*
|
||||
* Handed in rather than built here, and that is the whole of the wiring rule:
|
||||
* a `PMREMGenerator` and its render targets belong to the **renderer**, which
|
||||
* outlives every city on the page, so one rig is built beside the `Stage` and
|
||||
* shared. A rig per `createScene` would allocate a fresh blur chain and a
|
||||
* fresh target for every board and leak both on the next switch, which is
|
||||
* exactly the arithmetic `stage.ts` records for the renderer itself.
|
||||
*
|
||||
* Absent, everything renders as it did before the rig existed: duller metal,
|
||||
* no sky in the water, and no error.
|
||||
*/
|
||||
environment?: EnvironmentRig;
|
||||
/**
|
||||
* Opening light rig. Comes from an `Atmosphere` when there is one; without
|
||||
* one the city gets `cityDaylight()`, because a scene that renders black
|
||||
@@ -356,6 +392,9 @@ export async function createScene(
|
||||
// two disagree for the one frame before the app's first `setLighting`.
|
||||
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
|
||||
kit.applyLighting(opening);
|
||||
// The environment before the first layer is added, so the very first frame
|
||||
// has a sky to reflect rather than acquiring one a `setLighting` later.
|
||||
options.environment?.apply(scene, opening, "city");
|
||||
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
@@ -443,6 +482,15 @@ export async function createScene(
|
||||
|
||||
let flightLayer: FlightLayer | null = null;
|
||||
let flightTimer = 0;
|
||||
/**
|
||||
* The last observation, by id, so a pick has something to hand back.
|
||||
*
|
||||
* The layer interpolates between observations and keeps no record a caller
|
||||
* could read; this is the record. Rebuilt wholesale on every poll rather than
|
||||
* merged, so an aeroplane that has left the region leaves this table with it
|
||||
* and a card cannot be opened on a track that is no longer in the sky.
|
||||
*/
|
||||
const lastAircraft = new Map<string, Aircraft>();
|
||||
if (options.flights) {
|
||||
flightLayer = createFlightLayer(world);
|
||||
scene.add(flightLayer.group);
|
||||
@@ -553,12 +601,38 @@ export async function createScene(
|
||||
|
||||
// ---- Picking ------------------------------------------------------------
|
||||
|
||||
// `pickables` is mutated in place by the layer, so the array itself is the
|
||||
// live target list.
|
||||
kit.setPicking<Marker>({
|
||||
targets: markerLayer.pickables,
|
||||
resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
|
||||
onChange: (marker) => options.onMarkerPick?.(marker),
|
||||
/**
|
||||
* Two things on this board are worth pointing at, and both are resolved here.
|
||||
*
|
||||
* `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
|
||||
* place by their layers, so neither array can simply be concatenated once —
|
||||
* the picking target list has to be a getter that reads both at the moment of
|
||||
* the test. A pin is an authored place; an aeroplane is an observation, and
|
||||
* `Pick` keeps them apart as a union rather than flattening both to a string,
|
||||
* because the aircraft card is five fields and a provenance line and the
|
||||
* moment it becomes a sentence it can never be anything else again.
|
||||
*/
|
||||
const pickTargets = (): THREE.Object3D[] =>
|
||||
flightLayer === null
|
||||
? markerLayer.pickables
|
||||
: [...markerLayer.pickables, ...flightLayer.pickables];
|
||||
|
||||
kit.setPicking<Pick>({
|
||||
targets: pickTargets,
|
||||
resolve: (hit) => {
|
||||
const marker = hit.object.userData.marker as Marker | undefined;
|
||||
if (marker) return { kind: "marker", marker };
|
||||
const id = hit.object.userData.aircraftId as string | undefined;
|
||||
const aircraft = id === undefined ? undefined : lastAircraft.get(id);
|
||||
return aircraft ? { kind: "aircraft", aircraft } : null;
|
||||
},
|
||||
onChange: (picked) => {
|
||||
// Both callbacks fire on every change, including the change back to
|
||||
// `null`, so whichever card is up is retired by a pointer that leaves —
|
||||
// and by a pointer that moves from a pin straight onto an aeroplane.
|
||||
options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
|
||||
options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
|
||||
},
|
||||
});
|
||||
|
||||
// ---- The scene, as the stage sees it ------------------------------------
|
||||
@@ -588,7 +662,11 @@ export async function createScene(
|
||||
flightTimer -= dt;
|
||||
if (flightTimer <= 0) {
|
||||
flightTimer = options.flights.interval;
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
|
||||
void Promise.resolve(options.flights.poll()).then((ac) => {
|
||||
lastAircraft.clear();
|
||||
for (const a of ac) lastAircraft.set(a.id, a);
|
||||
flightLayer?.update(ac);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Every frame and on no timer of its own. The catalogue's sweep is
|
||||
@@ -610,16 +688,40 @@ export async function createScene(
|
||||
* dusk geometry that lights a Starlink pass.
|
||||
*/
|
||||
const when = skyOverride ?? new Date();
|
||||
const solar = solarPosition(city.center.lat, city.center.lng, when);
|
||||
/**
|
||||
* The sky's own brightness, and the fix for the worst thing on this
|
||||
* board at first load.
|
||||
*
|
||||
* Both satellite layers draw light *added* to the sky: the dot cloud is
|
||||
* `AdditiveBlending` and the near-field buses are unlit white. That is
|
||||
* exactly right against a night sky and is a hard white square against a
|
||||
* daytime one — which is what the California board showed at 15:55 with
|
||||
* the sun at +44°, scattered across the frame, reading as render
|
||||
* artefacts before anything else on the page registered.
|
||||
*
|
||||
* `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
|
||||
* the same one `nightlights.ts` switches the city on with, so the sky
|
||||
* does not empty at a different dusk from the one the windows light up
|
||||
* at. It is computed here rather than taken from the rig for the reason
|
||||
* the sun vector below is: `atmosphere.ts` floors the *rig's* light
|
||||
* direction at `shadowFloorDeg` to keep the shadow camera usable, and a
|
||||
* sun pinned above the horizon is precisely the wrong input for a
|
||||
* question about how dark it is.
|
||||
*/
|
||||
const darkness = nightFactor(solar.elevation);
|
||||
satelliteLayer.setSkyDarkness(darkness);
|
||||
starlinkMeshes?.setSkyDarkness(darkness);
|
||||
const fixes = options.satellites.fixes(when);
|
||||
satelliteLayer.update(fixes);
|
||||
starlinkMeshes?.update(
|
||||
fixes,
|
||||
kit.camera,
|
||||
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
|
||||
);
|
||||
starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
// Before anything else frees a texture: the rig holds this scene in a
|
||||
// ledger so a rebuilt environment can be pushed to every scene using it,
|
||||
// and a disposed city left in that ledger is the whole graph retained.
|
||||
options.environment?.release(scene);
|
||||
options.flights?.dispose?.();
|
||||
flightLayer?.dispose();
|
||||
satelliteLayer?.dispose();
|
||||
@@ -654,6 +756,17 @@ export async function createScene(
|
||||
setLighting: (state) => {
|
||||
kit.applyLighting(state);
|
||||
clouds.setLighting(state);
|
||||
/**
|
||||
* Every lighting change, and it is cheap to do it every one.
|
||||
*
|
||||
* The rig fingerprints the state coarsely and rebuilds only when the
|
||||
* fingerprint moves, so an unchanged sky is a map lookup and an
|
||||
* assignment. Calling it here rather than on a timer of its own is what
|
||||
* keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
|
||||
* rig, the scene is applying it, and the environment is derived from the
|
||||
* decision rather than being a second opinion about the light.
|
||||
*/
|
||||
options.environment?.apply(scene, state, "city");
|
||||
},
|
||||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||||
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
|
||||
|
||||
@@ -83,8 +83,35 @@ export interface StageOptions {
|
||||
/** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */
|
||||
maxPixelRatio?: number;
|
||||
shadows?: boolean;
|
||||
/**
|
||||
* Tone mapping exposure. Defaults to `DEFAULT_TONE_MAPPING_EXPOSURE`.
|
||||
*
|
||||
* Live afterwards as `stage.renderer.toneMappingExposure` — the renderer is
|
||||
* on the `Stage` for exactly this kind of reason, and a `setExposure` method
|
||||
* would widen the interface CONTRACT.md §1 pins down for one assignment.
|
||||
*/
|
||||
exposure?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exposure the world is tuned at.
|
||||
*
|
||||
* ACES takes linear radiance, divides by 0.6, runs the RRT+ODT fit and saturates,
|
||||
* so `toneMappingExposure` is a photographic stop dial and not a brightness
|
||||
* slider: at 1.0 an 18% grey card lands on 0.5 sRGB, which is the definition of
|
||||
* the curve being *neutral*. 1.15 is a little over a fifth of a stop above
|
||||
* neutral, and it is there because ACES darkens the bottom of the range — a
|
||||
* linear 0.02 that used to display at 0.152 comes out at 0.080 — and this world
|
||||
* spends a third of its day at night. The lift buys most of that back at the
|
||||
* bottom while the shoulder eats it at the top, where nothing is left to lose.
|
||||
*
|
||||
* Exported because the number is a fact about the whole picture, not about this
|
||||
* file: `atmosphere.ts`'s keyframe table is tuned against this exposure and
|
||||
* `environmentRig.ts` builds its sky radiances to sit under the same shoulder.
|
||||
* Anything that changes it is changing all three.
|
||||
*/
|
||||
export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.15;
|
||||
|
||||
/**
|
||||
* What kind of machine this is, to the extent a browser will say.
|
||||
*
|
||||
@@ -172,6 +199,59 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
|
||||
Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio),
|
||||
);
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||
|
||||
/**
|
||||
* The two lines that decide what every colour in the product looks like.
|
||||
*
|
||||
* ### Why the output colour space is written down
|
||||
*
|
||||
* `outputColorSpace` has defaulted to `SRGBColorSpace` since r152, and this
|
||||
* file relied on that default for its whole life. That is a bet on a default
|
||||
* staying put across a library this repo pins with a caret, and the same bet
|
||||
* lost once already three lines below: `PCFSoftShadowMap` was silently
|
||||
* demoted to unfiltered basic shadows by an upstream change nobody here saw.
|
||||
* A renderer's output transfer function is not a thing to inherit quietly —
|
||||
* if it ever moves, every texture, palette and keyframe in the repo is wrong
|
||||
* at once and the symptom is "the whole app looks washed out", which is the
|
||||
* least diagnosable bug shape there is. So it is stated.
|
||||
*
|
||||
* ### Why ACES, and what it fixes
|
||||
*
|
||||
* `NoToneMapping` is not "no transform". It is `saturate()`: everything above
|
||||
* linear 1.0 becomes exactly 1.0, and every distinction above that value is
|
||||
* destroyed before the sRGB encode ever runs. This world drives values well
|
||||
* past 1.0 on purpose — `atmosphere.ts` peaks the sun above 2.3, the office
|
||||
* assets set `emissiveIntensity` up to 3.2 — so the brightest and most
|
||||
* expensive third of the lighting range was being flattened into a single
|
||||
* flat white. That is the whole explanation for the two worst-looking things
|
||||
* in the product: sunlit walls with no shading gradient left in them, and
|
||||
* every light fitting and screen rendering as an identical white rectangle
|
||||
* regardless of how bright it was told to be.
|
||||
*
|
||||
* ACES filmic replaces the cliff with a shoulder. Linear 1.0 displays at
|
||||
* about 0.90, 2.0 at 0.95, 4.0 at 0.98 — still separable, all the way up — so
|
||||
* a diffuser at 0.85 and a screen at 3.2 finally look like different things.
|
||||
* It costs a little in the shadows, where the toe is steeper than a plain
|
||||
* gamma encode; the exposure above and the re-tuned night stops in
|
||||
* `atmosphere.ts` are what pay that back. Both halves of that trade are
|
||||
* required. Turning this on and leaving the intensity table alone gives a
|
||||
* world that is correctly *shaped* and too dark, which reads as worse.
|
||||
*
|
||||
* Two things this deliberately does not touch, and it is worth knowing which
|
||||
* so nobody goes hunting for a horizon seam that is not there. Three sets
|
||||
* `toneMapped = false` on the background mesh whenever the background texture
|
||||
* carries an sRGB transfer — `scenekit.ts`'s sky gradient does — and fog is
|
||||
* mixed in `fog_fragment` *after* both `tonemapping_fragment` and
|
||||
* `colorspace_fragment`, from a uniform already converted into the renderer's
|
||||
* output space. So the sky and the fog are still displayed exactly as
|
||||
* `atmosphere.ts` authored them, and they still agree with each other at the
|
||||
* horizon. Only lit geometry moves, which is precisely the surface the
|
||||
* intensity table controls.
|
||||
*/
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = options.exposure ?? DEFAULT_TONE_MAPPING_EXPOSURE;
|
||||
|
||||
if (options.shadows ?? true) {
|
||||
renderer.shadowMap.enabled = true;
|
||||
/**
|
||||
|
||||
@@ -320,6 +320,18 @@ export interface StarlinkMeshLayer {
|
||||
* pointing its solar panels at yesterday afternoon.
|
||||
*/
|
||||
update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void;
|
||||
/**
|
||||
* How dark the sky is, 0..1 — `nightFactor(sun.elevation)`, the same number
|
||||
* `SatelliteLayer` takes and from the same call site.
|
||||
*
|
||||
* These buses are unlit white boxes (`MeshBasicMaterial({ color: 0xffffff })`)
|
||||
* because that is what a sunlit satellite twenty pixels across looks like
|
||||
* against a night sky. Under a tone curve, against a *daytime* sky, they are
|
||||
* the literal white squares live defect 1 named — the most damaging thing on
|
||||
* the board at first load, and drawn at 15:55 with the sun at +44°, where no
|
||||
* naked eye would see a satellite at all.
|
||||
*/
|
||||
setSkyDarkness(darkness: number): void;
|
||||
setVisible(visible: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -382,6 +394,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
|
||||
* twelve kilometres of air a city sits in. This is 550 km above all of it.
|
||||
*/
|
||||
const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false });
|
||||
|
||||
/**
|
||||
* Whether the caller wants this layer, and whether the sky is dark enough for
|
||||
* it to be honest. Both have to be true, and they are two different questions:
|
||||
* the first is a godmode switch and the second is the hour.
|
||||
*/
|
||||
let wanted = true;
|
||||
let skyDarkness = 1;
|
||||
const arrayMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
fog: false,
|
||||
@@ -799,6 +819,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
setSkyDarkness(darkness: number) {
|
||||
skyDarkness = Math.min(1, Math.max(0, darkness));
|
||||
// Whole-group rather than a per-material opacity, because these two
|
||||
// materials are opaque by design: making them `transparent` to fade them
|
||||
// would buy a sort order and a blend for objects that are never partly
|
||||
// visible — they are either in a sky you could see them in or they are not.
|
||||
group.visible = wanted && skyDarkness > 0.02;
|
||||
},
|
||||
/**
|
||||
* Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the
|
||||
* early return in `update`. The distinction is not an inconsistency: that
|
||||
@@ -808,7 +836,8 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
|
||||
* on and the next visible frame is complete.
|
||||
*/
|
||||
setVisible(visible: boolean) {
|
||||
group.visible = visible;
|
||||
wanted = visible;
|
||||
group.visible = visible && skyDarkness > 0.02;
|
||||
},
|
||||
dispose() {
|
||||
// The instanced meshes first. `InstancedMesh.dispose()` releases the
|
||||
|
||||
+249
-82
@@ -5,15 +5,145 @@
|
||||
* Roads follow the terrain: each path is resampled far more finely than it is
|
||||
* written in the city pack, and every sample takes its height from the ground,
|
||||
* so a street climbs out of the flats instead of burrowing through the hill.
|
||||
*
|
||||
* ### Everything here is batched, and it has to be
|
||||
*
|
||||
* The city ran at 616 draw calls against a budget of 650 while the office spent
|
||||
* 8% of its triangle budget: quality is nearly free indoors and is not free at
|
||||
* all out here, so anything this module can hand back is headroom the exterior
|
||||
* vehicles and the aircraft get to spend. Batching the corridor and the bridges
|
||||
* took the California board to 557 measured — 59 calls, from 59 freeway meshes
|
||||
* down to 20 plus the four extra shadow-pass draws the guardrails and sign
|
||||
* posts used to cost.
|
||||
*
|
||||
* Two rules keep it honest, and both were broken before:
|
||||
*
|
||||
* 1. **Materials are cached by colour**, in a `Batch` that lives as long as
|
||||
* the build call. Twelve identical asphalt decks used to be twelve
|
||||
* `MeshLambertMaterial`s, which is twelve things that can never merge, and
|
||||
* a single suspension bridge minted a fresh material for its deck, each
|
||||
* tower, each brace, each cable and each hanger — about thirty-four.
|
||||
* 2. **Geometry is merged per material.** Every helper below returns a
|
||||
* `BufferGeometry` rather than a `Mesh`, and the caller drops it into a
|
||||
* named bucket; one mesh comes out per bucket at the end.
|
||||
*
|
||||
* The cache is deliberately *not* module-level. `createScene().dispose()` walks
|
||||
* the scene and disposes every material it finds, so a cache that outlived one
|
||||
* build would hand the next board a disposed material and render it black.
|
||||
*
|
||||
* The corollary for anyone adding a helper here: give every geometry the **same
|
||||
* attribute set** — position, normal, uv, indexed — or `mergeGeometries`
|
||||
* refuses the bucket and silently drops it. That is why the ribbons below carry
|
||||
* UVs they have no texture for.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
|
||||
import type { TransportPack } from "../transport/types.ts";
|
||||
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
|
||||
import type { Bridge, LatLng } from "./types.ts";
|
||||
import type { World } from "./world.ts";
|
||||
|
||||
// ---- Batching -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The three ways a surface out here is shaded.
|
||||
*
|
||||
* `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip
|
||||
* that has to survive being looked at from underneath on a bridge approach, and
|
||||
* a tower is a closed solid where a back face is a waste.
|
||||
*
|
||||
* `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the
|
||||
* one thing in the frame whose job is to be a fixed, known white — it is
|
||||
* retroreflective, it is what a driver navigates by, and putting it through the
|
||||
* ACES shoulder with everything else turns a lane line into a grey smear at
|
||||
* midday and loses it entirely at dusk.
|
||||
*/
|
||||
type SurfaceKind = "deck" | "solid" | "marking";
|
||||
|
||||
interface Bucket {
|
||||
readonly name: string;
|
||||
readonly material: THREE.Material;
|
||||
readonly castShadow: boolean;
|
||||
readonly receiveShadow: boolean;
|
||||
readonly parts: THREE.BufferGeometry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One build's worth of materials and geometry, merged on the way out.
|
||||
*
|
||||
* Buckets are keyed on **name and material together** rather than on the
|
||||
* material alone. Sharing the material is what saves the draw call; keeping the
|
||||
* name is what lets somebody looking at the scene graph still find the
|
||||
* guardrails, and the one extra call it costs where two classes happen to share
|
||||
* a material is worth being able to debug the thing.
|
||||
*/
|
||||
class Batch {
|
||||
private readonly materials = new Map<string, THREE.Material>();
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
/** The one material for a kind and colour in this build. */
|
||||
material(kind: SurfaceKind, color: number): THREE.Material {
|
||||
const key = `${kind}:${color.toString(16)}`;
|
||||
const hit = this.materials.get(key);
|
||||
if (hit) return hit;
|
||||
const made =
|
||||
kind === "marking"
|
||||
? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide })
|
||||
: new THREE.MeshLambertMaterial({
|
||||
color,
|
||||
side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide,
|
||||
});
|
||||
made.name = key;
|
||||
this.materials.set(key, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
add(
|
||||
name: string,
|
||||
geometry: THREE.BufferGeometry,
|
||||
material: THREE.Material,
|
||||
shadows: { cast?: boolean; receive?: boolean } = {},
|
||||
): void {
|
||||
const key = `${material.uuid}|${name}`;
|
||||
const bucket = this.buckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.parts.push(geometry);
|
||||
return;
|
||||
}
|
||||
this.buckets.set(key, {
|
||||
name,
|
||||
material,
|
||||
castShadow: shadows.cast ?? false,
|
||||
receiveShadow: shadows.receive ?? true,
|
||||
parts: [geometry],
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge every bucket and hang the results off `into`. */
|
||||
flush(into: THREE.Group): void {
|
||||
for (const bucket of this.buckets.values()) {
|
||||
const merged =
|
||||
bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
|
||||
// `mergeGeometries` returns null when the attribute sets disagree. Losing
|
||||
// the bucket silently is exactly the failure the module comment warns
|
||||
// about, so say so rather than rendering a road with no markings on it.
|
||||
if (!merged) {
|
||||
console.warn(`structures: "${bucket.name}" has mismatched attributes and was not merged`);
|
||||
continue;
|
||||
}
|
||||
if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose();
|
||||
const mesh = new THREE.Mesh(merged, bucket.material);
|
||||
mesh.name = bucket.name;
|
||||
mesh.castShadow = bucket.castShadow;
|
||||
mesh.receiveShadow = bucket.receiveShadow;
|
||||
into.add(mesh);
|
||||
}
|
||||
this.buckets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
||||
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
|
||||
const out: THREE.Vector3[] = [];
|
||||
@@ -35,25 +165,32 @@ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14
|
||||
return out;
|
||||
}
|
||||
|
||||
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
|
||||
/** A tube swept along a path — a bridge deck, a cable, a barrier. */
|
||||
function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry {
|
||||
const curve = new THREE.CatmullRomCurve3(points);
|
||||
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
|
||||
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false);
|
||||
}
|
||||
|
||||
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
|
||||
function roadRibbon(
|
||||
/**
|
||||
* A draped, flat road deck. A tube turns a freeway into a raised pipeline.
|
||||
*
|
||||
* The UVs run 0..1 across the carriageway and in **metres** along it, which is
|
||||
* the sane convention if anyone ever puts a surface texture on a road. Right
|
||||
* now nothing does, and they are here for a duller reason: `mergeGeometries`
|
||||
* only merges geometries whose attribute sets match exactly, so a strip without
|
||||
* UVs cannot share a bucket with the tube barriers beside it.
|
||||
*/
|
||||
function roadRibbonGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
color: number,
|
||||
lift = 0,
|
||||
): THREE.Mesh {
|
||||
): THREE.BufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const half = width / 2;
|
||||
let along = 0;
|
||||
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const point = points[index];
|
||||
@@ -65,11 +202,13 @@ function roadRibbon(
|
||||
const length = Math.hypot(dx, dz) || 1;
|
||||
const nx = -dz / length;
|
||||
const nz = dx / length;
|
||||
if (index > 0) along += point.distanceTo(previous);
|
||||
positions.push(
|
||||
point.x + nx * half, point.y + lift, point.z + nz * half,
|
||||
point.x - nx * half, point.y + lift, point.z - nz * half,
|
||||
);
|
||||
normals.push(0, 1, 0, 0, 1, 0);
|
||||
uvs.push(0, along, 1, along);
|
||||
if (index < points.length - 1) {
|
||||
const a = index * 2;
|
||||
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
|
||||
@@ -79,14 +218,10 @@ function roadRibbon(
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
|
||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeBoundingSphere();
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
|
||||
);
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
|
||||
@@ -100,15 +235,15 @@ function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vec
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge alternating path spans into one dashed marking mesh. */
|
||||
function dashedRibbon(
|
||||
/** Merge alternating path spans into one dashed marking geometry. */
|
||||
function dashedRibbonGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
offset: number,
|
||||
width: number,
|
||||
color: number,
|
||||
): THREE.Mesh {
|
||||
): THREE.BufferGeometry {
|
||||
const shifted = offsetPath(points, offset);
|
||||
const positions: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (let index = 0; index < shifted.length - 1; index += 2) {
|
||||
const a = shifted[index];
|
||||
@@ -127,18 +262,15 @@ function dashedRibbon(
|
||||
b.x + nx, b.y + 0.035, b.z + nz,
|
||||
b.x - nx, b.y + 0.035, b.z - nz,
|
||||
);
|
||||
uvs.push(0, 0, 1, 0, 0, 1, 1, 1);
|
||||
indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }),
|
||||
);
|
||||
mesh.name = "freeway:lane-dashes";
|
||||
return mesh;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
|
||||
@@ -178,6 +310,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const plan = buildFreewayWorldPlan(pack);
|
||||
group.userData.planSeed = plan.seed;
|
||||
|
||||
const batch = new Batch();
|
||||
const asphalt = [0x353a3d, 0x303538];
|
||||
const shoulder = [0x555759, 0x4e5153];
|
||||
const berm = [0x64705c, 0x74674c];
|
||||
@@ -211,6 +344,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
let poleCount = 0;
|
||||
let siloCount = 0;
|
||||
const dummy = new THREE.Object3D();
|
||||
const reflectorMatrices: THREE.Matrix4[] = [];
|
||||
|
||||
world.city.roads.forEach((road, roadIndex) => {
|
||||
if (road.kind !== "freeway") return;
|
||||
@@ -219,39 +353,58 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const identityIndex = route?.identity === "interstate" ? 1 : 0;
|
||||
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
|
||||
// Broad earthwork under separate decks makes grade and curve changes read.
|
||||
group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09));
|
||||
batch.add(
|
||||
"freeway:berm",
|
||||
roadRibbonGeometry(path, 2.75, -0.09),
|
||||
batch.material("deck", berm[identityIndex] ?? berm[0]!),
|
||||
);
|
||||
for (const side of [-1, 1] as const) {
|
||||
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004));
|
||||
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012));
|
||||
batch.add(
|
||||
"freeway:shoulder",
|
||||
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
|
||||
batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
|
||||
);
|
||||
batch.add(
|
||||
"freeway:carriageway",
|
||||
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
|
||||
batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
|
||||
);
|
||||
// Inner yellow edge, two lane dividers, outer white shoulder edge.
|
||||
group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038));
|
||||
group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038));
|
||||
group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec));
|
||||
group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec));
|
||||
batch.add(
|
||||
"freeway:edge-line",
|
||||
roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
|
||||
batch.material("deck", 0xf0c84f),
|
||||
);
|
||||
batch.add(
|
||||
"freeway:edge-line",
|
||||
roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
|
||||
batch.material("deck", 0xe8ece8),
|
||||
);
|
||||
const dashes = batch.material("marking", 0xf4f4ec);
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
|
||||
const guardPath = offsetPath(path, side * 1.27);
|
||||
const guard = new THREE.Mesh(
|
||||
batch.add(
|
||||
"freeway:outer-guardrail",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
|
||||
guardMaterial,
|
||||
{ cast: true },
|
||||
);
|
||||
guard.name = "freeway:outer-guardrail";
|
||||
guard.castShadow = true;
|
||||
group.add(guard);
|
||||
}
|
||||
// Low concrete median walls keep both carriageways visually independent.
|
||||
for (const side of [-1, 1] as const) {
|
||||
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
|
||||
const median = new THREE.Mesh(
|
||||
batch.add(
|
||||
"freeway:median-barrier",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
|
||||
barrierMaterial,
|
||||
);
|
||||
median.name = "freeway:median-barrier";
|
||||
group.add(median);
|
||||
}
|
||||
// Retroreflectors are instanced and restrained, never roadside light blobs.
|
||||
// The matrices are collected across every corridor and committed to one
|
||||
// `InstancedMesh` after the loop, because two corridors' worth of the same
|
||||
// 0.018 m box is two draw calls for something nobody can resolve.
|
||||
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
|
||||
const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4);
|
||||
reflectors.name = "freeway:reflectors";
|
||||
let reflectorIndex = 0;
|
||||
for (const pointIndex of reflectorPoints.keys()) {
|
||||
const point = reflectorPoints[pointIndex];
|
||||
if (!point) continue;
|
||||
@@ -261,11 +414,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.scale.setScalar(1);
|
||||
dummy.updateMatrix();
|
||||
reflectors.setMatrixAt(reflectorIndex++, dummy.matrix);
|
||||
reflectorMatrices.push(dummy.matrix.clone());
|
||||
}
|
||||
}
|
||||
reflectors.count = reflectorIndex;
|
||||
group.add(reflectors);
|
||||
|
||||
if (!route || !routePath) return;
|
||||
const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
|
||||
@@ -278,17 +429,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const pz = z + Math.sin(heading) * sceneSetback * feature.side;
|
||||
const ground = world.groundAt(sample.lat, sample.lng);
|
||||
if (feature.kind === "route-sign") {
|
||||
const sign = new THREE.Group();
|
||||
sign.name = `freeway:sign:${route.shield}`;
|
||||
const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial);
|
||||
post.position.y = 0.31;
|
||||
const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial);
|
||||
board.position.y = 0.69;
|
||||
board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0);
|
||||
sign.add(post, board);
|
||||
sign.position.set(px, ground + 0.08, pz);
|
||||
sign.userData.routeId = route.routeId;
|
||||
group.add(sign);
|
||||
// Baked into world space rather than parented under a per-sign `Group`.
|
||||
// Nine signs used to be nine groups of two meshes; they are now two
|
||||
// meshes for the whole route, and the shield's own name survives on the
|
||||
// board so the scene graph still says which route it belongs to.
|
||||
const post = new THREE.BoxGeometry(0.035, 0.62, 0.035);
|
||||
post.translate(px, ground + 0.08 + 0.31, pz);
|
||||
batch.add("freeway:sign-post", post, guardMaterial, { cast: true });
|
||||
|
||||
const board = new THREE.PlaneGeometry(0.42, 0.31);
|
||||
board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
|
||||
board.translate(px, ground + 0.08 + 0.69, pz);
|
||||
batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
|
||||
continue;
|
||||
}
|
||||
const visualScale = feature.scale * 0.58;
|
||||
@@ -309,6 +461,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
}
|
||||
}
|
||||
});
|
||||
batch.flush(group);
|
||||
|
||||
const reflectors = new THREE.InstancedMesh(
|
||||
reflectorGeometry,
|
||||
reflectorMaterial,
|
||||
Math.max(1, reflectorMatrices.length),
|
||||
);
|
||||
reflectors.name = "freeway:reflectors";
|
||||
reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix));
|
||||
reflectors.count = reflectorMatrices.length;
|
||||
group.add(reflectors);
|
||||
|
||||
trunks.count = trunkCount;
|
||||
poles.count = poleCount;
|
||||
silos.count = siloCount;
|
||||
@@ -321,16 +485,22 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
export function createRoads(world: World): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "roads";
|
||||
const batch = new Batch();
|
||||
for (const road of world.city.roads) {
|
||||
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
||||
const path = drapePath(world, road.path);
|
||||
group.add(roadRibbon(path, road.width, color));
|
||||
batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color));
|
||||
if (road.kind === "freeway") {
|
||||
// One warm median stroke is enough at corridor scale to read as divided
|
||||
// highway without spending a textured asset or a draw call per lane.
|
||||
group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012));
|
||||
batch.add(
|
||||
"road:median-stroke",
|
||||
roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012),
|
||||
batch.material("deck", 0xd7c27c),
|
||||
);
|
||||
}
|
||||
}
|
||||
batch.flush(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -347,32 +517,37 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
|
||||
const deckY = world.metres(bridge.deckHeight);
|
||||
const towerY = world.metres(bridge.towerHeight);
|
||||
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
|
||||
|
||||
/**
|
||||
* One material for the whole bridge, and one mesh out of it.
|
||||
*
|
||||
* This used to read `const material = () => new THREE.MeshLambertMaterial(…)`
|
||||
* and be called once per part, so the Golden Gate arrived as about
|
||||
* thirty-four meshes with thirty-four identical materials — thirty-four draw
|
||||
* calls the sorter had to keep apart, for one orange object. Everything a
|
||||
* bridge is made of is painted the same colour, so everything a bridge is made
|
||||
* of belongs in one bucket.
|
||||
*/
|
||||
const batch = new Batch();
|
||||
const paint = batch.material("solid", bridge.color);
|
||||
const part = (geometry: THREE.BufferGeometry) =>
|
||||
batch.add(bridge.name, geometry, paint, { cast: true });
|
||||
|
||||
const deckPoints = bridge.path.map(([lat, lng]) => {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
return new THREE.Vector3(x, deckY, z);
|
||||
});
|
||||
|
||||
const deck = ribbon(deckPoints, 0.5, bridge.color);
|
||||
deck.castShadow = true;
|
||||
group.add(deck);
|
||||
part(tubeGeometry(deckPoints, 0.5));
|
||||
|
||||
const towerTops: THREE.Vector3[] = [];
|
||||
for (const [lat, lng] of bridge.towers) {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
|
||||
geo.translate(0, towerY / 2, 0);
|
||||
const tower = new THREE.Mesh(geo, material());
|
||||
tower.position.set(x, 0, z);
|
||||
tower.castShadow = true;
|
||||
group.add(tower);
|
||||
part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z));
|
||||
|
||||
// Cross-braces, which is most of what you see of a tower at distance.
|
||||
for (const frac of [0.55, 0.82]) {
|
||||
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
|
||||
brace.position.set(x, towerY * frac, z);
|
||||
group.add(brace);
|
||||
part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z));
|
||||
}
|
||||
towerTops.push(new THREE.Vector3(x, towerY, z));
|
||||
}
|
||||
@@ -392,12 +567,7 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
p.y -= Math.sin(t * Math.PI) * sag;
|
||||
pts.push(p);
|
||||
}
|
||||
group.add(
|
||||
new THREE.Mesh(
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
|
||||
material(),
|
||||
),
|
||||
);
|
||||
part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false));
|
||||
|
||||
// Vertical hangers down to the deck.
|
||||
for (let s = 2; s < 18; s += 2) {
|
||||
@@ -406,14 +576,11 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
|
||||
const top = p.y - Math.sin(t * Math.PI) * sag;
|
||||
if (top <= deckY + 0.2) continue;
|
||||
const h = top - deckY;
|
||||
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
|
||||
geo.translate(0, h / 2, 0);
|
||||
const hanger = new THREE.Mesh(geo, material());
|
||||
hanger.position.set(p.x, deckY, p.z);
|
||||
group.add(hanger);
|
||||
part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z));
|
||||
}
|
||||
}
|
||||
|
||||
batch.flush(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -179,9 +179,27 @@ export function createWater(world: World): THREE.Group {
|
||||
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
|
||||
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
|
||||
|
||||
/**
|
||||
* Standard rather than Lambert, and it is the whole difference between an
|
||||
* ocean and a blue card.
|
||||
*
|
||||
* `MeshLambertMaterial` has no specular term at all — none, by construction —
|
||||
* so the Pacific, which is between a third and a half of the California
|
||||
* board's frame, rendered as one flat value at every hour and from every
|
||||
* angle. A low roughness gives it the sun's glint back, and now that the
|
||||
* scene carries an environment map (`engine/environmentRig.ts`) it also gives
|
||||
* it the sky: `MeshStandardMaterial` reads `scene.environment`, so the water
|
||||
* reflects whatever the atmosphere decided the sky is, for free and with no
|
||||
* second pass.
|
||||
*
|
||||
* `metalness: 0` is stated rather than defaulted because water is a
|
||||
* dielectric: its reflection is a Fresnel term over a coloured body, which is
|
||||
* exactly what metalness 0 with low roughness produces, and a metallic water
|
||||
* would lose `pal.sea` entirely.
|
||||
*/
|
||||
const sea = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
|
||||
new THREE.MeshLambertMaterial({ color: pal.sea }),
|
||||
new THREE.MeshStandardMaterial({ color: pal.sea, roughness: 0.14, metalness: 0 }),
|
||||
);
|
||||
sea.rotation.x = -Math.PI / 2;
|
||||
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
|
||||
@@ -192,9 +210,17 @@ export function createWater(world: World): THREE.Group {
|
||||
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
|
||||
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
|
||||
geo.rotateX(Math.PI / 2);
|
||||
// The same change as the sea above, and for the same reason. Slightly
|
||||
// rougher: an inland lake is sheltered, and a mirror-smooth bay next to a
|
||||
// wind-roughened ocean reads as the wrong way round.
|
||||
const lake = new THREE.Mesh(
|
||||
geo,
|
||||
new THREE.MeshLambertMaterial({ color: pal.lake, side: THREE.DoubleSide }),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: pal.lake,
|
||||
roughness: 0.2,
|
||||
metalness: 0,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
lake.position.y = 0.05;
|
||||
group.add(lake);
|
||||
|
||||
+6
-5
@@ -319,11 +319,12 @@ export interface Aircraft {
|
||||
/**
|
||||
* Where aircraft come from.
|
||||
*
|
||||
* An interface rather than a client because the obvious source — FlightRadar24
|
||||
* — cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid
|
||||
* redistributing the data. This package ships a simulator and open community
|
||||
* sources; anything commercial is an adapter in a private deployment. See
|
||||
* ARCHITECTURE.md §4.
|
||||
* An interface rather than a client, because this repo must not ship one for the
|
||||
* obvious source: FlightRadar24's terms do not permit scraping and do not permit
|
||||
* redistributing the data, so a client for it in an Apache-2.0 repo would be
|
||||
* publishing instructions for violating a ToS. This package ships a simulator
|
||||
* and open community sources; anything commercial is an adapter in a private
|
||||
* deployment. See ARCHITECTURE.md §4.
|
||||
*/
|
||||
export interface FlightSource {
|
||||
/** Current traffic. Called on a timer; must be cheap and must not throw. */
|
||||
|
||||
Reference in New Issue
Block a user