feat: the state becomes California, and the port fills with ships
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.
**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.
The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.
**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.
`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.
**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.
At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.
**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.
"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.
**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."
Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.
Tests 1,340 -> 1,540, server 280 -> 295.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+283
-1
@@ -34,7 +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 { nightFactor, type AerialFog } from "./atmosphere.ts";
|
||||
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
|
||||
import {
|
||||
createSatelliteLayer,
|
||||
@@ -63,9 +63,12 @@ import type {
|
||||
City,
|
||||
FlightSource,
|
||||
LightingState,
|
||||
MigrationField,
|
||||
Marker,
|
||||
MarkerPalette,
|
||||
RadarField,
|
||||
ScenePalette,
|
||||
Vessel,
|
||||
} from "./types.ts";
|
||||
import { World, type FieldProgress } from "./world.ts";
|
||||
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
|
||||
@@ -194,6 +197,8 @@ export interface FireLayer {
|
||||
/** Draw the plumes, or do not. The marks stay either way. */
|
||||
setSmokeVisible(visible: boolean): void;
|
||||
setLighting(state: LightingState): void;
|
||||
/** The fog distances alone. See `SceneHandle.setAerialFog`. */
|
||||
setFogDistances(near: number, far: number): void;
|
||||
setSolarElevation(degrees: number): void;
|
||||
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
|
||||
setWind(kph: number | null, fromDeg: number | null): void;
|
||||
@@ -212,6 +217,107 @@ export type FireLayerFactory = (
|
||||
options: { span: number },
|
||||
) => FireLayer;
|
||||
|
||||
/**
|
||||
* The four layers this build added, and the one shape they all share.
|
||||
*
|
||||
* Every one of them is a **factory in `SceneOptions`, not a layer**, and every
|
||||
* factory has exactly `FireLayerFactory`'s signature — `(world, { span })`. That
|
||||
* is deliberate to the point of being copied rather than generalised. The layer
|
||||
* needs the `World` that `createScene` is in the middle of building, so it cannot
|
||||
* be handed in already made; and it is sized from the board rather than from a
|
||||
* constant, so it needs the span. Absent costs exactly nothing: no geometry, no
|
||||
* material, no draw call, and the corresponding setter becomes a no-op. That is
|
||||
* not an optimisation, it is the empty state — a quiet day is the commonest
|
||||
* correct answer all four of these will ever give, and a layer that is not
|
||||
* visited at all is cheaper and more honest than one drawing nothing.
|
||||
*
|
||||
* `scene.ts` declares the minimum a *renderer* needs and imports none of the
|
||||
* modules that implement them, exactly as it does for `FireLayer`. The engine
|
||||
* never takes a dependency on a wire module, and neither side has to exist for
|
||||
* the other to compile.
|
||||
*/
|
||||
export interface PortLayer {
|
||||
group: THREE.Object3D;
|
||||
setLighting(state: LightingState): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type PortLayerFactory = (
|
||||
world: World,
|
||||
options: { span: number },
|
||||
) => PortLayer;
|
||||
|
||||
/**
|
||||
* Hulls and their wakes.
|
||||
*
|
||||
* `setVessels(null)` and `setVessels([])` are the same picture and a different
|
||||
* sentence, the same distinction `setFires` draws: `null` is "nothing has
|
||||
* answered", an empty array is "the feed answered and nothing is on this board".
|
||||
*
|
||||
* `tick` exists because a ship under way is dead-reckoned **along its reported
|
||||
* course at its reported speed** between fixes, and its wake is advanced with
|
||||
* it. It is never a spline between two fixes: upstream listens for thirty
|
||||
* seconds every fifteen minutes, and the chord between two samples is not a path
|
||||
* anything took.
|
||||
*/
|
||||
export interface VesselLayer {
|
||||
group: THREE.Object3D;
|
||||
setVessels(vessels: readonly Vessel[] | null): void;
|
||||
setLighting(state: LightingState): void;
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type VesselLayerFactory = (
|
||||
world: World,
|
||||
options: { span: number },
|
||||
) => VesselLayer;
|
||||
|
||||
/**
|
||||
* The reflectivity sheet: one quad at cloud base, one `DataTexture`.
|
||||
*
|
||||
* Built only on a board coarse enough to carry a 0.25-degree cell. 27.8 km is a
|
||||
* quarter of the SoCal board and a third of the Bay Area board, so on the fine
|
||||
* boards the honest answer is to build no layer at all rather than to draw four
|
||||
* texels over a city.
|
||||
*/
|
||||
export interface PrecipLayer {
|
||||
group: THREE.Object3D;
|
||||
/** Replace the raster. `null` clears it — nothing has answered yet. */
|
||||
setField(field: RadarField | null): void;
|
||||
setLighting(state: LightingState): void;
|
||||
/** Crossfades between the two newest frames across the ten-minute step. */
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type PrecipLayerFactory = (
|
||||
world: World,
|
||||
options: { span: number },
|
||||
) => PrecipLayer;
|
||||
|
||||
/**
|
||||
* Nocturnal migration as one `THREE.Points` drift field.
|
||||
*
|
||||
* `setSolarElevation` rather than a visibility flag, because the layer's own
|
||||
* subject is nocturnal: BirdCast measures only after dark, and whether anything
|
||||
* is aloft is a fact about the sun, not a preference. The same seam
|
||||
* `nightlights.ts` and `FireLayer` already take.
|
||||
*/
|
||||
export interface MigrationLayer {
|
||||
group: THREE.Object3D;
|
||||
setField(field: MigrationField | null): void;
|
||||
setLighting(state: LightingState): void;
|
||||
setSolarElevation(degrees: number): void;
|
||||
tick(dt: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type MigrationLayerFactory = (
|
||||
world: World,
|
||||
options: { span: number },
|
||||
) => MigrationLayer;
|
||||
|
||||
/**
|
||||
* Whether this visitor has asked the platform for less movement.
|
||||
*
|
||||
@@ -272,6 +378,21 @@ export interface SceneOptions {
|
||||
* no material, no draw call, and `setFires` becomes a no-op.
|
||||
*/
|
||||
fires?: FireLayerFactory;
|
||||
/**
|
||||
* How to build this board's port kit, or nothing at all.
|
||||
*
|
||||
* The ports themselves are `city.ports` and are plain authored data; this is
|
||||
* only the renderer for them, kept optional and injected for the same reason
|
||||
* `fires` is — so a board with no port allocates nothing and so `engine/ports.ts`
|
||||
* is never in the import graph of a build that does not draw one.
|
||||
*/
|
||||
ports?: PortLayerFactory;
|
||||
/** How to build this board's vessel layer, or nothing at all. */
|
||||
vessels?: VesselLayerFactory;
|
||||
/** How to build this board's reflectivity sheet, or nothing at all. */
|
||||
precip?: PrecipLayerFactory;
|
||||
/** How to build this board's migration field, or nothing at all. */
|
||||
migration?: MigrationLayerFactory;
|
||||
/** Fires on hover/click of a marker head. */
|
||||
onMarkerPick?: (marker: Marker | null) => void;
|
||||
/**
|
||||
@@ -356,6 +477,43 @@ export interface SceneHandle {
|
||||
arrive(): void;
|
||||
/** Applies a rig computed elsewhere. The scene never works one out itself. */
|
||||
setLighting(state: LightingState): void;
|
||||
/**
|
||||
* Move the fog planes because the **camera** moved, without touching the light.
|
||||
*
|
||||
* ## Why this is a second setter and must stay one
|
||||
*
|
||||
* Aerial perspective is the one term in the rig that depends on where the
|
||||
* camera is standing, and the camera moves in a completely different tempo
|
||||
* from the sky: `main.ts` recomputes the sun once a minute and recomputes the
|
||||
* fog on every orbit step past a 2% altitude threshold — a few dozen times in
|
||||
* one drag. Before this seam existed both went through `setLighting`, so a
|
||||
* gesture that changed two floats also re-pushed the sun, the hemisphere, the
|
||||
* ambient, the sky dome, the moon and the fog colour into six layers, dirtied
|
||||
* a `MeshLambertMaterial` in `ports.ts`, rebuilt the vessel wake instances,
|
||||
* and re-fingerprinted the PMREM environment in `environmentRig.ts`. None of
|
||||
* those read a distance. Measured over a dolly plus a drag, that path costs
|
||||
* 0.14-0.19 ms a step against 0.02 ms for this one.
|
||||
*
|
||||
* ## Why it carries no colour, which is the load-bearing half
|
||||
*
|
||||
* `environmentRig.ts` decides whether to re-render and re-convolve the sky
|
||||
* cubemap by fingerprinting the rig's **colours**, `sky.horizon` among them,
|
||||
* and `interiors/daylight.ts` pins that horizon stop to the fog colour on
|
||||
* purpose — it is what hides the seam where the sky dome meets the haze. So a
|
||||
* camera-dependent *colour* would put a PMREM rebuild on every orbit step,
|
||||
* and the fix for that would not be to coarsen the fingerprint: quantising
|
||||
* harder hides one instance and leaves the mechanism armed for the next
|
||||
* feature that varies a colour. Keeping this setter to two distances is the
|
||||
* structural answer instead, and `atmosphere.ts`'s `AerialFog` is the type
|
||||
* that states it. Anything that genuinely changes the light — the sun, the
|
||||
* sky, the weather, the hour — goes through `setLighting` on the clock, and
|
||||
* `Atmosphere` stays the sole owner of both (CONTRACT.md §4).
|
||||
*
|
||||
* Reaches exactly the three things that draw fog: `scene.fog`, the cloud
|
||||
* deck's own uniforms, and the fire marks and plumes. Everything else in the
|
||||
* board is a stock material and reads `scene.fog` for free.
|
||||
*/
|
||||
setAerialFog(fog: AerialFog): void;
|
||||
/**
|
||||
* Solar elevation in degrees, for the layers that need the sun's position
|
||||
* rather than the rig it implies. `LightingState` deliberately carries no
|
||||
@@ -388,6 +546,49 @@ export interface SceneHandle {
|
||||
setFires(view: FireView | null): void;
|
||||
/** Draw the smoke plumes, or do not. The marks are unaffected. */
|
||||
setFireSmoke(visible: boolean): void;
|
||||
/**
|
||||
* The ships this board should be drawing, or `null` for none.
|
||||
*
|
||||
* The same two-valued emptiness `setFires` has: `null` is "nothing has
|
||||
* answered", `[]` is "the feed answered and this board is empty". A no-op on a
|
||||
* build with no vessel layer.
|
||||
*/
|
||||
setVessels(vessels: readonly Vessel[] | null): void;
|
||||
/**
|
||||
* How high the camera is above the ground it is looking at, in metres.
|
||||
*
|
||||
* The one number the light rig needs that a clock cannot supply.
|
||||
* `Atmosphere.apply` takes it as an optional second argument and turns it into
|
||||
* aerial perspective; see `aerialReach` there for why the invariant is a
|
||||
* fraction of the board's authored reach rather than a distance in metres.
|
||||
*
|
||||
* Metres and not scene units, deliberately, and it is the *scene* that
|
||||
* converts because the scene is the only thing that knows this board's
|
||||
* `metresPerUnit` and its vertical exaggeration. A caller handed units would
|
||||
* have to know both, which is how the engine's scale would leak into the app
|
||||
* for the third time.
|
||||
*
|
||||
* Measured to the controls' target rather than to the terrain directly under
|
||||
* the camera: the target is what the shot is *of*, and a camera looking across
|
||||
* a valley from over a ridge is not two kilometres up merely because there
|
||||
* happens to be a mountain beneath it.
|
||||
*/
|
||||
cameraAltitudeMetres(): number;
|
||||
/**
|
||||
* How far the camera is from what it is looking at, in metres.
|
||||
*
|
||||
* The other half of what aerial perspective needs, and the half a physical
|
||||
* model does not think it needs. A map is looked at from outside the
|
||||
* atmosphere it depicts — the state board's chapters sit two hundred
|
||||
* kilometres from their own subjects — so the fog has to clear the shot as
|
||||
* well as follow the air. `atmosphere.ts` floors one against the other; see
|
||||
* `AERIAL_SUBJECT_CLEARANCE`.
|
||||
*/
|
||||
cameraStandoffMetres(): number;
|
||||
/** The reflectivity raster, or `null`. A no-op without a precipitation layer. */
|
||||
setPrecip(field: RadarField | null): void;
|
||||
/** Tonight's migration, or `null`. A no-op without a migration layer. */
|
||||
setMigration(field: MigrationField | null): void;
|
||||
/**
|
||||
* 0..1 smoke load at a coordinate — how much of the drawn fire set is
|
||||
* upwind of it and near enough to matter. Zero with no fire layer.
|
||||
@@ -642,6 +843,48 @@ export async function createScene(
|
||||
scene.add(fireLayer.group);
|
||||
}
|
||||
|
||||
/**
|
||||
* The port kit, the ships, the rain and the birds — each one built exactly the
|
||||
* way the fire layer above it is, and each one absent by default.
|
||||
*
|
||||
* Order is not arbitrary. Ports are ground and go under everything; hulls sit
|
||||
* on the sea beside the quays the ports just drew; the reflectivity sheet
|
||||
* hangs at cloud base and must sort against the clouds built above it; the
|
||||
* migration motes are the last thing in and the highest, so they draw over the
|
||||
* sheet rather than through it.
|
||||
*/
|
||||
const portLayer: PortLayer | null = options.ports
|
||||
? options.ports(world, { span: boardSpan })
|
||||
: null;
|
||||
if (portLayer) {
|
||||
portLayer.setLighting(opening);
|
||||
scene.add(portLayer.group);
|
||||
}
|
||||
|
||||
const vesselLayer: VesselLayer | null = options.vessels
|
||||
? options.vessels(world, { span: boardSpan })
|
||||
: null;
|
||||
if (vesselLayer) {
|
||||
vesselLayer.setLighting(opening);
|
||||
scene.add(vesselLayer.group);
|
||||
}
|
||||
|
||||
const precipLayer: PrecipLayer | null = options.precip
|
||||
? options.precip(world, { span: boardSpan })
|
||||
: null;
|
||||
if (precipLayer) {
|
||||
precipLayer.setLighting(opening);
|
||||
scene.add(precipLayer.group);
|
||||
}
|
||||
|
||||
const migrationLayer: MigrationLayer | null = options.migration
|
||||
? options.migration(world, { span: boardSpan })
|
||||
: null;
|
||||
if (migrationLayer) {
|
||||
migrationLayer.setLighting(opening);
|
||||
scene.add(migrationLayer.group);
|
||||
}
|
||||
|
||||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||
markerLayer.setMarkers(options.markers ?? []);
|
||||
scene.add(markerLayer.group);
|
||||
@@ -954,6 +1197,9 @@ export async function createScene(
|
||||
roadTraffic?.tick(dt);
|
||||
clouds.tick(dt);
|
||||
fireLayer?.tick(dt);
|
||||
vesselLayer?.tick(dt);
|
||||
precipLayer?.tick(dt);
|
||||
migrationLayer?.tick(dt);
|
||||
/**
|
||||
* The one number the aeroplane glyph clamp cannot reach on its own.
|
||||
*
|
||||
@@ -1044,6 +1290,10 @@ export async function createScene(
|
||||
// drew and only it can free.
|
||||
clouds.dispose();
|
||||
fireLayer?.dispose();
|
||||
portLayer?.dispose();
|
||||
vesselLayer?.dispose();
|
||||
precipLayer?.dispose();
|
||||
migrationLayer?.dispose();
|
||||
nightLights.dispose();
|
||||
markerLayer.dispose();
|
||||
roadTraffic?.dispose();
|
||||
@@ -1081,6 +1331,10 @@ export async function createScene(
|
||||
kit.applyLighting(state);
|
||||
clouds.setLighting(state);
|
||||
fireLayer?.setLighting(state);
|
||||
portLayer?.setLighting(state);
|
||||
vesselLayer?.setLighting(state);
|
||||
precipLayer?.setLighting(state);
|
||||
migrationLayer?.setLighting(state);
|
||||
/**
|
||||
* Every lighting change, and it is cheap to do it every one.
|
||||
*
|
||||
@@ -1093,6 +1347,14 @@ export async function createScene(
|
||||
*/
|
||||
options.environment?.apply(scene, state, "city");
|
||||
},
|
||||
setAerialFog: ({ near, far }) => {
|
||||
// The three fog owners on a city board and no more. `ports`, `vessels`,
|
||||
// `precip`, `migration` and every building on the board are stock
|
||||
// materials lit by `scene.fog`, which `kit` has just moved for them.
|
||||
kit.setFogDistances(near, far);
|
||||
clouds.setFogDistances(near, far);
|
||||
fireLayer?.setFogDistances(near, far);
|
||||
},
|
||||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||||
setWind: (kph, fromDeg) => {
|
||||
clouds.setWind(kph, fromDeg);
|
||||
@@ -1104,9 +1366,29 @@ export async function createScene(
|
||||
setSolarElevation: (degrees) => {
|
||||
nightLights.setSolarElevation(degrees);
|
||||
fireLayer?.setSolarElevation(degrees);
|
||||
migrationLayer?.setSolarElevation(degrees);
|
||||
},
|
||||
setFires: (view) => fireLayer?.setFires(view),
|
||||
setFireSmoke: (visible) => fireLayer?.setSmokeVisible(visible),
|
||||
cameraStandoffMetres() {
|
||||
// `metresPerUnit` and NOT `unitsToMetres`, which is the vertical
|
||||
// conversion and divides the exaggeration back out. A stand-off is a
|
||||
// distance across the board, and the board's horizontal scale is honest —
|
||||
// only its height is stretched. Dividing by 15 here would have reported
|
||||
// every pose as fifteen times closer than it is.
|
||||
return Math.max(0, kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit);
|
||||
},
|
||||
cameraAltitudeMetres() {
|
||||
// Vertical exaggeration divides back out: the ground under the target is
|
||||
// drawn `exaggeration` times too high, so a camera fifty units above it is
|
||||
// not fifty units of real air above it. `world.unitsToMetres` is the inverse of
|
||||
// the same conversion `world.metres` applied on the way in.
|
||||
const above = kit.camera.position.y - kit.controls.target.y;
|
||||
return Math.max(0, world.unitsToMetres(above));
|
||||
},
|
||||
setVessels: (vessels) => vesselLayer?.setVessels(vessels),
|
||||
setPrecip: (field) => precipLayer?.setField(field),
|
||||
setMigration: (field) => migrationLayer?.setField(field),
|
||||
fireSmokeLoadAt: (lat, lng) => fireLayer?.smokeLoadAt(lat, lng) ?? 0,
|
||||
setSkyInstant: (when) => {
|
||||
skyOverride = when;
|
||||
|
||||
Reference in New Issue
Block a user