From af0d4a7d57ff56b38e9f5e5f15243dbb424e92f9 Mon Sep 17 00:00:00 2001 From: Kartios Date: Fri, 7 Aug 2026 00:56:49 -0700 Subject: [PATCH] The office keeps its lights on, and something walks around under them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- src/assets/office/index.ts | 3 + src/assets/office/optimus.ts | 729 +++++++++++++++++ src/engine/aircraftGeometry.ts | 472 +++++++++++ src/engine/clouds.ts | 1368 ++++++++++++++++++++++++++++++++ src/engine/flights.ts | 46 +- src/engine/scene.ts | 71 +- src/engine/starlinkMesh.ts | 813 +++++++++++++++++++ src/interiors/daylight.ts | 74 ++ src/interiors/furnish.ts | Bin 8195 -> 10280 bytes src/interiors/luminaires.ts | 235 ++++++ src/interiors/officeScene.ts | 78 ++ src/interiors/robots.ts | 1014 +++++++++++++++++++++++ src/main.ts | 56 +- 13 files changed, 4917 insertions(+), 42 deletions(-) create mode 100644 src/assets/office/optimus.ts create mode 100644 src/engine/aircraftGeometry.ts create mode 100644 src/engine/clouds.ts create mode 100644 src/engine/starlinkMesh.ts create mode 100644 src/interiors/luminaires.ts create mode 100644 src/interiors/robots.ts diff --git a/src/assets/office/index.ts b/src/assets/office/index.ts index 04e1da6..cf2dd07 100644 --- a/src/assets/office/index.ts +++ b/src/assets/office/index.ts @@ -27,6 +27,7 @@ import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts"; import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts"; import { plantPotted, plantTall } from "./greenery.ts"; import { lightPendant, lightTroffer } from "./lighting.ts"; +import { robotOptimus } from "./optimus.ts"; import { screenMonitor, screenWallDisplay } from "./screens.ts"; import { seatLounge, seatTaskChair } from "./seating.ts"; import { storageLocker, storageShelf } from "./storage.ts"; @@ -49,6 +50,7 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [ plantTall, lightPendant, lightTroffer, + robotOptimus, rug, whiteboard, ]; @@ -66,6 +68,7 @@ export { deskWorkstation, lightPendant, lightTroffer, + robotOptimus, plantPotted, plantTall, rug, diff --git a/src/assets/office/optimus.ts b/src/assets/office/optimus.ts new file mode 100644 index 0000000..89bf436 --- /dev/null +++ b/src/assets/office/optimus.ts @@ -0,0 +1,729 @@ +/** + * A Tesla Optimus, procedurally, as a rig you can pose — plus the merged static + * version for a pack that just wants one standing in a corner. + * + * This is the first asset in the library that is **not** a single merged mesh, + * and the reason is the only reason that would justify it: something has to walk + * it around the office (`interiors/robots.ts`), and a walk cycle needs limbs + * that move independently. `parts.ts` says it plainly — anything that has to + * move on its own belongs in its own object rather than in a bin — so the figure + * is eleven small bins, one per articulating joint, each merged internally and + * hung off the joint it belongs to. Eighteen meshes per robot rather than two. + * That price is stated again, in draw calls, at the bottom of this comment and + * in `robots.ts`, because it is the whole cost of the feature. + * + * ### The figure + * + * Gen 3 from reference knowledge, and the proportions are what carry it, because + * ten metres is the distance this is normally seen from and at ten metres a + * silhouette is all there is. What has to be right, in order of how much it + * matters: + * + * 1. **1.73 m and slim.** Optimus is human-height and noticeably narrower than a + * human — a 0.35 m shoulder span on a 1.73 m frame. Build it at human width + * and it reads as a person in a costume. + * 2. **The waist.** The single most identifying line: a wide pelvis, a wide + * chest, and a genuinely thin dark column between them. It is 0.15 m across + * where the pelvis is 0.28 and the chest is 0.34. Widen it and the whole + * thing turns into a mannequin. + * 3. **The visor.** A smooth black panel filling the front of a small pale head, + * with no features on it at all. Two eyes, a mouth line, a "friendly" curve — + * any of them and it stops being Optimus. + * 4. **Pale shells over a dark frame.** Every limb is a light shell that stops + * short of the joint, with dark structure showing in the gap. That gap is + * what makes it read as a machine rather than as a white plastic doll, and it + * costs nothing but a few millimetres of geometry. + * 5. The knee actuator, the shoulder caps, the five-fingered hands. Detail, not + * silhouette. Present because they are cheap, not because they are load- + * bearing. + * + * ### Two materials, and why not three + * + * `paper` for the shells and `screenBezel` for the dark frame and the visor. + * Both are borrowed — `materials.ts` has a closed role list with no robot in it, + * and inventing a role is not on offer — so the borrow is chosen to survive a + * self-hoster recolouring the palette. `paper` is the library's palest + * untextured neutral, which is exactly what an Optimus shell is; `screenBezel` + * is its near-black, and the visor genuinely *is* a bezel, so a self-hoster who + * darkens their screen surrounds darkens the robot's face, which is the right + * coupling rather than a coincidental one. + * + * `metalTrim` was tried as a third material for the joint barrels and dropped. + * The office rig carries no environment map, so a `metalness: 0.85` role has + * nothing to reflect and renders as a dull dark grey — indistinguishable from + * `screenBezel` at ten metres — while costing another mesh in nine of the eleven + * groups. Two materials, eighteen meshes. + * + * ### The indexed/non-indexed rule bites here harder than anywhere else + * + * `common.ts` warns that every part under one material must be all-indexed or + * all-non-indexed or `mergeGeometries` silently drops the material. With + * eighteen bins there are eighteen chances to get it wrong and the symptom is an + * invisible shin, so the split is made structural rather than remembered: + * + * - **`shell` is drawn with `roundedBox()` and nothing else.** `ExtrudeGeometry`, + * never indexed. Every pale part of the robot is a rounded box, which is also + * what Optimus actually looks like — flat-sided shells with softened edges, + * not tubes. + * - **`frame` is drawn with `box()` and `cylinder()` and nothing else.** Both + * indexed. This is why the visor is a flat box and not a rounded one: it is + * drawn in the dark material, so it may not be an extrusion. + * + * Add a part and put it in the material whose primitive class it already + * belongs to. If you cannot, you want the *other* material, and it will usually + * turn out to look better there anyway. + * + * ### The joint frame + * + * The figure faces **−Z at yaw 0**, same as every other asset (`common.ts`), and + * the origin is on the floor between the feet. Consequences worth writing down, + * because getting a sign wrong here produces a robot that walks backwards + * through its own knees: + * + * - **+X is the robot's right, −X its left.** Stand behind it looking the way it + * faces — which is looking down −Z, the three.js default view direction — and + * its right hand is on your right, which is +X. So `hipR` is at `x > 0`. + * - **Every joint group's local axes are the root's axes.** No rotation is baked + * into a joint; a rig with every rotation at zero is a figure standing to + * attention. `OPTIMUS_REST` is applied on top of that, by `buildOptimus`, and + * is what "standing still" means to the walk cycle. + * - **`rotation.x` is the sagittal hinge and positive swings the limb forward.** + * A limb hangs along −Y; `Rx(θ)` sends (0,−1,0) to (0,−cos θ,−sin θ), and −Z + * is the front. So `hipL.rotation.x = +0.4` is a leg reaching forward and + * `shoulderR.rotation.x = +0.4` is a hand swung forward. + * - **A knee bends the other way, so `knee.rotation.x` must stay ≤ 0.** The shin + * folds backwards, toward +Z. Positive values hyperextend it, and because + * nothing clamps them, a sign slip in an animation shows up as a robot with + * its knees on backwards rather than as an error. This is the asymmetry to + * remember: hip, shoulder and elbow all bend positive; the knee is the one + * that bends negative. + * - **An elbow bends positive.** The forearm folds forward, toward the chest, + * which is −Z. It shares its sign with the shoulder and not with the knee. + * - **`rotation.y` is the transverse twist and positive turns left** + * (counter-clockwise from above — the house `Yaw` sense, unconverted). Used on + * `pelvis` and `torso` for the counter-rotation of a walk. + * - **`rotation.z` is the frontal lean and positive leans left.** `Rz(θ)` sends + * up (0,1,0) toward −X. Used on `torso` for sway and on `shoulder` for the + * outward splay that keeps the hands off the hips. + * - **`pelvis.position.y` is the body's height above the floor** and starts at + * `OPTIMUS.hipY`. Everything else hangs off it, feet included, so lowering the + * pelvis lowers the whole robot — which is what a walk's vertical oscillation + * wants and is why the bob is applied there and nowhere else. + * + * ### What it costs + * + * Eighteen meshes and about 7,600 triangles per figure. `buildOptimus` is + * meant to be called **once**; `cloneOptimus` gives you another figure sharing + * every geometry and both materials, which is how four robots cost four times + * the draw calls but one times the memory. Seventy-two draw calls for a crowd of + * four is real money against an office that draws in about thirty, and it is the + * number to look at first if a floor starts dropping frames. + * + * The `tera:robot.optimus` asset is the other end of that trade: it flattens the + * same rig into one mesh per material, so a pack that wants a robot standing + * still gets two draw calls and no articulation. `furnishings.ts` instances per + * mesh, so twenty static robots in a pack still cost those two. + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { defineAsset, type AssetContext } from "../kit.ts"; +import type { SurfaceMaterial } from "../materials.ts"; +import { MeshBin, type PartBin } from "../parts.ts"; + +/** + * Every dimension the figure and the walk cycle both have to agree on, in + * metres above the sole, in the rest pose. + * + * Exported because `robots.ts` needs `hipY` to bob the pelvis around and would + * otherwise carry a second copy of a number that must not drift. Everything + * else — shell widths, bevel radii, how far a finger sticks out — is local to + * the emitters below and deliberately not part of the contract. + */ +export const OPTIMUS = { + /** Sole to crown. Gen 3 is quoted at about 1.73 m. */ + height: 1.73, + ankleY: 0.085, + kneeY: 0.5, + hipY: 0.92, + /** Top of the pelvis shell; the waist column starts here and the torso pivots here. */ + waistY: 1.05, + /** Bottom of the chest shell. The 0.10 m between this and `waistY` is the slim bit. */ + chestY: 1.15, + elbowY: 1.12, + wristY: 0.865, + shoulderY: 1.4, + /** Top of the chest shell, where the neck column starts. */ + neckY: 1.43, + /** The head's pivot: the top of the neck, so a nod hinges where a neck does. */ + headY: 1.505, + /** Half the distance between the two hip pivots. A narrow stance; Optimus has one. */ + hipHalf: 0.085, + /** Half the distance between the two shoulder pivots. */ + shoulderHalf: 0.175, +} as const; + +/** + * What "standing still" is, in radians, applied by `buildOptimus` and used as + * the base every animated angle is added to. + * + * These are not zero because a figure with every joint at zero stands with its + * arms welded to its hips and its legs locked straight, which looks like a + * mannequin rather than like a machine that is idling. A few degrees of elbow + * and a few of outward arm splay is the whole difference. + * + * `shoulderZ` is a magnitude: it is applied as `+` on the right shoulder and `−` + * on the left, because positive `rotation.z` swings a hanging arm toward +X and + * +X is the robot's right — so the same sign splays one arm out and tucks the + * other one in. + * + * `hipX` and `kneeX` cancel, and that is not a coincidence: nothing here moves + * the ankle, so the foot's pitch is the *sum* of the two, and any sum but zero + * stands the robot on its heels or its toes. The first draft had a 0.01 hip and + * a −0.05 knee and buried six millimetres of toe in the carpet. Change one of + * these and change the other. The residue is that a bent leg is fractionally + * shorter than a straight one, so the soles float about a millimetre — which is + * a millimetre, and cheaper than a third joint to correct it. + */ +export const OPTIMUS_REST = { + hipX: 0.04, + /** Negative, and exactly −`hipX`. A knee only bends one way; see above. */ + kneeX: -0.04, + shoulderX: 0.03, + shoulderZ: 0.075, + elbowX: 0.16, +} as const; + +/** + * The joints a walk cycle drives, resolved once so an animation never has to + * search the scene graph per frame. + * + * `pelvis` carries the legs and the body's height; `torso` is a child of + * `pelvis` and carries everything above the waist, which is what lets the + * shoulders counter-rotate against the hips without the legs coming with them. + */ +export interface OptimusJoints { + /** Body height and hip twist. Pivots at `OPTIMUS.hipY`; the legs hang off it. */ + pelvis: THREE.Group; + /** Sway, lean and shoulder counter-twist. Pivots at the waist. */ + torso: THREE.Group; + head: THREE.Group; + hipL: THREE.Group; + hipR: THREE.Group; + kneeL: THREE.Group; + kneeR: THREE.Group; + shoulderL: THREE.Group; + shoulderR: THREE.Group; + elbowL: THREE.Group; + elbowR: THREE.Group; +} + +export interface OptimusRig { + /** Origin on the floor between the feet, facing −Z at `rotation.y === 0`. */ + root: THREE.Group; + joints: OptimusJoints; +} + +/** The two materials the whole figure is drawn in. See the header for the borrow. */ +interface Skin { + shell: SurfaceMaterial; + frame: SurfaceMaterial; +} + +/** A point in whichever joint frame the emitter is drawing into. */ +interface Anchor { + x: number; + y: number; + z: number; +} + +const JOINT_NAMES = [ + "pelvis", + "torso", + "head", + "hipL", + "hipR", + "kneeL", + "kneeR", + "shoulderL", + "shoulderR", + "elbowL", + "elbowR", +] as const; + +// ---- Emitters ------------------------------------------------------------- +// +// Each of these draws one body part into a bin, in that bin's own frame, with +// `a` naming the joint centre the part hangs from. They are written this way so +// that the rig and the flattened asset are the *same* geometry rather than two +// bodies that will drift apart the first time somebody widens a shin. + +/** + * A dark joint barrel lying across the body, centred on `a`. + * + * A unit cylinder stands along +Y with its base at the placement point, so a + * roll of +π/2 sends its length along −X — which means the placement has to sit + * half a length to the *right* of where the barrel should end up. Getting that + * offset wrong puts every joint on the robot half a barrel off-centre, which is + * subtle enough to survive a first look and obvious enough to ruin the second. + */ +function barrel( + bin: MeshBin, + P: PartBin, + material: SurfaceMaterial, + a: Anchor, + diameter: number, + length: number, +): void { + bin.add(P.cylinder(12), material, { + x: a.x + length / 2, + y: a.y, + z: a.z, + size: [diameter, length, diameter], + roll: Math.PI / 2, + }); +} + +/** The pelvis block, and the hip axle the legs turn on. Drawn in the pelvis frame. */ +function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void { + // Straddles the hip line: down to 0.855 m and up to the waist at 1.05 m. The + // width is what the slim waist above it is measured against. + bin.add(P.roundedBox(0.07), s.shell, { y: -0.065, size: [0.28, 0.195, 0.19] }); + // The axle runs right through and out both sides, so there is dark structure + // visible in the gap between the pelvis shell and the top of each thigh. + barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.05); +} + +/** + * Waist, chest, shoulder caps and neck. Drawn in the torso frame, whose origin + * is the waist at `OPTIMUS.waistY`. + * + * The shoulder caps live here rather than on the arms on purpose, and it is not + * only a draw-call saving: on the real machine the cap is bodywork bolted to the + * torso and the arm swings inside it. Putting the cap on the arm makes the whole + * shoulder rotate when an arm swings, which reads as a shrug. + */ +function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void { + const waist = OPTIMUS.chestY - OPTIMUS.waistY; + const chest = OPTIMUS.neckY - OPTIMUS.chestY; + + // The waist. 0.15 m across between a 0.28 m pelvis and a 0.34 m chest, and it + // is drawn in the dark material so the gap reads as structure rather than as + // a robot that skipped lunch. + bin.add(P.box(), s.frame, { y: -0.02, size: [0.15, waist + 0.04, 0.135] }); + + // Chest: a shell, then a plate a few millimetres proud of it. The plate is + // what catches the light and gives the chest an edge at ten metres; without it + // the torso is one flat pale slab. + bin.add(P.roundedBox(0.06), s.shell, { + y: waist, + size: [0.34, chest, 0.205], + }); + bin.add(P.roundedBox(0.055), s.shell, { + y: waist + 0.045, + z: -0.098, + size: [0.245, chest - 0.09, 0.022], + }); + + // The neck column, dark, running from the top of the chest into the head. + bin.add(P.cylinder(10), s.frame, { + y: OPTIMUS.neckY - OPTIMUS.waistY, + size: [0.072, OPTIMUS.headY - OPTIMUS.neckY + 0.012, 0.072], + }); + + const shoulderY = OPTIMUS.shoulderY - OPTIMUS.waistY; + for (const side of [-1, 1]) { + // The cap. Rounded hard, because a square shoulder is the other thing that + // makes a humanoid read as a costume. + bin.add(P.roundedBox(0.09), s.shell, { + x: side * OPTIMUS.shoulderHalf, + y: shoulderY - 0.078, + size: [0.13, 0.156, 0.15], + }); + // The ring the arm turns in, poking out beyond the cap. + barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.092, 0.15); + } +} + +/** + * The head: a small pale shell and a black visor. Drawn in the head frame, whose + * origin is the top of the neck. + * + * The visor is a plain `box` because the dark material is the indexed one — see + * the primitive-class rule in the header, which is why the one part of this + * figure most deserving of a soft edge does not get one. + * + * It sits about 3 mm *into* the flat middle of the face and about 1.5 mm *proud* + * of the curve at the edges, because the shell's corners round away in Z while + * the visor stays flat. That is not a compromise, it is the effect: a panel let + * into a face and wrapping round the sides of it. Sunk flush all the way across + * — which was the first version — it reads as a black rectangle painted on. The + * two small yawed side pieces finish the wrap. + */ +function emitHead(bin: MeshBin, P: PartBin, s: Skin): void { + const base = OPTIMUS.height - OPTIMUS.headY - 0.215; + bin.add(P.roundedBox(0.08), s.shell, { y: base, size: [0.165, 0.215, 0.185] }); + + const visorY = base + 0.082; + bin.add(P.box(), s.frame, { y: visorY, z: -0.079, size: [0.132, 0.075, 0.02] }); + for (const side of [-1, 1]) { + bin.add(P.box(), s.frame, { + x: side * 0.072, + y: visorY, + z: -0.062, + size: [0.05, 0.072, 0.018], + yaw: side * 0.62, + }); + } +} + +/** A thigh. Drawn in the hip frame; the shell stops short at both ends. */ +function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void { + const drop = OPTIMUS.hipY - OPTIMUS.kneeY; + // Two stacked shells rather than one, for the taper. A parallel-sided thigh is + // the difference between "slim humanoid" and "stilts". + bin.add(P.roundedBox(0.07), s.shell, { y: -drop * 0.52, size: [0.135, drop * 0.46, 0.165] }); + bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.045, size: [0.112, drop * 0.5, 0.14] }); +} + +/** + * Shin, ankle and foot, plus the knee actuator. Drawn in the knee frame. + * + * The actuator is the one piece of detail on the legs that is worth its + * geometry: a dark barrel across the front of the knee is the single most + * recognisable thing about an Optimus leg, and it is one cylinder. + */ +function emitShin(bin: MeshBin, P: PartBin, s: Skin): void { + const drop = OPTIMUS.kneeY - OPTIMUS.ankleY; + barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125); + + bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] }); + // The ankle, dark, in the gap the shin shell leaves above the foot. + bin.add(P.box(), s.frame, { y: -drop + 0.005, size: [0.072, 0.03, 0.085] }); + + // The foot. The sole is a separate dark slab so the robot has something to + // stand on that is not the same colour as its shins — a monochrome foot + // dissolves into a pale floor. + const sole = -OPTIMUS.kneeY; + bin.add(P.box(), s.frame, { y: sole, z: -0.035, size: [0.098, 0.014, 0.25] }); + bin.add(P.roundedBox(0.06), s.shell, { + y: sole + 0.014, + z: -0.035, + size: [0.106, 0.052, 0.243], + }); +} + +/** An upper arm. Drawn in the shoulder frame; the cap is on the torso. */ +function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void { + const drop = OPTIMUS.shoulderY - OPTIMUS.elbowY; + bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.048, size: [0.088, drop - 0.098, 0.098] }); +} + +/** + * Forearm, wrist and a five-fingered hand. Drawn in the elbow frame. + * + * The fingers are pale rather than dark, with only the knuckle bar in the frame + * material. At ten metres a hand reads from its outline, so what matters is that + * there are five of something and that they are separate — not what colour the + * gaps between them are, which is a decision worth about forty triangles of + * detail nobody will ever resolve. + * + * This is the one emitter that has to know which side it is on, because a thumb + * is the only part of the figure that is not left-right symmetric. It goes + * medial — toward the body — which is where a relaxed arm puts it, and which + * means `side` flips its sign. Drawing both thumbs at a fixed `+x`, as the first + * version did, gives a robot with two right hands and a bounding box 27 mm wider + * on one side than the other. + */ +function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void { + const drop = OPTIMUS.elbowY - OPTIMUS.wristY; + barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088); + bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] }); + bin.add(P.box(), s.frame, { y: -drop - 0.012, size: [0.062, 0.024, 0.07] }); + + // Palm, then four fingers and a thumb set off to the side and turned in. + const palmTop = -drop - 0.012; + bin.add(P.roundedBox(0.075), s.shell, { y: palmTop - 0.078, size: [0.068, 0.078, 0.032] }); + for (let i = 0; i < 4; i++) { + bin.add(P.roundedBox(0.08), s.shell, { + x: (i - 1.5) * 0.017, + y: palmTop - 0.148, + size: [0.014, 0.07, 0.024], + }); + } + bin.add(P.roundedBox(0.08), s.shell, { + x: -side * 0.03, + y: palmTop - 0.088, + z: -0.02, + size: [0.017, 0.056, 0.026], + roll: side * 0.5, + }); +} + +// ---- The rig -------------------------------------------------------------- + +/** A named, empty joint at a position in its parent's frame. */ +function joint(parent: THREE.Object3D, name: string, x: number, y: number): THREE.Group { + const group = new THREE.Group(); + group.name = name; + group.position.set(x, y, 0); + parent.add(group); + return group; +} + +/** Merge a joint's parts and hang them off it, if it has any. */ +function attach(target: THREE.Group, bin: MeshBin): void { + if (bin.size === 0) return; + target.add(bin.build(`${target.name}.mesh`)); +} + +/** + * Build one posable Optimus. + * + * Call this **once** and `cloneOptimus` for every figure after the first: a + * clone shares every geometry and both materials, so a crowd costs draw calls + * and nothing else. Whoever built the original disposes it with + * `disposeOptimus`, which frees the geometry the clones are all pointing at — + * so dispose last, and dispose exactly once. + * + * The materials come from `ctx.materials` and belong to the registry. Nothing + * here disposes them. + */ +export function buildOptimus(ctx: AssetContext): OptimusRig { + const P = ctx.parts; + const skin: Skin = { + shell: ctx.materials.get("paper"), + frame: ctx.materials.get("screenBezel"), + }; + + const root = new THREE.Group(); + root.name = "optimus"; + + // Pelvis, and the legs hanging off it. The legs are children of the pelvis so + // that dropping the pelvis drops the whole robot — see the header note on + // `pelvis.position.y`. + const pelvis = joint(root, "pelvis", 0, OPTIMUS.hipY); + const pelvisBin = new MeshBin(); + emitPelvis(pelvisBin, P, skin); + attach(pelvis, pelvisBin); + + const torso = joint(pelvis, "torso", 0, OPTIMUS.waistY - OPTIMUS.hipY); + const torsoBin = new MeshBin(); + emitTorso(torsoBin, P, skin); + attach(torso, torsoBin); + + const head = joint(torso, "head", 0, OPTIMUS.headY - OPTIMUS.waistY); + const headBin = new MeshBin(); + emitHead(headBin, P, skin); + attach(head, headBin); + + // `side` is −1 for the robot's left and +1 for its right, which is the sign of + // X: see the joint-frame note. The two limbs are mirror images in position + // only — the shells themselves are symmetric, so there is no mirrored + // geometry and no wound-backwards triangles to worry about. + for (const side of [-1, 1]) { + const suffix = side < 0 ? "L" : "R"; + + const hip = joint(pelvis, `hip${suffix}`, side * OPTIMUS.hipHalf, 0); + const hipBin = new MeshBin(); + emitThigh(hipBin, P, skin); + attach(hip, hipBin); + + const knee = joint(hip, `knee${suffix}`, 0, OPTIMUS.kneeY - OPTIMUS.hipY); + const kneeBin = new MeshBin(); + emitShin(kneeBin, P, skin); + attach(knee, kneeBin); + + const shoulder = joint( + torso, + `shoulder${suffix}`, + side * OPTIMUS.shoulderHalf, + OPTIMUS.shoulderY - OPTIMUS.waistY, + ); + const shoulderBin = new MeshBin(); + emitUpperArm(shoulderBin, P, skin); + attach(shoulder, shoulderBin); + + const elbow = joint(shoulder, `elbow${suffix}`, 0, OPTIMUS.elbowY - OPTIMUS.shoulderY); + const elbowBin = new MeshBin(); + emitForearm(elbowBin, P, skin, side); + attach(elbow, elbowBin); + } + + const rig: OptimusRig = { root, joints: optimusJoints(root) }; + restOptimus(rig.joints); + return rig; +} + +/** + * Resolve the joints of a rig root by name. + * + * Exported because `Object3D.clone(true)` copies names but hands back plain + * `Object3D`s with no idea which of them is a knee, so a clone has to be + * re-resolved. Throws rather than returning null: a root with no `kneeL` in it + * is not a rig, and the caller has nothing useful to do about that at runtime. + */ +export function optimusJoints(root: THREE.Object3D): OptimusJoints { + const found = {} as Record<(typeof JOINT_NAMES)[number], THREE.Group>; + for (const name of JOINT_NAMES) { + const object = root.getObjectByName(name); + if (!object) throw new Error(`optimus: rig has no joint named "${name}"`); + found[name] = object as THREE.Group; + } + return found; +} + +/** + * Put every joint back to the idle stance. Called by `buildOptimus`, and by an + * animation that wants a figure to stop moving without writing the eleven + * assignments out itself. + */ +export function restOptimus(j: OptimusJoints): void { + j.pelvis.position.y = OPTIMUS.hipY; + j.pelvis.rotation.set(0, 0, 0); + j.torso.rotation.set(0, 0, 0); + j.head.rotation.set(0, 0, 0); + j.hipL.rotation.set(OPTIMUS_REST.hipX, 0, 0); + j.hipR.rotation.set(OPTIMUS_REST.hipX, 0, 0); + j.kneeL.rotation.set(OPTIMUS_REST.kneeX, 0, 0); + j.kneeR.rotation.set(OPTIMUS_REST.kneeX, 0, 0); + // The splay is mirrored: positive `rotation.z` swings a hanging arm toward + // +X, so the right arm needs `+` and the left arm `−` to both move outward. + j.shoulderL.rotation.set(OPTIMUS_REST.shoulderX, 0, -OPTIMUS_REST.shoulderZ); + j.shoulderR.rotation.set(OPTIMUS_REST.shoulderX, 0, OPTIMUS_REST.shoulderZ); + j.elbowL.rotation.set(OPTIMUS_REST.elbowX, 0, 0); + j.elbowR.rotation.set(OPTIMUS_REST.elbowX, 0, 0); +} + +/** + * Another figure sharing the first one's geometry and materials. + * + * `Object3D.clone(true)` copies the hierarchy, the names and the transforms and + * *references* geometry and material, which is exactly the sharing wanted here — + * so do not dispose a clone. Dispose the original, once, with `disposeOptimus`. + */ +export function cloneOptimus(rig: OptimusRig): OptimusRig { + const root = rig.root.clone(true); + return { root, joints: optimusJoints(root) }; +} + +/** + * Free a rig's geometry. + * + * Materials are the registry's and are left alone, exactly as every asset in + * this directory leaves them alone. Call this on the rig `buildOptimus` + * returned, never on a clone, and only once everything cloned from it is out of + * the scene. + */ +export function disposeOptimus(rig: OptimusRig): void { + rig.root.traverse((child) => { + const mesh = child as THREE.Mesh; + if (mesh.isMesh) mesh.geometry.dispose(); + }); +} + +// ---- The static asset ----------------------------------------------------- + +/** + * Collapse a posed rig into one mesh per material. + * + * The rig's own geometries are baked at their world transforms and disposed, so + * what comes back owns everything it points at and can go through `MeshBin`'s + * usual life cycle. This is safe only because of the primitive-class rule in the + * header: `paper` is all extrusions and `screenBezel` is all indexed + * primitives, so neither merge can hit the mixed-index refusal that would drop a + * material and leave a robot with no shells on it. + */ +function flatten(rig: OptimusRig, name: string): THREE.Group { + rig.root.updateMatrixWorld(true); + + const byMaterial = new Map(); + rig.root.traverse((child) => { + const mesh = child as THREE.Mesh; + if (!mesh.isMesh || Array.isArray(mesh.material)) return; + const baked = mesh.geometry.clone().applyMatrix4(mesh.matrixWorld); + const list = byMaterial.get(mesh.material); + if (list) list.push(baked); + else byMaterial.set(mesh.material, [baked]); + }); + disposeOptimus(rig); + + const group = new THREE.Group(); + group.name = name; + for (const [material, list] of byMaterial) { + const merged = list.length === 1 ? list[0] : mergeGeometries(list, false); + if (list.length > 1) for (const geometry of list) geometry.dispose(); + if (!merged) continue; + const mesh = new THREE.Mesh(merged, material); + mesh.name = `${name}:${material.name || "material"}`; + mesh.castShadow = true; + mesh.receiveShadow = true; + group.add(mesh); + } + return group; +} + +type OptimusParams = { + /** Sole to crown, metres. The whole figure scales; 1.73 is the real one. */ + height: number; + /** + * The stance to bake in. `"rest"` stands to attention, `"stride"` freezes it + * mid-step so a pack can put one in a corridor without it looking parked. + */ + pose: "rest" | "stride"; +}; + +/** + * One Optimus, standing still, merged. + * + * Two draw calls, no articulation, and `furnishings.ts` instances it per mesh — + * so a pack with twenty of these still pays those two. Anything that has to walk + * wants `buildOptimus` instead, and pays eighteen. + * + * There is no `colorKey` and no tintable part. A robot that comes in a team + * colour is a mascot; this is a machine, and the two things it can be are the + * colour of its shells and the colour of its frame, both of which belong to the + * palette rather than to one instance. + */ +export const robotOptimus = defineAsset({ + id: "tera:robot.optimus", + label: "Optimus humanoid robot", + defaults: { height: OPTIMUS.height, pose: "rest" }, + + footprint(p) { + const scale = p.height / OPTIMUS.height; + // Measured off the built figure rather than guessed: 0.534 m across the + // splayed hands and 0.279 m from toe to heel, rounded up. The arms are the + // widest part of a standing humanoid and the feet are the deepest — not the + // shoulders and not the chest, which is what you would reach for. + return { width: 0.55 * scale, depth: 0.3 * scale, height: p.height, clearance: 0.35 }; + }, + + build(p, ctx) { + const rig = buildOptimus(ctx); + if (p.pose === "stride") { + // A single frame of the walk cycle, written out rather than shared with + // `robots.ts`: this is a fixed pose for a static prop, that is a function + // of distance travelled, and coupling them would mean a pack's decorative + // robot changing shape whenever somebody retunes a gait. + rig.joints.hipL.rotation.x = 0.36; + rig.joints.hipR.rotation.x = -0.3; + rig.joints.kneeL.rotation.x = -0.12; + rig.joints.kneeR.rotation.x = -0.42; + rig.joints.shoulderL.rotation.x = -0.26; + rig.joints.shoulderR.rotation.x = 0.28; + rig.joints.elbowL.rotation.x = 0.2; + rig.joints.elbowR.rotation.x = 0.34; + rig.joints.torso.rotation.y = -0.07; + rig.joints.pelvis.rotation.y = 0.04; + // Dropped by the amount the straddle costs, and no further: the pose is + // hand-written, so nothing checks that the feet reach the floor except + // measuring the result. At −0.022 the leading toe was 2 mm under it. + rig.joints.pelvis.position.y = OPTIMUS.hipY - 0.019; + } + + const group = flatten(rig, "robot.optimus"); + if (p.height !== OPTIMUS.height) group.scale.setScalar(p.height / OPTIMUS.height); + return group; + }, +}); diff --git a/src/engine/aircraftGeometry.ts b/src/engine/aircraftGeometry.ts new file mode 100644 index 0000000..cf018c7 --- /dev/null +++ b/src/engine/aircraftGeometry.ts @@ -0,0 +1,472 @@ +/** + * The aircraft over the city, as an aircraft. + * + * `flights.ts` has drawn traffic as a dart since the layer existed — a five-sided + * cone with a crossbar for a wing — and the dart was the right first answer, + * because the only thing a speck over a city has to communicate is *which way it + * is going*. It is the wrong last answer for one reason: the sky is the part of + * this scene a person looks at on purpose. Buildings do not move. A dozen darts + * crossing a coastline at three altitudes are the only thing on the board with + * anything happening to it, and they are worth more than eleven triangles. + * + * So: a swept-wing airliner, seen from where it is actually seen from. + * + * ### The one view that matters + * + * The camera on this board sits somewhere between a close chapter and two board + * spans out, and it is nearly always *above* the traffic — an aircraft at cruise + * is ~230 scene units up over a downtown whose tallest tower is 10, so even a low + * camera looks up at a shallow angle and a high one looks straight down. The + * silhouette from above is therefore the whole design, and the parts that carry + * it are, in order: **wing sweep, wing taper, the two engines, the tailplane.** + * A fuselage from above is a stripe. A fin from above is nothing at all. + * + * That ordering is why the budget goes where it does. The aerofoils — wings, fin, + * tailplanes, pylons — are flat sheets costing four triangles each, because + * thickness on a wing 0.44 units across is invisible at every distance this is + * ever seen from, and the planform is not. The engines get 24 triangles apiece, + * which is a quarter of the whole aircraft for two pods 0.03 units wide, because + * from above the engines are half of what makes a jet look like a jet. + * + * ### Scale: this is an icon, not a model + * + * `world.metresPerUnit` is ~94 m on the San Francisco board and ~391 m on the + * SoCal one, and the dart's 0.42 units are the same 0.42 units on both. Over San + * Francisco that is a 40 m aeroplane, which is an A320 to the metre and a happy + * accident; over SoCal it is a 164 m aeroplane, four times life size. Nobody has + * ever noticed, and nobody should: a truthfully-scaled airliner on the SoCal + * board would be a tenth of a unit long against a board a thousand units across, + * i.e. gone. Traffic here is a **map symbol drawn in 3-D**, sized for legibility + * and oriented for truth, and the size is deliberately not a measurement. + * + * The consequence for this file is that the dart's bounding box is the spec. + * This geometry is 0.42 long, 0.44 across and 0.145 tall against the dart's + * 0.42 × 0.44 × 0.18 — the same footprint, slightly shorter, because a dart's + * "height" was a fat cone and an airliner's is a fin. Nothing about the city's + * apparent scale changes. If a future city pack wants them smaller, scale the + * returned geometry once at the call site (`geo.scale(s, s, s)`); do not rescale + * per mesh, which would give every aircraft its own matrix for no reason. + * + * Related, and load-bearing in the same way: the fuselage is about 30% fatter + * than a real one relative to its length (radius 0.030 where proportion says + * 0.023), and the nacelles more so. Truthful proportions were tried and read as + * a thread with two dots on it the moment the camera pulled back past a board + * span. The exaggeration is the same one a model kit makes and for the same + * reason. + * + * ### Facing, and the bug this file must not reintroduce + * + * **Everything here is built nose along +Z.** `flights.ts` sets + * `mesh.rotation.y = Math.PI - heading`, which for a heading of 0 (north) is a + * half turn, and a half turn sends +Z to −Z, which is north in this scene. That + * mapping only works for a nose modelled along +Z, and its comment records the + * cost of getting it wrong: a bare negation of the heading once flew every + * aircraft tail-first and put departures out over the Pacific. Build the nose at + * −Z and every aeroplane on the board flies backwards, at 450 knots, forever, + * and it looks *almost* right — which is why it survived a review the first time. + * + * The origin is the point `flights.ts` positions and rotates about, so it sits + * mid-fuselage rather than at the nose: the layer also applies a pitch about X + * (`rotation.order = "YXZ"`), and an aircraft pitching about its nose swings its + * tail through a quarter of its own length. + * + * ### The triangle ledger + * + * 400 of these can be on screen at once when the godmode traffic dial is up, each + * one a separate `THREE.Mesh` and therefore a separate draw call over a *shared* + * geometry. The draw calls are `flights.ts`'s problem; the triangles are this + * file's, and they come to **100**: + * + * nose cone 6 6 sides, open-ended + * fuselage tube 12 6 sides, open-ended + * tail cone 6 6 sides, open-ended, sheared up + * wings 2 × 4 flat sheets, two faces each + * fin 4 + * tailplanes 2 × 4 + * engine pylons 2 × 4 + * nacelles 2 × 24 6 sides, both ends capped + * + * Nine times the dart's eleven, at 400 aircraft is 40k triangles — under one + * frame's worth of the terrain mesh, and the vertex work is not what is + * expensive about 400 objects anyway. The parts that were considered and cut for + * costing more than they show: winglets (edge-on from the only angle that + * matters), an engine fan face (a 0.03-unit disc), windows and a livery stripe + * (they need vertex colours or a texture, and the material here is a shared + * per-altitude-band Lambert with neither). + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; + +/** + * Sides on every body of revolution: the fuselage and the two nacelles. + * + * Six, and it can stay six because `CylinderGeometry` and `ConeGeometry` give + * their sides *radial* normals — adjacent faces share a normal at each seam — so + * a Lambert hexagon shades as a smooth tube rather than as six flats. The + * silhouette is a hexagon and nothing at this scale can tell. + * + * It also has to be the *same* six everywhere along the fuselage. The nose cone, + * the tube and the tail cone are three open-ended surfaces butted rim to rim + * with no caps between them, which is only watertight because all three rims are + * hexagons of identical radius at identical angles. Change one of them to eight + * sides and the joint becomes a ring of gaps you can see the inside of the + * aeroplane through — backfaces are culled, so it reads as a hole punched + * straight out to the city below rather than as a modelling error. + */ +const SIDES = 6; + +/** + * Fuselage radius, and the station of every joint along it, in scene units. + * + * Nose tip at +0.21 and tail tip at −0.21: a 0.42-unit aeroplane centred on its + * own origin, matching the dart it replaces. + * + * +0.210 nose tip + * +0.152 nose base / tube front ─┐ one radius, three surfaces, + * −0.055 tube rear / tail cone base ─┘ no caps (see SIDES) + * −0.210 tail tip + */ +const RADIUS = 0.03; +const NOSE_TIP_Z = 0.21; +const NOSE_BASE_Z = 0.152; +const TAIL_JOINT_Z = -0.055; +const TAIL_TIP_Z = -0.21; + +/** + * How steeply the tail cone sweeps up, as a gradient (rise per unit of z). + * + * Every airliner's tail cone kicks upward — it is where the aeroplane rotates + * about on take-off and it has to clear the runway — and it is one of the few + * cues in the side view that says "airliner" rather than "aeroplane". 0.09 lifts + * the tail tip by 0.014, about half a fuselage radius, which is roughly a real + * one at 5°. + * + * Applied as a **shear** rather than by rotating the cone, and the difference + * matters. A rotated cone pivots its whole base rim out of the z-plane it shares + * with the tube's rear rim, opening a sliver of gap round most of the joint — the + * same see-through hole described under `SIDES`, just subtler. A shear moves + * points in y as a function of z only, so every cross-section stays exactly where + * it was and the joint (where the shear evaluates to zero) stays exactly closed. + * + * Shearing does invalidate normals, which is the usual reason not to do this by + * hand. `BufferGeometry.applyMatrix4` transforms them by the inverse-transpose + * and renormalises, so the shaded surface comes out right; a manual pass over the + * position attribute would not, and the tail would light as though it were still + * straight. + */ +const TAIL_UPSWEEP = 0.09; + +/** + * A flat aerofoil: four corners, and no thickness at all. + * + * Every wing, fin, tailplane and pylon on this aircraft is one of these. A wing + * with real thickness costs 12 triangles instead of 4 to show a 0.006-unit edge + * that is sub-pixel at every distance the traffic layer is ever viewed from, and + * an airliner's planform — where the sweep starts, how hard it tapers, where the + * engines hang on it — is the entire recognisable content of the shape from + * above. + * + * **Both faces are emitted, wound opposite ways with opposed normals.** That is + * not decoration and it is not `side: DoubleSide` in disguise: + * + * - The material belongs to `flights.ts`, is shared with the fuselage, and is + * front-side by default. A single-sided wing is *invisible* from below — + * which is where the camera is for any aircraft between it and the sun. + * - Two coincident faces sound like z-fighting and are not. Backface culling + * picks exactly one of them for any viewpoint: from above the top face is + * front-facing and the bottom is culled, from below the reverse. They are + * never both rasterised, so there is nothing to fight. + * - A `DoubleSide` material would light the underside with the *upper* normal + * (three.js flips it for backfaces, but only in the shader, and only for the + * lighting term — which then makes the belly of the wing exactly as bright as + * the sunlit top). Two real faces with two real normals give a dark + * underside, which is what an aeroplane looks like. + * + * The corners are given in order round the polygon and must be **planar** — + * every quad in this file is, because each one's y varies linearly with x + * (dihedral) or not at all. One face normal is computed from the diagonals and + * shared by all four corners, so the sheet shades flat and exactly. + * + * Winding does not actually matter here (both faces exist either way, so getting + * it backwards swaps which one is called "front" and changes nothing visible), + * which is what makes mirroring a wing safe. + * + * UVs are emitted, and nothing samples them. They are here because + * `mergeGeometries` refuses — returns `null`, silently, for the whole aircraft — + * if the geometries handed to it do not all carry the *same set* of attributes. + * `ConeGeometry` and `CylinderGeometry` bring position, normal and uv, so these + * must too. + */ +type Point = readonly [number, number, number]; +type Quad = readonly [Point, Point, Point, Point]; + +function aerofoil(quad: Quad): THREE.BufferGeometry { + const [a, b, c, d] = quad; + // Diagonals rather than two edges: for a planar quad the cross product of + // AC × BD is the face normal, and it does not care which corner is first. + const px = c[0] - a[0]; + const py = c[1] - a[1]; + const pz = c[2] - a[2]; + const qx = d[0] - b[0]; + const qy = d[1] - b[1]; + const qz = d[2] - b[2]; + let nx = py * qz - pz * qy; + let ny = pz * qx - px * qz; + let nz = px * qy - py * qx; + const length = Math.hypot(nx, ny, nz) || 1; + nx /= length; + ny /= length; + nz /= length; + + const positions = new Float32Array(8 * 3); + const normals = new Float32Array(8 * 3); + const uvs = new Float32Array(8 * 2); + + for (const [i, corner] of quad.entries()) { + // The same four positions twice: 0..3 carry +n, 4..7 carry −n. + for (const half of [0, 4]) { + const v = i + half; + positions[v * 3] = corner[0]; + positions[v * 3 + 1] = corner[1]; + positions[v * 3 + 2] = corner[2]; + const sign = half === 0 ? 1 : -1; + normals[v * 3] = nx * sign; + normals[v * 3 + 1] = ny * sign; + normals[v * 3 + 2] = nz * sign; + uvs[v * 2] = i === 1 || i === 2 ? 1 : 0; + uvs[v * 2 + 1] = i >= 2 ? 1 : 0; + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + geo.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + // Indexed, because `mergeGeometries` rejects a mixed batch — all of the inputs + // must be indexed or none of them, and every three.js primitive is. + geo.setIndex([0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6]); + return geo; +} + +/** The same aerofoil on the other wing. Order reversed so the winding survives. */ +function mirrored(quad: Quad): Quad { + const flip = (p: Point): Point => [-p[0], p[1], p[2]]; + return [flip(quad[3]), flip(quad[2]), flip(quad[1]), flip(quad[0])]; +} + +/** + * The starboard wing, corners running leading-root → leading-tip → trailing-tip + * → trailing-root. + * + * Reading the numbers as an aeroplane: the root chord is 0.112 and the tip chord + * 0.040, a taper ratio of 0.36; the leading edge goes back 0.105 over a semi-span + * of 0.204, which is 27° of sweep; the tip sits 0.015 above the root, which is 5° + * of dihedral. Those are an A320's numbers, near enough, and they are the numbers a + * person recognises without being able to name any of them. + * + * The root is at x = 0.016, *inside* the fuselage rather than tangent to it. A + * wing that starts exactly on the hull leaves a hairline of daylight at the root + * the moment anything rounds off; a wing that starts inside it cannot, and the + * buried part costs nothing because the fuselage is closed and opaque and the + * depth buffer hides it. Same trick for the fin root, the tailplane roots, and + * both ends of each pylon. + * + * y is negative at the root: this is a low-wing aeroplane, like nearly every + * airliner, so from below the wing is clear of the fuselage stripe. + */ +const WING: Quad = [ + [0.016, -0.016, 0.05], + [0.22, -0.001, -0.055], + [0.22, -0.001, -0.095], + [0.016, -0.016, -0.062], +]; + +/** + * The fin, in the x = 0 plane. Root chord 0.095, tip chord 0.045, 34° of sweep, + * and it tops out at y = 0.098 — 0.23 of the aircraft's length above the + * centreline, which is where a 737's fin tip is. + * + * It is almost invisible from directly overhead, which is the view this whole + * file is designed around, and it is here anyway: the fin is what the eye finds + * at every *oblique* angle, and an airliner without one reads as a paper dart + * again the moment the camera drops. + * + * The root chord runs nearly level while the sheared tail cone falls away + * beneath it, so the fin emerges further out of the hull towards the back. That + * is the right way round — it is what a real root fairing looks like — and it is + * why the two root corners are at almost the same height rather than following + * the cone. + */ +const FIN: Quad = [ + [0, 0.014, -0.105], + [0, 0.013, -0.2], + [0, 0.098, -0.205], + [0, 0.098, -0.16], +]; + +/** + * The starboard tailplane. Total span across both is 0.156, 0.37 of the + * aircraft's length — tailplanes are proportionally enormous and this is the + * cue, along with the engines, that separates an airliner from an arrowhead when + * seen from straight above. + * + * Mounted on the tail cone rather than on top of the fin. A T-tail is the more + * distinctive shape and the wrong one: it is a regional-jet and business-jet + * signature, and the traffic over a large city is overwhelmingly the other kind. + */ +const TAILPLANE: Quad = [ + // The inboard edge is at x = −0.012, i.e. *through* the tail cone's centreline + // rather than on its surface. A root at x = 0.01 sat outside a cone that has + // narrowed to about 0.008 by this station, so the tailplane hung in free air + // over most of its chord — invisible head-on and obvious the moment an + // aircraft banked. The mirrored port half overlaps it, which is what buries + // both roots and is the same trick the wing and fin roots already use. + [-0.012, 0.01, -0.15], + [0.078, 0.016, -0.175], + [0.078, 0.016, -0.207], + [-0.012, 0.01, -0.203], +]; + +/** + * The starboard engine pylon: a flat strut in the plane of the nacelle axis, + * buried in the wing at the top and in the nacelle at the bottom. + * + * Edge-on and therefore invisible from directly above, which is most of the + * time. It is four triangles to stop the engine from visibly floating under the + * wing at every other angle, and there is no cheaper way to do that. + */ +const PYLON: Quad = [ + [0.09, -0.008, 0.035], + [0.09, -0.008, -0.03], + [0.09, -0.028, -0.03], + [0.09, -0.028, 0.035], +]; + +/** + * Where the engines are, and how big. + * + * x = 0.09 is 36% of the semi-span, which is where a twin's engines actually + * hang; the nacelle runs from z = +0.058 to −0.037, so it sits well *forward* of + * the local leading edge (z = +0.012 at that station) and below it. Both of + * those are what makes an engine read as an engine from above rather than as a + * lump on the wing: the pod has to break the leading-edge line. + * + * Fat, like the fuselage and for the same reason — 0.033 across is about 13 m on + * the SoCal board, which is a nonsense, and a proportionate 0.02 disappears at + * two board spans, which is worse than a nonsense. + * + * These are the only closed bodies here: both ends are capped, spending 12 of + * the 24 triangles on two hexagons nobody will look at straight on. That is + * deliberate. An open-ended nacelle is a tube you can see through — the far wall + * is backfacing and culled, so a pod ahead of a sunlit wing shows a bright hole + * where the intake should be, and it flickers as the aircraft turns. Six + * triangles is cheaper than that. + */ +const ENGINE_X = 0.09; +const ENGINE_Z = 0.0105; // centre of a nacelle running +0.058 → −0.037 +const ENGINE_Y = -0.03; +const ENGINE_LENGTH = 0.095; +const ENGINE_INTAKE_RADIUS = 0.0165; +const ENGINE_EXHAUST_RADIUS = 0.0135; + +/** + * An airliner: swept wings, two underslung engines, a fin and tailplanes, merged + * into a single `BufferGeometry` so that every aircraft on the board shares one + * geometry and one upload. + * + * Nose along **+Z**. See the note at the top of this file before changing that. + */ +export function airlinerGeometry(): THREE.BufferGeometry { + const parts: THREE.BufferGeometry[] = []; + + // ---- Fuselage ----------------------------------------------------------- + // + // Three open-ended surfaces butted rim to rim: cone, tube, cone. `rotateX` by + // a quarter turn takes three.js's +Y axis of revolution to +Z (a positive + // quarter turn sends +Y to +Z, a negative one to −Z), which is how the nose + // ends up forward and the tail cone ends up pointing aft. + + const nose = new THREE.ConeGeometry(RADIUS, NOSE_TIP_Z - NOSE_BASE_Z, SIDES, 1, true); + nose.rotateX(Math.PI / 2); + nose.translate(0, 0, (NOSE_TIP_Z + NOSE_BASE_Z) / 2); + parts.push(nose); + + const tube = new THREE.CylinderGeometry( + RADIUS, + RADIUS, + NOSE_BASE_Z - TAIL_JOINT_Z, + SIDES, + 1, + true, + ); + tube.rotateX(Math.PI / 2); + tube.translate(0, 0, (NOSE_BASE_Z + TAIL_JOINT_Z) / 2); + parts.push(tube); + + const tail = new THREE.ConeGeometry(RADIUS, TAIL_JOINT_Z - TAIL_TIP_Z, SIDES, 1, true); + tail.rotateX(-Math.PI / 2); + tail.translate(0, 0, (TAIL_JOINT_Z + TAIL_TIP_Z) / 2); + // y' = y + k·(jointZ − z): zero at the joint, rising all the way to the tip. + // Row-major, which is what `Matrix4.set` takes — the transposed version of + // this shears the aeroplane sideways and looks like a physics bug. + tail.applyMatrix4( + // prettier-ignore + new THREE.Matrix4().set( + 1, 0, 0, 0, + 0, 1, -TAIL_UPSWEEP, TAIL_UPSWEEP * TAIL_JOINT_Z, + 0, 0, 1, 0, + 0, 0, 0, 1, + ), + ); + parts.push(tail); + + // ---- Aerofoils ---------------------------------------------------------- + + for (const quad of [WING, FIN, TAILPLANE, PYLON]) { + parts.push(aerofoil(quad)); + // The fin is the one surface with no opposite number; mirroring it would put + // two coincident fins in the same plane, which is the one case where the + // coincident-faces argument in `aerofoil` does not save us. + if (quad !== FIN) parts.push(aerofoil(mirrored(quad))); + } + + // ---- Engines ------------------------------------------------------------ + + for (const side of [1, -1]) { + const nacelle = new THREE.CylinderGeometry( + ENGINE_INTAKE_RADIUS, + ENGINE_EXHAUST_RADIUS, + ENGINE_LENGTH, + SIDES, + 1, + false, + ); + // Same quarter turn as the nose, so the wider end (three.js's "top") ends up + // forward and the pod tapers towards the exhaust rather than away from it. + nacelle.rotateX(Math.PI / 2); + nacelle.translate(side * ENGINE_X, ENGINE_Y, ENGINE_Z); + parts.push(nacelle); + } + + const merged = mergeGeometries(parts); + for (const part of parts) part.dispose(); + if (merged) { + merged.name = "airliner"; + return merged; + } + + /** + * `mergeGeometries` returns null when its inputs disagree — a different set of + * attributes, or some indexed and some not. Everything here is built to agree + * (see `aerofoil`), so this is unreachable until somebody adds a part and + * forgets a uv, at which point they get an aeroplane-shaped nothing on every + * board and no error anywhere. A plain cone is a bad aeroplane and a much + * better failure: it still points where the aircraft is going, which is the + * one thing this layer exists to say. + */ + const fallback = new THREE.ConeGeometry(RADIUS, NOSE_TIP_Z - TAIL_TIP_Z, SIDES); + fallback.rotateX(Math.PI / 2); + fallback.name = "airliner:fallback"; + return fallback; +} diff --git a/src/engine/clouds.ts b/src/engine/clouds.ts new file mode 100644 index 0000000..5fe560c --- /dev/null +++ b/src/engine/clouds.ts @@ -0,0 +1,1368 @@ +/** + * A cloud layer over the city. + * + * One `THREE.Mesh` — one draw call — holding about fifteen hundred instanced + * quads, driven by a single number: `WeatherObservation.cloudCover`, which the + * app already has and until now spent only on the light rig. At 0 the layer does + * not draw at all; at 1 it is an overcast deck; and everything between is the + * same field of clouds with more of them, larger, lower and flatter. + * + * ## The hard case is looking *down* at it + * + * A cloud layer is easy to make convincing from underneath. Point a camera at + * the sky, put some soft sprites up there, and the eye supplies the rest. This + * board is not looked at from underneath. San Francisco's opening chapter puts + * the camera 430 units above the ground with the cloud base at 52, and the + * orbit reaches two board spans out — so the **default** framing is from above, + * looking down through the layer at the city. From there the obvious + * implementation gives itself away in about a tenth of a second: a sheet of + * camera-facing sprites at one altitude is a sheet of camera-facing sprites at + * one altitude, and it reads as a decal on the lens rather than as weather. + * + * Four things are doing the work, and none of them is optional: + * + * 1. **Every puff is shaded as a sphere, not as a sticker.** The fragment + * shader builds a fake hemisphere normal out of the sprite's own disc + * coordinates and lights it from the rig's sun direction. So each puff has + * a lit limb and a dark limb, and a field of them seen from directly above + * is a *lumpy* field with the light coming from one side, which is the cue + * that says "volume". Take that out and everything below stops working. + * 2. **Clouds have a base and a top.** Puffs are clustered into cells with a + * real vertical spread and a dome silhouette — wider and lower at the + * bottom, smaller and tighter at the top — and the shading knows where in + * its own cloud each puff sits. That is also where the requirement to be + * dark underneath is discharged: the bottom of a cell faces down, sees the + * ground rather than the sky, and gets `uBase` rather than `uShade`. + * 3. **There are two decks at different heights, drifting at different + * speeds.** A low cumulus field of camera-facing puffs, and a high veil of + * flat quads lying in the horizontal plane. That buys genuine parallax: + * orbit the board and the two layers slide across each other, which is + * something no single sheet can fake. The upper wind is faster and veered + * clockwise from the lower one, which is true of the real atmosphere in the + * northern hemisphere and, more usefully here, means the combined pattern + * never repeats even though each layer separately tiles. + * 4. **The veil is genuinely flat and the deck genuinely is not.** Cirrus is a + * sheet; drawing it as a sheet is correct rather than a shortcut, and the + * contrast between the flat upper layer and the lumpy lower one is most of + * what sells the altitude difference between them. + * + * ## Lighting is not ours + * + * This layer computes no light. It takes a `LightingState` — the thing + * `Atmosphere` produces and `SceneKit` applies — and derives its own colours + * from the terms already in it. CONTRACT.md §4 gives lighting exactly one owner + * and a layer that works out its own sun is precisely what that rule exists to + * forbid. + * + * There is one subtlety in that translation and it is worth stating in full, + * because getting it wrong makes an overcast day look broken. A `LightingState` + * describes **the light arriving at the ground**, and `applyCloud` in + * `atmosphere.ts` correctly collapses the sun's intensity by nearly four fifths + * at full cover — because the ground is *under* the deck. The top of the deck is + * not. A sunlit overcast layer seen from above is the brightest thing in the + * frame, and if this layer read its brightness off `sun.intensity` it would go + * grey at exactly the moment it ought to go white. + * + * So the level comes from the **hemisphere** term instead, which is the one + * number in the state that tracks day and night hard and barely moves with cloud + * (`applyCloud` desaturates it and lifts its intensity by 18%, which very nearly + * cancels). The sun's *hue* and *direction* still come from the sun, and its + * *intensity* still sets `uKey` — how strongly the puffs are modelled — so an + * overcast deck flattens out and a cumulus in clear noon sun does not. Nothing + * here is invented; the question answered is only which term of the state + * applies to a surface thirteen hundred metres up. + * + * ## What it costs + * + * One draw call, ~1,470 instances, 4 vertices each. No shadows: the mesh neither + * casts nor receives, the shadow budget is spent (`stage.ts` gives the whole + * city a 1024–2048 map over a 1504-unit box), and a cloud shadow at board scale + * would be one texel wide and would strobe. Real cloud shadows are a wonderful + * thing and they are a different renderer's problem. + * + * ## Scale: altitude is physics, size is not + * + * Altitudes go through `world.metres()`, so they pick up the city's vertical + * exaggeration exactly as a building's height does. That is not decoration — it + * is the only thing that keeps the deck above the towers. San Francisco is 94 m + * per unit at 3.6x, so a 326 m tower stands 12.5 units tall and a 1,350 m cloud + * base *without* the exaggeration would be 14.3 units: level with the roofs. + * With it, 51.7 — four tower-heights up, which is what a cloud base looks like. + * + * Horizontal sizes deliberately do **not** come from metres, and this is the one + * place the file lies. A fair-weather cumulus is a kilometre or two across, + * which on this board is ten to twenty units against a 1,003-unit span: from + * every camera position the orbit can reach, an honest cumulus is between one + * and four pixels and the whole layer reads as film grain. So puff size is + * derived from the *board* — see `CELL_GRID` — and the clouds this draws are + * five to twelve kilometres across, which is a stratocumulus complex rather than + * a cumulus humilis. It is the same trade `atmosphere.ts` makes for visibility + * and for moonlight: a map is looked at from outside the atmosphere it is + * depicting, and reproducing the true angular size of things in that atmosphere + * produces a correct picture of nothing. + */ + +import * as THREE from "three"; +import { deviceProfile } from "./stage.ts"; +import type { LightingState } from "./types.ts"; +import { fbm, seededRandom, type World } from "./world.ts"; + +// ---- The dials ------------------------------------------------------------ + +/** + * How much wider than the board the wrapping field is, per side. + * + * The field is a torus — every position is taken modulo `tile`, so it is + * infinite by tiling and can never run out however long it drifts. What the + * number buys is therefore not coverage but *the distance at which the repeat + * becomes findable*, and 2.2 spans puts the second copy of any given cloud far + * enough out that the scene's own fog has most of it. The two decks tile on the + * same period and drift at different rates, so the combined pattern is aperiodic + * even though each layer is not, which is why this can be as small as it is. + */ +const TILE_SPANS = 2.2; + +/** + * Cells per side of the low deck's jittered grid, and puffs in each cell. + * + * These two numbers set everything else about the deck's geometry, so it is + * worth following the chain. 14x14 cells over a 2.2-span tile puts one cell + * every 0.157 spans; a cell must therefore reach about 0.088 spans to own its + * share of the plane once neighbours overlap, which for San Francisco's 1,003 + * units is 88 — a cloud complex a little under nine kilometres across, sitting + * 1.35 km up. That is a real thing you can look at out of an aeroplane window. + * + * Seven puffs per cell is the smallest number that makes a cell read as a cloud + * rather than as a blob: one core, a couple of shoulders, and enough on top to + * build the dome. Below five the silhouette is a circle; above nine the extra + * ones land inside the ones already there and cost fill rate for nothing. + * + * 196 x 7 = 1,372 instances, plus the veil, and the whole layer is one draw + * call. For reference the city beside it is ~24,000 building instances. + */ +const CELL_GRID = 14; +const PUFFS_PER_CELL = 7; + +/** Flat quads in the high veil. Large, thin and few; see `buildVeil`. */ +const VEIL_QUADS = 96; + +/** + * How far a cell's centre may wander off its grid node, as a fraction of the + * grid pitch. + * + * A pure grid reads as a grid the instant the camera gets above it — the eye is + * extremely good at finding rows in a texture. A pure Poisson scatter clumps and + * leaves holes that never close at high cover. A jittered grid is the standard + * middle: even coverage, no rows. + */ +const CELL_JITTER = 0.5; + +/** + * The width of a cell's fade-in, in units of cover. + * + * Each cell holds a threshold and appears when `cover` passes it, which would be + * a pop; this is the ramp that makes it a growth instead. Thresholds are drawn + * uniformly from `[0, 1 - COVER_FEATHER]` rather than from `[0, 1]` precisely so + * that `cover = 1` leaves every cell fully present — with thresholds up at 0.98 + * a nominally overcast sky would still have a scatter of half-formed clouds in + * it, which is the one thing overcast never has. + */ +const COVER_FEATHER = 0.22; + +/** Below this the layer stops drawing entirely rather than drawing nothing. */ +const MIN_COVER = 0.004; + +/** + * How fast a change in cover is taken up, as a time constant in seconds. + * + * The weather arrives every few minutes and a poll that steps cover from 0.2 to + * 0.8 between one frame and the next would materialise a sky. Eased, it is + * clouds building — which is what actually happened in the twenty minutes the + * two observations are apart. Godmode scrubs this dial directly and gets the + * same ease, which is the point of it being here rather than in the caller. + */ +const COVER_TAU = 6; + +/** + * How much faster than life the field drifts. + * + * A 22 km/h wind is 6.1 m/s, which at San Francisco's 94 m per unit is 0.065 + * units a second: a cloud crosses one cell pitch in forty minutes and the layer + * is, to anyone looking at it, nailed down. That is *correct* — real clouds + * really are that slow against a ninety-kilometre board — and it is useless, + * because a still image of weather reads as a bug in the weather. + * + * At 12x, the deck crosses the whole Bay Area board in about twenty minutes and + * moves a visible amount over the ten or fifteen seconds someone actually spends + * looking at one frame. It is still unmistakably a drift rather than a scroll. + * Raise it much past this and the clouds start to look like smoke. + */ +const DRIFT_EXAGGERATION = 12; + +/** Wind assumed when nobody has reported one. A westerly, because most are. */ +const DEFAULT_WIND_KPH = 22; +const DEFAULT_WIND_FROM_DEG = 270; + +/** + * How much the upper wind veers clockwise from the lower one, in degrees, and + * how much faster it runs. + * + * Both are real — friction slows and backs the wind near the surface, so it + * veers and accelerates with height in the northern hemisphere — and both are + * here for a rendering reason as much as a meteorological one. Two layers + * sliding on the same vector are one layer; two layers sliding on different + * vectors are a sky. + */ +const VEIL_VEER_DEG = 25; +const VEIL_SPEED_FACTOR = 1.9; + +/** + * Altitudes, in metres above the ground, before the city's vertical + * exaggeration is applied. + * + * A stratocumulus base sits around 1,200–1,500 m and a deck is 400–900 m thick; + * cirrus lives at 6–10 km. These are the numbers that go through + * `world.metres()`, which is what makes the layer respect a city pack's own + * exaggeration instead of hard-coding San Francisco's. + */ +const BASE_ALTITUDE_M = 1350; +const DECK_THICKNESS_M = 750; +const VEIL_ALTITUDE_M = 7000; + +/** + * How far the deck drops and flattens as it consolidates. + * + * Broken fair-weather cloud is high-based and deep; a solid overcast layer is + * lower and thinner, which is why an overcast day feels like a lid. Applied on + * `deckOf(cover)` so it arrives over the top half of the cover range rather than + * at a threshold. + */ +const DECK_ALTITUDE_DROP = 0.62; +const DECK_FLATTEN = 0.55; + +/** + * Per-puff alpha at no cover and at full cover. + * + * These are the numbers that stop an overcast sky deleting the city, and the + * arithmetic behind them is worth writing down because it is not obvious that + * the *lower* number belongs at the *higher* cover. + * + * Opacity accumulates along the view ray: n overlapping puffs at alpha a leave + * `1 - (1 - a)^n`. `buildDeck` packs a cell tightly enough that a ray through + * its core crosses about four of its seven puffs; at full cover, with + * neighbouring cells grown into each other, it crosses seven or eight. To land + * the fully-overlapped deck short of opaque the per-puff figure therefore has to + * *fall* as cover rises: at 0.28 against a texture whose core sits near 0.9, + * seven layers leave `1 - 0.75^7 = 0.87` at the very centre of a cell and + * visibly less than that everywhere else, because the texture is at its core + * value over only a third of its radius. Measured against a rendered frame, the + * city stays legible through the deck. At low cover the same ray crosses four + * puffs, and 0.46 gives `1 - 0.59^4 = 0.88` — a cumulus that is properly opaque, + * which a cumulus is. + * + * The first pass at these was less than half as large, on the theory that soft + * translucent puffs would build up gently. They do not build up at all: two + * sprites at 0.2 leave 0.36, the sky reads straight through, and the entire + * layer looks like lens flare rather than weather. Cloud is *opaque*. The + * softness belongs in the silhouette, which is what the texture is for, and not + * in the body. + * + * Landing near 0.8 rather than at 1 is a deliberate lie of the same family as + * `minVisibilityM` in `atmosphere.ts`. A genuinely opaque deck seen from the + * whole-board pose is a grey rectangle where the map was, and a map that goes + * blank when the weather turns is read as an outage, not as weather. At 0.8 the + * coastline and the freeways are still findable through it and it is still + * unmistakably overcast. + * + * Nothing can *bound* the accumulation exactly — how many puffs a ray crosses is + * a property of the ray — so these are tuned, not derived, and `opacityScale` is + * the dial for a deployment that wants a heavier or lighter sky. + */ +const PUFF_ALPHA_CLEAR = 0.46; +const PUFF_ALPHA_OVERCAST = 0.28; + +/** + * Puff radius multiplier at no cover and at full cover. + * + * The growth is what closes the deck, and it is doing a job the cell *count* + * could otherwise do and must not. Covering a 2.2-span tile with clouds small + * enough to still be individually cumulus at overcast would take several + * thousand cells and as many thousand instances; growing two hundred of them + * until they touch costs nothing and is what a consolidating stratocumulus sheet + * genuinely does. At 1.75 a cell reaches 1.06 pitches, so every cell overlaps + * all four of its neighbours and the gaps close. Below about 1.6 they do not, + * and full overcast renders as a lattice of separate blobs with sky between them + * — which is the one arrangement the real sky never produces and the eye finds + * instantly from above. + */ +const PUFF_GROWTH_CLEAR = 0.7; +const PUFF_GROWTH_OVERCAST = 1.75; + +/** + * Hemisphere luminance taken as "full daylight" when normalising the deck's own + * brightness. + * + * `atmosphere.ts`'s noon rig is `hemiSky 0xdcecf7` at intensity 1.05, whose + * Rec.709 luminance in the linear working space is 0.88. Overcast noon lands + * fractionally above it and gets clamped, which is the intended behaviour: the + * top of an overcast deck at midday is as bright as anything ever gets. + */ +const DAY_REFERENCE = 0.88; + +/** Edge of the puff texture, in texels. Only the alpha channel carries anything. */ +const PUFF_TEXTURE_SIZE = 256; + +/** Draw after the satellite dome, so an overcast night hides the constellation. */ +const CLOUD_RENDER_ORDER = 2; + +/** + * How often the depth sort may run, in seconds, and how far the camera may move + * before it runs early. See `maybeSort`. + */ +const SORT_INTERVAL = 0.5; + +// ---- Options and handle --------------------------------------------------- + +export interface CloudLayerOptions { + /** + * Board span in scene units. Defaults to the same derivation `scene.ts` uses + * — the larger of the two projected extents of `city.bounds` — so that a + * caller who has already computed one can pass it and a caller who has not + * gets the same answer anyway. + */ + span?: number; + /** Cloud base above ground, in metres, before vertical exaggeration. */ + baseAltitudeM?: number; + /** Deck thickness in metres, before vertical exaggeration. */ + thicknessM?: number; + /** The high veil's altitude in metres, before vertical exaggeration. */ + veilAltitudeM?: number; + /** + * Multiplier on every puff's alpha. The one dial for a heavier or a lighter + * sky; see `PUFF_ALPHA_CLEAR` for what the default is holding. + */ + opacityScale?: number; + /** + * Multiplier on the instance count, 0..1. Defaults to 0.6 on a handheld and 1 + * elsewhere, off `deviceProfile()` — the layer is fill-rate bound rather than + * vertex bound, and on a phone the cheapest thing to give up is the number of + * overlapping transparent quads. + */ + density?: number; + /** Seed for the field. A reload must produce the same sky; see `seededRandom`. */ + seed?: number; +} + +export interface CloudLayer { + /** + * Add this to the **scene root**, not to a translated parent. The vertex + * shader wraps positions against a tile anchored at `uOrigin` in world space + * and the depth sort measures against the camera in world space; both assume + * the group's own transform is the identity. + */ + group: THREE.Group; + /** + * How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover` + * exactly. Eased internally over `COVER_TAU`, so this may be called as often + * as the weather changes without anything jumping. + */ + setCover(fraction: number): void; + /** Applies a rig computed elsewhere. This layer never works one out itself. */ + setLighting(state: LightingState): void; + /** + * The wind, as `WeatherObservation` reports it: speed in km/h and the bearing + * it blows *from*, degrees clockwise from true north. Either may be `null` — + * plenty of stations do not report wind — and the layer falls back to a light + * westerly rather than standing still, because a sky with no drift in it reads + * as a frozen frame. + * + * Not in the shape the brief asked for, and it earns the extra method: the + * observation the app already holds carries these two numbers, they cost + * fifteen lines, and a cloud field that drifts against the reported wind is + * the difference between weather and wallpaper. + */ + setWind(kph: number | null, fromDeg: number | null): void; + /** Draw the layer, or do not. Independent of cover. */ + setVisible(visible: boolean): void; + /** Advance the drift and the cover ease. Seconds. */ + tick(dt: number): void; + dispose(): void; +} + +// ---- Construction --------------------------------------------------------- + +export function createCloudLayer(world: World, options: CloudLayerOptions = {}): CloudLayer { + const group = new THREE.Group(); + group.name = "clouds"; + + const { bounds } = world.city; + const [westX, northZ] = world.project(bounds.maxLat, bounds.minLng); + const [eastX, southZ] = world.project(bounds.minLat, bounds.maxLng); + const span = options.span ?? Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)); + + /** + * The field is centred on the **board**, not on the scene origin. + * + * Scene space is centred on `city.center`, and the Bay Area pack runs forty + * kilometres down the peninsula from there — so the origin is nowhere near the + * middle of what is drawn. A field centred on it would put the wrapping tile's + * seam somewhere over San Jose. This is the same trap `satellites.ts` records + * under `DOME_RADIUS_FACTOR`, arrived at from the other direction. + */ + const originX = (westX + eastX) / 2; + const originZ = (northZ + southZ) / 2; + + const tile = span * TILE_SPANS; + + const density = options.density ?? (isHandheld() ? 0.6 : 1); + const grid = Math.max(4, Math.round(CELL_GRID * Math.sqrt(clamp(density, 0.1, 1)))); + const veilQuads = Math.max(8, Math.round(VEIL_QUADS * clamp(density, 0.1, 1))); + const count = grid * grid * PUFFS_PER_CELL + veilQuads; + + const baseY = world.metres(options.baseAltitudeM ?? BASE_ALTITUDE_M); + const thickY = world.metres(options.thicknessM ?? DECK_THICKNESS_M); + const veilY = world.metres(options.veilAltitudeM ?? VEIL_ALTITUDE_M); + const opacityScale = options.opacityScale ?? 1; + + // The field as authored, never touched again after the build. The buffers the + // GPU reads are a permutation of these; see `maybeSort`. + const srcCenter = new Float32Array(count * 3); + const srcShape = new Float32Array(count * 4); + const srcFade = new Float32Array(count * 4); + + const rand = seededRandom(options.seed ?? 0x2f9a_c1d3); + const written = buildDeck(srcCenter, srcShape, srcFade, rand, { + grid, + tile, + pitch: tile / grid, + thickY, + }); + buildVeil(srcCenter, srcShape, srcFade, rand, { + from: written, + quads: veilQuads, + tile, + pitch: tile / grid, + }); + + // ---- Geometry ----------------------------------------------------------- + + const geometry = new THREE.InstancedBufferGeometry(); + /** + * A hand-built unit quad rather than a `PlaneGeometry` we then throw away. + * + * Sharing a `PlaneGeometry`'s attribute objects and disposing the plane looks + * tidy and is a use-after-free: `BufferGeometry.dispose()` fires the event the + * renderer uses to delete the GL buffers *for those attribute objects*, which + * are the ones still bound here. Four vertices are cheaper than the comment + * explaining the crash. + */ + geometry.setAttribute( + "position", + new THREE.BufferAttribute( + Float32Array.of(-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0), + 3, + ), + ); + geometry.setIndex([0, 1, 2, 0, 2, 3]); + + const gpuCenter = new Float32Array(srcCenter); + const gpuShape = new Float32Array(srcShape); + const gpuFade = new Float32Array(srcFade); + const centerAttr = new THREE.InstancedBufferAttribute(gpuCenter, 3); + const shapeAttr = new THREE.InstancedBufferAttribute(gpuShape, 4); + const fadeAttr = new THREE.InstancedBufferAttribute(gpuFade, 4); + centerAttr.setUsage(THREE.DynamicDrawUsage); + shapeAttr.setUsage(THREE.DynamicDrawUsage); + fadeAttr.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute("aCenter", centerAttr); + geometry.setAttribute("aShape", shapeAttr); + geometry.setAttribute("aFade", fadeAttr); + geometry.instanceCount = count; + + // ---- Material ----------------------------------------------------------- + + const texture = drawPuffTexture(); + + const uniforms: Record = { + uOrigin: { value: new THREE.Vector2(originX, originZ) }, + uTile: { value: tile }, + uDriftLow: { value: new THREE.Vector2(0, 0) }, + uDriftHigh: { value: new THREE.Vector2(0, 0) }, + uTierBaseY: { value: new THREE.Vector2(baseY, veilY) }, + uTierSpreadY: { value: new THREE.Vector2(1, 1) }, + uCover: { value: 0 }, + uFeather: { value: COVER_FEATHER }, + uGrow: { value: PUFF_GROWTH_CLEAR }, + uOpacity: { value: PUFF_ALPHA_CLEAR * opacityScale }, + uTime: { value: 0 }, + uSunWorld: { value: new THREE.Vector3(0, 1, 0) }, + uLit: { value: new THREE.Color(1, 1, 1) }, + uShade: { value: new THREE.Color(0.5, 0.55, 0.6) }, + uBase: { value: new THREE.Color(0.3, 0.33, 0.36) }, + uKey: { value: 1 }, + uUnderside: { value: 0.35 }, + uRim: { value: 0 }, + uFogColor: { value: new THREE.Color(0.8, 0.85, 0.9) }, + uFogNear: { value: span }, + uFogFar: { value: span * 4 }, + }; + if (texture) uniforms.uMap = { value: texture }; + + const material = new THREE.ShaderMaterial({ + uniforms, + defines: texture ? { USE_PUFF_MAP: "" } : {}, + vertexShader: VERTEX_SHADER, + fragmentShader: FRAGMENT_SHADER, + transparent: true, + /** + * Depth tested, never written. + * + * Tested, because a cloud behind Sutro Tower is behind Sutro Tower — from a + * chapter pose down in the city the towers genuinely occlude the deck, and + * turning the test off makes the sky float in front of the skyline. Not + * written, because these are overlapping translucent quads and the first one + * to draw would punch a hole in every one behind it, which on a deck seven + * puffs deep is most of the deck. + */ + depthWrite: false, + depthTest: true, + /** + * Both faces, for the veil. The low deck's quads are built in view space and + * keep their winding, but a flat quad lying in the horizontal plane is seen + * from underneath for most of the orbit, and single-sided it simply is not + * there. + */ + side: THREE.DoubleSide, + /** + * Without this, the layer draws **twice** and recompiles itself forever. + * + * `WebGLRenderer.renderObject` special-cases a material that is both + * `transparent` and `DoubleSide` with `forceSinglePass` off: it renders the + * object once with `side = BackSide`, once with `side = FrontSide`, and sets + * `needsUpdate = true` on each flip. So the deck — whose quads are always + * camera-facing and front-winding — pays a full vertex pass that is then + * culled outright, on the one layer in the scene that is fill-rate bound. + * + * The second consequence is worse and is permanent: `needsUpdate` bumps + * `material.version`, so `setProgram`'s version check misses on every pass + * and rebuilds the parameters object and the program cache key twice per + * frame, for as long as the page is open. A steady-state per-frame + * allocation, from a property nobody set. + * + * Turning the two-pass split off keeps both faces — culling is simply + * disabled — and it is also what makes the layer's own back-to-front sort + * authoritative, since a split by winding would otherwise only order + * instances *within* each pass. + */ + forceSinglePass: true, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.name = "cloud-puffs"; + /** + * Never culled, and never in a shadow map. + * + * The bounding sphere is permanently wrong — positions are wrapped against the + * tile in the vertex shader, so the geometry the CPU thinks it has is not the + * geometry that gets drawn — and culling on it would cull the whole sky from + * some angles and not others. Shadows are refused outright: see the file + * header. + */ + mesh.frustumCulled = false; + mesh.castShadow = false; + mesh.receiveShadow = false; + mesh.renderOrder = CLOUD_RENDER_ORDER; + group.add(mesh); + + // ---- State -------------------------------------------------------------- + + let targetCover = 0; + let cover = 0; + let visible = true; + let elapsed = 0; + + let windKph = DEFAULT_WIND_KPH; + let windFromDeg = DEFAULT_WIND_FROM_DEG; + + const driftLow = uniforms.uDriftLow?.value as THREE.Vector2; + const driftHigh = uniforms.uDriftHigh?.value as THREE.Vector2; + + /** Depth sort scratch. `order` is a permutation of instance indices. */ + const order = new Uint32Array(count); + for (let i = 0; i < count; i++) order[i] = i; + const depth = new Float32Array(count); + const lastSortAt = new THREE.Vector3(Infinity, Infinity, Infinity); + let sortAge = Infinity; + const sortMoveEps = (tile * 0.006) ** 2; + + function applyCover(): void { + const deck = deckOf(cover); + uniforms.uCover!.value = cover; + uniforms.uGrow!.value = lerp(PUFF_GROWTH_CLEAR, PUFF_GROWTH_OVERCAST, cover); + uniforms.uOpacity!.value = lerp(PUFF_ALPHA_CLEAR, PUFF_ALPHA_OVERCAST, cover) * opacityScale; + // The deck drops and flattens as it consolidates; the veil does neither, + // because cirrus does not care what the layer below it is doing. + (uniforms.uTierBaseY!.value as THREE.Vector2).set( + baseY * lerp(1, DECK_ALTITUDE_DROP, deck), + veilY, + ); + (uniforms.uTierSpreadY!.value as THREE.Vector2).set(lerp(1, DECK_FLATTEN, deck), 1); + group.visible = visible && cover > MIN_COVER; + } + applyCover(); + + const lowVelocity = new THREE.Vector2(); + const highVelocity = new THREE.Vector2(); + + function applyWind(): void { + // `windDirDeg` is the bearing the wind blows *from*, so the velocity points + // along the reciprocal. Scene axes are `solar.ts`'s throughout the engine: + // azimuth measured clockwise from north, north is -Z, east is +X. A bearing + // b therefore points along `(sin b, -cos b)`, and the reciprocal of `from` + // is `(-sin from, cos from)` — a westerly (270) comes out as +X, blowing + // east, which is what a westerly does. + const speed = ((windKph / 3.6) / world.metresPerUnit) * DRIFT_EXAGGERATION; + const from = (windFromDeg * Math.PI) / 180; + lowVelocity.set(-Math.sin(from) * speed, Math.cos(from) * speed); + const veered = ((windFromDeg + VEIL_VEER_DEG) * Math.PI) / 180; + const veilSpeed = speed * VEIL_SPEED_FACTOR; + highVelocity.set(-Math.sin(veered) * veilSpeed, Math.cos(veered) * veilSpeed); + } + applyWind(); + + /** + * Re-order the instance buffers back to front, if it is worth doing. + * + * Alpha blending is order dependent and there are up to eight overlapping + * quads on any ray, so an unsorted field composites a dark cloud base over a + * sunlit top as readily as the other way round. At sunset, where the two are + * furthest apart in colour, that shows as puffs cutting flat holes in each + * other. + * + * It is throttled rather than per-frame because it does not need to be + * per-frame. The drift is slow by construction and `OrbitControls` moves the + * camera on a sphere, so a wrong order stays very nearly right for a long + * time; twice a second, plus immediately whenever the camera has moved more + * than about half a percent of the tile, is indistinguishable from exact. The + * work itself is ~1,500 distance evaluations and an `n log n` sort of the same + * — call it a fifth of a millisecond — against the 2 ms `satellites.ts` spends + * every frame propagating. + * + * Driven from `onBeforeRender` rather than from `tick` because that is where + * the camera is, without this layer having to be handed one or having to guess + * which of several it is being drawn for. + */ + function maybeSort(camera: THREE.Camera): void { + const moved = camera.position.distanceToSquared(lastSortAt); + if (sortAge < SORT_INTERVAL && moved < sortMoveEps) return; + sortAge = 0; + lastSortAt.copy(camera.position); + + const cx = camera.position.x; + const cy = camera.position.y; + const cz = camera.position.z; + const half = tile * 0.5; + for (let i = 0; i < count; i++) { + const tier = srcFade[i * 4 + 3] ?? 0; + const drift = tier < 0.5 ? driftLow : driftHigh; + // The same wrap the vertex shader does, or the sort is sorting positions + // nothing is drawn at. + const x = originX + posMod((srcCenter[i * 3] ?? 0) + drift.x, tile) - half; + const z = originZ + posMod((srcCenter[i * 3 + 2] ?? 0) + drift.y, tile) - half; + const baseTier = tier < 0.5 ? (uniforms.uTierBaseY!.value as THREE.Vector2).x : veilY; + const spread = tier < 0.5 ? (uniforms.uTierSpreadY!.value as THREE.Vector2).x : 1; + const y = baseTier + (srcCenter[i * 3 + 1] ?? 0) * spread; + depth[i] = (x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2; + } + + // Furthest first. `sort` rather than an insertion pass over the previous + // order: a camera crossing the deck reverses the whole array at once, and an + // insertion sort's best case is not worth its worst one here. + order.sort((a, b) => (depth[b] ?? 0) - (depth[a] ?? 0)); + + for (let k = 0; k < count; k++) { + const i = order[k] ?? 0; + gpuCenter[k * 3] = srcCenter[i * 3] ?? 0; + gpuCenter[k * 3 + 1] = srcCenter[i * 3 + 1] ?? 0; + gpuCenter[k * 3 + 2] = srcCenter[i * 3 + 2] ?? 0; + for (let c = 0; c < 4; c++) { + gpuShape[k * 4 + c] = srcShape[i * 4 + c] ?? 0; + gpuFade[k * 4 + c] = srcFade[i * 4 + c] ?? 0; + } + } + centerAttr.needsUpdate = true; + shapeAttr.needsUpdate = true; + fadeAttr.needsUpdate = true; + } + + mesh.onBeforeRender = (_renderer, _scene, camera) => maybeSort(camera); + + // ---- The handle --------------------------------------------------------- + + return { + group, + + setCover(fraction) { + targetCover = clamp(fraction, 0, 1); + }, + + setLighting(state) { + const sun = new THREE.Color().setHex(state.sun.color); + const sky = new THREE.Color().setHex(state.hemisphere.sky); + const ground = new THREE.Color().setHex(state.hemisphere.ground); + const ambient = new THREE.Color().setHex(state.ambient.color); + + /** + * Daylight level, off the hemisphere rather than off the sun. + * + * See the file header for the argument. In short: `sun.intensity` is the + * light reaching the *ground* and collapses under the very deck this layer + * is drawing, whereas the hemisphere term tracks day and night hard and + * barely moves with cloud — which is exactly the behaviour a cloud top + * needs, since it is in the sunshine the ground has lost. + */ + const day = clamp(luminance(sky) * state.hemisphere.intensity / DAY_REFERENCE, 0, 1); + + // A cloud top is a near-perfect diffuse reflector wearing the sun's + // colour. 0.06 is not black: even a moonless overcast night has a deck you + // can see against the sky, and zero here loses the layer entirely rather + // than darkening it. + (uniforms.uLit!.value as THREE.Color).copy(sun).multiplyScalar(0.06 + 0.92 * day); + + // The flanks are lit by the sky dome, plus whatever ambient the rig is + // carrying. Both terms are colour times intensity, so both have to be + // multiplied out — flooring one and not the other is how a night sky ends + // up with daylight-coloured clouds in it. + // `THREE.Color` has no `addScaledVector`, so the scaling happens on the + // scratch copies made above — which is why `sky` and `ambient` are fresh + // colours per call rather than cached ones. + // + // The two weights are generous, and the first render is why. At 0.42 and + // 0.5 the sunset rig — a hemisphere at 0.6 over a dark blue sky — put the + // shaded side of every cumulus at about 4% grey, and a field of clouds + // that dark against a mauve sky reads as a smoke plume. Cloud is the most + // multiply-scattering thing in the sky: the side facing away from the sun + // is still being lit by the entire rest of the dome *and* by the several + // hundred metres of its own body the light came through, which is why a + // cumulus never has a black side and a sphere of rock does. + (uniforms.uShade!.value as THREE.Color) + .copy(sky) + .multiplyScalar(state.hemisphere.intensity * 0.55) + .add(ambient.multiplyScalar(state.ambient.intensity * 0.7)); + + /** + * The underside sees the ground and the haze between here and it, which is + * why the fog colour is in this term and in no other. It is also what + * makes an overcast night the right colour without this layer knowing + * anything about street lighting: `atmosphere.ts` floors the night fog at + * a lifted horizon blue, and the deck picks that up off its own base. + */ + const base = uniforms.uBase!.value as THREE.Color; + base.copy(ground).multiplyScalar(state.hemisphere.intensity * 0.3); + if (state.fog) { + base.add(new THREE.Color().setHex(state.fog.color).multiplyScalar(0.3)); + (uniforms.uFogColor!.value as THREE.Color).setHex(state.fog.color); + uniforms.uFogNear!.value = state.fog.near; + uniforms.uFogFar!.value = state.fog.far; + } + + const [dx, dy, dz] = state.sun.direction; + (uniforms.uSunWorld!.value as THREE.Vector3).set(dx, dy, dz); + + /** + * How hard the puffs are modelled, and how dark their bases go. + * + * `uKey` is the one place `sun.intensity` is still the right number: it is + * a *contrast*, not a level, and a key that has collapsed under cloud + * genuinely does flatten the deck it collapsed under. An overcast layer has + * almost no modelling in it, which is why an overcast photograph has no + * shapes in the sky. + * + * `lowSun` is read off the direction's own `y`, which is `sin(elevation)`. + * `atmosphere.ts` floors that at 7 degrees (`shadowFloorDeg`) so this can + * never see a true sunset angle, and it does not need to: what it needs to + * know is "is the light coming in sideways", and 0.12 to 0.55 is that + * question asked over the range the floor still permits. + */ + const key = clamp(state.sun.intensity / 2.1, 0, 1); + const lowSun = 1 - smoothstep(0.12, 0.55, dy); + uniforms.uKey!.value = 0.35 + 0.65 * key; + uniforms.uUnderside!.value = clamp(0.26 + 0.38 * lowSun + 0.2 * deckOf(cover), 0, 0.72); + // The silver lining: forward scattering round the limb of a puff with the + // sun behind it. Only worth drawing when there is a real sun and it is + // low, which is exactly when anybody has ever noticed one. + uniforms.uRim!.value = 0.6 * lowSun * smoothstep(0.05, 0.5, key); + }, + + setWind(kph, fromDeg) { + windKph = kph === null || !Number.isFinite(kph) ? DEFAULT_WIND_KPH : Math.max(0, kph); + windFromDeg = + fromDeg === null || !Number.isFinite(fromDeg) ? DEFAULT_WIND_FROM_DEG : fromDeg; + applyWind(); + }, + + setVisible(next) { + visible = next; + group.visible = visible && cover > MIN_COVER; + }, + + tick(dt) { + elapsed += dt; + sortAge += dt; + uniforms.uTime!.value = elapsed; + + if (cover !== targetCover) { + // Exponential approach, framerate independent. A linear ramp would take + // the same wall-clock time from 0.1 to 0.2 as from 0.1 to 0.9, and the + // small change is the one that has to be invisible. + const k = 1 - Math.exp(-dt / COVER_TAU); + cover += (targetCover - cover) * k; + if (Math.abs(targetCover - cover) < 0.0005) cover = targetCover; + applyCover(); + } + + if (!group.visible) return; + + // Kept inside one tile so the accumulator can run for a week without + // losing float precision, and kept *per tier* rather than scaling one + // shared accumulator — a scaled copy of a wrapped number is not itself + // continuous at the wrap, and the veil would teleport once a tile. + driftLow.x = posMod(driftLow.x + lowVelocity.x * dt, tile); + driftLow.y = posMod(driftLow.y + lowVelocity.y * dt, tile); + driftHigh.x = posMod(driftHigh.x + highVelocity.x * dt, tile); + driftHigh.y = posMod(driftHigh.y + highVelocity.y * dt, tile); + }, + + dispose() { + geometry.dispose(); + material.dispose(); + texture?.dispose(); + group.clear(); + }, + }; +} + +// ---- The field ------------------------------------------------------------ + +interface DeckSpec { + grid: number; + tile: number; + pitch: number; + thickY: number; +} + +/** + * The low deck: a jittered grid of cells, each a dome of puffs. + * + * Returns how many instances were written, so the veil can carry on from there + * into the same buffers — one buffer, one draw call, two layers. + * + * The shape of a cell is the whole point and is worth reading as geometry rather + * than as arithmetic. Puffs are laid from the bottom up (`heightRank` walks 0 to + * 1 with jitter, so the vertical spread is even rather than clumped); each one + * sits on a radius that *narrows* with height, so the silhouette is a dome and + * not a column; and each one is *smaller* with height, so the top of the cloud + * breaks into smaller lumps the way a real cumulus crown does. A cell built with + * uniform radii at uniform heights is a pillar of identical bubbles and is + * instantly recognisable as one. + */ +function buildDeck( + center: Float32Array, + shape: Float32Array, + fade: Float32Array, + rand: () => number, + spec: DeckSpec, +): number { + const { grid, tile, pitch, thickY } = spec; + let n = 0; + + for (let gz = 0; gz < grid; gz++) { + for (let gx = 0; gx < grid; gx++) { + const cellX = (gx + 0.5 + (rand() - 0.5) * 2 * CELL_JITTER) * pitch; + const cellZ = (gz + 0.5 + (rand() - 0.5) * 2 * CELL_JITTER) * pitch; + + // Thresholds are independent per cell, so cloud arrives scattered rather + // than sweeping across the board as a front. A front would be the more + // beautiful model and it would need a second field of spatially coherent + // noise, a direction, and something to say when it passes — none of which + // is recoverable from one number between 0 and 1. + const threshold = rand() * (1 - COVER_FEATHER); + + // How far this cell's puffs wander from its centre, and the base altitude + // it sits at. The altitude jitter is what stops the deck being a plane: + // seen from above at a shallow angle, a deck with no variation in its own + // base reads as a sheet of paper. + // Tighter than the puffs are wide, which is the relationship that makes a + // cell one cloud rather than seven. At `spread` 0.33 of the pitch against + // a puff radius of 0.42, a ray through the core meets four of the seven + // and the arithmetic in `PUFF_ALPHA_CLEAR` holds. Loosen it and the cell + // becomes a constellation of separate bubbles with sky between them. + const spread = pitch * (0.26 + rand() * 0.14); + const cellBase = (rand() - 0.5) * thickY * 0.8; + const cellOpacity = 0.85 + rand() * 0.3; + + /** + * How big this particular cloud is, against its neighbours. + * + * Added after looking at the first render, where every cell came out the + * same size because every cell was built from the same pitch, and a field + * of equal-sized clouds at equal spacing on a common condensation level + * reads unmistakably as popcorn. Real cumulus fields have an enormous + * spread of sizes — a handful of large cells and a great many small ones — + * so the roll is squared to bias it that way rather than spread evenly. + * + * The flat *bases* stay: those really are all at the same height, because + * they are all at the lifting condensation level, and it is one of the most + * recognisable things about a cumulus field seen from the side. + */ + const cellScale = 0.55 + 1.05 * rand() ** 2; + + for (let p = 0; p < PUFFS_PER_CELL; p++) { + const heightRank = clamp((p + rand()) / PUFFS_PER_CELL, 0, 1); + const angle = rand() * Math.PI * 2; + // `sqrt` of a uniform is what makes the scatter uniform *by area*; the + // uniform itself piles everything into the middle. + const reach = Math.sqrt(rand()) * spread * cellScale * (1 - 0.55 * heightRank); + + center[n * 3] = posMod(cellX + Math.cos(angle) * reach, tile); + center[n * 3 + 1] = cellBase + heightRank * thickY; + center[n * 3 + 2] = posMod(cellZ + Math.sin(angle) * reach, tile); + + shape[n * 4] = pitch * (0.32 + rand() * 0.2) * cellScale * (1 - 0.32 * heightRank); + shape[n * 4 + 1] = 1 + rand() * 0.35; + shape[n * 4 + 2] = rand() * Math.PI * 2; + shape[n * 4 + 3] = heightRank; + + fade[n * 4] = threshold; + fade[n * 4 + 1] = cellOpacity; + fade[n * 4 + 2] = rand(); + fade[n * 4 + 3] = 0; + n += 1; + } + } + } + return n; +} + +interface VeilSpec { + from: number; + quads: number; + tile: number; + pitch: number; +} + +/** + * The high veil: a few dozen long, faint, flat quads. + * + * Cirrus is a sheet of ice crystals seven kilometres up, and drawing it as a + * sheet — quads lying in the horizontal plane rather than turning to face the + * camera — is the correct model rather than the cheap one. It foreshortens + * properly as the camera comes down toward the horizon, it is unambiguously + * *above* the cumulus when seen from a chapter pose, and from the whole-board + * view it slides across the deck below at nearly twice the speed, which is the + * single strongest cue in the layer that there is more than one altitude in it. + * + * Thresholds are drawn from the bottom half of the range on purpose. A high thin + * veil is what a 0.15 sky usually *is*, and it is the thing that arrives a day + * ahead of a front; making the veil the first cloud to appear as cover rises is + * both the commoner observation and the more interesting one to look at, since a + * sky with two small cumulus in it and nothing else is indistinguishable from a + * clear one at this scale. + */ +function buildVeil( + center: Float32Array, + shape: Float32Array, + fade: Float32Array, + rand: () => number, + spec: VeilSpec, +): void { + const { from, quads, tile, pitch } = spec; + // Streaks share a rough bearing, because the wind that made them did. Chosen + // at build time and not steered by `setWind`: re-orienting an existing streak + // when the reported wind veers would be a sky visibly rotating in place, which + // is a worse artefact than a veil whose grain is a few degrees off the drift. + const bias = rand() * Math.PI; + + for (let i = 0; i < quads; i++) { + const n = from + i; + center[n * 3] = rand() * tile; + // Spread through a shallow band, so the veil is a layer rather than a plane + // and two streaks can cross with one plainly in front. + center[n * 3 + 1] = (rand() - 0.5) * pitch * 0.35; + center[n * 3 + 2] = rand() * tile; + + shape[n * 4] = pitch * (0.5 + rand() * 0.55); + // Long and thin. This is the one place a large aspect ratio is wanted, and + // it is applied before the rotation so the streak lies along its own axis + // rather than along the world's. + shape[n * 4 + 1] = 3 + rand() * 4; + shape[n * 4 + 2] = bias + (rand() - 0.5) * 0.5; + // Full height rank: a veil has no underside to darken, because it is thin + // enough to transmit. See the fragment shader's base term. + shape[n * 4 + 3] = 1; + + fade[n * 4] = rand() * 0.55 * (1 - COVER_FEATHER); + fade[n * 4 + 1] = 0.45 + rand() * 0.3; + fade[n * 4 + 2] = rand(); + fade[n * 4 + 3] = 1; + } +} + +// ---- The texture ---------------------------------------------------------- + +/** + * One soft, ragged puff, drawn on a canvas. + * + * No image file, ever — CONTRACT.md §3, and the same reason `assets/textures.ts` + * draws every surface in the office. The noise is the engine's own `fbm`, so the + * clouds are grained by the function that grained the hills. + * + * **The alpha is stored in the red channel and the alpha channel is left at + * 255**, which looks perverse and is the one genuinely load-bearing decision in + * this function. A 2D canvas keeps a premultiplied backing store; a texture + * uploaded from one and read as straight alpha comes back with its colour + * quantised to death wherever alpha is low, and low alpha is the entire useful + * range of a cloud sprite. Keeping the canvas fully opaque means no + * premultiplication ever happens and the value that comes out of the sampler is + * the value that went in. The colour space is irrelevant for the same reason + * nothing else here is: the shader reads one channel as a mask, not as a colour. + * + * A four-tile atlas was tried and rejected. It gives more silhouette variety per + * draw call, and at the framing this layer exists to survive — two board spans + * out, where a puff is three or four pixels and therefore several mip levels + * down — the tiles bleed into each other and every cloud acquires a faint square + * halo. Per-puff rotation and a mild aspect ratio buy most of the same variety + * with none of that, and cost two lines of vertex shader. + * + * Returns `null` where there is no DOM: the server workspace and the CI + * typecheck run under Node, and an engine module that throws on import there + * would break the zero-config boot CONTRACT.md §5.1 is tested on. The shader + * falls back to an analytic falloff, which is rounder and perfectly usable. + */ +function drawPuffTexture(): THREE.Texture | null { + if (typeof document === "undefined") return null; + const size = PUFF_TEXTURE_SIZE; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const image = ctx.createImageData(size, size); + const data = image.data; + for (let y = 0; y < size; y++) { + const v = ((y + 0.5) / size) * 2 - 1; + for (let x = 0; x < size; x++) { + const u = ((x + 0.5) / size) * 2 - 1; + const r = Math.hypot(u, v); + let a = 0; + if (r < 1) { + /** + * A **solid** core with a soft rim, not a Gaussian. + * + * The falloff started at `smoothstep(0, 0.92, r)`, which is a blur + * rather than a cloud: the alpha is already halved a third of the way + * out, so a sprite never contributes its full opacity anywhere and a + * whole cell of them reads as a smear of fog. Holding alpha at 1 out to + * `r = 0.34` and spending the rest of the radius on the edge is what + * makes a puff have a *body*. Everything soft about a cloud is in its + * silhouette, and this is the silhouette. + */ + const core = 1 - smoothstep(0.34, 0.99, r); + const n = fbm(u * 2.6 + 13.7, v * 2.6 + 5.1) / 0.94; + // Centred a little above 1 and clamped, so the noise mostly bites into + // the rim — where it produces lumps — and leaves the core alone. + const lump = 0.55 + 0.75 * clamp(n, 0, 1); + // The rim guarantee. Without it the noise can hold alpha out to r = 1, + // where the quad's own edge cuts it off as a straight line. + a = clamp(core * lump, 0, 1) * (1 - smoothstep(0.86, 1, r)); + a = a ** 0.8; + } + const i = (y * size + x) * 4; + const byte = Math.round(a * 255); + data[i] = byte; + data[i + 1] = byte; + data[i + 2] = byte; + data[i + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + + const texture = new THREE.CanvasTexture(canvas); + texture.name = "cloudPuff"; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + texture.needsUpdate = true; + return texture; +} + +// ---- Shaders -------------------------------------------------------------- + +/** + * Two billboard modes, chosen per instance off `aFade.w`. + * + * Tier 0 offsets in **view space**, which is a screen-aligned billboard: always + * face-on, no degenerate pose when the camera looks straight down at it, and no + * shimmer as it passes overhead. The alternative — an axis-constrained billboard + * pivoting about world up — is what a tree impostor wants and is wrong here, + * because the camera spends most of its time above the layer and an + * axis-constrained quad seen from above is edge-on and invisible. + * + * Tier 1 offsets in **world space in the horizontal plane**, so it does not + * billboard at all. See `buildVeil`. + */ +const VERTEX_SHADER = /* glsl */ ` +attribute vec3 aCenter; +attribute vec4 aShape; +attribute vec4 aFade; + +uniform vec2 uOrigin; +uniform float uTile; +uniform vec2 uDriftLow; +uniform vec2 uDriftHigh; +uniform vec2 uTierBaseY; +uniform vec2 uTierSpreadY; +uniform float uCover; +uniform float uFeather; +uniform float uGrow; +uniform float uOpacity; +uniform float uTime; + +varying vec2 vTexUv; +varying vec2 vDisc; +varying float vAlpha; +varying float vHeight; +varying float vTier; +varying vec3 vViewPos; + +void main() { + float tier = step(0.5, aFade.w); + vTier = tier; + vHeight = aShape.w; + + // Continuous by construction: a cell is not switched on at its threshold, it + // grows through a window uFeather wide. At zero cover every window is still + // ahead of us and the whole field collapses to zero-area quads, which the + // rasteriser drops for free. + float present = smoothstep(aFade.x, aFade.x + uFeather, uCover); + + // The wrap. This is what makes the field infinite: the tile is a torus and the + // drift is a translation on it, so nothing ever reaches an edge. + vec2 drift = mix(uDriftLow, uDriftHigh, tier); + vec2 plan = mod(aCenter.xz + drift, uTile) - uTile * 0.5 + uOrigin; + + float baseY = mix(uTierBaseY.x, uTierBaseY.y, tier); + float spread = mix(uTierSpreadY.x, uTierSpreadY.y, tier); + vec3 centre = vec3(plan.x, baseY + aCenter.y * spread, plan.y); + + // A slow breath, a fifteenth of a radius, on a per-puff phase. Small enough + // that nobody sees a puff pulse and large enough that the field is never + // completely still even with no wind reported. + float boil = 1.0 + 0.07 * sin(uTime * 0.21 + aFade.z * 6.2831853); + float radius = aShape.x * uGrow * boil * present; + + vec2 corner = position.xy; + vTexUv = corner * 0.5 + 0.5; + + // Stretched along the puff's own axis and *then* turned, which is the order + // that makes aShape.y an elongation of the cloud rather than a squash of the + // world. The disc coordinate the fragment shader builds its fake normal from + // is the turned-but-unstretched corner, so the shading stays spherical however + // long the silhouette is drawn. + float c = cos(aShape.z); + float s = sin(aShape.z); + vec2 stretched = vec2(corner.x * aShape.y, corner.y); + vec2 offset = vec2(stretched.x * c - stretched.y * s, stretched.x * s + stretched.y * c) * radius; + vDisc = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c); + + vec4 mv; + if (tier < 0.5) { + mv = modelViewMatrix * vec4(centre, 1.0); + mv.xy += offset; + } else { + mv = modelViewMatrix * vec4(centre + vec3(offset.x, 0.0, offset.y), 1.0); + } + vViewPos = mv.xyz; + vAlpha = present * aFade.y * uOpacity; + gl_Position = projectionMatrix * mv; +} +`; + +/** + * The shading, and why a flat sprite can read as a solid. + * + * Every puff builds a hemisphere normal out of its own disc coordinates — `z` + * from `sqrt(1 - r^2)`, so the middle of the sprite faces the camera and the + * limb faces away — in **view space**, which is exactly the space the tier-0 + * billboard was built in, so it needs no transform to be correct. Dotted against + * the rig's sun direction, that gives a lit limb and a dark limb per puff, and a + * field of those seen from above is the lumpy thing the file header is about. + * + * The terminator is wrapped hard (`key * 0.5 + 0.5`) rather than clamped at + * zero, because a cloud is not an opaque ball. It is a scattering volume and + * light gets a very long way round the back of one; a lambertian terminator on a + * cumulus looks like a snooker ball and is the classic tell of a first attempt. + * + * `viewMatrix` is available here — three declares it in every fragment prefix — + * so the sun and world up are transformed per fragment rather than per frame on + * the CPU. That is two mat4-by-vec4 products against not having to be handed a + * camera, and it means `setLighting` can stay a pure write of uniforms. + */ +const FRAGMENT_SHADER = /* glsl */ ` +#ifdef USE_PUFF_MAP +uniform sampler2D uMap; +#endif + +uniform vec3 uSunWorld; +uniform vec3 uLit; +uniform vec3 uShade; +uniform vec3 uBase; +uniform float uKey; +uniform float uUnderside; +uniform float uRim; +uniform vec3 uFogColor; +uniform float uFogNear; +uniform float uFogFar; + +varying vec2 vTexUv; +varying vec2 vDisc; +varying float vAlpha; +varying float vHeight; +varying float vTier; +varying vec3 vViewPos; + +void main() { + float r2 = dot(vDisc, vDisc); + // The quad's corners, which the texture's rim guarantee has already emptied. + if (r2 > 1.0) discard; + + #ifdef USE_PUFF_MAP + float mask = texture2D(uMap, vTexUv).r; + #else + float mask = 1.0 - smoothstep(0.2, 1.0, sqrt(r2)); + #endif + float alpha = mask * vAlpha; + + vec3 sunView = normalize((viewMatrix * vec4(uSunWorld, 0.0)).xyz); + vec3 upView = normalize((viewMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz); + + // A fake sphere for a puff; the plane's own normal for a veil, which really is + // flat and really is lit from above. + vec3 n = vTier < 0.5 ? vec3(vDisc, sqrt(max(0.0, 1.0 - r2))) : upView; + + /** + * The veil fades out edge-on. + * + * A flat quad is a proxy for a volume and the proxy is only honest near + * face-on: seen along its own plane it collapses to a line, and a line of + * accumulated alpha is a bright horizontal scratch across the sky rather than + * a band of cirrus. The camera spends a lot of time at exactly that angle, + * because the deck below is what it is looking at. Weighting by how squarely + * the sheet faces the eye costs one dot product and removes the artefact + * without needing the veil to be billboarded — which it must not be, since + * being flat is the whole reason it looks like a different kind of cloud. + */ + if (vTier >= 0.5) { + alpha *= mix(0.35, 1.0, abs(dot(normalize(vViewPos), upView))); + } + if (alpha < 0.004) discard; + + float key = dot(n, sunView); + float lit = clamp(key * 0.5 + 0.5, 0.0, 1.0); + // uKey collapses toward a flat 0.5 as the rig's sun does, so an overcast + // deck has no modelling in it and a clear-noon cumulus has all of it. + lit = mix(0.5, pow(lit, 1.4), uKey); + // Where in its own cloud this puff sits. The crown is in the light; the shelf + // underneath is in the cloud's own shadow, which is a thing no per-puff normal + // can know about on its own. + lit = clamp(lit * mix(0.72, 1.06, vHeight), 0.0, 1.2); + + vec3 color = mix(uShade, uLit, lit); + + // The underside. What faces down sees the ground and the haze over it rather + // than the sky, and at a low sun it sees almost nothing — which is the whole + // of "dark on the bottom at low sun". Weighted by height so a cloud's base + // goes dark and its crown does not. + float down = clamp(-dot(n, upView), 0.0, 1.0); + color = mix(color, uBase, uUnderside * down * mix(1.0, 0.22, vHeight)); + + // The silver lining: a rim of forward-scattered light where the limb points + // back toward a low sun. + float rim = pow(clamp(key, 0.0, 1.0), 6.0) * smoothstep(0.35, 0.95, sqrt(r2)); + color += uLit * rim * uRim; + + /** + * The same fog the rest of the scene is in, out of the same LightingState. + * + * Not THREE.Fog and not the material's fog: true path, which would mean + * merging UniformsLib.fog and threading four shader chunks through a custom + * program for numbers this layer is already holding. Reading them off the rig + * it was handed is fewer moving parts and cannot drift from the scene's, since + * it is literally the same three values SceneKit gave scene.fog. + * + * Colour only, alpha untouched — which is what three does too, and is right: a + * distant cloud does not become transparent, it becomes the same colour as the + * air in front of it. That is also what quietly hides the far edge of the + * wrapping tile at the whole-board framing. + */ + color = mix(color, uFogColor, smoothstep(uFogNear, uFogFar, -vViewPos.z)); + + gl_FragColor = vec4(color, alpha); + #include +} +`; + +// ---- Small maths ---------------------------------------------------------- + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function smoothstep(edge0: number, edge1: number, x: number): number { + if (edge1 <= edge0) return x < edge0 ? 0 : 1; + const t = clamp((x - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +/** + * `%` with the sign of the divisor, matching GLSL's `mod`. + * + * JavaScript's `%` is a remainder and keeps the sign of the *dividend*, so a + * westerly wind — which drives the drift negative on one axis — would wrap + * positions to the wrong half of the tile and tear the field along a line. The + * shader uses `mod`; the sort has to agree with the shader or it sorts positions + * nothing is drawn at. + */ +function posMod(v: number, m: number): number { + return ((v % m) + m) % m; +} + +/** Rec.709 luminance, in whatever space the colour is already in. */ +function luminance(c: THREE.Color): number { + return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; +} + +/** + * How consolidated the deck is, 0..1. + * + * Deliberately not `cover` itself. Cloud *amount* rises linearly from the first + * cumulus; the deck only starts behaving like a lid — dropping, flattening, + * darkening underneath — once the cells have grown into each other, which + * happens over the top of the range. + */ +function deckOf(cover: number): number { + return smoothstep(0.5, 1, cover); +} + +/** `deviceProfile()` reads `window`; the typecheck and the tests do not have one. */ +function isHandheld(): boolean { + if (typeof window === "undefined") return false; + return deviceProfile().handheld; +} diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 0018694..5c965ab 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -15,7 +15,7 @@ */ import * as THREE from "three"; -import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { airlinerGeometry } from "./aircraftGeometry.ts"; import type { Aircraft, City, FlightSource } from "./types.ts"; import { seededRandom, type World } from "./world.ts"; @@ -470,7 +470,7 @@ const SAME_POSITION_EPSILON = 1e-4; * It is a real ceiling now. It was declared and then referenced only by the * buffer sizing, so `tracks` grew without limit and `rebuildTrails` silently * ran out of vertices — which mattered the moment the godmode dial could put - * four hundred aircraft in the sky. Tracks past this many still get a dart; + * four hundred aircraft in the sky. Tracks past this many still get an aeroplane; * what they do not get is a trail, which is the graceful half to drop. */ const MAX_TRACKS = 192; @@ -484,7 +484,7 @@ const TRACK_GRACE_SECONDS = 32; /** * Opacity at the head of a trail, fading to nothing at the tail. Well under 1 - * on purpose: the trail is context for the dart, not a second subject, and a + * on purpose: the trail is context for the aircraft, not a second subject, and a * dozen opaque lines over a city read as a wiring diagram. */ const TRAIL_ALPHA = 0.55; @@ -537,7 +537,7 @@ const CRUISE_METRES = 9000; /** Distinct materials along the ramp. Enough to look continuous, few enough to cache. */ const COLOR_BANDS = 12; -/** Steepest nose-up or nose-down attitude a dart is drawn at, in radians. */ +/** Steepest nose-up or nose-down attitude an aircraft is drawn at, in radians. */ const MAX_PITCH = 0.42; interface TrailSample { @@ -571,12 +571,12 @@ interface Track { * longer drawn, while its history is still held. See `tick`. */ stale: boolean; - /** Altitude at `head`, which is what the dart's colour is chosen from. */ + /** Altitude at `head`, which is what the aircraft's colour is chosen from. */ headAltitude: number; } /** - * Aircraft as small darts, each dragging a fading trail of where it has been. + * Aircraft as small airliners, each dragging a fading trail of where it has been. * * Rendered at true altitude through the world's vertical exaggeration, so a jet * on approach sits visibly below one at cruise, and coloured by that altitude so @@ -593,7 +593,7 @@ export function createFlightLayer(world: World): FlightLayer { const group = new THREE.Group(); group.name = "flights"; - const geo = dartGeometry(); + const geo = airlinerGeometry(); const materials = new Map(); const tracks = new Map(); @@ -847,7 +847,7 @@ export function createFlightLayer(world: World): FlightLayer { track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha; track.mesh.position.copy(track.head); - // A heading of 0 is north, and north is -z, so a dart whose nose is + // A heading of 0 is north, and north is -z, so an aircraft whose nose is // modelled along +z has to be turned all the way round before the compass // and the scene agree. The previous mapping was a bare negation of the // heading, which flew every aircraft tail-first and put an easterly @@ -871,7 +871,7 @@ export function createFlightLayer(world: World): FlightLayer { * * The spine is every observation except the newest, followed by the * interpolated head — the newest observation is where the aircraft is *going*, - * and drawing to it would put the trail in front of the dart. + * and drawing to it would put the trail in front of the aircraft. */ function rebuildTrails() { let vertex = 0; @@ -981,35 +981,7 @@ export function createFlightLayer(world: World): FlightLayer { }; } -/** - * A dart: a five-sided body with a wing and a tailplane, merged into one - * geometry so an aircraft is one draw call. - * - * The wing is what earns its keep. A bare cone at this scale is a bright speck - * with no orientation, and the whole reason to draw traffic on a city map is - * that it is going somewhere — the crossbar is the only part of the silhouette - * that says which way. - */ -function dartGeometry(): THREE.BufferGeometry { - const body = new THREE.ConeGeometry(0.09, 0.42, 5); - body.rotateX(Math.PI / 2); // nose along +z, so heading is a rotation about Y - const wing = new THREE.BoxGeometry(0.44, 0.016, 0.085); - wing.translate(0, -0.005, -0.02); - const tail = new THREE.BoxGeometry(0.15, 0.014, 0.055); - tail.translate(0, 0.02, -0.165); - const parts = [body, wing, tail]; - const merged = mergeGeometries(parts); - for (const part of parts) part.dispose(); - if (merged) return merged; - - // `mergeGeometries` returns null when the inputs disagree about their - // attributes, which three primitives from the same library cannot — but the - // signature allows it, and a missing aircraft is worse than a plain one. - const fallback = new THREE.ConeGeometry(0.09, 0.42, 5); - fallback.rotateX(Math.PI / 2); - return fallback; -} /** * The climb angle of a leg, from the real numbers rather than the scene's. diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 7223986..4a44807 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -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; diff --git a/src/engine/starlinkMesh.ts b/src/engine/starlinkMesh.ts new file mode 100644 index 0000000..6a099f4 --- /dev/null +++ b/src/engine/starlinkMesh.ts @@ -0,0 +1,813 @@ +/** + * The few satellites you are actually looking at, drawn as satellites. + * + * `satellites.ts` renders the whole catalogue as one `THREE.Points` cloud at a + * fixed 3.5 screen pixels, and that is the right way to draw six thousand + * objects: a dot on a dome is a *direction*, which is the only thing about a + * satellite that survives the projection this engine has to make (read that + * file's header before this one — the argument for the dome is made there and is + * not repeated here). What a dot cannot do is say what the object *is*. Every + * point in that cloud looks like every other point, so a Starlink train reads as + * a line of specks, and the constellation whose entire visual signature is one + * enormous solar panel hanging off one side of a flat box reads as nothing at + * all. + * + * So this layer draws geometry for the handful nearest the middle of the view + * and leaves everything else as points. Two instanced meshes, sixty-four + * instances, two draw calls, and the dots go on being dots underneath. + * + * ### One array, on one side + * + * The shape is the point of the whole file, so it is worth being blunt about + * what it is not. The satellite everybody draws is a cube with two symmetric + * wings — the Hubble/comsat silhouette that has meant "spacecraft" since the + * seventies. A Starlink is not that and has never been that. It is a **flat + * rectangular bus** — flat because sixty of them stack in a fairing like plates, + * which is the design decision the entire constellation is built on — with a + * **single solar array** that unrolls from one edge and is two to three times + * the length of the bus it hangs off. The thing is profoundly lopsided, and that + * asymmetry is what you would recognise if you could see one. + * + * Drawing two symmetric wings here would be worse than drawing nothing, because + * it would be a confident, legible, wrong answer. The whole reason to promote a + * dot to a mesh is to say something true about the object. + * + * ### Deliberately, enormously, not to scale + * + * A Starlink is about ten metres across the deployed array, at a range of + * roughly 550 km. That is 1.8e-5 radians, near enough four arcseconds — at this + * scene's 42° field of view over a thousand-pixel canvas, **a fiftieth of a + * pixel**. There is no honest scale at which this layer draws anything at all. + * + * `satellites.ts` already made the same concession in the other direction: its + * dots are 3.5 px regardless of range, because an object at effectively infinite + * distance has an apparent size set by the eye and not by the geometry. This + * file takes that further and says so plainly. `SPAN_FRACTION` puts the drawn + * satellite at about a degree of arc — twice the moon, roughly twenty pixels, + * some nine hundred times its true angular size. The number was chosen as the + * smallest one at which the array-and-bus silhouette is still readable, and it + * carries exactly as much information as the dome's radius does, which is none. + * + * What *is* true is everything angular. The dome falsifies distance and + * preserves direction, so anything that can be expressed as an angle at the + * observer can still be right — and two of them are: + * + * - **The attitude.** A Starlink flies nadir-pointing, belly to the ground. + * Seen from here that is not the same as "belly toward the observer" except + * when it is directly overhead; at the horizon the nadir direction is 67° + * off the line of sight and you are looking at the thing nearly edge-on. + * `nadirOf` works that angle out from the range the fix already carries, so + * one overhead shows you its antenna face and one low in the north-west is + * a foreshortened sliver. That difference is free and it is real. + * - **The phase.** A satellite has phases for the same reason the moon does, + * and it is why a Starlink pass is a dusk-and-dawn event rather than a + * midnight one: at local midnight the object is in the earth's shadow, and + * at noon the sun is *behind* it from here and you are looking at its dark + * side. `phase` below is that geometry, and the array brightening as it + * approaches full is the flare people photograph. + * + * ### What this layer does not do + * + * It does not tell `satellites.ts` to stop drawing dots for the objects it has + * promoted, and it should not: the dot lands dead centre on the bus, is 3.5 px + * across against a mesh twenty px across, and reads as the specular glint off + * the chassis. Suppressing it would cost a coupling between two layers to make + * the picture slightly worse. + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import type { SatelliteFix } from "./satellites.ts"; + +const RAD = 180 / Math.PI; + +/** + * The dome factor, restated. + * + * `satellites.ts` keeps `DOME_RADIUS_FACTOR = 1.05` private, and the meshes have + * to land on **exactly** the shell the dots are on — not a similar one. Put them + * on different radii and the two layers agree only when the camera is at the + * scene origin; anywhere else the mesh separates from its own dot by parallax, + * which reads as a rendering fault rather than as a rounding error. + * + * This is a mirror and is meant to stop being one: see the wiring note. Export + * the constant from `satellites.ts`, import it here, and delete this. + */ +export const DOME_RADIUS_FACTOR = 1.05; + +/** + * Ceiling on meshes, and the reason the layer is affordable at all. + * + * Sixty-four is well past what is ever in shot at once — a busy Starlink sky + * over one city is a few hundred objects spread over the whole hemisphere, of + * which the selection cone below takes maybe a dozen — so in practice the cap + * never binds and exists to bound the buffers. Both instance buffers are + * allocated once at this size and `count` is moved, which is the cheap + * operation; growing an `InstancedMesh` means building a new one. + */ +const MAX_MESHES = 64; + +/** + * The selection cone, in degrees off the camera's own axis: full size inside + * `SELECT_FULL_DEG`, gone by `SELECT_EDGE_DEG`. + * + * Off the view *centre* rather than merely on screen, and that is the whole + * selection rule: the geometry should be where the user is looking. The scene's + * field of view is 42° vertical, so 12° is the middle third of the frame at full + * size and 26° reaches into the corners — a satellite drifting in from the edge + * has most of the screen width to grow across. + * + * Ranking by this angle rather than by range is deliberate. Every object on the + * dome is at the same radius, so range sorts by where the *camera* is and not by + * what it is aimed at, and an orbiting camera would see the meshes migrate + * around the sky for no reason the user could name. + */ +const SELECT_FULL_DEG = 12; +const SELECT_EDGE_DEG = 26; + +/** + * How far from the camera a mesh survives, as a multiple of the dome radius. + * + * This is the other half of the fade, and it exists because the camera can get + * outside the dome — `scene.ts` puts `maxDistance` at 2.0 board spans against a + * dome at about 0.99, precisely so the constellation can be looked at from + * above. From out there the near side of the dome is about one radius away and + * still worth drawing; the far side is three, where a twenty-pixel satellite has + * become four pixels of noise sitting on top of a dot that says the same thing + * more clearly. So the far side goes back to being points. + * + * At the other end of the zoom the camera is near the origin, every point on the + * dome is one radius away, and this term is a constant 1 — it never interferes + * with the case it is not there for. + */ +const RANGE_FULL = 1.15; +const RANGE_EDGE = 2.2; + +/** + * How many slots at the tail of the ranked list fade out, when the cap binds. + * + * Belt and braces. The cone fade already means the objects nearest the cut are + * the ones nearest the cone's edge and therefore already small — but that is a + * statement about a *typical* sky, and a genuinely dense cone would put sixty- + * fourth place somewhere near the middle of the screen at full size, popping in + * and out as the ordering churned. Applied only when there are more candidates + * than slots, so a sparse sky never sees it. + */ +const RANK_FADE_SLOTS = 8; + +/** + * Tip-to-tip size of a drawn satellite, as a fraction of the dome radius. + * + * Since the dome radius is also roughly how far away these things are, this is + * very nearly the angular size in radians: 0.016 rad is 0.92°, about twenty + * pixels at this field of view. See the header for why that is nine hundred + * times too big and why the alternative is a layer that renders nothing. + */ +const SPAN_FRACTION = 0.016; + +/** + * Elevation below which a satellite is not promoted, in degrees. + * + * The same number as `HORIZON_FADE_DEG` in `satellites.ts` and for the same + * reason — an object a degree up is behind the hills and behind more air than it + * can be seen through — restated because that constant is private too. It has to + * agree with the dot layer's or the mesh would fade in over a dot that was + * fading out. + */ +const HORIZON_FADE_DEG = 8; + +/** + * Brightness of a satellite whose lit side is facing entirely away, relative to + * one at full phase. + * + * Not zero, for the reason `satellites.ts` gives for `SHADOW_ALPHA`: the + * physically honest answer is that you cannot see it, and a layer that draws + * nothing at noon reads as broken rather than as correct. Higher than that + * file's 0.16 because a shape has to be legible to be a shape, where a dot only + * has to be present. + */ +const PHASE_FLOOR = 0.42; + +/** + * Brightness in the earth's umbra. Deliberately `SHADOW_ALPHA` from + * `satellites.ts`, so that a satellite entering eclipse dims by the same factor + * whether it is currently a dot or a mesh — the moment the two layers disagree + * about that is the moment a mesh crossing the terminator visibly steps. + */ +const ECLIPSE_FLOOR = 0.16; + +/** + * The bus. Pale because it is: white thermal blanket and bare aluminium, which + * is the brightest thing on the spacecraft and most of what a naked-eye pass + * actually is. + */ +const BUS_COLOR = new THREE.Color(0xd7dde6); + +/** + * The array, unlit and lit. + * + * Solar cells are the *darkest* part of any spacecraft — they are built to + * absorb, and they reflect under a tenth of what hits them — so the array is a + * near-black silhouette against a daylit sky, which is exactly the read the + * asymmetry needs. `ARRAY_GLINT` is the other half of the same fact: at high + * phase the cover glass throws a specular sheet back at the observer and the + * panel flares steely blue. Interpolated on the cube of the phase so the flare + * happens in the last part of the approach to full and not gradually across it. + */ +const ARRAY_COLOR = new THREE.Color(0x121a2c); +const ARRAY_GLINT = new THREE.Color(0x9db4d6); + +/** + * The spacecraft, in metres of real spacecraft. + * + * Built at true proportions and shrunk by exactly one number (`scale`, below), + * so the lie about size lives in one place and the shape stays honest. Roughly + * three metres of bus against eight of array is the ratio that matters; the + * absolute figures are approximate and nothing downstream reads them as fact. + * + * Axes are the local frame every matrix below is built in: **+X is the boom**, + * along which the array deploys, **+Y is zenith** so that −Y is the nadir face + * carrying the phased array, and +Z is what is left over. + */ +const BUS_LENGTH = 3.2; +const BUS_DEPTH = 1.6; +const BUS_THICK = 0.28; +const ARRAY_LENGTH = 8.4; +const ARRAY_WIDTH = 1.5; +const ARRAY_THICK = 0.06; +const BOOM_GAP = 0.6; +const BOOM_RADIUS = 0.08; + +/** Centre of the array, in the same frame. It hangs off +X and only +X. */ +const ARRAY_CENTRE_X = BUS_LENGTH / 2 + BOOM_GAP + ARRAY_LENGTH / 2; + +/** Tip of the array to the far edge of the bus — what `SPAN_FRACTION` scales. */ +const MODEL_SPAN = BUS_LENGTH + BOOM_GAP + ARRAY_LENGTH; + +/** Sentinel for an unused candidate slot. Finite, so the comparator is total. */ +const UNUSED_SCORE = 1e9; + +/** Earth's mean radius, for the nadir angle. Sphere is plenty at one degree. */ +const EARTH_RADIUS_KM = 6371; + +/** + * A direction *toward* the sun in the engine's axes — exactly what + * `solar.ts`'s `sunDirection` returns, and structurally a `THREE.Vector3`, so a + * caller holding either can pass it straight in. + */ +export interface SunVector { + readonly x: number; + readonly y: number; + readonly z: number; +} + +export interface StarlinkMeshLayer { + group: THREE.Group; + /** + * Redraw from the same fix list `SatelliteLayer.update` is given. + * + * The camera is a parameter rather than something the layer remembers because + * the selection is a function of where it is aimed *this frame*, and the sun + * is a parameter for the same reason `SatelliteCatalogue.fixes` takes a + * `when`: godmode scrubs the clock, and a layer that quietly called + * `solarPosition(new Date())` would be the one thing in the scene still + * pointing its solar panels at yesterday afternoon. + */ + update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void; + setVisible(visible: boolean): void; + dispose(): void; +} + +/** + * One satellite that got through the filters, with everything the ranking and + * the draw need. + * + * These are pooled and reused rather than built per frame. `update` runs at 60 + * Hz over a few hundred fixes, and a few hundred short-lived objects a frame is + * twenty thousand a second of pure garbage for a layer whose entire job is to + * be cheap enough to leave on. + */ +interface Candidate { + fix: SatelliteFix | null; + /** Degrees off the camera's axis. Ascending; `UNUSED_SCORE` sorts to the end. */ + score: number; + /** 0 to 1. Drives the scale, which is how a mesh grows out of its own dot. */ + fade: number; + /** Where on the dome it sits, in scene space. */ + readonly at: THREE.Vector3; +} + +export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer { + const group = new THREE.Group(); + group.name = "starlink-meshes"; + + const busGeometry = buildBus(); + const arrayGeometry = buildArray(); + + /** + * `MeshBasicMaterial`, and the scene's lights are deliberately ignored. + * + * This is the one decision here that looks like laziness and is not. A + * `MeshLambertMaterial` would be lit by the city's rig — and that rig is a + * model of the light *at the ground*, which after sunset is a tenth of an + * intensity with the sun pushed below the horizon. A Starlink is visible + * precisely when the ground is dark and the satellite is not, so shading these + * with the city's sun would black out the constellation at exactly the hour it + * exists to be looked at, and light it in the middle of the day when it cannot + * be seen at all. Backwards in both directions. + * + * So the shading is computed per instance on the CPU — the phase term in + * `update` — and written into `instanceColor`, which a basic material + * multiplies straight into its diffuse. It costs one dot product per drawn + * satellite, of which there are at most sixty-four, and it is the only shading + * model in this file that has the satellite's own geometry to work from rather + * than the city's. + * + * `fog: false` for the reason `satellites.ts` states for its points and which + * is, if anything, stronger for a solid: the city's linear fog reaches its far + * plane at 2.8 board spans, so a mesh out on the dome would be mixed most of + * the way to the fog colour and the constellation would dim as the camera + * pulled back, exactly as more of it came into view. Haze belongs to the + * twelve kilometres of air a city sits in. This is 550 km above all of it. + */ + const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false }); + const arrayMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + fog: false, + /** + * The array is a flat panel edge-on for part of every orbit, and a + * back-faced panel disappears entirely at the moment it is most + * foreshortened. It has two sides in reality — cells one way, substrate the + * other — and drawing both is a hundred and forty-four extra triangles + * across the whole layer. + */ + side: THREE.DoubleSide, + }); + + const bus = new THREE.InstancedMesh(busGeometry, busMaterial, MAX_MESHES); + const array = new THREE.InstancedMesh(arrayGeometry, arrayMaterial, MAX_MESHES); + bus.name = "starlink-bus"; + array.name = "starlink-array"; + for (const mesh of [bus, array]) { + /** + * `InstancedMesh` culls on a bounding sphere it computes **once** from the + * instance matrices and then caches. Every matrix here is rewritten every + * frame from a different set of satellites, so that sphere is stale from the + * second frame onward and culling on it would cull the layer at random. The + * cost of not culling is two draw calls that were going to happen anyway. + */ + mesh.frustumCulled = false; + mesh.count = 0; + group.add(mesh); + } + + /** + * Metres of spacecraft to scene units. The one place the size lie is told. + */ + const scale = (SPAN_FRACTION * domeRadius) / MODEL_SPAN; + + const pool: Candidate[] = []; + + // Scratch, all of it. Nothing in `update` allocates. + const eye = new THREE.Vector3(); + const forward = new THREE.Vector3(); + const sunDir = new THREE.Vector3(); + const toSat = new THREE.Vector3(); + const radial = new THREE.Vector3(); + const nadir = new THREE.Vector3(); + const zenith = new THREE.Vector3(); + const boom = new THREE.Vector3(); + const third = new THREE.Vector3(); + const perpendicular = new THREE.Vector3(); + const scaleVec = new THREE.Vector3(); + const busMatrix = new THREE.Matrix4(); + const arrayMatrix = new THREE.Matrix4(); + const hinge = new THREE.Matrix4(); + const tint = new THREE.Color(); + + function slot(index: number): Candidate { + const existing = pool[index]; + if (existing !== undefined) return existing; + const made: Candidate = { fix: null, score: UNUSED_SCORE, fade: 0, at: new THREE.Vector3() }; + pool.push(made); + return made; + } + + /** + * Azimuth and elevation to a point on the dome. + * + * The same arithmetic as `satellites.ts`'s own `place`, restated because it is + * a closure in there, and it must stay identical: azimuth is clockwise from + * north, scene north is −Z and east is +X, which is `sin` on X and `−cos` on Z + * with no sign fudge anywhere. Get it wrong and the meshes are a mirror image + * of the dots they are supposed to be sitting on. + */ + function place(fix: SatelliteFix, into: THREE.Vector3): void { + const cosEl = Math.cos(fix.elevation); + into.set( + Math.sin(fix.azimuth) * cosEl * domeRadius, + Math.sin(fix.elevation) * domeRadius, + -Math.cos(fix.azimuth) * cosEl * domeRadius, + ); + } + + /** + * Which way is down, from the satellite's point of view, expressed as a + * direction in the observer's sky. + * + * Not `-radial`. That would be "point the belly at the middle of the board", + * which is right for a satellite at the zenith and increasingly wrong as it + * descends: the spacecraft's nadir points at the *earth's centre*, and the + * observer is not the earth's centre. The angle between the two — the nadir + * angle η, the same one a ground station's link budget is written in — grows + * to about 67° at the horizon for a 550 km orbit, which is the difference + * between seeing the antenna face and seeing the edge of the chassis. + * + * It falls out of the triangle centre–observer–satellite with no new inputs, + * because the fix already carries the range. With Re the earth's radius, r the + * slant range and e the elevation, the satellite's geocentric radius is + * + * Rs² = Re² + r² + 2·Re·r·sin e + * + * (law of cosines, the interior angle at the observer being 90° + e), and then + * the law of sines gives sin η = Re·cos e / Rs directly. At e = 0 and 550 km + * that is 6371/6921 = 0.92, so η = 67°; at the zenith it is 0 and the belly + * genuinely does point at the observer. + * + * The rotation is in the vertical plane through the satellite, tilted from the + * line of sight *downward* — away from the zenith — because the sub-satellite + * point is further from the observer than the observer is from themselves. The + * cheap check: at e = 45° over the north this returns very nearly straight + * down with a slight lean back toward the south, which is where the ground + * under the satellite is relative to the ground under the viewer. + */ + function nadirOf(fix: SatelliteFix, up: THREE.Vector3, out: THREE.Vector3): void { + const rs = Math.sqrt( + EARTH_RADIUS_KM ** 2 + + fix.rangeKm ** 2 + + 2 * EARTH_RADIUS_KM * fix.rangeKm * Math.sin(fix.elevation), + ); + const eta = + rs > 0 ? Math.asin(clamp((EARTH_RADIUS_KM * Math.cos(fix.elevation)) / rs, 0, 1)) : 0; + + // The line of sight, satellite to observer. + out.copy(up).negate(); + + // The downward-pointing unit vector perpendicular to it, in the vertical + // plane: −Y with its component along the line of sight projected out. + perpendicular.set(0, -1, 0).addScaledVector(out, out.y); + const length = perpendicular.length(); + // Zero only when the line of sight is itself vertical — the satellite is at + // the zenith — where η is zero as well and the answer is already correct. + if (length < 1e-6) return; + + perpendicular.divideScalar(length); + out.multiplyScalar(Math.cos(eta)).addScaledVector(perpendicular, Math.sin(eta)).normalize(); + } + + function update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void { + if (!group.visible) return; + + /** + * Both of these call `updateWorldMatrix` on the way through, which matters: + * the renderer updates the world matrices during `render`, so a layer + * ticked before it is looking at last frame's camera. One frame of lag in a + * *position* is invisible; one frame of lag in a selection rule means the + * meshes trail the aim during an orbit, which is the artefact this layer + * would be blamed for. + */ + camera.getWorldPosition(eye); + camera.getWorldDirection(forward); + + sunDir.set(sun.x, sun.y, sun.z); + // A zero sun direction has no meaning and would make every basis below + // degenerate. Straight up is arbitrary and keeps the geometry well-formed. + if (sunDir.lengthSq() < 1e-12) sunDir.set(0, 1, 0); + else sunDir.normalize(); + + let found = 0; + for (const fix of fixes) { + /** + * Starlink only, and the file is named for it. + * + * This shape is a specific spacecraft, not a generic satellite: a GPS bird + * is a drum with two wings and the ISS is neither. Drawing a Galileo + * satellite with a Starlink's single unrolled array would be the same + * error as the two-symmetric-wings clip-art, only pointed the other way. + * Every other group stays a dot, which claims nothing. + */ + if (fix.group !== "starlink") continue; + + // `> 0` rather than `>= 0` and written to fail on NaN, for the reason + // `SatelliteCatalogue.fixOne` gives: a degenerate element set produces NaN + // look angles, and a NaN in an instance matrix takes out the whole + // instanced draw rather than one satellite. + const elevationDeg = fix.elevation * RAD; + if (!(elevationDeg > 0)) continue; + const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG); + + const candidate = slot(found); + place(fix, candidate.at); + + toSat.subVectors(candidate.at, eye); + const distance = toSat.length(); + // The camera standing exactly on a satellite has no direction to it. It + // cannot happen from any reachable pose; it costs one compare to make sure + // it cannot produce a NaN either. + if (distance < 1e-6) continue; + + const offDeg = Math.acos(clamp(toSat.dot(forward) / distance, -1, 1)) * RAD; + const aim = falloff(offDeg, SELECT_FULL_DEG, SELECT_EDGE_DEG); + if (aim <= 0) continue; + + const range = falloff(distance / domeRadius, RANGE_FULL, RANGE_EDGE); + if (range <= 0) continue; + + candidate.fade = horizon * aim * range; + candidate.score = offDeg; + candidate.fix = fix; + found += 1; + } + + // Release the rest of the pool so the sort puts them past the end. The + // objects are kept; only their claim on a slot is dropped. + for (let i = found; i < pool.length; i++) { + const stale = pool[i]; + if (stale !== undefined) { + stale.fix = null; + stale.score = UNUSED_SCORE; + } + } + pool.sort(byScore); + + const drawn = Math.min(found, MAX_MESHES); + for (let i = 0; i < drawn; i++) { + const candidate = pool[i]; + const fix = candidate?.fix; + if (candidate === undefined || !fix) continue; + + radial.copy(candidate.at).normalize(); + nadirOf(fix, radial, nadir); + zenith.copy(nadir).negate(); + + /** + * Yaw steering, which is what the real spacecraft does and what makes one + * hinge sufficient. + * + * The array has a single degree of freedom — it rotates about the boom — + * so it can only face the sun if the boom is perpendicular to the sun to + * begin with. A real satellite achieves that by rotating its whole body + * about the nadir axis as it goes round the orbit, which costs it nothing + * because nadir-pointing leaves that rotation free. Choosing the boom as + * `zenith × sun` is exactly that manoeuvre, solved in closed form: it is + * perpendicular to the nadir axis, so the bus is still belly-down, and + * perpendicular to the sun, so the hinge below can then aim the panel + * dead-on rather than approximately. + * + * The cross product collapses only when the sun is straight up from the + * satellite — the subsolar point — where the hinge angle comes out zero + * and any perpendicular gives the right answer anyway, which is why the + * fallback can be arbitrary. + */ + boom.crossVectors(zenith, sunDir); + if (boom.lengthSq() < 1e-8) anyPerpendicular(zenith, boom); + boom.normalize(); + third.crossVectors(boom, zenith); + + /** + * The hinge. The array's face is local +Y, so after a rotation of θ about + * the boom it points along cos θ · zenith + sin θ · third, and the θ that + * lands it on the sun is the arctangent of the sun's components in that + * plane. Because the boom was chosen perpendicular to the sun, the sun has + * no component outside the plane and this is exact rather than nearest. + */ + const theta = Math.atan2(sunDir.dot(third), sunDir.dot(zenith)); + + /** + * The fade is a *scale*, not an opacity, and that is what makes the + * transition from point to mesh invisible. + * + * Opacity was the obvious version and is worse in three ways: a standard + * material has no per-instance alpha, so it would have taken a shader + * patch; transparency would have forced `depthWrite: false` and let the + * bus and the array punch holes in each other; and a half-transparent + * satellite over a half-bright dot is a muddier picture than either. A + * mesh scaled to a fifth is *smaller than the dot it is standing on* and + * simply hides inside it, so the object grows out of its own point and + * shrinks back into it. `falloff` is a smoothstep, so the size ramp has + * zero derivative at both ends and there is no moment where it starts. + * + * `rank` is the same trick applied to the cap rather than to the cone: + * the last few slots of a list that has run out of room shrink away, so + * the object bumped by the sixty-fifth arrival was already tiny when it + * went. It is 1 whenever the cap is not binding, which is nearly always. + */ + const rank = + found > MAX_MESHES ? clamp((MAX_MESHES - i) / RANK_FADE_SLOTS, 0, 1) : 1; + scaleVec.setScalar(scale * candidate.fade * rank); + + busMatrix.makeBasis(boom, zenith, third).scale(scaleVec).setPosition(candidate.at); + bus.setMatrixAt(i, busMatrix); + + /** + * The array rides the same origin and basis as the bus with the hinge + * rotation inserted, and its offset down the boom is baked into its + * geometry rather than into this matrix — which is why rotating about the + * boom pivots the panel about the hinge instead of swinging it around the + * bus. Same position, same scale, one extra rotation. + */ + hinge.makeRotationX(theta); + arrayMatrix + .makeBasis(boom, zenith, third) + .multiply(hinge) + .scale(scaleVec) + .setPosition(candidate.at); + array.setMatrixAt(i, arrayMatrix); + + /** + * Phase, exactly as for the moon: how much of the lit side is turned this + * way. `radial` points from the earth to the satellite, so `−radial` is + * near enough the direction from the satellite to the observer, and its + * dot with the sun is the cosine of the phase angle. Positive when the sun + * is below the observer's horizon and the object is still in daylight, + * which is the entire observing window for a Starlink pass; zero at noon, + * when the sun is behind it from here and the side facing down is the side + * in shadow. + * + * (The sun's direction from 550 km up differs from its direction at the + * ground by about a thousandth of a degree, so the scene's own vector is + * used without correction.) + */ + const phase = clamp(-radial.dot(sunDir), 0, 1); + const lit = ECLIPSE_FLOOR + (1 - ECLIPSE_FLOOR) * (1 - clamp(fix.shadow, 0, 1)); + const facing = PHASE_FLOOR + (1 - PHASE_FLOOR) * phase; + + tint.copy(BUS_COLOR).multiplyScalar(facing * lit); + bus.setColorAt(i, tint); + tint.copy(ARRAY_COLOR).lerp(ARRAY_GLINT, phase ** 3).multiplyScalar(lit); + array.setColorAt(i, tint); + } + + bus.count = drawn; + array.count = drawn; + bus.instanceMatrix.needsUpdate = true; + array.instanceMatrix.needsUpdate = true; + // Allocated lazily by the first `setColorAt`, which on a sky with nothing + // above the horizon has not happened yet. + if (bus.instanceColor) bus.instanceColor.needsUpdate = true; + if (array.instanceColor) array.instanceColor.needsUpdate = true; + } + + return { + group, + update, + /** + * Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the + * early return in `update`. The distinction is not an inconsistency: that + * layer keeps propagating while hidden because its state is a *sweep* that + * would otherwise resume half a catalogue behind reality. This layer holds + * no state between frames at all, so a hidden one has nothing to catch up + * on and the next visible frame is complete. + */ + setVisible(visible: boolean) { + group.visible = visible; + }, + dispose() { + // The instanced meshes first. `InstancedMesh.dispose()` releases the + // per-instance matrix and colour buffers, which are the layer's own + // allocation and are not reached by disposing the geometry they wrap — + // two `Float32Array`s of 64 instances each, orphaned on the GL context on + // every city switch until this line existed. + bus.dispose(); + array.dispose(); + busGeometry.dispose(); + arrayGeometry.dispose(); + busMaterial.dispose(); + arrayMaterial.dispose(); + group.clear(); + }, + }; +} + +/** + * The bus: a flat slab, the phased-array antenna stepped out of its underside, + * and the boom stub the panel deploys along. + * + * The antenna step is worth its four triangles because the slab alone is a + * shape with no side to it — the whole read of "belly pointing down" comes from + * being able to see which face is which at an oblique angle. The boom is in the + * bus rather than the array partly because it is structure rather than panel and + * takes the pale material, and partly because a cylinder lying along the hinge + * axis is invariant under the hinge rotation, so it looks identical either way + * and this way it costs no second matrix. + */ +function buildBus(): THREE.BufferGeometry { + const chassis = new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH); + + const antenna = new THREE.BoxGeometry(BUS_LENGTH * 0.78, BUS_THICK * 0.45, BUS_DEPTH * 0.72); + antenna.translate(0, -BUS_THICK * 0.6, 0); + + const stub = new THREE.CylinderGeometry(BOOM_RADIUS, BOOM_RADIUS, BOOM_GAP * 1.4, 6); + // `CylinderGeometry` runs along +Y; the boom runs along +X. + stub.rotateZ(Math.PI / 2); + stub.translate(BUS_LENGTH / 2 + BOOM_GAP / 2, 0, 0); + + const parts = [chassis, antenna, stub]; + const merged = mergeGeometries(parts); + for (const part of parts) part.dispose(); + if (merged) return merged; + + // The same non-null dance as `flights.ts`'s `dartGeometry`, for the same + // reason: three primitives out of the same library cannot disagree about their + // attributes, the signature permits it anyway, and a plain slab is a better + // failure than a missing layer. + return new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH); +} + +/** + * The array: one panel, on one side, offset down the boom in its own geometry so + * that the instance matrix can be a pure rotation about the hinge. + * + * A box rather than a plane. A plane would halve the triangles and is the + * obvious choice for something two centimetres thick at eight metres long — but + * the edge is what you see during the part of the orbit where the panel is + * turned away from you, and a zero-thickness panel vanishes completely at + * exactly that moment. Six centimetres of scene-space thickness is a fiction in + * the same way the overall size is, and it buys a silhouette that never + * disappears. + */ +function buildArray(): THREE.BufferGeometry { + const panel = new THREE.BoxGeometry(ARRAY_LENGTH, ARRAY_THICK, ARRAY_WIDTH); + panel.translate(ARRAY_CENTRE_X, 0, 0); + return panel; +} + +/** Ascending by angle off the view centre; released slots sort to the back. */ +function byScore(a: Candidate, b: Candidate): number { + return a.score - b.score; +} + +/** + * 1 at or below `full`, 0 at or above `edge`, smoothstepped between — so both + * ends of every ramp in this file arrive with zero slope, which is the whole + * anti-pop argument in one function. + */ +function falloff(x: number, full: number, edge: number): number { + if (x <= full) return 1; + if (x >= edge) return 0; + const t = (x - full) / (edge - full); + return 1 - t * t * (3 - 2 * t); +} + +/** + * Any unit vector perpendicular to `v`, for the one degenerate case where the + * caller genuinely does not care which. Crossed against whichever world axis `v` + * is least aligned with, because crossing against a near-parallel axis is how a + * "just pick one" helper returns a zero vector. + */ +function anyPerpendicular(v: THREE.Vector3, out: THREE.Vector3): void { + if (Math.abs(v.y) < 0.9) out.set(0, 1, 0).cross(v); + else out.set(1, 0, 0).cross(v); + out.normalize(); +} + +function clamp(x: number, lo: number, hi: number): number { + return x < lo ? lo : x > hi ? hi : x; +} + +/** + * ---- Numbers a reviewer can check without running anything ----------------- + * + * On the Bay Area board `boardRadius` is about 0.94 of a 1,003-unit span, so the + * dome is at 990 units and a drawn satellite is 15.8 of them tip to tip — about + * one and a half kilometres of city, at a + * range of roughly 1,000 units, which is 0.92° of arc or some twenty pixels of a + * 1,000-pixel canvas at this scene's 42° field of view. + * + * `nadirOf` at 550 km, checked against the numbers in its own derivation: + * + * elevation 90° range 550 km Rs 6921 η 0.0° belly at the observer + * elevation 45° range 749 km Rs 6921 η 40.6° belly nearly straight + * down, leaning back + * toward the observer + * elevation 20° range 1,294 km Rs 6921 η 59.9° + * elevation 0° range 2,704 km Rs 6921 η 67.0° seen edge-on + * + * Over the north those come out as nadir vectors of (0, −1, 0), (0, −0.997, + * 0.077), (0, −0.984, 0.176) and (0, −0.921, 0.391) — the lean being southward, + * back over the observer, and reaching 23° off vertical at the horizon. + * + * The orientation as a whole holds two invariants that are worth asserting if + * this ever grows a test: the bus's local −Y lands exactly on the nadir vector + * (dot 1.0000), and the array's face lands exactly on the sun (dot 1.0000) for + * every azimuth, elevation and sun position, including the subsolar degeneracy + * where the boom has to be guessed. The basis is right-handed throughout + * (determinant +1), so nothing is drawn inside out. + * + * `phase` for a satellite at the zenith is `−sin(sun elevation)`: 0 with the sun + * anywhere above the horizon, 0.5 with it 30° down, 1 at solar midnight — at + * which point the same satellite is in the earth's shadow and `lit` has taken it + * to 0.16 anyway. The band where a Starlink is both at high phase and out of + * eclipse is the hour or so after sunset and before sunrise, which is when + * anybody has ever seen one. + */ diff --git a/src/interiors/daylight.ts b/src/interiors/daylight.ts index 42fdb24..b4fa03f 100644 --- a/src/interiors/daylight.ts +++ b/src/interiors/daylight.ts @@ -95,6 +95,80 @@ export function officeDaylight(state: LightingState, site: OfficeSite): Lighting }; } +/** + * 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. * diff --git a/src/interiors/furnish.ts b/src/interiors/furnish.ts index d611d681f10cb5925407e7969c8124c60e0b4555..bd8244542d33b7826884e39cf358b43511fdb582 100644 GIT binary patch delta 2080 zcmZ`)(Te0m6b12NnPGom=fTUo*xj_*1rcEuU6mc!K^H{_eGs85$xWx6q$*TZCKHz- zf}b$@4?YU>8-9V0eu?K+lF5u>Gfa1q%Bg$LJ?GRfpZxySA0HeJPdA$b+R&GVDODjW z$re=UeHp0GA!xJrTk3QuDU?i|byZG{^*U&4WGP>=>*uAU4e5{oPat0@lXFhC4dgBU zY7QmvNTv3YDN}|*>jwM>aNN-oF#rDfSMtoXtJ+!{r5*bu_?>{#Iabb_ds2?$yPKv` z8P{xr-=Nk~qY3QfQB}pHD#t4CsZ==yOPQ^kHsH3&1+g3Aa%0ERxbkCVjx!YLPOy%4 zXT}F*GR|MKFLzYhD)$k{dh}PT;B>}*D|mpVMrRkXPYO;MU#ZAds&J7ZU& zR3SF2Jf*F!Fj{BpaZcCg)&&eNuq!|%eKAx2SyaTE1ELzOpV0ZMuTM{t2Yfuo zLuiP;`;RpAL>uk&Zc8e}iY8IL>#4S_33@>7n4jTKmnlr zG-`Tu!#%##sM7bQ7D#!|A)(W{3B4#^p$g16n#I@=>Yj(9&}6>k+eQe7b}(RQQ6w+T zxWUR;yj&P;7AWn<2YNrksS48B5?`q-R6Qy{Yt+eD8H|Vm`f>CC;VbR22aJQBgxbYW zC;yu8Ozy2YyngG+|F66K>v8cok_5~c9Qej(L!Um(3~53vv^QETf;MCXkucKvmf*Mf zChWsbt18wR!KC2$Bl=zE0@gvo6Tt(iHk6ou=j>=?SpX)}il0ZiW=OfO7PO7Sppg*b z(OnFjm%1og&u$kHN(4$ijt@NE3k2~`3_4J4!r&K3I9C*y(C6HX43S?l$h!hYo0bZB zS3_gcLeV1i7U6omHw&IeVi}q2k5-%6)*0(ceoq-WNd1VhX}?aiBH=2bfdZ=wMzYNe zj=>Q#dAhYc+s!7}A#h3HaJhD19>=ka(QKegW1};5ivJPf(bdxK~;FQj<)GNA;%Zdh`{Be3tBneL;9{y zaCX@0lF#y0p`8y2VmV(Y+GJImvtJ#bL`sw6^_nE(#!qIETX#8h^Ul$e!EiT4-;_qH zCYjHn 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 = []; + }, + }; +} diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index 3dd84a8..f3e1cc2 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -82,6 +82,8 @@ import { createFurnishings, type Furnishings } from "./furnish.ts"; import { Plan, type Depth, type PlanOptions } from "./plan.ts"; import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts"; import { createShell, type Shell, type WallInfo } from "./shell.ts"; +import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts"; +import { createRobotLayer, type RobotLayer, type RobotSpec } from "./robots.ts"; import type { Office, Point2, Presence, Viewpoint } from "./types.ts"; // Re-exported so a caller can name the tier it is asking for without importing @@ -189,6 +191,17 @@ export interface OfficeSceneOptions { * those are the three things you actually feel. */ horizon?: { drop: number }; + /** + * Humanoids to walk about the floor, one entry per robot. + * + * **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.** + * The build-time-exclusion rule in this file's header is about *occupancy* — a + * `Presence` names a person and comes from an authenticated API, so the public + * office must not construct one. A robot is nobody: it carries no id anybody + * issued, no seat binding, and no data from anywhere. There is nothing to + * withhold, so a stranger gets them too. + */ + robots?: readonly RobotSpec[]; /** Defaults to false — the lid comes off, because that is the whole view. */ showCeilings?: boolean; /** Fade the walls you are looking through. Defaults to true. */ @@ -227,6 +240,26 @@ export interface OfficeScene extends StageScene { anchors: Map; setCeilingsVisible(visible: boolean): void; setLighting(state: LightingState): void; + /** + * The sun's height, in degrees, from whatever clock the app is running. + * + * This is what turns the lights on. It is a separate call from `setLighting` + * and not a field on `LightingState` for the reason `scene.ts` gives for the + * city's identical pair: a `LightingState` is a rig, and how far below the + * horizon the sun is is a fact about the sky that the rig has already spent. + */ + setSolarElevation(degrees: number): void; + /** + * How much of the building's own light is on, 0..1, after the last + * `setSolarElevation`. The caller adds it to the rig — see `withHouseLights` + * in `daylight.ts`, and CONTRACT.md §4 on why this file does not. + */ + houseLevel(): number; + /** + * Who is moving about the floor, so the fittings above them can come up. + * Cheap; call it every frame. An empty list is the normal state. + */ + setWalkers(walkers: readonly Walker[]): void; } export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene { @@ -391,6 +424,38 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): ...(options.registry ? { registry: options.registry } : {}), ...(options.colorFor ? { colorFor: options.colorFor } : {}), }); + + /** + * The ceiling, made switchable. + * + * Built from the furnishings rather than from the pack, because what a + * fitting *is* has already been resolved by then: an id has been through the + * registry's override table, and a self-hoster who pointed + * `tera:light.troffer` at their own asset gets their fitting switched on + * rather than a fitting nobody placed. + * + * Harmless on a pack with no fittings — the list is empty, `tick` does + * nothing, and `houseLevel` still reports the hour so the caller's rig can + * make its own decision. + */ + const luminaires: Luminaires = createLuminaires(furnishings.luminaires); + + const robots: RobotLayer | null = + options.robots && options.robots.length > 0 + ? createRobotLayer(plan, { materials, robots: options.robots }) + : null; + if (robots) { + scene.add(robots.group); + /** + * Once, not per frame. + * + * `robots()` hands back a stable array of stable `Vector3`s that the layer + * mutates in place, so the luminaires are reading this frame's positions + * through a reference taken at setup. Calling it every frame would allocate + * nothing extra but would imply the array were a snapshot, which it is not. + */ + luminaires.setWalkers(robots.robots()); + } // A public office has no presence layer, rather than an empty one. The // difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group` // named "presence" hanging in the scene graph, a `setPresence` that works, and @@ -676,6 +741,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): kit.applyLighting(state); paintHorizon(state); }, + setSolarElevation(degrees) { + luminaires.setSolarElevation(degrees); + }, + houseLevel: () => luminaires.houseLevel(), + setWalkers(walkers) { + luminaires.setWalkers(walkers); + }, // Stepping back out to the city should retire the hover with it, or the // detail card for whoever the pointer was over survives the journey. onExit: () => kit.resetPick(), @@ -683,8 +755,14 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): if (disposed) return; kit.tick(dt); updateOcclusion(); + // Robots first: the lights above them should respond to where they are + // *now*, not to where they were last frame. + robots?.tick(dt); + luminaires.tick(dt); }, dispose() { + robots?.dispose(); + luminaires.dispose(); if (horizonPlane) { horizonPlane.geometry.dispose(); (horizonPlane.material as THREE.Material).dispose(); diff --git a/src/interiors/robots.ts b/src/interiors/robots.ts new file mode 100644 index 0000000..9e1b4aa --- /dev/null +++ b/src/interiors/robots.ts @@ -0,0 +1,1014 @@ +/** + * Optimus robots walking around the office. + * + * This is `presence.ts`'s noisy cousin and it is deliberately a different shape, + * because it is solving a different problem. A presence is a person *at a seat*: + * it has an id, it comes from an API, it never moves, and the whole design + * effort went into making sure the geometry and the people stay on opposite + * sides of a line. A robot is nobody. It has no id worth publishing, it comes + * from nowhere, and it exists to make a still building look like a place where + * something is happening. So there is no palette, no `colorKey`, no binding to + * anything in the pack, and nothing here can leak: the only inputs are a `Plan` + * and a list of levels. + * + * ### What it costs + * + * Four robots is the budget and roughly the right number — one is a mascot, ten + * is a warehouse. + * + * - **18 draw calls each, 72 for four.** This is the whole cost and it is not + * small; the office shell and its furniture together draw in about forty. + * The reason is in `assets/office/optimus.ts`: a figure that has to bend at + * eleven joints cannot be one merged mesh, so it is eighteen small merged + * meshes instead. If a floor needs the budget back, drop the count — the + * cost is exactly linear in it, and a pack that only wants a robot standing + * somewhere should place the `tera:robot.optimus` asset, which is two. + * - **One set of geometry, 7.6k triangles, however many robots there are.** + * `buildOptimus` runs once and every figure after the first is a + * `cloneOptimus`, which shares every buffer and both materials. + * - **About 30 µs per tick for the crowd**, measured over thirty simulated + * minutes on the reference office — roughly 0.2% of a 60 Hz frame. Most of a + * tick is `plan.blocked`, which is linear in the level's collision segments + * (fifty-four on that floor); a robot spends one or two calls a frame + * steering and up to fifteen on the frames where it is boxed in and fanning + * out. Nothing here is worth caching. + * - **Picking a destination costs up to 24 `blocked` calls**, but only on the + * frame a robot arrives somewhere, which is every few seconds. A robot that + * finds nowhere to go waits `RETRY_PAUSE` before trying again, so even one + * that has been sealed into a cupboard costs a burst every second and a half + * rather than one every frame. + * + * ### Navigation: rejection sampling, not a navmesh + * + * Building a navmesh for an office would mean a floor decomposition, a portal + * graph, A*, string-pulling and a funnel — several hundred lines, a new build + * product to keep in step with `Plan`, and a whole second definition of "where + * can you stand" beside the one the wall split already produces. All of that to + * decide which way a decorative robot walks round a desk. + * + * So: pick a random point on the floor, keep it if `plan.roomAt` says it is + * indoors and `plan.blocked` says the straight line from here to there crosses + * no wall, and walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch + * one robot for a minute and it looks like it is wandering; watch the algorithm + * and it is playing join-the-dots with its own line of sight. Both readings are + * correct and only one of them is visible. + * + * Two refinements on top of that, and both exist because the plain version was + * measured and found wanting rather than because they seemed like good ideas. + * Each is documented where it lives: + * + * - Candidates are drawn from a **room chosen by area**, not from the level's + * bounding box, because a level's rooms cover a fraction of its bounds and + * most candidates were landing in the void outside the walls (`samplerFor`). + * - A robot that can see nowhere to go walks to a **doorway** instead, via a + * waypoint in the opening itself, because line of sight out of a small room + * through a 0.9 m gap almost never exists and three of four robots spent + * nineteen simulated minutes parked in one (`pickDoor`). + * + * Together those take the crowd from 82% of the session standing still to under + * 30%, which is the difference between an office with robots in it and an office + * with four statues. + * + * Two things this deliberately does not know about: + * + * - **Furniture.** `plan.blocked` is the wall collider and nothing else, so + * robots walk through desks. Fixing it means asking the asset registry for + * every prop's footprint and building a second collider, which is a real + * feature with a real cost and is not this. If it ever matters, the place to + * put it is `Plan`, next to the wall split, so that the walk controller and + * the robots get the same answer. + * - **Stairs.** A robot belongs to one level for its whole life. Levels are + * connected by nothing in the office contract, so there is nowhere for it to + * go, and a robot that walked off a mezzanine would be a bug rather than a + * feature. + * + * ### Getting unstuck, which is the part that actually needs care + * + * A straight line that was clear when the destination was chosen can stop being + * clear, because a robot turns on an arc rather than pivoting on the spot. So + * every step is re-checked, and the machinery for that is three rules that have + * to hold together — each of them broke on its own during development, and each + * failure looked like a robot standing still and thinking: + * + * 1. **One desired heading, one turn.** The heading is chosen by fanning out + * from the direction the destination wants, and then the yaw is turned + * toward it once, rate-limited. Turning toward the destination *and* toward + * the probe result in the same frame gives two rate-limited turns that + * cancel exactly, and a robot locked at a fixed angle off course forever. + * 2. **Walk only what was tested.** The rate limit means that after turning, + * the robot faces somewhere between where it was and where it probed. That + * direction has not been checked, and walking it is how a robot ends up + * inside the clearance band. + * 3. **Probe a fixed lookahead, never the step length.** `plan.blocked` is + * true when the capsule comes within `radius` of a wall *including at its + * start*, so a robot already closer to a wall than its own radius has every + * direction blocked, away from the wall included. `PROBE_AHEAD` keeps it + * well clear of that band, and `PROBE_RELIEF` gets it back out if it ever + * gets in. + * + * And because none of that is a proof, there is a watchdog on top: a robot that + * covers less than `STUCK_DISTANCE` in `STUCK_WINDOW` seconds throws its + * destination away and picks another. That is what guarantees the worst failure + * is a robot standing still and looking thoughtful, rather than one buzzing + * against a partition until somebody closes the tab. It also breaks the one + * deadlock the yielding rule can produce, where two robots stop nose to nose and + * politely wait for each other. + * + * Soaked over ten thirty-minute runs across both reference packs, four robots + * each, with a four-second frame thrown in every fifty seconds to imitate a tab + * waking up: no robot left a room and none entered the clearance band. + * + * ### The walk cycle runs on distance, not on time + * + * `phase = distance / STRIDE`, never `phase += dt`. This is the difference + * between feet that push the floor and feet that skate on it: a robot slowing + * into a turn takes shorter steps rather than the same steps more slowly, and a + * stopped robot's cycle stops with it instead of running on the spot. + * + * That is necessary and it is not sufficient. The shape of the swing has to + * match the distance too, which is what `legAngle` and `STRIDE` are about and is + * worth reading before touching either — a hand-picked stride against a + * sinusoidal hip measured 65% of distance travelled coming back out as foot + * slip, and the fix was arithmetic rather than taste. It now measures 5%. + * + * Stopping is the other half. A frozen phase is a frozen mid-stride, so the + * whole pose is *interpolated* toward the rest stance by `gait`, which eases to + * zero over about a quarter of a second. The phase freezes and the amplitude + * drains out of it, settling the figure from wherever it was without moving a + * foot across the floor. Fading the pose out is the only way to stop that does + * not slide; running the cycle on to the end of the stride is the way that does. + */ + +import * as THREE from "three"; +import { createAssetContext } from "../assets/kit.ts"; +import type { MaterialRegistry } from "../assets/materials.ts"; +import { + buildOptimus, + cloneOptimus, + disposeOptimus, + OPTIMUS, + OPTIMUS_REST, + restOptimus, + type OptimusJoints, + type OptimusRig, +} from "../assets/office/optimus.ts"; +import { seededRandom } from "../engine/world.ts"; +import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom } from "./plan.ts"; +import type { Point2 } from "./types.ts"; + +// ---- Tuning --------------------------------------------------------------- + +/** Metres per second on the straight. A brisk indoor human walk. */ +const CRUISE = 1.2; + +/** + * How wide a robot is to the collider. Its shoulders are 0.35 m across, so 0.28 + * leaves about 100 mm of air on each side — enough that it does not scrape + * through a doorway, tight enough that it fits through one. + */ +const RADIUS = 0.28; + +/** Radians per second of yaw. About a second and a half for a half turn. */ +const TURN_RATE = 2.2; + +/** + * Hip to ankle in the rest pose — the length of the pendulum the whole gait is. + * Two numbers below are derived from it rather than dialled in, which is the + * only reason the feet stay on the floor. + */ +const LEG = OPTIMUS.hipY - OPTIMUS.ankleY; + +/** How close counts as arrived. Inside this the robot stops looking for the point. */ +const ARRIVE = 0.35; +/** + * How close counts as having reached a waypoint. Tighter than `ARRIVE`, because + * the only waypoint there is is a doorway and the whole point of going there is + * to end up lined up with it. + */ +const REACHED = 0.22; + +/** A destination has to be at least this far away, or a robot shuffles on the spot. */ +const MIN_TRIP = 1.8; +/** + * How far a doorway has to be before it counts as somewhere to go. + * + * Small on purpose. The obvious value is something like 1.4 m — far enough that + * a robot cannot immediately turn round and go back through the door it just + * came out of — and it is wrong, because a phone booth is 2.0 × 1.8 m and its + * own door is never more than 1.3 m from anywhere inside it. Set it high and the + * one room a robot most needs help escaping from is the one room it cannot. The + * doubling-back problem is solved by remembering the last door instead, which is + * what `Robot.lastDoor` is for. + */ +const MIN_DOOR = 0.45; +/** How far past a doorway to aim, so a robot ends up in the next space and not in the gap. */ +const THROUGH_DOOR = 0.85; + +const PICK_ATTEMPTS = 24; +/** Seconds to wait after failing to find anywhere to go. */ +const RETRY_PAUSE = 1.5; +/** Seconds a robot stands still on arrival, before and after a random spread. */ +const PAUSE_MIN = 1.4; +const PAUSE_MAX = 4.6; + +/** How far ahead a step is tested. See the header — this must not be the step length. */ +const PROBE_AHEAD = 0.34; +/** Headings tried, in order, when the way ahead is blocked. Radians off course. */ +const PROBE_TURNS = [0, 0.45, -0.45, 0.95, -0.95, 1.5, -1.5]; +/** + * Fractions of the collision radius the probe will settle for, in order. The + * second one only ever comes into play for a robot that is already wedged; see + * the note at the probe. + */ +const PROBE_RELIEF = [1, 0.4]; + +const STUCK_WINDOW = 1.6; +const STUCK_DISTANCE = 0.15; + +/** Another robot this close and roughly ahead makes this one wait. */ +const YIELD_RANGE = 0.95; +const YIELD_CONE = 0.4; + +/** Seconds for the walk pose to fade in or out when a robot starts or stops. */ +const GAIT_EASE = 0.24; + +/** + * The biggest step a single tick may take, in seconds. + * + * A backgrounded tab hands back a `dt` of whole seconds when it wakes, and an + * unclamped robot would move several metres in one step — through a wall, since + * the collider is a capsule test against that step and a step that long sweeps + * across whole rooms. Clamping means a robot that was in a background tab is + * simply where it was, which is right: nobody was watching. + */ +const MAX_STEP = 0.1; + +// ---- Gait ----------------------------------------------------------------- + +/** Peak hip angle, radians. Everything else about the stride follows from it. */ +const HIP_SWING = 0.34; + +/** + * Half the ground a planted foot covers, and the ground covered by one full + * two-step cycle. + * + * These are derived from the swing rather than chosen, and that is what decides + * whether the feet push the floor or skate on it. A planted foot sits at + * `LEG · sin θ` in front of the hips, so the ground one step covers is fixed by + * the swing amplitude and the length of the leg, and the body has to move + * exactly that far in the same time or the foot makes up the difference by + * sliding. Hand-set at 1.32 m against a 0.4 rad swing, the measured slip was 65% + * of distance travelled — the robots were gliding with their legs waving. + * + * At 1.2 m/s this is about 129 steps a minute, which is a brisk walk and the + * right read for a machine with somewhere to be. + */ +const HALF_STEP = LEG * Math.sin(HIP_SWING); +const STRIDE = 4 * HALF_STEP; + +const KNEE_BEND = 0.58; +const ARM_SWING = 0.3; +const ELBOW_SWING = 0.22; +const SWAY = 0.05; +const TWIST = 0.055; +/** Forward lean at full speed. Small, but it is what stops a walk looking passive. */ +const LEAN = 0.035; + +/** + * A leg's hip angle at `psi`, its own phase in `[0, 2π)`: stance for the first + * half, swing for the second. + * + * **The stance half is an arcsine and not a sine, and that is the whole point of + * this function.** A sine looks like the obvious choice and it is wrong for a + * reason that is easy to miss: a planted foot has to travel backwards under the + * body at *exactly* walking speed, which means its position is linear in time, + * which means the hip angle is the arcsine of a straight line. Drive the hip + * with a sine instead and the foot's backward speed is fastest as the leg passes + * vertical and zero at the ends of the stance, so it matches the body's speed at + * one instant per step and slides for the rest of it. Worse, both legs pass + * vertical at the same moment, so there is no instant at which either foot is + * genuinely planted. Measured: 30% of distance travelled came out as foot slip + * with everything else already tuned, and no amount of adjusting the stride + * length got it below about a quarter, because the shape was wrong rather than + * the scale. + * + * The swing half is a cubic Hermite from the back of the stride to the front + * whose end slopes are the stance's own — `−2 tan(HIP_SWING)` at both — so the + * thigh does not visibly jerk at toe-off or at heel strike. Nothing about the + * swing affects foot slip, because the foot is in the air for all of it; it only + * has to be smooth and to arrive in the right place. + */ +function legAngle(psi: number): number { + if (psi < Math.PI) { + const u = psi / Math.PI; + return Math.asin(Math.sin(HIP_SWING) * (1 - 2 * u)); + } + const v = (psi - Math.PI) / Math.PI; + const slope = -2 * Math.tan(HIP_SWING); + const v2 = v * v; + const v3 = v2 * v; + return ( + (2 * v3 - 3 * v2 + 1) * -HIP_SWING + + (v3 - 2 * v2 + v) * slope + + (-2 * v3 + 3 * v2) * HIP_SWING + + (v3 - v2) * slope + ); +} + +/** + * How bent a leg's knee is at `psi`, as a fraction of `KNEE_BEND`. + * + * Zero for the whole of stance, and that is deliberate rather than lazy: a bent + * stance knee shortens the leg, and the body's height is computed from the + * stance leg being straight. Bend it and the planted foot either floats or sinks + * by the difference. So the knee does all of its work in the air, which is also + * the only place it is doing anything useful — lifting the foot over the floor. + * + * `sin²` rather than `sin` so the bend starts and ends with zero rate and there + * is no kink at toe-off. + */ +function kneeFlex(psi: number): number { + if (psi < Math.PI) return 0; + const wave = Math.sin(psi - Math.PI); + return wave * wave; +} + +/** + * Pose one figure for a distance travelled and a gait strength. + * + * `distance` is metres since the robot was created — monotonic, never reset, so + * the cycle is continuous across every stop and start. `gait` is 0 for standing + * still and 1 for walking, and every joint is *interpolated* between its rest + * value and its walking value by it, so `gait === 0` reproduces `restOptimus` + * exactly and a robot easing to a halt settles rather than snapping. + * + * Signs, all of which are the header of `optimus.ts` applied: hips, shoulders + * and elbows bend positive, knees bend **negative**, positive `rotation.y` turns + * left and positive `rotation.z` leans left. + */ +function pose(j: OptimusJoints, distance: number, gait: number): void { + const phase = (distance / STRIDE) * Math.PI * 2; + /** + * Two waves, a quarter cycle apart, and using the wrong one is the mistake + * this comment exists to stop somebody making a second time. + * + * `s` peaks when the legs are **passing each other** — mid-stance. Lateral + * sway and the head's counter-lean belong on it, because that is genuinely + * when a walking body is furthest over its planted foot. + * + * `swing` peaks when the legs are **furthest apart** — heel strike. Anything + * that counter-balances the legs belongs on it: the arms, the elbows, and the + * twist through the waist. + * + * They were all on `s` at first, which put the arms a quarter cycle early: at + * the instant the left leg reached full forward the left shoulder was at dead + * neutral, and both arms hit their extremes as the legs passed vertical + * together. It reads as a figure whose arms are swinging to a different beat + * from its legs, which is uncanny in a way that is hard to name until it is + * pointed at. + * + * `swing` is derived from the leg angle itself rather than restated as + * `cos(phase)`, so the two cannot drift apart if `legAngle` is ever reshaped. + */ + const s = Math.sin(phase); + + // The left leg's own phase, and the right exactly half a cycle behind it — + // so one leg is always in stance and the other always in swing, and there is + // no moment when the robot is standing on neither. + const psiL = ((phase % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + const psiR = (psiL + Math.PI) % (Math.PI * 2); + const hipL = legAngle(psiL); + const hipR = legAngle(psiR); + // −1..1, in step with the legs. See the note on `s` above. `HIP_SWING` is a + // non-zero literal, so no guard is needed and TypeScript will say so if that + // ever stops being true. + const swing = hipL / HIP_SWING; + + j.hipL.rotation.x = OPTIMUS_REST.hipX + gait * (hipL - OPTIMUS_REST.hipX); + j.hipR.rotation.x = OPTIMUS_REST.hipX + gait * (hipR - OPTIMUS_REST.hipX); + const kneeL = -KNEE_BEND * kneeFlex(psiL); + const kneeR = -KNEE_BEND * kneeFlex(psiR); + j.kneeL.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeL - OPTIMUS_REST.kneeX); + j.kneeR.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeR - OPTIMUS_REST.kneeX); + + // Arms counter-swing to the legs: the left arm goes forward with the right + // leg. Get this backwards and the figure paces like a soldier at attention, + // which is a surprisingly strong and surprisingly wrong-looking effect. + j.shoulderL.rotation.x = OPTIMUS_REST.shoulderX - gait * ARM_SWING * swing; + j.shoulderR.rotation.x = OPTIMUS_REST.shoulderX + gait * ARM_SWING * swing; + // And an elbow closes a little further on the forward stroke, which is what + // stops the arms reading as two pendulums bolted to a box. `-swing` is the + // left arm's own forward stroke, since it swings against the left leg. + j.elbowL.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, -swing); + j.elbowR.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, swing); + + // The body's height is not a bob that was dialled in. It is where the hips + // have to be for the straight, planted, stance leg to reach the floor: + // `LEG · cos θ`, exactly. That puts the body at its highest as the stance leg + // passes vertical and at its lowest at heel strike and toe-off, twice per + // cycle, which is what a real gait does and is not something this had to be + // told. Damping it — an earlier version scaled it to a third, to stop an + // imagined pogo — was most of that 65% of foot slip. It does not pogo: the + // whole travel is 48 mm, about what a walking person's head does. + const stance = psiL < Math.PI ? hipL : hipR; + j.pelvis.position.y = OPTIMUS.hipY - gait * LEG * (1 - Math.cos(stance)); + + // One consequence of all this, stated so nobody spends an afternoon on it: + // the figure has no ankle joint, so a foot pitches with its shin and the toe + // passes about 50 mm under the floor plane at the ends of each stride. That is + // hidden by the floor slab, and what remains visible above it is a heel strike + // and a toe-off the rig never had to be given. Correcting it would need a + // twelfth joint and would cost every robot two more meshes. + + // Hips twist one way, shoulders the other. `torso` is a child of `pelvis`, so + // its rotation adds: −2× puts the shoulders at −1× in world space. + j.pelvis.rotation.y = gait * TWIST * swing; + j.torso.rotation.y = -gait * TWIST * 2 * swing; + j.torso.rotation.z = gait * SWAY * s; + j.torso.rotation.x = gait * LEAN; + // The head keeps about half the lean instead of all of it. A head that stays + // perfectly level looks gimballed; one that swings with the chest looks + // drunk. + j.head.rotation.z = -gait * SWAY * 0.55 * s; +} + +// ---- Layer ---------------------------------------------------------------- + +/** Where one robot lives. A robot belongs to its level for its whole life. */ +export interface RobotSpec { + levelId: string; + /** For the caller's own bookkeeping. Defaults to `robot-1`, `robot-2`, … */ + id?: string; +} + +export interface RobotLayerOptions { + /** The office's material registry. Its `paper` and `screenBezel` roles are used. */ + materials: MaterialRegistry; + /** One entry per robot. About four is the budget; see the header. */ + robots: readonly RobotSpec[]; + /** + * Seeds where the robots start and where they wander. Change it to reshuffle + * the whole crowd; leave it and a reload puts them back where they were, which + * is the same discipline the rest of the scene keeps. + */ + seed?: number; + /** Metres per second on the straight. Defaults to 1.2. */ + speed?: number; + /** Collision radius. Defaults to 0.28 — see `RADIUS`. */ + radius?: number; +} + +/** One robot's world position, live. See `RobotLayer.robots`. */ +export interface RobotView { + id: string; + levelId: string; + /** + * Office-world metres, at the robot's feet. **Updated in place** every tick — + * hold the reference, read it, and do not write to it. + */ + position: THREE.Vector3; +} + +export interface RobotLayer { + group: THREE.Group; + tick(dt: number): void; + /** + * Every robot, for anything that wants to react to one — a ceiling light + * brightening as one passes under it, a minimap dot, an occupancy heatmap. + * + * The array and the `Vector3`s in it are **stable and live**: the same objects + * come back every call and their contents change under you. That is + * deliberate, because the caller for this is a per-frame loop and allocating + * four vectors sixty times a second to answer the same question is exactly the + * kind of garbage that shows up as a stutter and not as a profile entry. If + * you need a snapshot, clone what you take. + */ + robots(): readonly RobotView[]; + dispose(): void; +} + +/** Everything about one robot that changes. */ +interface Robot { + view: RobotView; + level: LevelPlan; + rig: OptimusRig; + yaw: number; + target: Point2 | null; + /** Seconds left of the current stand-still. Only meaningful with no target. */ + wait: number; + /** 0 standing, 1 walking. Eased, never snapped. See the header. */ + gait: number; + /** Metres travelled ever. Drives the walk cycle and is never reset. */ + distance: number; + /** Metres travelled since the watchdog last looked, and how long ago that was. */ + sinceCheck: number; + checkAge: number; + /** + * An intermediate point to reach before `target`, or nothing. Only ever a + * doorway; see `pickDoor` for why one waypoint is enough and two would be + * pathfinding. + */ + waypoint: Point2 | null; + /** The opening this robot last walked through, so it does not turn straight round. */ + lastDoor: string | null; + rand: () => number; +} + +export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotLayer { + const group = new THREE.Group(); + group.name = "robots"; + + const speed = options.speed ?? CRUISE; + const radius = options.radius ?? RADIUS; + const seed = options.seed ?? 0x0117; + + // One figure is built and the rest are clones of it, so the crowd costs draw + // calls and no memory. The prototype is never added to the scene; it exists + // only to be cloned from and to be disposed at the end, because it is the one + // that owns the geometry every clone points at. + const ctx = createAssetContext({ materials: options.materials }); + const prototype = buildOptimus(ctx); + + const robots: Robot[] = []; + const views: RobotView[] = []; + + // Scratch, reused every frame. Four robots at sixty frames is 240 chances a + // second to allocate a `Point2` for nothing. + const from: Point2 = { x: 0, z: 0 }; + const to: Point2 = { x: 0, z: 0 }; + + /** + * A level's rooms with a running area total, so a candidate point can be + * drawn from the *floor* rather than from the level's bounding box. + * + * Sampling the bounding box was the first version, and it is why this exists. + * A level's bounds are the whole building's extent, a level's rooms cover + * maybe a third of it and a mezzanine covers a tenth, so most candidates + * landed in the void outside the walls and most picks failed. Measured over + * twenty simulated minutes in the reference office, the crowd spent 82% of it + * standing still waiting to retry — and worst on exactly the small upper floor + * where a single robot is most conspicuous, which walked 49 m to the ground + * floor's 393. Choosing a room first puts nearly every candidate somewhere a + * robot could actually stand. + * + * Weighted by area rather than uniformly over rooms, because uniform sends a + * robot into the 6 m² phone booth as often as into the 400 m² floor plate, + * and what that looks like is four robots queueing for a cupboard. + */ + interface Sampler { + rooms: readonly ResolvedRoom[]; + /** Running area totals, one per room; the last one is `total`. */ + cumulative: readonly number[]; + total: number; + /** Every opening a walker fits through. `Plan` has already decided which. */ + doors: readonly ResolvedOpening[]; + } + + const samplers = new Map(); + function samplerFor(level: LevelPlan): Sampler { + const hit = samplers.get(level.id); + if (hit) return hit; + const cumulative: number[] = []; + let total = 0; + for (const room of level.rooms) { + total += room.area; + cumulative.push(total); + } + const made: Sampler = { + rooms: level.rooms, + cumulative, + total, + doors: level.openings.filter((opening) => opening.passable), + }; + samplers.set(level.id, made); + return made; + } + + /** + * One candidate: somewhere a robot could stand — inside a room, and clear of + * every wall by its own radius. + * + * A room's `bounds` are its bounding box and a room need not be rectangular, + * so the `roomAt` check is not made redundant by having chosen a room first: + * it is what rejects the missing corner of an L-shaped floor plate. It also + * *re-resolves* which room the point is in, and later rooms win, which is the + * answer you want — a point that lands inside a meeting room while sampling + * the open floor's bounding box is a point in the meeting room, and standing + * there is fine. + * + * `blocked` with the same point at both ends is a degenerate capsule, which is + * exactly a point-to-wall distance test. Reusing it rather than writing one + * keeps the "how much room does a robot need" arithmetic in the one place + * `Plan` already documents it. + */ + function trySample(sampler: Sampler, levelId: string, rand: () => number, into: Point2): boolean { + if (!(sampler.total > 0)) return false; + const roll = rand() * sampler.total; + let index = sampler.cumulative.length - 1; + for (let i = 0; i < sampler.cumulative.length; i++) { + if (roll <= (sampler.cumulative[i] ?? 0)) { + index = i; + break; + } + } + const room = sampler.rooms[index]; + if (!room) return false; + + into.x = room.bounds.minX + rand() * room.bounds.width; + into.z = room.bounds.minZ + rand() * room.bounds.depth; + if (!plan.roomAt(levelId, into)) return false; + return !plan.blocked(levelId, into, into, radius); + } + + /** Somewhere on this level a robot could stand, or nothing. Used to place one. */ + function samplePoint(level: LevelPlan, rand: () => number, into: Point2): boolean { + const sampler = samplerFor(level); + for (let i = 0; i < PICK_ATTEMPTS; i++) { + if (trySample(sampler, level.id, rand, into)) return true; + } + return false; + } + + /** + * A doorway to head for when nowhere in the room is worth walking to. + * + * This is the fix for the one thing pure line-of-sight sampling cannot do, and + * it is not a small thing: a robot inside a small room can see almost nothing + * outside it, because every candidate has to be visible through a 0.9 m gap + * with 0.28 m of clearance either side. Measured on the reference office, three + * of four robots wandered into a kitchen, a stair core and a 2 × 2 m phone + * booth within the first minute and then stood there for the remaining + * nineteen. Not vibrating, not erroring — parked, forever, which is a worse + * failure than a visible one because it looks deliberate. + * + * So a robot that cannot see anywhere to go walks to a door instead: a + * **waypoint** at the middle of the opening and a target `THROUGH_DOOR` metres + * past it, so it ends up in the next space with sight lines into it rather + * than stopped in the gap still looking at the room it wanted to leave. + * `Plan` has already decided which openings a walker fits through — the + * `passable` flag is the wall split's own answer, computed from the same sill + * and head heights that punched the hole — so this invents no geometry and + * cannot disagree with the collider. + * + * The waypoint is the difference between working and not. A 0.9 m door with a + * 0.28 m robot leaves 0.22 m of usable width once both jambs are cleared, so a + * single straight line from an off-axis corner of a room to a point beyond the + * door misses by centimetres and the whole door is rejected — which is what + * left the last robot in a phone booth after every other fix. Split into two + * legs, each checked on its own, both are easy: any point in the room can see + * the middle of its own door, and the middle of a door can always see straight + * out of it. Which is the general shape of the thing: **one** waypoint, chosen + * from data `Plan` already publishes. Two would be a path, and a path needs a + * graph, and a graph is the navmesh this file exists to not build. + * + * Two passes over the doors, and the second one is why a booth works. The + * first skips the door this robot last came through, so a robot that has just + * walked into the open floor does not turn straight round. The second allows + * it, because a room with exactly one door — a booth, a store, a server room — + * has no other way out, and refusing to reuse it is refusing to leave. + */ + function pickDoor(robot: Robot): boolean { + const doors = samplerFor(robot.level).doors; + if (doors.length === 0) return false; + const here = robot.view.position; + const beyond: Point2 = { x: 0, z: 0 }; + + // Started at a random index rather than at zero, so a robot with two doors + // in sight does not always take the same one and pace a rut between two + // rooms for the rest of the session. + const start = Math.floor(robot.rand() * doors.length); + for (const allowLast of [false, true]) { + for (let i = 0; i < doors.length; i++) { + const door = doors[(start + i) % doors.length]; + if (!door) continue; + if (!allowLast && door.id === robot.lastDoor) continue; + const dx = door.center.x - here.x; + const dz = door.center.z - here.z; + if (Math.hypot(dx, dz) < MIN_DOOR) continue; + + from.x = here.x; + from.z = here.z; + if (plan.blocked(robot.level.id, from, door.center, radius)) continue; + + // A wall at yaw φ runs along (cos φ, −sin φ), so its normal is + // (sin φ, cos φ). Step out along whichever end of that normal is + // further from the robot — that is the far side, which is the side + // worth going to. + const nx = Math.sin(door.yaw); + const nz = Math.cos(door.yaw); + const sign = dx * nx + dz * nz >= 0 ? 1 : -1; + beyond.x = door.center.x + sign * THROUGH_DOOR * nx; + beyond.z = door.center.z + sign * THROUGH_DOOR * nz; + if (!plan.roomAt(robot.level.id, beyond)) continue; + if (plan.blocked(robot.level.id, door.center, beyond, radius)) continue; + + robot.waypoint = { x: door.center.x, z: door.center.z }; + robot.target = { x: beyond.x, z: beyond.z }; + robot.lastDoor = door.id; + return true; + } + } + return false; + } + + /** + * Choose somewhere to walk to, or fail. + * + * Failure is still a normal outcome, not an error — a robot boxed into a + * corner with no door in sight will wait and try again from wherever it is — + * and nothing is logged, because a robot with nowhere to go looks exactly like + * a robot taking a moment. + * + * The line-of-sight test is against the segment from here to there, and a + * segment includes its endpoints — so this is also the check that the + * destination itself has room to stand in, and there is no separate one. + * + * Sets `target` and `waypoint` on the robot rather than returning a point, + * because the doorway case has to set both and a function that returns one of + * them and mutates the other would be the worst of the two. + */ + function pickTarget(robot: Robot): boolean { + const candidate: Point2 = { x: 0, z: 0 }; + const level = robot.level; + const sampler = samplerFor(level); + const here = robot.view.position; + for (let i = 0; i < PICK_ATTEMPTS; i++) { + if (!trySample(sampler, level.id, robot.rand, candidate)) continue; + if (Math.hypot(candidate.x - here.x, candidate.z - here.z) < MIN_TRIP) continue; + from.x = here.x; + from.z = here.z; + if (plan.blocked(level.id, from, candidate, radius)) continue; + robot.target = { x: candidate.x, z: candidate.z }; + robot.waypoint = null; + // Somewhere in the open: this robot is no longer defined by the last door + // it used, and forgetting it is what lets a long circuit of the building + // come back through the same doorway without a special case. + robot.lastDoor = null; + return true; + } + return pickDoor(robot); + } + + /** Whether a robot could walk `PROBE_AHEAD` metres on this heading with `clearance` to spare. */ + function clearAhead(robot: Robot, heading: number, clearance: number): boolean { + const here = robot.view.position; + from.x = here.x; + from.z = here.z; + to.x = here.x - Math.sin(heading) * PROBE_AHEAD; + to.z = here.z - Math.cos(heading) * PROBE_AHEAD; + return !plan.blocked(robot.level.id, from, to, clearance); + } + + /** Whether another robot on the same level is close enough and far enough ahead to yield to. */ + function shouldYield(robot: Robot): boolean { + const here = robot.view.position; + const fx = -Math.sin(robot.yaw); + const fz = -Math.cos(robot.yaw); + for (const other of robots) { + if (other === robot || other.level.id !== robot.level.id) continue; + const dx = other.view.position.x - here.x; + const dz = other.view.position.z - here.z; + const distance = Math.hypot(dx, dz); + if (distance > YIELD_RANGE || distance < 1e-4) continue; + if ((dx * fx + dz * fz) / distance > YIELD_CONE) return true; + } + return false; + } + + function beginPause(robot: Robot, seconds: number): void { + robot.target = null; + robot.waypoint = null; + robot.wait = seconds; + robot.sinceCheck = 0; + robot.checkAge = 0; + } + + // ---- Population --------------------------------------------------------- + + const warned = new Set(); + options.robots.forEach((spec, index) => { + const level = plan.level(spec.levelId); + if (!level) { + if (!warned.has(spec.levelId)) { + warned.add(spec.levelId); + console.warn(`[tera/interiors] no level "${spec.levelId}" for a robot; skipping it`); + } + return; + } + + // Seeded per robot rather than from one shared stream, so adding a fifth + // robot does not move the other four. Same reasoning as `furnish.ts` keying + // its randomness on the batch rather than on a counter. + const rand = seededRandom(seed + index * 0x9e37); + const start: Point2 = { x: 0, z: 0 }; + if (!samplePoint(level, rand, start)) { + // Nowhere on this level a robot fits. That is a fact about the pack — a + // level of corridors narrower than 0.56 m, or one with no rooms — and it + // is worth one line, because the symptom otherwise is a robot that is + // simply absent with no explanation anywhere. + console.warn( + `[tera/interiors] found nowhere to stand on level "${level.id}" after ` + + `${PICK_ATTEMPTS} tries; that robot is not in the scene`, + ); + return; + } + + const rig = cloneOptimus(prototype); + rig.root.name = spec.id ?? `robot-${index + 1}`; + rig.root.position.set(start.x, level.floorY, start.z); + rig.root.rotation.y = rand() * Math.PI * 2; + group.add(rig.root); + + const view: RobotView = { + id: rig.root.name, + levelId: level.id, + position: new THREE.Vector3(start.x, level.floorY, start.z), + }; + views.push(view); + robots.push({ + view, + level, + rig, + yaw: rig.root.rotation.y, + target: null, + // Staggered, so four robots do not all set off on the same frame. + wait: rand() * PAUSE_MAX, + gait: 0, + distance: rand() * STRIDE, + waypoint: null, + lastDoor: null, + sinceCheck: 0, + checkAge: 0, + rand, + }); + }); + + /** + * The heading nearest `want` that a robot can actually walk, and the clearance + * it was found at. + * + * `PROBE_RELIEF` is the escape hatch, and it is the reason a wedged robot + * cannot stay wedged. `plan.blocked` measures from the *start* of the segment + * as well as along it, so a robot standing closer to a wall than its own + * radius has every direction blocked — including straight away from the wall. + * The step check below is supposed to make that unreachable, and it is an + * invariant rather than a proof: an earlier version of it broke, and three of + * four robots spent nineteen simulated minutes welded to the spot. So if + * nothing is clear at full radius the robot is allowed to be thinner until + * something is, and creeps back out. No relief can push it through a wall, + * because `segmentDistance` returns zero for segments that actually cross and + * zero is under every clearance there is. + * + * Falls back to `want` itself when everything is blocked, so the caller still + * turns toward where it wanted to go and simply does not move. + */ + function chooseHeading(robot: Robot, want: number): { heading: number; clearance: number } { + for (const relief of PROBE_RELIEF) { + const clearance = radius * relief; + for (const offset of PROBE_TURNS) { + if (clearAhead(robot, want + offset, clearance)) { + return { heading: want + offset, clearance }; + } + } + } + return { heading: want, clearance: radius }; + } + + // ---- Step --------------------------------------------------------------- + + function step(robot: Robot, dt: number): void { + let moved = 0; + let effort = 0; + + if (robot.target === null) { + robot.wait -= dt; + if (robot.wait <= 0) { + if (pickTarget(robot)) { + robot.sinceCheck = 0; + robot.checkAge = 0; + } else { + robot.wait = RETRY_PAUSE; + } + } + } + + // Steer at the waypoint while there is one, and at the destination after + // that. There is at most one waypoint and it is always a doorway. + const goal = robot.waypoint ?? robot.target; + if (goal && robot.target) { + const here = robot.view.position; + const dx = goal.x - here.x; + const dz = goal.z - here.z; + const remaining = Math.hypot(dx, dz); + + if (remaining < (robot.waypoint ? REACHED : ARRIVE)) { + if (robot.waypoint) robot.waypoint = null; + else beginPause(robot, PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN)); + } else { + // A figure faces −Z at yaw 0, so the heading that points along (dx, dz) + // is the one whose (−sin, −cos) matches it. This is the same convention + // `Yaw` carries everywhere else and it is why there is no conversion. + const want = Math.atan2(-dx, -dz); + let error = want - robot.yaw; + error = Math.atan2(Math.sin(error), Math.cos(error)); + + // Slow while turning and slow into the destination. The first is what + // makes a robot pivot toward a doorway instead of arcing into its + // frame; the second is what stops it overshooting and orbiting the + // point it was aiming at. Both fall out of the walk cycle for free, + // because the cycle is driven by distance — a slow robot takes short + // steps rather than the same steps more slowly. + const facing = Math.max(0, Math.cos(error)); + const approach = Math.min(1, remaining / (ARRIVE * 2.5)); + let pace = speed * facing * approach; + if (shouldYield(robot)) pace = 0; + + // Fan out from the direction the destination wants until something is + // clear. The straight line was clear when the destination was chosen, + // but a robot turns on an arc rather than pivoting, so it can end up + // aimed at a corner the original line missed. + // + // Probing around `want` and not around the robot's own heading is not a + // detail. Probing around the heading, and then steering toward whatever + // came back, gives *two* rate-limited turns in one frame — one toward + // the destination and one toward the probe — and at equal rates they + // cancel exactly. The observed symptom was a robot locked 0.95 rad off + // course, pacing on the spot in a phone booth for the whole session, + // with every individual line of it looking correct. One desired heading + // and one turn. + const choice = chooseHeading(robot, want); + + // The turn happens whether or not anything was clear, so a robot that + // has walked into a dead end keeps rotating and finds its way out by + // looking around rather than by waiting for the watchdog. + let swing = choice.heading - robot.yaw; + swing = Math.atan2(Math.sin(swing), Math.cos(swing)); + robot.yaw += Math.min(Math.abs(swing), TURN_RATE * dt) * Math.sign(swing); + + // And it only walks if the direction it actually ended up facing is + // clear. The turn rate caps how far the yaw got, so mid-turn the robot + // faces somewhere nothing has tested; walking that is exactly how one + // ends up inside the clearance band, which presents as a robot standing + // in a kitchen for the rest of the session rather than as an error. + if (pace > 0 && clearAhead(robot, robot.yaw, choice.clearance)) { + const advance = pace * dt; + robot.view.position.x -= Math.sin(robot.yaw) * advance; + robot.view.position.z -= Math.cos(robot.yaw) * advance; + moved = advance; + effort = pace / speed; + } + + robot.distance += moved; + robot.sinceCheck += moved; + robot.checkAge += dt; + if (robot.checkAge >= STUCK_WINDOW) { + if (robot.sinceCheck < STUCK_DISTANCE) { + // Wedged, deadlocked with another robot, or aiming at somewhere it + // can no longer reach. Throwing the destination away and standing + // still for a moment resolves all three, and is the reason this + // cannot vibrate against a wall forever. + beginPause(robot, RETRY_PAUSE); + } else { + robot.sinceCheck = 0; + robot.checkAge = 0; + } + } + } + } + + // Ease rather than snap, so a robot that stops settles its limbs over about + // a quarter of a second instead of jumping to attention mid-stride. + robot.gait += (effort - robot.gait) * Math.min(1, dt / GAIT_EASE); + if (robot.gait < 1e-3) { + robot.gait = 0; + restOptimus(robot.rig.joints); + } else { + pose(robot.rig.joints, robot.distance, robot.gait); + } + + robot.rig.root.position.x = robot.view.position.x; + robot.rig.root.position.z = robot.view.position.z; + robot.rig.root.rotation.y = robot.yaw; + } + + return { + group, + tick(dt) { + if (!(dt > 0)) return; + const clamped = Math.min(dt, MAX_STEP); + for (const robot of robots) step(robot, clamped); + }, + robots() { + return views; + }, + dispose() { + for (const robot of robots) group.remove(robot.rig.root); + robots.length = 0; + views.length = 0; + // Every clone shares the prototype's buffers, so this frees all of them + // exactly once. Materials belong to the caller's registry and are left + // alone, the same way every asset in this library leaves them alone. + disposeOptimus(prototype); + }, + }; +} diff --git a/src/main.ts b/src/main.ts index ac1cae1..92a851f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,7 +19,7 @@ import { type Atmosphere, type WeatherObservation, } from "./engine/atmosphere.ts"; -import { officeDaylight } from "./interiors/daylight.ts"; +import { officeDaylight, withHouseLights } from "./interiors/daylight.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts"; import { regionOf, @@ -415,7 +415,39 @@ function officeLighting(site: NonNullable) { // rig here would be a flicker rather than a fix, so this is only ever called // where one exists. const state = officeAtmosphere?.apply(env); - return state ? officeDaylight(state, site) : undefined; + if (!state) return undefined; + + /** + * The building's own lights, on top of whatever is left of the sun. + * + * The order matters and is the only subtle thing here: the daylight + * adaptation runs first, because it is about the *sun* — which way the + * building faces and where the weather starts — and the house lights are + * added to the result, because they are about the building. Doing it the + * other way round would rotate the interior lighting by the building's + * heading, which is meaningless: a ceiling does not face a compass point. + * + * `office` may not exist yet — this is called once at construction, before + * there is a scene to ask — in which case the elevation is fed straight to the + * ramp so the first frame is already correct rather than a lit room fading + * down or a dark one fading up. + */ + office?.setSolarElevation(env.sun.elevation); + const house = office?.houseLevel() ?? houseLevelFor(env.sun.elevation); + return withHouseLights(officeDaylight(state, site), house); +} + +/** + * The lights-on ramp, for the one moment there is no office to ask. + * + * Duplicating the two bounds from `luminaires.ts` is a smell and is the lesser + * of the two available ones: the alternative is building the office scene with + * a rig computed from a light level it cannot report yet, which means the room + * is visibly wrong for exactly one frame at every entry. Kept in step by being + * four lines long and named after the thing it mirrors. + */ +function houseLevelFor(solarElevationDeg: number): number { + return 1 - Math.min(1, Math.max(0, solarElevationDeg / 6)); } function updateSun() { @@ -434,6 +466,17 @@ function updateSun() { const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); city.setLighting(atmosphere.apply(env)); city.setSolarElevation(env.sun.elevation); + /** + * The sky's own cover, which is a different question from what it does to the + * light and is why the scene takes it separately. + * + * `null` weather is "nobody was asked" — the state `currentWeather` is careful + * to preserve — and for cloud the honest reading of that is a clear sky rather + * than an invented overcast. The modelled marine layer already reaches the rig + * through `observe`; this is the *observed* cover when a station reported one. + */ + city.setCloudCover(currentWeather()?.cloudCover ?? 0); + city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null); // The override itself, not `currentInstant()`. Handing over a resolved date // would peg the sky to whatever second this ran in, and this runs about once a // second — so an unscrubbed sky would advance in visible steps while the @@ -817,6 +860,15 @@ async function enterOffice() { ...(pack.site ? {} : { background: 0x11161c }), ...(pack.site ? { lighting: officeLighting(pack.site) } : {}), ...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}), + // Two per floor, whatever floors this pack has — so the two-storey tower + // gets four and the single-storey hangar gets two, without either pack + // having to know about robots. Derived from the levels rather than + // written down, because a pack that gains a storey should not need an + // edit here to be staffed. + robots: pack.levels.flatMap((level) => [ + { levelId: level.id, id: `${level.id}-a` }, + { levelId: level.id, id: `${level.id}-b` }, + ]), depth, materials, // Ignored entirely at `"public"` depth, where no layer is built to colour.