/** * 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(); 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 ", `#include #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 = []; }, }; }