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) {
|
||||
|
||||
Reference in New Issue
Block a user