1
0

The office keeps its lights on, and something walks around under them

**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.

**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.

**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.

**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.

**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.

Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:

  - The walk cycle's arms were a quarter cycle out of step with its legs —
    the legs are cosine-shaped and the arms were on `sin`, so at the
    instant the left leg reached full forward the left shoulder was at dead
    neutral. Uncanny, and hard to name until it is pointed at.
  - Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
    primitive turns into a near-circular cross-section — the figure was
    built out of lozenges, not panels. The rest of the library uses
    0.02–0.09.
  - The cloud material was `transparent` + `DoubleSide` without
    `forceSinglePass`, so three rendered it twice per frame *and* bumped
    `material.version` on each pass — rebuilding the program cache key
    forever, on the one layer that is fill-rate bound.
  - `starlinkMesh.dispose()` freed the geometries but not the
    `InstancedMesh`es, orphaning their instance buffers on every city
    switch.
  - The airliner's tailplane roots sat outside the tail cone and hung in
    free air over most of their chord.

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