From 9631ddf0a476c7d8687902ef7e19deeb002eb3c6 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Thu, 17 Sep 2026 17:57:29 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 64 +++++++++++++ .../galaxy-system-scene.component.ts | 95 +++++++++++-------- 2 files changed, 121 insertions(+), 38 deletions(-) diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 480be3d..a61c1e8 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -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'); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index f6f8f00..94d7ea3 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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,49 +822,58 @@ 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; + } 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; 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, marginPc / (SURVEY_EDGE_PC + centre.length())); + } + 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, 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); + this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars, view: this.starFieldView }); + this.starFieldCamera = camera; + this.starFieldQuaternion.copy(camera.quaternion); + this.starFieldFocus.copy(centre); + this.starFieldHalfHeight = halfHeight; + this.starFieldPins = pins; + } } - const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; - const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre)); - // 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); - 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())); + // 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(); } - if ( - 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; - } - // 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); - this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars, view: this.starFieldView }); - this.starFieldCamera = camera; - this.starFieldQuaternion.copy(camera.quaternion); - 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. - this.scheduleJumpLinks(); } private updateLabels(camera: SceneCamera): void { @@ -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); } /**