The office learns where it stands, and the sky stops being a backdrop
**Aircraft actually move now, and the reason they did not is the headline.**
`HttpFlights` holds a frozen snapshot between network refreshes and is
polled at 1 Hz, so a live feed handed the layer the same position five to
fifteen times and then jumped. `span` therefore measured the poll interval
rather than the gap between the two positions that differ, the teleport
test saw an airliner covering 36 units in a "second" against a ceiling of
8, and **every live track's history was wiped on every refresh** — so no
aircraft on the deployed site could ever grow a trail, however long
TRAIL_POINTS was set. Skipping the repeat fixes the motion and the trail
at once. Trails then go to 72 points / 240 s, which is about seventy
seconds of flying.
Three more defects in the same file, found while looking: trail
truncation dropped the segments nearest the aircraft (leaving a streak
with no aeroplane attached), MAX_TRACKS was declared and never enforced,
and one missing target deleted its whole trail. The buffer now uploads
only what it wrote, rather than 46 MB/s of untouched array.
**You can get above the constellation.** Dome to 1.05 board *radii* and
the orbit to 2.0 spans. Radii, not spans: scene space is centred on the
city and the Bay Area board runs forty kilometres down the peninsula, so
the furthest corner is 0.94 spans out where the half-diagonal is 0.65 —
sized off the half-diagonal the dome sat inside its own city. The far
plane goes to 4 spans to stop clipping the sky from off-centre chapters,
and `PointsMaterial` defaults `fog: true`, which was quietly dimming the
whole constellation with the city's haze.
**An office can say where it stands.** `Office.site` — lat, lng, height
above the ground outside, and the compass bearing the pack's −Z points
along — and with one it gets the same sun the city does, a sky, and a
horizon at `-elevation`. CONTRACT §4 reserved this as "a later
refinement"; it is taken up rather than overturned, and `daylight.ts`
computes no light of its own. It does the two things a room needs that a
map does not: turn the sun into the building's frame, and move the fog
outdoors before it greys out the far wall.
Two buildings now, and they are deliberately unalike: Lumbridge HQ 188 m
up a Transbay tower facing 205°, and **Frontier Valley**, a startup in a
hangar at Alameda Point — one room, 54 x 30 m, nine metres to the
trusses, four metres above reclaimed ground.
Floor-to-floor in the reference pack is now 16.8 m: the interstitial is
ten times a real one, so the space between the slabs is somewhere things
can hang. It is frankly not architecture, `PLENUM` is the one number to
change, and the file says so.
Also fixed, all found by review rather than by looking at the screen:
- `sun.shadow.camera.updateProjectionMatrix()` was never called, so
three's default ±5 unit box has been in force this whole time and
every `shadowExtent` this repo passes — including the city's ±752 —
has been silently ignored.
- A missing aircraft was kept alive by the new grace period and *drawn*,
so it froze in mid-air at full opacity for 32 s.
- Frontier Valley's mezzanine was a `Room`, which carries no height: its
slab lay on the concrete, its chairs floated 4.4 m over it, and its
balustrade fenced off a patch of ground floor. It is a `Level`.
- Overlapping floor slabs z-fought. The format permits overlap and
resolves later-first, so `shell.ts` now lifts a slab a hair per
earlier slab it overlaps — and by nothing at all in a pack, like the
reference office, whose rooms only ever abut.
- `switchOffice` bypassed the `entering` guard (leaking a whole scene
per double-click) and tore down the old room before knowing the new
one would load, with no way back.
Known and not fixed: raising MAX_SPAN to 30 s doubles the worst-case
re-base snap when a feed's gap shortens. It is bounded, pre-existing in
kind, and the fix wants carrying the live head into the next leg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+207
-11
@@ -434,16 +434,54 @@ export interface FlightLayer {
|
||||
* How many observations a trail remembers, and how long it may hold one.
|
||||
*
|
||||
* Both limits are needed. The count keeps the shared vertex buffer bounded, and
|
||||
* the age keeps a slow feed from drawing a trail across the entire bay: at
|
||||
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes
|
||||
* of flying, which is most of a leg.
|
||||
* the age keeps a slow feed from drawing a trail across the entire bay.
|
||||
*
|
||||
* These were 20 and 45, which made a trail about twenty seconds long — enough to
|
||||
* say "this thing is moving" and not enough to say where it came from. At 72
|
||||
* points the simulator, polled at 1 Hz, draws about seventy seconds of flying:
|
||||
* a quarter to a half of one of `sample.ts`'s legs, so an arrival trails a
|
||||
* visible curve down the approach rather than a tick behind it.
|
||||
*
|
||||
* The count is what binds for a 1 Hz source; the age binds for a slow one. 240 s
|
||||
* is four minutes, which at a live feed's 5–15 s refresh is 16–48 samples — so
|
||||
* a real ADS-B track fills a good part of the buffer without either limit
|
||||
* cutting it short.
|
||||
*
|
||||
* The cost is bounded and was measured rather than guessed: the trail is one
|
||||
* preallocated `LineSegments` and therefore one draw call at any length, and the
|
||||
* buffer goes from 210 KiB to 756 KiB. What actually scales is the per-vertex
|
||||
* work in `rebuildTrails`, which is why `writeTrailVertex` now uploads only the
|
||||
* range it wrote instead of the whole array.
|
||||
*/
|
||||
const TRAIL_POINTS = 20;
|
||||
const TRAIL_SECONDS = 45;
|
||||
const TRAIL_POINTS = 72;
|
||||
const TRAIL_SECONDS = 240;
|
||||
|
||||
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */
|
||||
/**
|
||||
* How far two positions may differ and still be "the same one repeated".
|
||||
*
|
||||
* Scene units. See `samePosition`, and the note on the repeat check in
|
||||
* `update` for why a repeated observation must not become a sample.
|
||||
*/
|
||||
const SAME_POSITION_EPSILON = 1e-4;
|
||||
|
||||
/**
|
||||
* Ceiling on tracks that get a trail, so the buffer can be allocated once.
|
||||
*
|
||||
* 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;
|
||||
* what they do not get is a trail, which is the graceful half to drop.
|
||||
*/
|
||||
const MAX_TRACKS = 192;
|
||||
|
||||
/**
|
||||
* How long a track survives not being in a snapshot before it is forgotten.
|
||||
*
|
||||
* Two refreshes of a slow live feed. See the note where it is used.
|
||||
*/
|
||||
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
|
||||
@@ -460,7 +498,14 @@ const TRAIL_ALPHA = 0.55;
|
||||
* the real gap was four times that gives an aircraft that darts and then waits.
|
||||
*/
|
||||
const MIN_SPAN = 0.2;
|
||||
const MAX_SPAN = 15;
|
||||
/**
|
||||
* 30 rather than 15, because the repeat check in `update` changed what this
|
||||
* measures. It used to bound a poll interval; it now bounds the gap between two
|
||||
* positions that actually differ, and for a live feed that gap *is* the server's
|
||||
* cache TTL — 5 to 15 seconds, plus whatever the network adds. Clamping at 15
|
||||
* would have made every slow refresh look like a dart-and-wait again.
|
||||
*/
|
||||
const MAX_SPAN = 30;
|
||||
|
||||
/**
|
||||
* Above this, a step is a teleport rather than a flight.
|
||||
@@ -516,6 +561,16 @@ interface Track {
|
||||
band: number;
|
||||
/** Interpolated position, reused rather than reallocated every frame. */
|
||||
head: THREE.Vector3;
|
||||
/**
|
||||
* When this track first went missing from a snapshot, or `0` while it is
|
||||
* present. See `TRACK_GRACE_SECONDS`.
|
||||
*/
|
||||
missingSince: number;
|
||||
/**
|
||||
* Missing, and finished moving toward wherever it was last seen — so no
|
||||
* 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. */
|
||||
headAltitude: number;
|
||||
}
|
||||
@@ -629,11 +684,48 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
band: -1,
|
||||
head: position.clone(),
|
||||
headAltitude: a.altitude,
|
||||
missingSince: 0,
|
||||
stale: false,
|
||||
};
|
||||
tracks.set(a.id, track);
|
||||
}
|
||||
|
||||
const previous = track.samples[track.samples.length - 1];
|
||||
|
||||
/**
|
||||
* A source repeating itself is not a new observation, and treating it as
|
||||
* one is what stopped live traffic from ever moving.
|
||||
*
|
||||
* `HttpFlights` is polled at 1 Hz and holds a *frozen* snapshot between
|
||||
* network refreshes, which the server caches for 5–15 s. So a live feed
|
||||
* hands over the identical position five to fifteen times in a row and
|
||||
* then jumps. Pushing each repeat as its own sample had two consequences,
|
||||
* and both of them were visible on the deployed site:
|
||||
*
|
||||
* 1. `span` measured the poll interval (~1 s) rather than the gap between
|
||||
* the two positions that actually differ (5–15 s). The teleport test
|
||||
* below then saw an airliner covering 36 units in a "second" against a
|
||||
* ceiling of 8 and **wiped the entire trail on every refresh** — so no
|
||||
* live aircraft could ever grow a trail at all, however large
|
||||
* `TRAIL_POINTS` was set.
|
||||
* 2. The trail filled with a dozen coincident points, so the spine had no
|
||||
* length and the dart sat still and then jumped.
|
||||
*
|
||||
* Skipping the repeat fixes both at once: `span` becomes the real gap, the
|
||||
* jump test sees ~2.5 units/s and passes, and the interpolation in `tick`
|
||||
* spreads the movement smoothly across the whole refresh interval.
|
||||
*
|
||||
* Compared on position rather than on an observation timestamp because a
|
||||
* `FlightSource` is not required to carry one — `Aircraft` has no `at`
|
||||
* field, and the two real sources disagree about whether they could
|
||||
* supply one honestly.
|
||||
*/
|
||||
if (previous && samePosition(previous, sample)) {
|
||||
// Nothing to record. The head keeps interpolating toward the newest
|
||||
// distinct sample, which is what makes the motion continuous.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
// The clamp is load-bearing on both ends. Two polls arriving in the same
|
||||
// millisecond — a manual refresh, a tab waking up — divide by nearly
|
||||
@@ -666,8 +758,27 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
trim(track, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* An aircraft missing from one snapshot has not landed.
|
||||
*
|
||||
* This used to delete the track the instant an id was absent, which meant a
|
||||
* single dropped target — an ADS-B receiver losing a line of sight for one
|
||||
* refresh, which is routine — threw away its entire history and rebuilt it
|
||||
* from nothing. At a 20-point trail that cost twenty seconds; at 72 it would
|
||||
* cost over a minute, so the longer trails are what made this worth fixing
|
||||
* rather than merely worth noticing.
|
||||
*
|
||||
* `ADSB_HOLD_SECONDS` covers the whole feed going dark. This covers one
|
||||
* target going quiet inside an otherwise healthy snapshot, which is a
|
||||
* different failure and needs a different answer.
|
||||
*/
|
||||
for (const [id, track] of tracks) {
|
||||
if (seen.has(id)) continue;
|
||||
if (seen.has(id)) {
|
||||
track.missingSince = 0;
|
||||
continue;
|
||||
}
|
||||
if (track.missingSince === 0) track.missingSince = now;
|
||||
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
|
||||
group.remove(track.mesh);
|
||||
tracks.delete(id);
|
||||
}
|
||||
@@ -675,6 +786,22 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feed has handed back the same position it did last time.
|
||||
*
|
||||
* Scene units, and the tolerance is deliberately tiny: this is asking "did the
|
||||
* source repeat itself", not "did it move much". A genuinely stationary
|
||||
* aircraft on a taxiway still reports jitter well above this, and if it did
|
||||
* not, an aircraft that is not moving has nothing to draw a trail from anyway.
|
||||
*/
|
||||
function samePosition(a: TrailSample, b: TrailSample): boolean {
|
||||
return (
|
||||
Math.abs(a.position.x - b.position.x) < SAME_POSITION_EPSILON &&
|
||||
Math.abs(a.position.z - b.position.z) < SAME_POSITION_EPSILON &&
|
||||
Math.abs(a.altitude - b.altitude) < 1
|
||||
);
|
||||
}
|
||||
|
||||
/** Forget history that is too old or too long to be worth drawing. */
|
||||
function trim(track: Track, now: number) {
|
||||
while (track.samples.length > TRAIL_POINTS) track.samples.shift();
|
||||
@@ -696,6 +823,26 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
const from = track.samples[n - 2] ?? to;
|
||||
const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);
|
||||
|
||||
/**
|
||||
* A missing aircraft stops being drawn once it has finished arriving.
|
||||
*
|
||||
* The grace period holds a track's *history* across a dropped refresh,
|
||||
* which is the point of it — but holding the history is not the same as
|
||||
* going on drawing the aeroplane. Left drawn, a target that genuinely
|
||||
* left the feed froze in mid-air at full opacity with its whole trail
|
||||
* attached, for the full thirty-two seconds, indistinguishable from an
|
||||
* aircraft that had stopped flying. The godmode dial made it unmissable:
|
||||
* turning 400 fabricated aircraft down to zero left 400 darts hanging.
|
||||
*
|
||||
* Gated on having run out of interpolation rather than simply on being
|
||||
* missing, so a target absent for one refresh keeps moving to where it was
|
||||
* last seen and never blinks — and if it comes back, it resumes its trail
|
||||
* instead of rebuilding it.
|
||||
*/
|
||||
track.stale = track.missingSince !== 0 && now - to.at > track.span;
|
||||
track.mesh.visible = !track.stale;
|
||||
if (track.stale) continue;
|
||||
|
||||
track.head.lerpVectors(from.position, to.position, alpha);
|
||||
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
|
||||
|
||||
@@ -728,12 +875,39 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
*/
|
||||
function rebuildTrails() {
|
||||
let vertex = 0;
|
||||
let drawn = 0;
|
||||
for (const track of tracks.values()) {
|
||||
// Past the ceiling, an aircraft keeps its dart and loses its trail. The
|
||||
// buffer was sized for this many and the constant meant nothing until now.
|
||||
if (drawn >= MAX_TRACKS) break;
|
||||
// Before `drawn`, so an expiring ghost does not hold a trail slot ahead of
|
||||
// a genuinely new arrival — `tracks` is walked in insertion order, and the
|
||||
// ghosts are the oldest entries in it.
|
||||
if (track.stale) continue;
|
||||
const spine = track.samples.length - 1;
|
||||
if (spine < 1) continue;
|
||||
drawn += 1;
|
||||
const points = spine + 1; // the spine, plus the head
|
||||
|
||||
for (let i = 1; i < points; i++) {
|
||||
/**
|
||||
* Where to start drawing this track, so that a buffer that cannot hold
|
||||
* everything loses the **oldest** segments rather than the newest.
|
||||
*
|
||||
* The loop writes tail-first, so the previous `break`-when-full dropped
|
||||
* the segments nearest the aircraft. That is the worst possible end to
|
||||
* lose: it left a streak floating in open air with no aeroplane attached
|
||||
* to it, which reads as a rendering fault rather than as a shortened
|
||||
* trail. It was invisible at 7–8 aircraft and unmissable the moment the
|
||||
* godmode dial put four hundred in the sky.
|
||||
*
|
||||
* Clamping the start index instead means a crowded sky draws shorter
|
||||
* trails, every one of them still joined to its dart.
|
||||
*/
|
||||
const budget = Math.max(0, (maxVertices - vertex) / 2);
|
||||
if (budget < 1) break;
|
||||
const first = Math.max(1, points - Math.floor(budget));
|
||||
|
||||
for (let i = first; i < points; i++) {
|
||||
if (vertex + 2 > maxVertices) break;
|
||||
const a = track.samples[i - 1];
|
||||
if (!a) continue;
|
||||
@@ -749,8 +923,30 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
}
|
||||
}
|
||||
trailGeo.setDrawRange(0, vertex);
|
||||
trailGeo.attributes.position!.needsUpdate = true;
|
||||
trailGeo.attributes.color!.needsUpdate = true;
|
||||
|
||||
/**
|
||||
* Upload only the vertices actually written this frame.
|
||||
*
|
||||
* `needsUpdate` alone re-sends the entire `Float32Array` — three.js takes an
|
||||
* empty update range to mean "all of it" — which was 12.9 MB/s at 60 Hz with
|
||||
* a 20-point trail and would have been 46 MB/s at 72 for a scene that
|
||||
* normally holds seven aircraft and writes under 2% of the buffer. The rest
|
||||
* of the array is stale data nothing draws, because `setDrawRange` already
|
||||
* bounds what is read.
|
||||
*
|
||||
* `clearUpdateRanges` first, or the ranges accumulate frame on frame and the
|
||||
* saving disappears within a second.
|
||||
*/
|
||||
const positionAttr = trailGeo.attributes.position as THREE.BufferAttribute;
|
||||
const colorAttr = trailGeo.attributes.color as THREE.BufferAttribute;
|
||||
positionAttr.clearUpdateRanges();
|
||||
colorAttr.clearUpdateRanges();
|
||||
if (vertex > 0) {
|
||||
positionAttr.addUpdateRange(0, vertex * 3);
|
||||
colorAttr.addUpdateRange(0, vertex * 4);
|
||||
positionAttr.needsUpdate = true;
|
||||
colorAttr.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
|
||||
|
||||
+40
-13
@@ -309,21 +309,37 @@ export interface SatelliteLayer {
|
||||
}
|
||||
|
||||
/**
|
||||
* The dome's radius, as a fraction of the board's longest side.
|
||||
* The dome's radius, as a fraction of **how far the board reaches from the scene
|
||||
* origin** — `boardRadius` in `scene.ts`, not the board's width.
|
||||
*
|
||||
* Three constraints, and the middle one is the one that is easy to miss:
|
||||
* That distinction was got wrong once and is worth stating plainly. Scene space
|
||||
* is centred on `city.center`, and the Bay Area board runs forty kilometres down
|
||||
* the peninsula from there, so its furthest corner is 0.94 spans from the origin
|
||||
* while its half-diagonal is 0.65. A dome sized at 0.7 *spans* therefore sat
|
||||
* **inside the southern third of its own city** — satellites rendering below the
|
||||
* terrain and behind the hills, which looks like a depth bug and is a units bug.
|
||||
*
|
||||
* 1. Outside the city, or the buildings occlude the sky.
|
||||
* 2. **Outside the camera's maximum orbit**, which `scene.ts` sets at
|
||||
* `boardSpan * 1.5`. This was 1.2 first, which put the far end of the zoom
|
||||
* *outside the dome* — pull back far enough and you were looking at the sky
|
||||
* from space, with half the constellation behind the camera. A sky you can
|
||||
* leave is not a sky.
|
||||
* 3. Inside the far plane, which `scene.ts` sets at `boardSpan * 3`.
|
||||
* **You are meant to be able to get above this.** That is a deliberate reversal:
|
||||
* an earlier version put the dome at 2.0 spans specifically so the camera could
|
||||
* never leave it, on the theory that a sky you can step outside of is not a sky.
|
||||
* That theory loses to the thing people actually want to look at — a
|
||||
* constellation is an object, and the view of it from above, with the city
|
||||
* underneath, is the shot. Standing inside a sphere of dots is a planetarium.
|
||||
*
|
||||
* 2.0 sits in the middle of the only window that satisfies all three.
|
||||
* So the geometry is worked out from the other end. The board's half-diagonal is
|
||||
* 0.649 spans for San Francisco and 0.635 for the Southland, so 0.70 clears
|
||||
* every corner of the city while sitting well inside the camera's new 2.0-span
|
||||
* orbit. At the far end of the zoom the whole dome is in frame at this fov and
|
||||
* the board still fills about two thirds of the screen height.
|
||||
*
|
||||
* Two bugs died with the old number, and both were invisible from the default
|
||||
* pose. At 2.0 the dome reached 3.5 spans from the far side of the orbit against
|
||||
* a far plane at 3.0, so roughly a sixth of the sky was being **clipped**; and
|
||||
* everything past 2.8 spans was **fully fogged** by the city's own linear fog.
|
||||
* The band where the constellation was both unclipped and unfogged did not
|
||||
* overlap the band where it fitted on screen at all.
|
||||
*/
|
||||
const DOME_RADIUS_FACTOR = 2.0;
|
||||
const DOME_RADIUS_FACTOR = 1.05;
|
||||
|
||||
/**
|
||||
* How large a dot is drawn, in **pixels**, at any camera distance.
|
||||
@@ -386,11 +402,11 @@ const HORIZON_FADE_DEG = 8;
|
||||
*/
|
||||
const SHADOW_ALPHA = 0.16;
|
||||
|
||||
export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
|
||||
export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "satellites";
|
||||
|
||||
const radius = boardSpan * DOME_RADIUS_FACTOR;
|
||||
const radius = boardRadius * DOME_RADIUS_FACTOR;
|
||||
|
||||
const positions = new Float32Array(MAX_DOTS * 3);
|
||||
const colors = new Float32Array(MAX_DOTS * 4);
|
||||
@@ -418,6 +434,17 @@ export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
|
||||
// and additive blending is what makes a lit satellite read as a light source
|
||||
// rather than as a grey sticker.
|
||||
blending: THREE.AdditiveBlending,
|
||||
/**
|
||||
* Satellites are not in the weather.
|
||||
*
|
||||
* `PointsMaterial` defaults `fog: true`, and the city runs a linear fog
|
||||
* whose far plane is 2.8 board spans — so every dot was being mixed toward
|
||||
* the fog colour by distance, and the constellation dimmed as the camera
|
||||
* pulled back, exactly when more of it came into view. Haze is a property of
|
||||
* the twelve kilometres of air a city sits in; an object 550 km up is on the
|
||||
* far side of all of it.
|
||||
*/
|
||||
fog: false,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geo, material);
|
||||
|
||||
+48
-3
@@ -194,14 +194,59 @@ export async function createScene(
|
||||
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
|
||||
const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
|
||||
|
||||
/**
|
||||
* How far the board reaches **from the scene origin**, which is not the same
|
||||
* as how big it is.
|
||||
*
|
||||
* Scene space is centred on `city.center` — the city — and the Bay Area board
|
||||
* runs forty kilometres down the peninsula from there, so the origin is
|
||||
* nowhere near the middle of it. The furthest corner is 0.94 spans out where
|
||||
* the half-diagonal is only 0.65, and anything sized off the half-diagonal is
|
||||
* therefore too small by half.
|
||||
*
|
||||
* The satellite dome is centred on the origin, because that is where its look
|
||||
* angles were computed for, so this is the radius it has to clear.
|
||||
*/
|
||||
const boardRadius = Math.max(
|
||||
Math.hypot(westX, northZ),
|
||||
Math.hypot(eastX, northZ),
|
||||
Math.hypot(westX, southZ),
|
||||
Math.hypot(eastX, southZ),
|
||||
);
|
||||
|
||||
const kit = createSceneKit({
|
||||
scene,
|
||||
dom: stage.renderer.domElement,
|
||||
fov: 42,
|
||||
near: 0.1,
|
||||
far: boardSpan * 3,
|
||||
/**
|
||||
* Everything, at the worst pose, plus room.
|
||||
*
|
||||
* The furthest thing from the camera is the back of the satellite dome seen
|
||||
* from a chapter at the far end of the board: `maxChapterOffset` (0.94
|
||||
* spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9.
|
||||
* At 3.0 the sky was being clipped from any off-centre chapter, which is
|
||||
* most of them.
|
||||
*
|
||||
* It also now sits at the fog's far plane, which is the honest statement of
|
||||
* the invariant: nothing should be cut off before it has fully faded out.
|
||||
*/
|
||||
far: boardSpan * 4,
|
||||
minDistance: Math.max(4, boardSpan * 0.02),
|
||||
maxDistance: boardSpan * 1.5,
|
||||
/**
|
||||
* Far enough out to be **above the satellites**, which is what the extra
|
||||
* half-span buys.
|
||||
*
|
||||
* The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the
|
||||
* camera was always inside the constellation and could only ever look up
|
||||
* through it. At 2.0 the far end of the zoom is outside it looking down, with
|
||||
* the whole dome in frame at this field of view and the board still filling
|
||||
* about two thirds of the screen.
|
||||
*
|
||||
* The far plane already covers it: the furthest thing from the camera is then
|
||||
* the back of the dome at 2.7 spans, against `far` at 3.0.
|
||||
*/
|
||||
maxDistance: boardSpan * 2.0,
|
||||
shadowExtent: boardSpan * 0.75,
|
||||
shadowFar: boardSpan * 2.2,
|
||||
});
|
||||
@@ -235,7 +280,7 @@ export async function createScene(
|
||||
|
||||
let satelliteLayer: SatelliteLayer | null = null;
|
||||
if (options.satellites) {
|
||||
satelliteLayer = createSatelliteLayer(boardSpan);
|
||||
satelliteLayer = createSatelliteLayer(boardRadius);
|
||||
scene.add(satelliteLayer.group);
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
sun.shadow.camera.top = extent;
|
||||
sun.shadow.camera.bottom = -extent;
|
||||
sun.shadow.bias = options.shadowBias ?? -0.0012;
|
||||
/**
|
||||
* Without this line, none of the six numbers above exist.
|
||||
*
|
||||
* `OrthographicCamera` bakes its frustum into a projection matrix in the
|
||||
* constructor, and mutating `.left`/`.right`/`.near`/`.far` afterwards does
|
||||
* nothing until the matrix is rebuilt. three.js rebuilds it for a *spot*
|
||||
* light's shadow (`SpotLightShadow.updateMatrices` recomputes when the fov or
|
||||
* far changes) and **not** for a directional light's — `LightShadow.
|
||||
* updateMatrices` only does `lookAt` and `updateMatrixWorld`.
|
||||
*
|
||||
* So every caller here was configuring a shadow camera that was still
|
||||
* three's default `(-5, 5, 5, -5, 0.5, 500)`: a ten-unit box. The city asks
|
||||
* for `shadowExtent: boardSpan * 0.75`, which is ±752 units on the Bay Area
|
||||
* board, and got ±5 — a shadow map covering a square about the size of one
|
||||
* house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
|
||||
*/
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
|
||||
scene.add(sun, hemisphere, ambient);
|
||||
|
||||
Reference in New Issue
Block a user