1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/interiors/daylight.ts
T
karti af0d4a7d57 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>
2026-08-07 00:56:49 -07:00

198 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Real daylight for a building that knows where it stands.
*
* CONTRACT.md §4 gives lighting one owner — `Atmosphere` — and says an office
* gets a fixed rig instead, with daylight through the windows named as "a later
* refinement, not a v1 coupling". This is that refinement, and it is written to
* keep the rule it is extending: **nothing here computes light.** `Atmosphere`
* still owns that. This takes the `LightingState` it produced for a place on the
* earth and answers the two questions an interior asks that a city never does.
*
* ### One: which way is the building pointing
*
* `Atmosphere` works in the city's frame, where Z is true north because a city
* pack is a map. An office is a *building*, and buildings are rotated to face
* streets. `OfficeSite.heading` is the compass bearing the pack's Z actually
* points along, and until the sun is turned by it, "the daylight side" in a
* pack's comments is a label rather than a fact — the light would come through
* whichever wall the author happened to draw at the top of the page.
*
* ### Two: where does the weather start
*
* A city's fog begins a kilometre away and that is fine, because a city is
* ninety kilometres across. An office is fifty metres across, and the same fog
* would sit *inside the room*, greying out the far wall and the people at it.
* So the colour is kept and the distances are replaced: clear air out to the
* building's own scale, haze beyond it, saturated long before the horizon plane
* ends. That is what turns a flat backdrop into a view.
*
* A pack with no `site` never reaches this file and keeps the fixed rig, which
* is the promise the format makes: you can author a floor plan without owning a
* coordinate.
*/
import type { LightingState } from "../engine/types.ts";
import type { OfficeSite } from "./types.ts";
/**
* Where the clear air ends and the haze begins, in metres from the camera.
*
* It has to clear the whole *camera orbit*, and the orbit is centred on an
* authored viewpoint's target rather than on the middle of the building. The
* camera pulls back to 1.8 spans — 97 m for the 54 m hangar — and from an
* off-centre target the far roof corner is another fifty or so beyond that, so
* the real worst case is about 149 m rather than the 110 m the building's width
* alone suggests. 150 m clears it, and clears the tower pack's 137 m with room
* to spare.
*
* Panning moves the target, so no finite number is a guarantee. It does not need
* to be: the fog ramps at 0.025 % per metre, so being a few metres inside it is
* imperceptible rather than a visible grey wall.
*/
const FOG_NEAR_M = 150;
/**
* Where haze becomes total. Well inside `HORIZON_EXTENT` in `officeScene.ts`, on
* purpose: the ground plane has to reach full fog colour before its own edge, or
* the horizon ends in a visible seam rather than in distance.
*/
const FOG_FAR_M = 4200;
/**
* Turn a city-frame lighting state into an office-frame one.
*
* The input is whatever `Atmosphere.apply()` produced for `site.lat/lng` at the
* instant being rendered. The output differs in exactly two ways — the sun is
* rotated into the building's frame, and the fog is moved outdoors — and is
* otherwise the same object's values, because everything else `Atmosphere`
* decided is as true inside a building as outside one.
*/
export function officeDaylight(state: LightingState, site: OfficeSite): LightingState {
const fog =
state.fog === null ? null : { color: state.fog.color, near: FOG_NEAR_M, far: FOG_FAR_M };
return {
...state,
sun: { ...state.sun, direction: intoBuildingFrame(state.sun.direction, site.heading) },
fog,
/**
* The sky's horizon stop is pinned to the fog colour, which is what makes
* the horizon a horizon instead of a seam.
*
* The sky is a **screen-space** gradient: `applyLighting` paints it top to
* bottom of the viewport, and it does not tilt with the camera. The ground
* plane, meanwhile, converges on the fog colour at the distance the fog
* saturates. So the two meet at whatever screen row the world horizon
* happens to fall on — which moves every time you orbit — and unless the
* colours they meet with are the same, that line is a visible step.
*
* Matching them makes the join invisible wherever it lands, with no
* per-frame work and no second piece of geometry. It costs the sky a little
* of the atmosphere's chosen horizon tint; a step across the middle of the
* frame costs more.
*/
sky: state.sky === null || fog === null ? state.sky : { ...state.sky, horizon: fog.color },
};
}
/**
* The colour of the light a building makes for itself.
*
* Warm, and warmer than daylight on purpose: an office at night is lit at
* something like 3500 K against a 5500 K sun, and the shift is most of what
* makes an interior at night read as *interior* rather than as a badly exposed
* afternoon. It is also what stops the night rig looking like a dimmer switch
* on the day rig, which is what a neutral lift would give.
*/
const HOUSE_COLOR = 0xffe4bd;
/** Ambient and hemisphere added at full darkness. */
const HOUSE_AMBIENT = 0.5;
const HOUSE_HEMISPHERE = 0.85;
/**
* Add the building's own lights to a rig that has run out of sun.
*
* Kept here, next to the other adaptation of a `LightingState` for an interior,
* and kept **out** of `luminaires.ts` — that file drives the glowing panels and
* decides how much artificial light there is, and this one applies it, because
* CONTRACT.md §4 gives the rig one owner and two files writing lights is exactly
* what that rule exists to prevent.
*
* `level` is `Luminaires.houseLevel()`: 0 in daylight, 1 once the sun is down.
* At 0 this returns the state unchanged, so a daylit office pays nothing and
* looks identical to before any of this existed.
*
* Note what is **not** touched: `sun`. The sun is where the sun is, and at
* midnight it is below the floor contributing nothing. Interior light is
* ambient and hemispherical because that is what a ceiling of diffusers
* actually produces — a room lit from a hundred soft sources has almost no
* directional term, which is why offices at night have such flat shadows.
*/
export function withHouseLights(state: LightingState, level: number): LightingState {
const t = Math.min(1, Math.max(0, level));
if (t === 0) return state;
return {
...state,
ambient: {
// Blended toward the interior colour rather than replaced, so dusk — when
// both are running — does not jump between two different whites.
color: mixHex(state.ambient.color, HOUSE_COLOR, t),
intensity: state.ambient.intensity + HOUSE_AMBIENT * t,
},
hemisphere: {
sky: mixHex(state.hemisphere.sky, HOUSE_COLOR, t),
// The floor of a lit office bounces its own light back up, and leaving the
// ground term at the night sky's near-black is what makes a figure's legs
// vanish while their head is lit.
ground: mixHex(state.hemisphere.ground, HOUSE_COLOR, t * 0.6),
intensity: state.hemisphere.intensity + HOUSE_HEMISPHERE * t,
},
};
}
/**
* Blend two packed 0xRRGGBB colours.
*
* Per channel on the raw bytes, which is not a perceptual blend and does not
* need to be: both ends are near-white and the path between them stays there.
* Doing it by hand avoids constructing two `THREE.Color`s per frame in a module
* that deliberately imports no three.js.
*/
function mixHex(from: number, to: number, t: number): number {
const mix = (shift: number) => {
const a = (from >> shift) & 0xff;
const b = (to >> shift) & 0xff;
return Math.round(a + (b - a) * t) & 0xff;
};
return (mix(16) << 16) | (mix(8) << 8) | mix(0);
}
/**
* Rotate a world-frame direction into the building's frame.
*
* The world frame is the city's: **Z is true north, +X is east**. The building
* frame is the pack's, whose Z points along the compass bearing `heading`.
*
* Both frames measure a bearing as `atan2(x, z)`, so a direction at world
* bearing `B` is at building bearing `B heading`, and expanding
* `sin(B h)` and `cos(B h)` gives the two lines below. It is a rotation of
* `heading` about +Y, written out rather than delegated to a `Vector3` because
* this module deliberately imports no three.js — the same reason `plan.ts`
* imports none, and what keeps it testable without a GL context.
*
* `y` is untouched: rotating about the vertical cannot change how high the sun
* is, only where on the compass it sits.
*/
export function intoBuildingFrame(
direction: [number, number, number],
heading: number,
): [number, number, number] {
const [x, y, z] = direction;
const h = (heading * Math.PI) / 180;
const cos = Math.cos(h);
const sin = Math.sin(h);
return [x * cos + z * sin, y, -x * sin + z * cos];
}