Answer the review: keep up with flights frame by frame, respect portrait frames, and let links follow an orbit

- Flights: the drawn stars were checked once a label pass, and a flight outruns that. Leaving a
  system jumps the camera to face another way, then zooms out forty-fold in a second: 74-83% of
  the stars that belong on screen were missing on the first frames back in parsec space, 33-50%
  before each re-choice on the way out. While the rig animates, the check now runs every frame.
  Probe on real flights (Gl 806, Barnard's Star, out): 0.90-1.00 of a fresh choice drawn on
  screen in flight, 1.00 on the frame of the jump; frame p95 12.2 ms, no long tasks. Choosing for
  the whole sky during flights was tried first and measured worse (0.15-0.18).
- Portrait frames: the turn and pan limits use the narrower half-extent, not the height.
- Links: a new drawn set arms the rebuild timer only when none is pending, so a continuous orbit
  gets a graph at most every 250 ms instead of never; an unchanged set asks for nothing.
- The centre-move test lets the first pass happen before moving the centre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-17 17:57:29 +02:00
co-authored by Claude Opus 5
parent 9ad0815acd
commit 9631ddf0a4
2 changed files with 121 additions and 38 deletions
@@ -222,6 +222,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
it('chooses the drawn stars again once the view centre has moved, and not for a small drift', async () => {
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
// The first pass always chooses; what is under test is the move after it.
await advanceFrames(engine, 0.3);
refocus.mockClear();
component.controls.target.set(40, 0, 0);
await advanceFrames(engine, 0.3);
@@ -343,6 +346,53 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(refocus).toHaveBeenCalledTimes(before + 1);
});
it('holds a turn to the narrower side of a portrait frame', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
engine.getPerspectiveCamera().aspect = 0.4;
engine.getPerspectiveCamera().updateProjectionMatrix();
await advanceFrames(engine, 0.3);
// Inside half the margin above and below, past half of it at the sides.
orbit(component, 2);
await advanceFrames(engine, 0.3);
expect(refocus).toHaveBeenCalledTimes(2);
});
it('keeps up with a flight frame by frame, from the frame it comes back into parsec space', async () => {
const component = fixture.componentInstance as unknown as ViewScene & { galaxyGroup: THREE.Group; rig: { isAnimating: boolean } };
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
refocus.mockClear();
navigationStore.selectStar(null);
await flushAsync();
let choicesOnReturningFrame = -1;
let flightFrames = 0;
let flightChoices = 0;
for (let frame = 0; frame < 80; frame++) {
const wasInSystem = !component.galaxyGroup.visible;
const before = refocus.mock.calls.length;
engine.tick(0.05);
await flushAsync(1);
if (wasInSystem && component.galaxyGroup.visible) {
choicesOnReturningFrame = refocus.mock.calls.length - before;
}
if (component.galaxyGroup.visible && component.rig.isAnimating) {
flightFrames++;
flightChoices += refocus.mock.calls.length - before;
}
}
// Chosen for the view in the very frame the camera jumps back, not up to a pass later.
expect(choicesOnReturningFrame).toBe(1);
// The return zooms out from inside the system to the opening view: more re-choices than one a
// pass could make, and every one of them for the view.
expect(flightChoices).toBeGreaterThan(Math.ceil((flightFrames * 0.05) / 0.2));
expect(refocus.mock.calls.every(([focus]) => focus.view !== undefined)).toBe(true);
});
it('chooses once for the whole sky on the way out to the Galaxy, then leaves them alone', async () => {
const component = fixture.componentInstance as unknown as ViewScene;
engine.getCamera().position.set(0, 0, 30000);
@@ -421,6 +471,20 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
expect(links).toHaveBeenCalledTimes(2);
});
it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
// A new drawn set about every 150 ms for a second, as an orbit makes one each pass.
for (let pass = 1; pass <= 7; pass++) {
await changeDrawnStars(component, pass * 40);
await new Promise((resolve) => setTimeout(resolve, 120));
}
expect(links.mock.calls.length).toBeGreaterThanOrEqual(3);
});
it('draws a late graph for the range still asked for, and not one for a range left behind', async () => {
const answers: Array<(segments: Float32Array) => void> = [];
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
@@ -72,8 +72,8 @@ const LABEL_EDGE_NDC = 0.7;
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
const LABEL_REACH_NDC = 0.3;
/**
* How long the range control, and the set of drawn stars, have to be still before the graph is
* rebuilt for them: a drag emits per pixel, and a flight re-chooses the drawn stars every few passes.
* How long the range control has to be still before the graph is rebuilt at its value, since a drag
* emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars.
*/
const JUMP_LINK_REBUILD_DELAY_MS = 250;
@@ -635,6 +635,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps
// the zoom continuous rather than stepping between two discrete scales.
this.updateGalacticCrossfade(camera);
// A flight turns and zooms far faster than a label pass: the return from a system zooms out
// forty-fold in a second. So while one is under way the drawn stars are checked every frame,
// and chosen again whenever the view has used up half the margin.
if (this.rig?.isAnimating) {
this.refocusStarField(camera);
}
}
this.labelUpdateAccumulator += deltaSeconds;
@@ -799,9 +805,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* Chosen for a frame widened by `VIEW_MARGIN`, and chosen again, at the label cadence, once the
* view could have used up half that margin: turned, zoomed or moved by half of it, switched
* projection or resized. So a turn slower than a margin every two passes, about 25° a second,
* brings no empty edge into view. Two things outrun it: stars much nearer the camera than the
* view's centre, which an orbit sweeps across the frame faster than it turns, and deep stars
* under a zoomed-in plan view, which a turn moves by their depth.
* brings no empty edge into view. Two things still outrun it, measured and accepted: stars much
* nearer the camera than the view's centre, which an orbit sweeps across the frame faster than it
* turns, and deep stars under a zoomed-in plan view, which a turn moves by their depth. Flights are
* checked every frame instead of every pass; the galactic scale gets the whole sky.
*/
private refocusStarField(camera: SceneCamera): void {
if (!this.starField || !this.neighbourhood) {
@@ -815,38 +822,40 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const neighbourhood = this.neighbourhood;
const pinned = () => pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined);
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const drawnBefore = this.starField.drawnStars;
// At galactic scale the whole catalogue is a smudge a few pixels across, and the view sweeps
// hundreds of parsecs a pass: chosen once for the whole sky on the way out, then left alone,
// rather than frozen on whatever narrow frame the zoom-out last passed through.
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
if (this.starFieldCamera !== null || pins !== this.starFieldPins) {
this.starField.refocus({ pinned: pinned(), hosts: this.hostStars });
this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars });
this.starFieldCamera = null;
this.starFieldPins = pins;
this.scheduleJumpLinks();
}
return;
}
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
} else {
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre));
const perspective = this.engine.getPerspectiveCamera();
// The narrower of the frame's two half-extents: on a portrait screen the width, where the
// same share of margin is the fewest degrees and the fewest parsecs.
const narrowing = Math.min(1, perspective.aspect);
const marginPc = (VIEW_MARGIN / 2) * halfHeight * narrowing;
// The turn that moves a star at the frame's edge half the margin further out. Under a plan
// view a turn moves a star by its depth times the angle instead, so the deepest star the
// catalogue draws sets the limit too.
const tanHalfFov = Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
// view a turn moves a star by its depth times the angle instead; the survey edge stands in
// for the depth of the stars drawn.
const tanHalfFov = Math.tan((perspective.fov * Math.PI) / 360) * narrowing;
let turnLimit = (Math.atan((1 + VIEW_MARGIN) * tanHalfFov) - Math.atan(tanHalfFov)) / 2;
if (this.engine.currentProjection === 'orthographic') {
turnLimit = Math.min(turnLimit, ((VIEW_MARGIN / 2) * halfHeight) / (SURVEY_EDGE_PC + centre.length()));
turnLimit = Math.min(turnLimit, marginPc / (SURVEY_EDGE_PC + centre.length()));
}
if (
const held =
camera === this.starFieldCamera &&
pins === this.starFieldPins &&
camera.quaternion.angleTo(this.starFieldQuaternion) <= turnLimit &&
Math.abs(halfHeight / this.starFieldHalfHeight - 1) <= VIEW_MARGIN / 2 &&
this.starFieldFocus.distanceTo(centre) <= Math.min(STAR_FIELD_REFOCUS_PC, (VIEW_MARGIN / 2) * halfHeight)
) {
return;
}
this.starFieldFocus.distanceTo(centre) <= Math.min(STAR_FIELD_REFOCUS_PC, marginPc);
if (!held) {
// The tick runs before the frame is drawn, so the camera's matrices can still be last frame's.
camera.updateMatrixWorld();
this.starFieldView.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
@@ -856,9 +865,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.starFieldFocus.copy(centre);
this.starFieldHalfHeight = halfHeight;
this.starFieldPins = pins;
// The graph links the drawn stars, so a new set wants a new graph once it stops changing.
}
}
// The graph links the drawn stars, so a new set wants a new graph. Not one per pass while the
// view keeps moving, and not one pushed back by every pass either, or an orbit would never get
// one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`.
if (this.starField.drawnStars !== drawnBefore && this.jumpLinkRebuild === undefined) {
this.scheduleJumpLinks();
}
}
private updateLabels(camera: SceneCamera): void {
const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
@@ -1503,7 +1519,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
/** Rebuilds the graph once the range and the drawn stars have held still. */
private scheduleJumpLinks(): void {
clearTimeout(this.jumpLinkRebuild);
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
this.jumpLinkRebuild = setTimeout(() => {
this.jumpLinkRebuild = undefined;
this.refreshJumpLinks();
}, JUMP_LINK_REBUILD_DELAY_MS);
}
/**