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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+9
-37
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
/**
|
||||
* The few satellites you are actually looking at, drawn as satellites.
|
||||
*
|
||||
* `satellites.ts` renders the whole catalogue as one `THREE.Points` cloud at a
|
||||
* fixed 3.5 screen pixels, and that is the right way to draw six thousand
|
||||
* objects: a dot on a dome is a *direction*, which is the only thing about a
|
||||
* satellite that survives the projection this engine has to make (read that
|
||||
* file's header before this one — the argument for the dome is made there and is
|
||||
* not repeated here). What a dot cannot do is say what the object *is*. Every
|
||||
* point in that cloud looks like every other point, so a Starlink train reads as
|
||||
* a line of specks, and the constellation whose entire visual signature is one
|
||||
* enormous solar panel hanging off one side of a flat box reads as nothing at
|
||||
* all.
|
||||
*
|
||||
* So this layer draws geometry for the handful nearest the middle of the view
|
||||
* and leaves everything else as points. Two instanced meshes, sixty-four
|
||||
* instances, two draw calls, and the dots go on being dots underneath.
|
||||
*
|
||||
* ### One array, on one side
|
||||
*
|
||||
* The shape is the point of the whole file, so it is worth being blunt about
|
||||
* what it is not. The satellite everybody draws is a cube with two symmetric
|
||||
* wings — the Hubble/comsat silhouette that has meant "spacecraft" since the
|
||||
* seventies. A Starlink is not that and has never been that. It is a **flat
|
||||
* rectangular bus** — flat because sixty of them stack in a fairing like plates,
|
||||
* which is the design decision the entire constellation is built on — with a
|
||||
* **single solar array** that unrolls from one edge and is two to three times
|
||||
* the length of the bus it hangs off. The thing is profoundly lopsided, and that
|
||||
* asymmetry is what you would recognise if you could see one.
|
||||
*
|
||||
* Drawing two symmetric wings here would be worse than drawing nothing, because
|
||||
* it would be a confident, legible, wrong answer. The whole reason to promote a
|
||||
* dot to a mesh is to say something true about the object.
|
||||
*
|
||||
* ### Deliberately, enormously, not to scale
|
||||
*
|
||||
* A Starlink is about ten metres across the deployed array, at a range of
|
||||
* roughly 550 km. That is 1.8e-5 radians, near enough four arcseconds — at this
|
||||
* scene's 42° field of view over a thousand-pixel canvas, **a fiftieth of a
|
||||
* pixel**. There is no honest scale at which this layer draws anything at all.
|
||||
*
|
||||
* `satellites.ts` already made the same concession in the other direction: its
|
||||
* dots are 3.5 px regardless of range, because an object at effectively infinite
|
||||
* distance has an apparent size set by the eye and not by the geometry. This
|
||||
* file takes that further and says so plainly. `SPAN_FRACTION` puts the drawn
|
||||
* satellite at about a degree of arc — twice the moon, roughly twenty pixels,
|
||||
* some nine hundred times its true angular size. The number was chosen as the
|
||||
* smallest one at which the array-and-bus silhouette is still readable, and it
|
||||
* carries exactly as much information as the dome's radius does, which is none.
|
||||
*
|
||||
* What *is* true is everything angular. The dome falsifies distance and
|
||||
* preserves direction, so anything that can be expressed as an angle at the
|
||||
* observer can still be right — and two of them are:
|
||||
*
|
||||
* - **The attitude.** A Starlink flies nadir-pointing, belly to the ground.
|
||||
* Seen from here that is not the same as "belly toward the observer" except
|
||||
* when it is directly overhead; at the horizon the nadir direction is 67°
|
||||
* off the line of sight and you are looking at the thing nearly edge-on.
|
||||
* `nadirOf` works that angle out from the range the fix already carries, so
|
||||
* one overhead shows you its antenna face and one low in the north-west is
|
||||
* a foreshortened sliver. That difference is free and it is real.
|
||||
* - **The phase.** A satellite has phases for the same reason the moon does,
|
||||
* and it is why a Starlink pass is a dusk-and-dawn event rather than a
|
||||
* midnight one: at local midnight the object is in the earth's shadow, and
|
||||
* at noon the sun is *behind* it from here and you are looking at its dark
|
||||
* side. `phase` below is that geometry, and the array brightening as it
|
||||
* approaches full is the flare people photograph.
|
||||
*
|
||||
* ### What this layer does not do
|
||||
*
|
||||
* It does not tell `satellites.ts` to stop drawing dots for the objects it has
|
||||
* promoted, and it should not: the dot lands dead centre on the bus, is 3.5 px
|
||||
* across against a mesh twenty px across, and reads as the specular glint off
|
||||
* the chassis. Suppressing it would cost a coupling between two layers to make
|
||||
* the picture slightly worse.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import type { SatelliteFix } from "./satellites.ts";
|
||||
|
||||
const RAD = 180 / Math.PI;
|
||||
|
||||
/**
|
||||
* The dome factor, restated.
|
||||
*
|
||||
* `satellites.ts` keeps `DOME_RADIUS_FACTOR = 1.05` private, and the meshes have
|
||||
* to land on **exactly** the shell the dots are on — not a similar one. Put them
|
||||
* on different radii and the two layers agree only when the camera is at the
|
||||
* scene origin; anywhere else the mesh separates from its own dot by parallax,
|
||||
* which reads as a rendering fault rather than as a rounding error.
|
||||
*
|
||||
* This is a mirror and is meant to stop being one: see the wiring note. Export
|
||||
* the constant from `satellites.ts`, import it here, and delete this.
|
||||
*/
|
||||
export const DOME_RADIUS_FACTOR = 1.05;
|
||||
|
||||
/**
|
||||
* Ceiling on meshes, and the reason the layer is affordable at all.
|
||||
*
|
||||
* Sixty-four is well past what is ever in shot at once — a busy Starlink sky
|
||||
* over one city is a few hundred objects spread over the whole hemisphere, of
|
||||
* which the selection cone below takes maybe a dozen — so in practice the cap
|
||||
* never binds and exists to bound the buffers. Both instance buffers are
|
||||
* allocated once at this size and `count` is moved, which is the cheap
|
||||
* operation; growing an `InstancedMesh` means building a new one.
|
||||
*/
|
||||
const MAX_MESHES = 64;
|
||||
|
||||
/**
|
||||
* The selection cone, in degrees off the camera's own axis: full size inside
|
||||
* `SELECT_FULL_DEG`, gone by `SELECT_EDGE_DEG`.
|
||||
*
|
||||
* Off the view *centre* rather than merely on screen, and that is the whole
|
||||
* selection rule: the geometry should be where the user is looking. The scene's
|
||||
* field of view is 42° vertical, so 12° is the middle third of the frame at full
|
||||
* size and 26° reaches into the corners — a satellite drifting in from the edge
|
||||
* has most of the screen width to grow across.
|
||||
*
|
||||
* Ranking by this angle rather than by range is deliberate. Every object on the
|
||||
* dome is at the same radius, so range sorts by where the *camera* is and not by
|
||||
* what it is aimed at, and an orbiting camera would see the meshes migrate
|
||||
* around the sky for no reason the user could name.
|
||||
*/
|
||||
const SELECT_FULL_DEG = 12;
|
||||
const SELECT_EDGE_DEG = 26;
|
||||
|
||||
/**
|
||||
* How far from the camera a mesh survives, as a multiple of the dome radius.
|
||||
*
|
||||
* This is the other half of the fade, and it exists because the camera can get
|
||||
* outside the dome — `scene.ts` puts `maxDistance` at 2.0 board spans against a
|
||||
* dome at about 0.99, precisely so the constellation can be looked at from
|
||||
* above. From out there the near side of the dome is about one radius away and
|
||||
* still worth drawing; the far side is three, where a twenty-pixel satellite has
|
||||
* become four pixels of noise sitting on top of a dot that says the same thing
|
||||
* more clearly. So the far side goes back to being points.
|
||||
*
|
||||
* At the other end of the zoom the camera is near the origin, every point on the
|
||||
* dome is one radius away, and this term is a constant 1 — it never interferes
|
||||
* with the case it is not there for.
|
||||
*/
|
||||
const RANGE_FULL = 1.15;
|
||||
const RANGE_EDGE = 2.2;
|
||||
|
||||
/**
|
||||
* How many slots at the tail of the ranked list fade out, when the cap binds.
|
||||
*
|
||||
* Belt and braces. The cone fade already means the objects nearest the cut are
|
||||
* the ones nearest the cone's edge and therefore already small — but that is a
|
||||
* statement about a *typical* sky, and a genuinely dense cone would put sixty-
|
||||
* fourth place somewhere near the middle of the screen at full size, popping in
|
||||
* and out as the ordering churned. Applied only when there are more candidates
|
||||
* than slots, so a sparse sky never sees it.
|
||||
*/
|
||||
const RANK_FADE_SLOTS = 8;
|
||||
|
||||
/**
|
||||
* Tip-to-tip size of a drawn satellite, as a fraction of the dome radius.
|
||||
*
|
||||
* Since the dome radius is also roughly how far away these things are, this is
|
||||
* very nearly the angular size in radians: 0.016 rad is 0.92°, about twenty
|
||||
* pixels at this field of view. See the header for why that is nine hundred
|
||||
* times too big and why the alternative is a layer that renders nothing.
|
||||
*/
|
||||
const SPAN_FRACTION = 0.016;
|
||||
|
||||
/**
|
||||
* Elevation below which a satellite is not promoted, in degrees.
|
||||
*
|
||||
* The same number as `HORIZON_FADE_DEG` in `satellites.ts` and for the same
|
||||
* reason — an object a degree up is behind the hills and behind more air than it
|
||||
* can be seen through — restated because that constant is private too. It has to
|
||||
* agree with the dot layer's or the mesh would fade in over a dot that was
|
||||
* fading out.
|
||||
*/
|
||||
const HORIZON_FADE_DEG = 8;
|
||||
|
||||
/**
|
||||
* Brightness of a satellite whose lit side is facing entirely away, relative to
|
||||
* one at full phase.
|
||||
*
|
||||
* Not zero, for the reason `satellites.ts` gives for `SHADOW_ALPHA`: the
|
||||
* physically honest answer is that you cannot see it, and a layer that draws
|
||||
* nothing at noon reads as broken rather than as correct. Higher than that
|
||||
* file's 0.16 because a shape has to be legible to be a shape, where a dot only
|
||||
* has to be present.
|
||||
*/
|
||||
const PHASE_FLOOR = 0.42;
|
||||
|
||||
/**
|
||||
* Brightness in the earth's umbra. Deliberately `SHADOW_ALPHA` from
|
||||
* `satellites.ts`, so that a satellite entering eclipse dims by the same factor
|
||||
* whether it is currently a dot or a mesh — the moment the two layers disagree
|
||||
* about that is the moment a mesh crossing the terminator visibly steps.
|
||||
*/
|
||||
const ECLIPSE_FLOOR = 0.16;
|
||||
|
||||
/**
|
||||
* The bus. Pale because it is: white thermal blanket and bare aluminium, which
|
||||
* is the brightest thing on the spacecraft and most of what a naked-eye pass
|
||||
* actually is.
|
||||
*/
|
||||
const BUS_COLOR = new THREE.Color(0xd7dde6);
|
||||
|
||||
/**
|
||||
* The array, unlit and lit.
|
||||
*
|
||||
* Solar cells are the *darkest* part of any spacecraft — they are built to
|
||||
* absorb, and they reflect under a tenth of what hits them — so the array is a
|
||||
* near-black silhouette against a daylit sky, which is exactly the read the
|
||||
* asymmetry needs. `ARRAY_GLINT` is the other half of the same fact: at high
|
||||
* phase the cover glass throws a specular sheet back at the observer and the
|
||||
* panel flares steely blue. Interpolated on the cube of the phase so the flare
|
||||
* happens in the last part of the approach to full and not gradually across it.
|
||||
*/
|
||||
const ARRAY_COLOR = new THREE.Color(0x121a2c);
|
||||
const ARRAY_GLINT = new THREE.Color(0x9db4d6);
|
||||
|
||||
/**
|
||||
* The spacecraft, in metres of real spacecraft.
|
||||
*
|
||||
* Built at true proportions and shrunk by exactly one number (`scale`, below),
|
||||
* so the lie about size lives in one place and the shape stays honest. Roughly
|
||||
* three metres of bus against eight of array is the ratio that matters; the
|
||||
* absolute figures are approximate and nothing downstream reads them as fact.
|
||||
*
|
||||
* Axes are the local frame every matrix below is built in: **+X is the boom**,
|
||||
* along which the array deploys, **+Y is zenith** so that −Y is the nadir face
|
||||
* carrying the phased array, and +Z is what is left over.
|
||||
*/
|
||||
const BUS_LENGTH = 3.2;
|
||||
const BUS_DEPTH = 1.6;
|
||||
const BUS_THICK = 0.28;
|
||||
const ARRAY_LENGTH = 8.4;
|
||||
const ARRAY_WIDTH = 1.5;
|
||||
const ARRAY_THICK = 0.06;
|
||||
const BOOM_GAP = 0.6;
|
||||
const BOOM_RADIUS = 0.08;
|
||||
|
||||
/** Centre of the array, in the same frame. It hangs off +X and only +X. */
|
||||
const ARRAY_CENTRE_X = BUS_LENGTH / 2 + BOOM_GAP + ARRAY_LENGTH / 2;
|
||||
|
||||
/** Tip of the array to the far edge of the bus — what `SPAN_FRACTION` scales. */
|
||||
const MODEL_SPAN = BUS_LENGTH + BOOM_GAP + ARRAY_LENGTH;
|
||||
|
||||
/** Sentinel for an unused candidate slot. Finite, so the comparator is total. */
|
||||
const UNUSED_SCORE = 1e9;
|
||||
|
||||
/** Earth's mean radius, for the nadir angle. Sphere is plenty at one degree. */
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
/**
|
||||
* A direction *toward* the sun in the engine's axes — exactly what
|
||||
* `solar.ts`'s `sunDirection` returns, and structurally a `THREE.Vector3`, so a
|
||||
* caller holding either can pass it straight in.
|
||||
*/
|
||||
export interface SunVector {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
}
|
||||
|
||||
export interface StarlinkMeshLayer {
|
||||
group: THREE.Group;
|
||||
/**
|
||||
* Redraw from the same fix list `SatelliteLayer.update` is given.
|
||||
*
|
||||
* The camera is a parameter rather than something the layer remembers because
|
||||
* the selection is a function of where it is aimed *this frame*, and the sun
|
||||
* is a parameter for the same reason `SatelliteCatalogue.fixes` takes a
|
||||
* `when`: godmode scrubs the clock, and a layer that quietly called
|
||||
* `solarPosition(new Date())` would be the one thing in the scene still
|
||||
* pointing its solar panels at yesterday afternoon.
|
||||
*/
|
||||
update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void;
|
||||
setVisible(visible: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One satellite that got through the filters, with everything the ranking and
|
||||
* the draw need.
|
||||
*
|
||||
* These are pooled and reused rather than built per frame. `update` runs at 60
|
||||
* Hz over a few hundred fixes, and a few hundred short-lived objects a frame is
|
||||
* twenty thousand a second of pure garbage for a layer whose entire job is to
|
||||
* be cheap enough to leave on.
|
||||
*/
|
||||
interface Candidate {
|
||||
fix: SatelliteFix | null;
|
||||
/** Degrees off the camera's axis. Ascending; `UNUSED_SCORE` sorts to the end. */
|
||||
score: number;
|
||||
/** 0 to 1. Drives the scale, which is how a mesh grows out of its own dot. */
|
||||
fade: number;
|
||||
/** Where on the dome it sits, in scene space. */
|
||||
readonly at: THREE.Vector3;
|
||||
}
|
||||
|
||||
export function createStarlinkMeshLayer(domeRadius: number): StarlinkMeshLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "starlink-meshes";
|
||||
|
||||
const busGeometry = buildBus();
|
||||
const arrayGeometry = buildArray();
|
||||
|
||||
/**
|
||||
* `MeshBasicMaterial`, and the scene's lights are deliberately ignored.
|
||||
*
|
||||
* This is the one decision here that looks like laziness and is not. A
|
||||
* `MeshLambertMaterial` would be lit by the city's rig — and that rig is a
|
||||
* model of the light *at the ground*, which after sunset is a tenth of an
|
||||
* intensity with the sun pushed below the horizon. A Starlink is visible
|
||||
* precisely when the ground is dark and the satellite is not, so shading these
|
||||
* with the city's sun would black out the constellation at exactly the hour it
|
||||
* exists to be looked at, and light it in the middle of the day when it cannot
|
||||
* be seen at all. Backwards in both directions.
|
||||
*
|
||||
* So the shading is computed per instance on the CPU — the phase term in
|
||||
* `update` — and written into `instanceColor`, which a basic material
|
||||
* multiplies straight into its diffuse. It costs one dot product per drawn
|
||||
* satellite, of which there are at most sixty-four, and it is the only shading
|
||||
* model in this file that has the satellite's own geometry to work from rather
|
||||
* than the city's.
|
||||
*
|
||||
* `fog: false` for the reason `satellites.ts` states for its points and which
|
||||
* is, if anything, stronger for a solid: the city's linear fog reaches its far
|
||||
* plane at 2.8 board spans, so a mesh out on the dome would be mixed most of
|
||||
* the way to the fog colour and the constellation would dim as the camera
|
||||
* pulled back, exactly as more of it came into view. Haze belongs to the
|
||||
* twelve kilometres of air a city sits in. This is 550 km above all of it.
|
||||
*/
|
||||
const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false });
|
||||
const arrayMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
fog: false,
|
||||
/**
|
||||
* The array is a flat panel edge-on for part of every orbit, and a
|
||||
* back-faced panel disappears entirely at the moment it is most
|
||||
* foreshortened. It has two sides in reality — cells one way, substrate the
|
||||
* other — and drawing both is a hundred and forty-four extra triangles
|
||||
* across the whole layer.
|
||||
*/
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
const bus = new THREE.InstancedMesh(busGeometry, busMaterial, MAX_MESHES);
|
||||
const array = new THREE.InstancedMesh(arrayGeometry, arrayMaterial, MAX_MESHES);
|
||||
bus.name = "starlink-bus";
|
||||
array.name = "starlink-array";
|
||||
for (const mesh of [bus, array]) {
|
||||
/**
|
||||
* `InstancedMesh` culls on a bounding sphere it computes **once** from the
|
||||
* instance matrices and then caches. Every matrix here is rewritten every
|
||||
* frame from a different set of satellites, so that sphere is stale from the
|
||||
* second frame onward and culling on it would cull the layer at random. The
|
||||
* cost of not culling is two draw calls that were going to happen anyway.
|
||||
*/
|
||||
mesh.frustumCulled = false;
|
||||
mesh.count = 0;
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metres of spacecraft to scene units. The one place the size lie is told.
|
||||
*/
|
||||
const scale = (SPAN_FRACTION * domeRadius) / MODEL_SPAN;
|
||||
|
||||
const pool: Candidate[] = [];
|
||||
|
||||
// Scratch, all of it. Nothing in `update` allocates.
|
||||
const eye = new THREE.Vector3();
|
||||
const forward = new THREE.Vector3();
|
||||
const sunDir = new THREE.Vector3();
|
||||
const toSat = new THREE.Vector3();
|
||||
const radial = new THREE.Vector3();
|
||||
const nadir = new THREE.Vector3();
|
||||
const zenith = new THREE.Vector3();
|
||||
const boom = new THREE.Vector3();
|
||||
const third = new THREE.Vector3();
|
||||
const perpendicular = new THREE.Vector3();
|
||||
const scaleVec = new THREE.Vector3();
|
||||
const busMatrix = new THREE.Matrix4();
|
||||
const arrayMatrix = new THREE.Matrix4();
|
||||
const hinge = new THREE.Matrix4();
|
||||
const tint = new THREE.Color();
|
||||
|
||||
function slot(index: number): Candidate {
|
||||
const existing = pool[index];
|
||||
if (existing !== undefined) return existing;
|
||||
const made: Candidate = { fix: null, score: UNUSED_SCORE, fade: 0, at: new THREE.Vector3() };
|
||||
pool.push(made);
|
||||
return made;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azimuth and elevation to a point on the dome.
|
||||
*
|
||||
* The same arithmetic as `satellites.ts`'s own `place`, restated because it is
|
||||
* a closure in there, and it must stay identical: azimuth is clockwise from
|
||||
* north, scene north is −Z and east is +X, which is `sin` on X and `−cos` on Z
|
||||
* with no sign fudge anywhere. Get it wrong and the meshes are a mirror image
|
||||
* of the dots they are supposed to be sitting on.
|
||||
*/
|
||||
function place(fix: SatelliteFix, into: THREE.Vector3): void {
|
||||
const cosEl = Math.cos(fix.elevation);
|
||||
into.set(
|
||||
Math.sin(fix.azimuth) * cosEl * domeRadius,
|
||||
Math.sin(fix.elevation) * domeRadius,
|
||||
-Math.cos(fix.azimuth) * cosEl * domeRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way is down, from the satellite's point of view, expressed as a
|
||||
* direction in the observer's sky.
|
||||
*
|
||||
* Not `-radial`. That would be "point the belly at the middle of the board",
|
||||
* which is right for a satellite at the zenith and increasingly wrong as it
|
||||
* descends: the spacecraft's nadir points at the *earth's centre*, and the
|
||||
* observer is not the earth's centre. The angle between the two — the nadir
|
||||
* angle η, the same one a ground station's link budget is written in — grows
|
||||
* to about 67° at the horizon for a 550 km orbit, which is the difference
|
||||
* between seeing the antenna face and seeing the edge of the chassis.
|
||||
*
|
||||
* It falls out of the triangle centre–observer–satellite with no new inputs,
|
||||
* because the fix already carries the range. With Re the earth's radius, r the
|
||||
* slant range and e the elevation, the satellite's geocentric radius is
|
||||
*
|
||||
* Rs² = Re² + r² + 2·Re·r·sin e
|
||||
*
|
||||
* (law of cosines, the interior angle at the observer being 90° + e), and then
|
||||
* the law of sines gives sin η = Re·cos e / Rs directly. At e = 0 and 550 km
|
||||
* that is 6371/6921 = 0.92, so η = 67°; at the zenith it is 0 and the belly
|
||||
* genuinely does point at the observer.
|
||||
*
|
||||
* The rotation is in the vertical plane through the satellite, tilted from the
|
||||
* line of sight *downward* — away from the zenith — because the sub-satellite
|
||||
* point is further from the observer than the observer is from themselves. The
|
||||
* cheap check: at e = 45° over the north this returns very nearly straight
|
||||
* down with a slight lean back toward the south, which is where the ground
|
||||
* under the satellite is relative to the ground under the viewer.
|
||||
*/
|
||||
function nadirOf(fix: SatelliteFix, up: THREE.Vector3, out: THREE.Vector3): void {
|
||||
const rs = Math.sqrt(
|
||||
EARTH_RADIUS_KM ** 2 +
|
||||
fix.rangeKm ** 2 +
|
||||
2 * EARTH_RADIUS_KM * fix.rangeKm * Math.sin(fix.elevation),
|
||||
);
|
||||
const eta =
|
||||
rs > 0 ? Math.asin(clamp((EARTH_RADIUS_KM * Math.cos(fix.elevation)) / rs, 0, 1)) : 0;
|
||||
|
||||
// The line of sight, satellite to observer.
|
||||
out.copy(up).negate();
|
||||
|
||||
// The downward-pointing unit vector perpendicular to it, in the vertical
|
||||
// plane: −Y with its component along the line of sight projected out.
|
||||
perpendicular.set(0, -1, 0).addScaledVector(out, out.y);
|
||||
const length = perpendicular.length();
|
||||
// Zero only when the line of sight is itself vertical — the satellite is at
|
||||
// the zenith — where η is zero as well and the answer is already correct.
|
||||
if (length < 1e-6) return;
|
||||
|
||||
perpendicular.divideScalar(length);
|
||||
out.multiplyScalar(Math.cos(eta)).addScaledVector(perpendicular, Math.sin(eta)).normalize();
|
||||
}
|
||||
|
||||
function update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void {
|
||||
if (!group.visible) return;
|
||||
|
||||
/**
|
||||
* Both of these call `updateWorldMatrix` on the way through, which matters:
|
||||
* the renderer updates the world matrices during `render`, so a layer
|
||||
* ticked before it is looking at last frame's camera. One frame of lag in a
|
||||
* *position* is invisible; one frame of lag in a selection rule means the
|
||||
* meshes trail the aim during an orbit, which is the artefact this layer
|
||||
* would be blamed for.
|
||||
*/
|
||||
camera.getWorldPosition(eye);
|
||||
camera.getWorldDirection(forward);
|
||||
|
||||
sunDir.set(sun.x, sun.y, sun.z);
|
||||
// A zero sun direction has no meaning and would make every basis below
|
||||
// degenerate. Straight up is arbitrary and keeps the geometry well-formed.
|
||||
if (sunDir.lengthSq() < 1e-12) sunDir.set(0, 1, 0);
|
||||
else sunDir.normalize();
|
||||
|
||||
let found = 0;
|
||||
for (const fix of fixes) {
|
||||
/**
|
||||
* Starlink only, and the file is named for it.
|
||||
*
|
||||
* This shape is a specific spacecraft, not a generic satellite: a GPS bird
|
||||
* is a drum with two wings and the ISS is neither. Drawing a Galileo
|
||||
* satellite with a Starlink's single unrolled array would be the same
|
||||
* error as the two-symmetric-wings clip-art, only pointed the other way.
|
||||
* Every other group stays a dot, which claims nothing.
|
||||
*/
|
||||
if (fix.group !== "starlink") continue;
|
||||
|
||||
// `> 0` rather than `>= 0` and written to fail on NaN, for the reason
|
||||
// `SatelliteCatalogue.fixOne` gives: a degenerate element set produces NaN
|
||||
// look angles, and a NaN in an instance matrix takes out the whole
|
||||
// instanced draw rather than one satellite.
|
||||
const elevationDeg = fix.elevation * RAD;
|
||||
if (!(elevationDeg > 0)) continue;
|
||||
const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);
|
||||
|
||||
const candidate = slot(found);
|
||||
place(fix, candidate.at);
|
||||
|
||||
toSat.subVectors(candidate.at, eye);
|
||||
const distance = toSat.length();
|
||||
// The camera standing exactly on a satellite has no direction to it. It
|
||||
// cannot happen from any reachable pose; it costs one compare to make sure
|
||||
// it cannot produce a NaN either.
|
||||
if (distance < 1e-6) continue;
|
||||
|
||||
const offDeg = Math.acos(clamp(toSat.dot(forward) / distance, -1, 1)) * RAD;
|
||||
const aim = falloff(offDeg, SELECT_FULL_DEG, SELECT_EDGE_DEG);
|
||||
if (aim <= 0) continue;
|
||||
|
||||
const range = falloff(distance / domeRadius, RANGE_FULL, RANGE_EDGE);
|
||||
if (range <= 0) continue;
|
||||
|
||||
candidate.fade = horizon * aim * range;
|
||||
candidate.score = offDeg;
|
||||
candidate.fix = fix;
|
||||
found += 1;
|
||||
}
|
||||
|
||||
// Release the rest of the pool so the sort puts them past the end. The
|
||||
// objects are kept; only their claim on a slot is dropped.
|
||||
for (let i = found; i < pool.length; i++) {
|
||||
const stale = pool[i];
|
||||
if (stale !== undefined) {
|
||||
stale.fix = null;
|
||||
stale.score = UNUSED_SCORE;
|
||||
}
|
||||
}
|
||||
pool.sort(byScore);
|
||||
|
||||
const drawn = Math.min(found, MAX_MESHES);
|
||||
for (let i = 0; i < drawn; i++) {
|
||||
const candidate = pool[i];
|
||||
const fix = candidate?.fix;
|
||||
if (candidate === undefined || !fix) continue;
|
||||
|
||||
radial.copy(candidate.at).normalize();
|
||||
nadirOf(fix, radial, nadir);
|
||||
zenith.copy(nadir).negate();
|
||||
|
||||
/**
|
||||
* Yaw steering, which is what the real spacecraft does and what makes one
|
||||
* hinge sufficient.
|
||||
*
|
||||
* The array has a single degree of freedom — it rotates about the boom —
|
||||
* so it can only face the sun if the boom is perpendicular to the sun to
|
||||
* begin with. A real satellite achieves that by rotating its whole body
|
||||
* about the nadir axis as it goes round the orbit, which costs it nothing
|
||||
* because nadir-pointing leaves that rotation free. Choosing the boom as
|
||||
* `zenith × sun` is exactly that manoeuvre, solved in closed form: it is
|
||||
* perpendicular to the nadir axis, so the bus is still belly-down, and
|
||||
* perpendicular to the sun, so the hinge below can then aim the panel
|
||||
* dead-on rather than approximately.
|
||||
*
|
||||
* The cross product collapses only when the sun is straight up from the
|
||||
* satellite — the subsolar point — where the hinge angle comes out zero
|
||||
* and any perpendicular gives the right answer anyway, which is why the
|
||||
* fallback can be arbitrary.
|
||||
*/
|
||||
boom.crossVectors(zenith, sunDir);
|
||||
if (boom.lengthSq() < 1e-8) anyPerpendicular(zenith, boom);
|
||||
boom.normalize();
|
||||
third.crossVectors(boom, zenith);
|
||||
|
||||
/**
|
||||
* The hinge. The array's face is local +Y, so after a rotation of θ about
|
||||
* the boom it points along cos θ · zenith + sin θ · third, and the θ that
|
||||
* lands it on the sun is the arctangent of the sun's components in that
|
||||
* plane. Because the boom was chosen perpendicular to the sun, the sun has
|
||||
* no component outside the plane and this is exact rather than nearest.
|
||||
*/
|
||||
const theta = Math.atan2(sunDir.dot(third), sunDir.dot(zenith));
|
||||
|
||||
/**
|
||||
* The fade is a *scale*, not an opacity, and that is what makes the
|
||||
* transition from point to mesh invisible.
|
||||
*
|
||||
* Opacity was the obvious version and is worse in three ways: a standard
|
||||
* material has no per-instance alpha, so it would have taken a shader
|
||||
* patch; transparency would have forced `depthWrite: false` and let the
|
||||
* bus and the array punch holes in each other; and a half-transparent
|
||||
* satellite over a half-bright dot is a muddier picture than either. A
|
||||
* mesh scaled to a fifth is *smaller than the dot it is standing on* and
|
||||
* simply hides inside it, so the object grows out of its own point and
|
||||
* shrinks back into it. `falloff` is a smoothstep, so the size ramp has
|
||||
* zero derivative at both ends and there is no moment where it starts.
|
||||
*
|
||||
* `rank` is the same trick applied to the cap rather than to the cone:
|
||||
* the last few slots of a list that has run out of room shrink away, so
|
||||
* the object bumped by the sixty-fifth arrival was already tiny when it
|
||||
* went. It is 1 whenever the cap is not binding, which is nearly always.
|
||||
*/
|
||||
const rank =
|
||||
found > MAX_MESHES ? clamp((MAX_MESHES - i) / RANK_FADE_SLOTS, 0, 1) : 1;
|
||||
scaleVec.setScalar(scale * candidate.fade * rank);
|
||||
|
||||
busMatrix.makeBasis(boom, zenith, third).scale(scaleVec).setPosition(candidate.at);
|
||||
bus.setMatrixAt(i, busMatrix);
|
||||
|
||||
/**
|
||||
* The array rides the same origin and basis as the bus with the hinge
|
||||
* rotation inserted, and its offset down the boom is baked into its
|
||||
* geometry rather than into this matrix — which is why rotating about the
|
||||
* boom pivots the panel about the hinge instead of swinging it around the
|
||||
* bus. Same position, same scale, one extra rotation.
|
||||
*/
|
||||
hinge.makeRotationX(theta);
|
||||
arrayMatrix
|
||||
.makeBasis(boom, zenith, third)
|
||||
.multiply(hinge)
|
||||
.scale(scaleVec)
|
||||
.setPosition(candidate.at);
|
||||
array.setMatrixAt(i, arrayMatrix);
|
||||
|
||||
/**
|
||||
* Phase, exactly as for the moon: how much of the lit side is turned this
|
||||
* way. `radial` points from the earth to the satellite, so `−radial` is
|
||||
* near enough the direction from the satellite to the observer, and its
|
||||
* dot with the sun is the cosine of the phase angle. Positive when the sun
|
||||
* is below the observer's horizon and the object is still in daylight,
|
||||
* which is the entire observing window for a Starlink pass; zero at noon,
|
||||
* when the sun is behind it from here and the side facing down is the side
|
||||
* in shadow.
|
||||
*
|
||||
* (The sun's direction from 550 km up differs from its direction at the
|
||||
* ground by about a thousandth of a degree, so the scene's own vector is
|
||||
* used without correction.)
|
||||
*/
|
||||
const phase = clamp(-radial.dot(sunDir), 0, 1);
|
||||
const lit = ECLIPSE_FLOOR + (1 - ECLIPSE_FLOOR) * (1 - clamp(fix.shadow, 0, 1));
|
||||
const facing = PHASE_FLOOR + (1 - PHASE_FLOOR) * phase;
|
||||
|
||||
tint.copy(BUS_COLOR).multiplyScalar(facing * lit);
|
||||
bus.setColorAt(i, tint);
|
||||
tint.copy(ARRAY_COLOR).lerp(ARRAY_GLINT, phase ** 3).multiplyScalar(lit);
|
||||
array.setColorAt(i, tint);
|
||||
}
|
||||
|
||||
bus.count = drawn;
|
||||
array.count = drawn;
|
||||
bus.instanceMatrix.needsUpdate = true;
|
||||
array.instanceMatrix.needsUpdate = true;
|
||||
// Allocated lazily by the first `setColorAt`, which on a sky with nothing
|
||||
// above the horizon has not happened yet.
|
||||
if (bus.instanceColor) bus.instanceColor.needsUpdate = true;
|
||||
if (array.instanceColor) array.instanceColor.needsUpdate = true;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
/**
|
||||
* Unlike `SatelliteLayer.setVisible`, this one also stops the work — see the
|
||||
* early return in `update`. The distinction is not an inconsistency: that
|
||||
* layer keeps propagating while hidden because its state is a *sweep* that
|
||||
* would otherwise resume half a catalogue behind reality. This layer holds
|
||||
* no state between frames at all, so a hidden one has nothing to catch up
|
||||
* on and the next visible frame is complete.
|
||||
*/
|
||||
setVisible(visible: boolean) {
|
||||
group.visible = visible;
|
||||
},
|
||||
dispose() {
|
||||
// The instanced meshes first. `InstancedMesh.dispose()` releases the
|
||||
// per-instance matrix and colour buffers, which are the layer's own
|
||||
// allocation and are not reached by disposing the geometry they wrap —
|
||||
// two `Float32Array`s of 64 instances each, orphaned on the GL context on
|
||||
// every city switch until this line existed.
|
||||
bus.dispose();
|
||||
array.dispose();
|
||||
busGeometry.dispose();
|
||||
arrayGeometry.dispose();
|
||||
busMaterial.dispose();
|
||||
arrayMaterial.dispose();
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bus: a flat slab, the phased-array antenna stepped out of its underside,
|
||||
* and the boom stub the panel deploys along.
|
||||
*
|
||||
* The antenna step is worth its four triangles because the slab alone is a
|
||||
* shape with no side to it — the whole read of "belly pointing down" comes from
|
||||
* being able to see which face is which at an oblique angle. The boom is in the
|
||||
* bus rather than the array partly because it is structure rather than panel and
|
||||
* takes the pale material, and partly because a cylinder lying along the hinge
|
||||
* axis is invariant under the hinge rotation, so it looks identical either way
|
||||
* and this way it costs no second matrix.
|
||||
*/
|
||||
function buildBus(): THREE.BufferGeometry {
|
||||
const chassis = new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH);
|
||||
|
||||
const antenna = new THREE.BoxGeometry(BUS_LENGTH * 0.78, BUS_THICK * 0.45, BUS_DEPTH * 0.72);
|
||||
antenna.translate(0, -BUS_THICK * 0.6, 0);
|
||||
|
||||
const stub = new THREE.CylinderGeometry(BOOM_RADIUS, BOOM_RADIUS, BOOM_GAP * 1.4, 6);
|
||||
// `CylinderGeometry` runs along +Y; the boom runs along +X.
|
||||
stub.rotateZ(Math.PI / 2);
|
||||
stub.translate(BUS_LENGTH / 2 + BOOM_GAP / 2, 0, 0);
|
||||
|
||||
const parts = [chassis, antenna, stub];
|
||||
const merged = mergeGeometries(parts);
|
||||
for (const part of parts) part.dispose();
|
||||
if (merged) return merged;
|
||||
|
||||
// The same non-null dance as `flights.ts`'s `dartGeometry`, for the same
|
||||
// reason: three primitives out of the same library cannot disagree about their
|
||||
// attributes, the signature permits it anyway, and a plain slab is a better
|
||||
// failure than a missing layer.
|
||||
return new THREE.BoxGeometry(BUS_LENGTH, BUS_THICK, BUS_DEPTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* The array: one panel, on one side, offset down the boom in its own geometry so
|
||||
* that the instance matrix can be a pure rotation about the hinge.
|
||||
*
|
||||
* A box rather than a plane. A plane would halve the triangles and is the
|
||||
* obvious choice for something two centimetres thick at eight metres long — but
|
||||
* the edge is what you see during the part of the orbit where the panel is
|
||||
* turned away from you, and a zero-thickness panel vanishes completely at
|
||||
* exactly that moment. Six centimetres of scene-space thickness is a fiction in
|
||||
* the same way the overall size is, and it buys a silhouette that never
|
||||
* disappears.
|
||||
*/
|
||||
function buildArray(): THREE.BufferGeometry {
|
||||
const panel = new THREE.BoxGeometry(ARRAY_LENGTH, ARRAY_THICK, ARRAY_WIDTH);
|
||||
panel.translate(ARRAY_CENTRE_X, 0, 0);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Ascending by angle off the view centre; released slots sort to the back. */
|
||||
function byScore(a: Candidate, b: Candidate): number {
|
||||
return a.score - b.score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 at or below `full`, 0 at or above `edge`, smoothstepped between — so both
|
||||
* ends of every ramp in this file arrive with zero slope, which is the whole
|
||||
* anti-pop argument in one function.
|
||||
*/
|
||||
function falloff(x: number, full: number, edge: number): number {
|
||||
if (x <= full) return 1;
|
||||
if (x >= edge) return 0;
|
||||
const t = (x - full) / (edge - full);
|
||||
return 1 - t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any unit vector perpendicular to `v`, for the one degenerate case where the
|
||||
* caller genuinely does not care which. Crossed against whichever world axis `v`
|
||||
* is least aligned with, because crossing against a near-parallel axis is how a
|
||||
* "just pick one" helper returns a zero vector.
|
||||
*/
|
||||
function anyPerpendicular(v: THREE.Vector3, out: THREE.Vector3): void {
|
||||
if (Math.abs(v.y) < 0.9) out.set(0, 1, 0).cross(v);
|
||||
else out.set(1, 0, 0).cross(v);
|
||||
out.normalize();
|
||||
}
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return x < lo ? lo : x > hi ? hi : x;
|
||||
}
|
||||
|
||||
/**
|
||||
* ---- Numbers a reviewer can check without running anything -----------------
|
||||
*
|
||||
* On the Bay Area board `boardRadius` is about 0.94 of a 1,003-unit span, so the
|
||||
* dome is at 990 units and a drawn satellite is 15.8 of them tip to tip — about
|
||||
* one and a half kilometres of city, at a
|
||||
* range of roughly 1,000 units, which is 0.92° of arc or some twenty pixels of a
|
||||
* 1,000-pixel canvas at this scene's 42° field of view.
|
||||
*
|
||||
* `nadirOf` at 550 km, checked against the numbers in its own derivation:
|
||||
*
|
||||
* elevation 90° range 550 km Rs 6921 η 0.0° belly at the observer
|
||||
* elevation 45° range 749 km Rs 6921 η 40.6° belly nearly straight
|
||||
* down, leaning back
|
||||
* toward the observer
|
||||
* elevation 20° range 1,294 km Rs 6921 η 59.9°
|
||||
* elevation 0° range 2,704 km Rs 6921 η 67.0° seen edge-on
|
||||
*
|
||||
* Over the north those come out as nadir vectors of (0, −1, 0), (0, −0.997,
|
||||
* 0.077), (0, −0.984, 0.176) and (0, −0.921, 0.391) — the lean being southward,
|
||||
* back over the observer, and reaching 23° off vertical at the horizon.
|
||||
*
|
||||
* The orientation as a whole holds two invariants that are worth asserting if
|
||||
* this ever grows a test: the bus's local −Y lands exactly on the nadir vector
|
||||
* (dot 1.0000), and the array's face lands exactly on the sun (dot 1.0000) for
|
||||
* every azimuth, elevation and sun position, including the subsolar degeneracy
|
||||
* where the boom has to be guessed. The basis is right-handed throughout
|
||||
* (determinant +1), so nothing is drawn inside out.
|
||||
*
|
||||
* `phase` for a satellite at the zenith is `−sin(sun elevation)`: 0 with the sun
|
||||
* anywhere above the horizon, 0.5 with it 30° down, 1 at solar midnight — at
|
||||
* which point the same satellite is in the earth's shadow and `lit` has taken it
|
||||
* to 0.16 anyway. The band where a Starlink is both at high phase and out of
|
||||
* eclipse is the hour or so after sunset and before sunrise, which is when
|
||||
* anybody has ever seen one.
|
||||
*/
|
||||
@@ -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.
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user