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/luminaires.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

236 lines
9.3 KiB
TypeScript

/**
* Turning the office lights on, and letting them notice you.
*
* `src/assets/office/lighting.ts` says plainly that a fitting emits no light:
* lighting has one owner (CONTRACT.md §4) and a hundred `PointLight`s is both
* the wrong owner and, past about four shadow casters, the end of the frame
* budget. That rule is kept here in full. **Nothing in this file is a light
* source.** What it does is make the *diffusers* — the glowing panel of each
* fitting, which is the thing you actually look at — brighter or dimmer, and
* hand the scene a single scalar for how much interior light the rig should
* add on top.
*
* ### Why the room needs this at all
*
* A sited office follows the real sun (`daylight.ts`), and the real sun spends
* half its time below the horizon. Before this, that produced a technically
* correct and completely useless picture: an unlit floor plate at midnight, with
* ceiling fittings drawn as pale grey discs, in a building whose entire premise
* is that you can see who is at which desk. Every office on earth solves this
* the same way and it is not subtle — the lights are on.
*
* ### Two effects, one of which is the interesting one
*
* **House lights** are the baseline: as the sun goes down, the diffusers come up
* and the caller lifts the ambient and hemisphere terms with a warm interior
* colour. That is a whole-building state and a single number.
*
* **Presence** is the per-fitting one. A fitting within `PRESENCE_RADIUS` of
* somebody walking underneath brightens further, and fades back when they leave.
* Real buildings genuinely do this — occupancy sensors on a floor at night are
* why a lit corridor follows you through an empty office — so it is not a
* flourish, it is the behaviour. It is also the cheapest interesting thing in
* the scene: no lights, no raycasts, one distance test per fitting per frame
* against a handful of walkers.
*
* ### How a single fitting can be brighter than its neighbour
*
* They are one `InstancedMesh` sharing one material, so `emissiveIntensity`
* cannot vary between them — it is a uniform. `instanceColor` *can*, but three
* multiplies it into the diffuse term only, and a diffuser's whole appearance is
* its emissive term. So `onBeforeCompile` patches six lines of the emissive
* chunk to multiply by the instance colour as well.
*
* The alternative was one mesh per fitting, which is forty draw calls of ceiling
* in a building that currently spends about twenty on everything.
*/
import * as THREE from "three";
import type { LuminaireBatch } from "./furnish.ts";
/**
* How far from a fitting somebody has to be to bring it up, in metres.
*
* Four metres is about one structural bay, so walking a corridor brings on the
* fitting ahead of you before you are under it and lets the one behind you fade
* — which is what makes it read as the building responding rather than as a
* lamp attached to a robot.
*/
const PRESENCE_RADIUS = 4.0;
/** How much brighter a fitting goes when somebody is directly under it. */
const PRESENCE_GAIN = 1.5;
/**
* How fast a fitting reaches its target brightness, per second.
*
* Deliberately not instant. A hard cut tracks the walker exactly and looks like
* a bug; a slow fade reads as a real fitting warming up and, more usefully,
* hides the fact that the trigger is a hard radius rather than a sensor cone.
*/
const FADE_PER_SECOND = 3.2;
/**
* Below this solar elevation the lights are fully on; above the upper bound they
* are fully off. Degrees.
*
* The band is civil twilight rather than a step at the horizon, because that is
* when an occupied building actually switches over — the sun is down well before
* anybody needs the lights, and the ramp across those six degrees is what stops
* the whole floor changing state in one frame at sunset.
*/
const LIGHTS_ON_BELOW_DEG = 0;
const LIGHTS_OFF_ABOVE_DEG = 6;
export interface Walker {
position: THREE.Vector3;
}
export interface Luminaires {
/**
* How much artificial light is in this building right now, 0..1.
*
* The caller adds it to the rig; this file does not, because the rig has one
* owner. See `houseLightContribution` in `daylight.ts`.
*/
houseLevel(): number;
/** Solar elevation in degrees, from the same clock everything else follows. */
setSolarElevation(degrees: number): void;
/** Who is walking about, so fittings can respond to them. Cheap to call often. */
setWalkers(walkers: readonly Walker[]): void;
tick(dt: number): void;
dispose(): void;
}
export function createLuminaires(batches: readonly LuminaireBatch[]): Luminaires {
// Only the glowing halves respond. A fitting's housing is painted metal and
// brightening it would make the ceiling look like it was made of lamps.
const lit = batches.filter((b) => b.emissive);
/** Current and target brightness per instance, per batch. Preallocated. */
const state = lit.map((b) => ({
batch: b,
current: new Float32Array(b.positions.length),
target: new Float32Array(b.positions.length),
}));
const patched = new Set<THREE.Material>();
for (const entry of state) {
const mesh = entry.batch.mesh;
// `setColorAt` allocates the attribute on first use; doing it here means the
// per-frame path only ever writes into it.
const white = new THREE.Color(1, 1, 1);
for (let i = 0; i < entry.batch.positions.length; i += 1) mesh.setColorAt(i, white);
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
const material = mesh.material as THREE.Material;
if (patched.has(material)) continue;
patched.add(material);
/**
* Make the instance colour reach the emissive term.
*
* three multiplies `vColor` into `diffuseColor` and stops there, which for
* an object whose appearance is almost entirely emissive means
* `instanceColor` does very nearly nothing. The chunk below runs after
* `emissivemap_fragment`, which is where `totalEmissiveRadiance` has its
* final value.
*
* Guarded on `USE_INSTANCING_COLOR` so the same shared material stays
* correct for any non-instanced user of the `lightDiffuser` role.
*/
material.onBeforeCompile = (shader) => {
shader.fragmentShader = shader.fragmentShader.replace(
"#include <emissivemap_fragment>",
`#include <emissivemap_fragment>
#ifdef USE_INSTANCING_COLOR
totalEmissiveRadiance *= vColor.rgb;
#endif`,
);
};
material.needsUpdate = true;
}
let house = 0;
let walkers: readonly Walker[] = [];
const scratch = new THREE.Color();
return {
houseLevel: () => house,
setSolarElevation(degrees) {
const span = LIGHTS_OFF_ABOVE_DEG - LIGHTS_ON_BELOW_DEG;
const t = (degrees - LIGHTS_ON_BELOW_DEG) / span;
house = 1 - Math.min(1, Math.max(0, t));
},
setWalkers(next) {
walkers = next;
},
tick(dt) {
// Fittings are dark in daylight and there is nothing to interpolate, so a
// sunlit building costs one comparison rather than a pass over the ceiling.
const step = Math.min(1, dt * FADE_PER_SECOND);
for (const entry of state) {
const { positions } = entry.batch;
let changed = false;
for (let i = 0; i < positions.length; i += 1) {
const at = positions[i];
if (!at) continue;
let want = house;
if (house > 0 && walkers.length > 0) {
let nearest = Infinity;
for (const walker of walkers) {
// Horizontal distance only: a fitting is on the ceiling and the
// walker is on the floor, and including the three-metre vertical
// gap would mean nobody ever gets close enough to trigger one.
const dx = walker.position.x - at.x;
const dz = walker.position.z - at.z;
const d = Math.hypot(dx, dz);
if (d < nearest) nearest = d;
}
if (nearest < PRESENCE_RADIUS) {
// Smoothstep rather than linear, so the bright patch has a soft
// edge instead of a visible circle travelling across the ceiling.
const near = 1 - nearest / PRESENCE_RADIUS;
want += house * (PRESENCE_GAIN - 1) * near * near * (3 - 2 * near);
}
}
entry.target[i] = want;
const current = entry.current[i] ?? 0;
const next = current + (want - current) * step;
if (Math.abs(next - current) > 1e-4) {
entry.current[i] = next;
changed = true;
}
}
if (!changed) continue;
for (let i = 0; i < positions.length; i += 1) {
const v = entry.current[i] ?? 0;
scratch.setScalar(v);
entry.batch.mesh.setColorAt(i, scratch);
}
if (entry.batch.mesh.instanceColor) entry.batch.mesh.instanceColor.needsUpdate = true;
}
},
dispose() {
// The meshes and materials belong to `furnish.ts` and are disposed there.
// What is owned here is the shader patch, which has to come off or a
// material reused by the next office keeps a compile hook pointing at a
// scene that no longer exists.
for (const material of patched) {
material.onBeforeCompile = () => {};
material.needsUpdate = true;
}
patched.clear();
walkers = [];
},
};
}