The office keeps its lights on, and something walks around under them
**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.
**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.
**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.
**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.
**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.
Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:
- The walk cycle's arms were a quarter cycle out of step with its legs —
the legs are cosine-shaped and the arms were on `sin`, so at the
instant the left leg reached full forward the left shoulder was at dead
neutral. Uncanny, and hard to name until it is pointed at.
- Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
primitive turns into a near-circular cross-section — the figure was
built out of lozenges, not panels. The rest of the library uses
0.02–0.09.
- The cloud material was `transparent` + `DoubleSide` without
`forceSinglePass`, so three rendered it twice per frame *and* bumped
`material.version` on each pass — rebuilding the program cache key
forever, on the one layer that is fill-rate bound.
- `starlinkMesh.dispose()` freed the geometries but not the
`InstancedMesh`es, orphaning their instance buffers on every city
switch.
- The airliner's tailplane roots sat outside the tail cone and hung in
free air over most of their chord.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+68
-3
@@ -31,7 +31,14 @@ import * as THREE from "three";
|
||||
import { createBlocks, createLandmarks } from "./blocks.ts";
|
||||
import { createNightLights, type NightLights } from "./nightlights.ts";
|
||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
|
||||
import { solarPosition, sunDirection } from "./solar.ts";
|
||||
import {
|
||||
createStarlinkMeshLayer,
|
||||
DOME_RADIUS_FACTOR,
|
||||
type StarlinkMeshLayer,
|
||||
} from "./starlinkMesh.ts";
|
||||
import {
|
||||
createSatelliteLayer,
|
||||
type SatelliteCatalogue,
|
||||
@@ -111,6 +118,16 @@ export interface SceneHandle {
|
||||
* elevation, so the number has to arrive separately.
|
||||
*/
|
||||
setSolarElevation(degrees: number): void;
|
||||
/**
|
||||
* How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`.
|
||||
*
|
||||
* Separate from `setLighting` for the same reason `setSolarElevation` is: a
|
||||
* `LightingState` deliberately carries no cover, so the number has to arrive
|
||||
* on its own rather than be reverse-engineered out of a rig.
|
||||
*/
|
||||
setCloudCover(fraction: number): void;
|
||||
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
|
||||
setWind(kph: number | null, fromDeg: number | null): void;
|
||||
/**
|
||||
* Freeze the satellite sky at an instant, or pass `null` to follow the wall
|
||||
* clock. Exactly the shape of `main.ts`'s own time override, deliberately.
|
||||
@@ -250,7 +267,11 @@ export async function createScene(
|
||||
shadowExtent: boardSpan * 0.75,
|
||||
shadowFar: boardSpan * 2.2,
|
||||
});
|
||||
kit.applyLighting(options.lighting ?? cityDaylight(pal, boardSpan));
|
||||
// Held, because the cloud layer needs the same opening rig the kit just got —
|
||||
// and it must be the same object, not a second call to `cityDaylight`, or the
|
||||
// two disagree for the one frame before the app's first `setLighting`.
|
||||
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
|
||||
kit.applyLighting(opening);
|
||||
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
@@ -268,6 +289,10 @@ export async function createScene(
|
||||
const nightLights: NightLights = createNightLights({ world, blocks });
|
||||
scene.add(nightLights.group);
|
||||
|
||||
const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
|
||||
clouds.setLighting(opening);
|
||||
scene.add(clouds.group);
|
||||
|
||||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||
scene.add(markerLayer.group);
|
||||
|
||||
@@ -279,9 +304,16 @@ export async function createScene(
|
||||
}
|
||||
|
||||
let satelliteLayer: SatelliteLayer | null = null;
|
||||
let starlinkMeshes: StarlinkMeshLayer | null = null;
|
||||
if (options.satellites) {
|
||||
satelliteLayer = createSatelliteLayer(boardRadius);
|
||||
scene.add(satelliteLayer.group);
|
||||
// The same dome the points are on, so a satellite that grows geometry does
|
||||
// not also jump. `DOME_RADIUS_FACTOR` is exported for exactly this: the two
|
||||
// layers must agree, and the only safe way for them to agree is to be
|
||||
// multiplying the same number by the same constant.
|
||||
starlinkMeshes = createStarlinkMeshLayer(boardRadius * DOME_RADIUS_FACTOR);
|
||||
scene.add(starlinkMeshes.group);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,6 +389,7 @@ export async function createScene(
|
||||
onExit: () => kit.resetPick(),
|
||||
tick(dt) {
|
||||
kit.tick(dt);
|
||||
clouds.tick(dt);
|
||||
if (options.flights && flightLayer) {
|
||||
flightTimer -= dt;
|
||||
if (flightTimer <= 0) {
|
||||
@@ -368,13 +401,40 @@ export async function createScene(
|
||||
// time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
|
||||
// often makes it walk the catalogue sooner, never makes it cost more.
|
||||
if (options.satellites && satelliteLayer) {
|
||||
satelliteLayer.update(options.satellites.fixes(skyOverride ?? new Date()));
|
||||
/**
|
||||
* One instant, one sweep, shared.
|
||||
*
|
||||
* `fixes()` advances the catalogue's rolling propagation, so calling it
|
||||
* twice in a frame spends twice the budget for no new information — and
|
||||
* two different `when`s would put the near-field satellites' sun
|
||||
* attitude on a different clock from the sky they are in.
|
||||
*
|
||||
* The sun comes from `solar.ts` directly rather than from the rig,
|
||||
* because `atmosphere.ts` floors the light direction at
|
||||
* `shadowFloorDeg` to keep the shadow camera usable. That floor pins the
|
||||
* sun above the horizon, and a sun ten degrees *down* is precisely the
|
||||
* dusk geometry that lights a Starlink pass.
|
||||
*/
|
||||
const when = skyOverride ?? new Date();
|
||||
const fixes = options.satellites.fixes(when);
|
||||
satelliteLayer.update(fixes);
|
||||
starlinkMeshes?.update(
|
||||
fixes,
|
||||
kit.camera,
|
||||
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
|
||||
);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
options.flights?.dispose?.();
|
||||
flightLayer?.dispose();
|
||||
satelliteLayer?.dispose();
|
||||
starlinkMeshes?.dispose();
|
||||
// Before the `scene.traverse` sweep below, and required rather than tidy:
|
||||
// the sweep reaches geometries and materials, and a `ShaderMaterial`'s
|
||||
// uniform textures are neither — the cloud texture is a canvas this layer
|
||||
// drew and only it can free.
|
||||
clouds.dispose();
|
||||
nightLights.dispose();
|
||||
markerLayer.dispose();
|
||||
kit.dispose();
|
||||
@@ -394,7 +454,12 @@ export async function createScene(
|
||||
chapters: city.chapters,
|
||||
stage,
|
||||
stageScene,
|
||||
setLighting: (state) => kit.applyLighting(state),
|
||||
setLighting: (state) => {
|
||||
kit.applyLighting(state);
|
||||
clouds.setLighting(state);
|
||||
},
|
||||
setCloudCover: (fraction) => clouds.setCover(fraction),
|
||||
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
|
||||
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
|
||||
setSkyInstant: (when) => {
|
||||
skyOverride = when;
|
||||
|
||||
Reference in New Issue
Block a user