diff --git a/e2e/bookmarks.spec.ts b/e2e/bookmarks.spec.ts index ad5d801..209eba8 100644 --- a/e2e/bookmarks.spec.ts +++ b/e2e/bookmarks.spec.ts @@ -14,7 +14,10 @@ test.describe('Bookmarks', () => { // A later visit, on a different page: kept places outlive the one they were kept from. await page.goto('/'); - await expect(page.getByTestId('scene-canvas')).toBeVisible(); + // Generously timed on purpose: this suite shares one software rasterizer, and the boot + // it waits on is the slowest thing in it. The default five seconds is a coin toss + // under a full parallel run. + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); await page.getByRole('tab', { name: 'Bookmarks' }).click(); const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' }); diff --git a/e2e/galaxy-view.spec.ts b/e2e/galaxy-view.spec.ts index b9e31c9..7ef7238 100644 --- a/e2e/galaxy-view.spec.ts +++ b/e2e/galaxy-view.spec.ts @@ -9,7 +9,10 @@ test.describe('Galaxy view', () => { test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => { await page.goto('/?stars=4000'); - await expect(page.getByTestId('scene-canvas')).toBeVisible(); + // Generously timed on purpose: this suite shares one software rasterizer, and the boot + // it waits on is the slowest thing in it. The default five seconds is a coin toss + // under a full parallel run. + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); // The dock's tab strip is up before the scene finishes booting; the search is one tab in it. await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible(); await expect(backButtonLocator(page)).toHaveCount(0); @@ -23,7 +26,7 @@ test.describe('Galaxy view', () => { // up to more than the default per-test budget. test.setTimeout(90_000); await page.goto('/?stars=4000'); - await expect(page.getByTestId('scene-canvas')).toBeVisible(); + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); await page.getByRole('button', { name: 'Milky Way' }).click(); diff --git a/e2e/plan-view.spec.ts b/e2e/plan-view.spec.ts new file mode 100644 index 0000000..42a26e4 --- /dev/null +++ b/e2e/plan-view.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; + +import { openSearch } from './support/open-search'; + +test.describe('Plan view', () => { + test('flattens a system onto its own orbital plane, and unflattens it', async ({ page }) => { + test.setTimeout(120_000); + await page.goto('/?stars=4000'); + + const searchInput = await openSearch(page); + await searchInput.fill('Sol'); + await page.getByRole('button', { name: /^Sol\b/ }).first().click(); + await expect(page.getByTestId('hud-title')).toHaveText('Sol', { timeout: 45_000 }); + + // Where the planets are on screen before and after: under a plan view they lie on one + // circle around the star, so the spread of their distances from it collapses. + const spread = async () => + page.evaluate(() => { + const labels = [...document.querySelectorAll('.map-label:not(.map-label--ghost)')]; + const centre = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + const radii = labels.map((label) => { + const box = label.getBoundingClientRect(); + return Math.hypot(box.left - centre.x, box.top + box.height / 2 - centre.y); + }); + return radii.length; + }); + + await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2); + + await page.getByRole('tab', { name: 'Display' }).click(); + const plan = page.getByRole('button', { name: 'Plan view', exact: true }); + await expect(plan).toHaveAttribute('aria-pressed', 'false'); + await plan.click(); + await expect(plan).toHaveAttribute('aria-pressed', 'true'); + + // The scene survives the swap: it is still this system, still labelled, still readable. + // Generously timed on purpose: this suite shares one software rasterizer, and the boot + // it waits on is the slowest thing in it. The default five seconds is a coin toss + // under a full parallel run. + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); + await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2); + await page.getByRole('tab', { name: 'Readout' }).click(); + await expect(page.getByTestId('hud-title')).toHaveText('Sol'); + + await page.getByRole('tab', { name: 'Display' }).click(); + await plan.click(); + await expect(plan).toHaveAttribute('aria-pressed', 'false'); + await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2); + }); + + test('keeps the scale ladder honest about how far out the view is', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/?stars=4000'); + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood', { timeout: 30_000 }); + + await page.getByRole('tab', { name: 'Display' }).click(); + await page.getByRole('button', { name: 'Plan view', exact: true }).click(); + + // Under an orthographic camera the distance from the origin no longer sets what is in + // frame, so the level would be read from a number that stopped meaning anything. + await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood'); + await page.getByRole('button', { name: 'Milky Way' }).click(); + await expect(page.getByTestId('hud-current-level')).toHaveText('Milky Way', { timeout: 45_000 }); + }); +}); diff --git a/e2e/route-plotting.spec.ts b/e2e/route-plotting.spec.ts index d308d06..ab0737a 100644 --- a/e2e/route-plotting.spec.ts +++ b/e2e/route-plotting.spec.ts @@ -4,7 +4,10 @@ test.describe('Route plotting', () => { test('chains one star to another through the crossings a chosen range allows', async ({ page }) => { test.setTimeout(90_000); await page.goto('/?stars=4000'); - await expect(page.getByTestId('scene-canvas')).toBeVisible(); + // Generously timed on purpose: this suite shares one software rasterizer, and the boot + // it waits on is the slowest thing in it. The default five seconds is a coin toss + // under a full parallel run. + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); await page.getByRole('tab', { name: 'Routes' }).click(); @@ -32,7 +35,7 @@ test.describe('Route plotting', () => { test('says what range a crossing would need, rather than only that there is none', async ({ page }) => { test.setTimeout(90_000); await page.goto('/?stars=4000'); - await expect(page.getByTestId('scene-canvas')).toBeVisible(); + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); await page.getByRole('tab', { name: 'Routes' }).click(); await page.locator('#route-from').fill('Sol'); diff --git a/src/app/core/engine/engine.service.spec.ts b/src/app/core/engine/engine.service.spec.ts new file mode 100644 index 0000000..60094d0 --- /dev/null +++ b/src/app/core/engine/engine.service.spec.ts @@ -0,0 +1,109 @@ +import { NgZone } from '@angular/core'; +import * as THREE from 'three/webgpu'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { EngineService } from './engine.service'; + +/** + * The projection half of the engine, which is the half that can be tested without a GPU: no + * renderer is created, the two cameras are placed by hand, and what is asserted is the + * arithmetic that keeps them showing the same thing. + */ +function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } { + const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone); + const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000); + const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000); + // The two cameras are private, because nothing outside should choose between them by hand. + Object.assign(engine as unknown as Record, { perspective, orthographic }); + return { engine, perspective, orthographic }; +} + +/** Half the height of what a perspective camera frames at `distance`, in world units. */ +function perspectiveHalfHeight(camera: THREE.PerspectiveCamera, distance: number): number { + return distance * Math.tan((camera.fov * Math.PI) / 360); +} + +describe('EngineService projection', () => { + let engine: EngineService; + let perspective: THREE.PerspectiveCamera; + let orthographic: THREE.OrthographicCamera; + + beforeEach(() => { + ({ engine, perspective, orthographic } = engineWithCameras()); + }); + + it('draws through the perspective camera until told otherwise', () => { + expect(engine.currentProjection).toBe('perspective'); + expect(engine.getCamera()).toBe(perspective); + }); + + it('frames the same extent through either camera, which is the point of the swap', () => { + perspective.position.set(0, 0, 200); + + engine.setProjection('orthographic', 200); + + expect(engine.getCamera()).toBe(orthographic); + expect(engine.visibleHalfHeight(200)).toBeCloseTo(perspectiveHalfHeight(perspective, 200), 6); + // And as wide as the frame is, not as wide as it is tall. + expect(orthographic.right - orthographic.left).toBeCloseTo((orthographic.top - orthographic.bottom) * perspective.aspect, 6); + }); + + it('carries the pose across, so the swap changes the projection and not the view', () => { + perspective.position.set(3, 4, 12); + perspective.lookAt(0, 0, 0); + + engine.setProjection('orthographic', 13); + + expect(orthographic.position.toArray()).toEqual(perspective.position.toArray()); + expect(orthographic.quaternion.toArray()).toEqual(perspective.quaternion.toArray()); + }); + + it('sees behind itself, because a parallel camera does not back away from what it frames', () => { + engine.setProjection('orthographic', 100); + + // A perspective camera pulls back as its frame grows and leaves the scene in front of it. An + // orthographic one does not move at all, so half the Galaxy ends up behind its own plane — + // and a near plane in front would clip it away. Parallel depth is linear, so the precision + // that a perspective near plane is guarding for does not apply. + expect(orthographic.near).toBe(-perspective.far); + expect(orthographic.far).toBe(perspective.far); + }); + + it('goes back, and hands out the perspective camera again', () => { + engine.setProjection('orthographic', 100); + engine.setProjection('perspective', 100); + + expect(engine.currentProjection).toBe('perspective'); + expect(engine.getCamera()).toBe(perspective); + expect(engine.visibleHalfHeight(100)).toBeCloseTo(perspectiveHalfHeight(perspective, 100), 6); + }); + + it('reports the extent the orthographic camera is zoomed to, not the one it was built at', () => { + engine.setProjection('orthographic', 100); + const framed = engine.visibleHalfHeight(100); + + orthographic.zoom = 2; + + // Zoomed in twice: half as much in frame. Distance says nothing about it, which is why + // nothing downstream may read the camera's distance under this projection. + expect(engine.visibleHalfHeight(100)).toBeCloseTo(framed / 2, 6); + expect(engine.visibleHalfHeight(999)).toBeCloseTo(framed / 2, 6); + }); + + it('never divides by a camera sitting on its own target', () => { + expect(() => engine.setProjection('orthographic', 0)).not.toThrow(); + expect(Number.isFinite(orthographic.top)).toBe(true); + }); + + it('widens rather than magnifies when the window gets wider', () => { + engine.setProjection('orthographic', 100); + const height = orthographic.top - orthographic.bottom; + + // No renderer, so resize returns early — the frustum is re-fitted by hand the same way. + orthographic.left = (-height / 2) * (21 / 9); + orthographic.right = (height / 2) * (21 / 9); + + expect(orthographic.top - orthographic.bottom).toBeCloseTo(height, 6); + expect(orthographic.right - orthographic.left).toBeCloseTo(height * (21 / 9), 6); + }); +}); diff --git a/src/app/core/engine/engine.service.ts b/src/app/core/engine/engine.service.ts index 2a6a69b..b68420b 100644 --- a/src/app/core/engine/engine.service.ts +++ b/src/app/core/engine/engine.service.ts @@ -3,6 +3,12 @@ import * as THREE from 'three/webgpu'; export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void; +/** Which projection the scene is drawn through. */ +export type Projection = 'perspective' | 'orthographic'; + +/** Either camera, as everything downstream of the projection sees it. */ +export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera; + /** * Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera, * and the render loop. The loop always runs outside Angular's zone so per-frame work never @@ -20,7 +26,14 @@ export class EngineService { private canvas?: HTMLCanvasElement; private renderer?: THREE.WebGPURenderer; private scene?: THREE.Scene; - private camera?: THREE.PerspectiveCamera; + private perspective?: THREE.PerspectiveCamera; + /** + * Built alongside the perspective one and kept in step with it, rather than made on demand: + * the two share a position, an orientation and a depth range, and a camera that only exists + * while it is being looked through is a camera whose state is always one swap out of date. + */ + private orthographic?: THREE.OrthographicCamera; + private projection: Projection = 'perspective'; private running = false; constructor(private readonly ngZone: NgZone) {} @@ -33,8 +46,71 @@ export class EngineService { return this.requireInitialized(this.scene); } - getCamera(): THREE.PerspectiveCamera { - return this.requireInitialized(this.camera); + /** The camera the scene is currently drawn through. */ + getCamera(): SceneCamera { + return this.projection === 'orthographic' ? this.requireInitialized(this.orthographic) : this.requireInitialized(this.perspective); + } + + /** + * The perspective camera, whichever is active. For the handful of places that need a field of + * view to reason with — framing a system, sizing a star — and that go on meaning the same + * thing in either projection because the sizes were tuned against this one. + */ + getPerspectiveCamera(): THREE.PerspectiveCamera { + return this.requireInitialized(this.perspective); + } + + get currentProjection(): Projection { + return this.projection; + } + + /** + * Switches projection, carrying the pose across. The orthographic frustum is sized to show + * the same extent at `distanceToTarget` that the perspective camera showed from there, so the + * swap changes how the scene is projected and not how much of it is in frame. + */ + setProjection(projection: Projection, distanceToTarget: number): void { + const perspective = this.requireInitialized(this.perspective); + const orthographic = this.requireInitialized(this.orthographic); + this.projection = projection; + + orthographic.zoom = 1; + orthographic.position.copy(perspective.position); + orthographic.quaternion.copy(perspective.quaternion); + this.frameOrthographic(distanceToTarget); + } + + /** + * Sizes the orthographic frustum to show, at `distanceToTarget`, what the perspective camera + * would show from there. Called every frame while that projection is active, which is what + * makes the camera flights work through it: they move the camera, and the frame follows. + * + * The depth range is symmetric about the camera rather than starting in front of it. An + * orthographic camera does not pull back as its frame grows, so at galactic framing the + * backdrop shell and half the Milky Way lie behind its own plane and would be clipped away. + * A parallel projection has linear depth, so the precision argument that makes a perspective + * near plane worth guarding does not apply here. + */ + frameOrthographic(distanceToTarget: number): void { + const perspective = this.requireInitialized(this.perspective); + const orthographic = this.requireInitialized(this.orthographic); + const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((perspective.fov * Math.PI) / 360); + orthographic.top = halfHeight; + orthographic.bottom = -halfHeight; + orthographic.left = -halfHeight * perspective.aspect; + orthographic.right = halfHeight * perspective.aspect; + orthographic.far = perspective.far; + orthographic.near = -perspective.far; + orthographic.updateProjectionMatrix(); + } + + /** Half the height of what is in frame at the target, in world units, under either camera. */ + visibleHalfHeight(distanceToTarget: number): number { + if (this.projection === 'orthographic') { + const orthographic = this.requireInitialized(this.orthographic); + return (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom); + } + return distanceToTarget * Math.tan((this.requireInitialized(this.perspective).fov * Math.PI) / 360); } getRenderer(): THREE.WebGPURenderer { @@ -52,8 +128,10 @@ export class EngineService { this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.scene = new THREE.Scene(); - this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); - this.camera.position.set(0, 0, 5); + this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); + this.perspective.position.set(0, 0, 5); + this.orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000); + this.orthographic.position.copy(this.perspective.position); const { width, height } = this.canvasSize(); this.resize(width, height); @@ -99,11 +177,18 @@ export class EngineService { * Updates the camera aspect ratio and renderer drawing buffer size. */ resize(width: number, height: number): void { - if (!this.renderer || !this.camera || width <= 0 || height <= 0) { + if (!this.renderer || !this.perspective || !this.orthographic || width <= 0 || height <= 0) { return; } - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); + const aspect = width / height; + this.perspective.aspect = aspect; + this.perspective.updateProjectionMatrix(); + // The orthographic frustum keeps its height and re-fits its width, so a window getting wider + // shows more to the sides rather than magnifying what was already there. + const halfHeight = (this.orthographic.top - this.orthographic.bottom) / 2; + this.orthographic.left = -halfHeight * aspect; + this.orthographic.right = halfHeight * aspect; + this.orthographic.updateProjectionMatrix(); this.renderer.setSize(width, height, false); } @@ -116,7 +201,8 @@ export class EngineService { this.renderer?.dispose(); this.renderer = undefined; this.scene = undefined; - this.camera = undefined; + this.perspective = undefined; + this.orthographic = undefined; this.canvas = undefined; } @@ -128,7 +214,7 @@ export class EngineService { callback(deltaSeconds, elapsedSeconds); } - this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera)); + this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.getCamera()); } private canvasSize(): { width: number; height: number } { diff --git a/src/app/features/galaxy-system/camera-rig-controller.ts b/src/app/features/galaxy-system/camera-rig-controller.ts index df1c91d..f5176f0 100644 --- a/src/app/features/galaxy-system/camera-rig-controller.ts +++ b/src/app/features/galaxy-system/camera-rig-controller.ts @@ -1,4 +1,6 @@ import * as THREE from 'three/webgpu'; + +import { SceneCamera } from '../../core/engine/engine.service'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; export interface CameraPose { @@ -27,7 +29,7 @@ export class CameraRigController { }; constructor( - private readonly camera: THREE.PerspectiveCamera, + private readonly camera: SceneCamera, private readonly controls: OrbitControls ) {} 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 3949d25..afa3a81 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 @@ -64,7 +64,9 @@ const EARTH: BodyRecord = { class FakeEngineService { private readonly scene = new THREE.Scene(); private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); + private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000); private readonly tickCallbacks = new Set(); + projection: 'perspective' | 'orthographic' = 'perspective'; get isInitialized(): boolean { return true; @@ -78,10 +80,37 @@ class FakeEngineService { return this.scene; } - getCamera(): THREE.PerspectiveCamera { + getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera { + return this.projection === 'orthographic' ? this.orthographic : this.camera; + } + + getPerspectiveCamera(): THREE.PerspectiveCamera { return this.camera; } + get currentProjection(): 'perspective' | 'orthographic' { + return this.projection; + } + + setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void { + this.projection = projection; + const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360); + this.orthographic.top = halfHeight; + this.orthographic.bottom = -halfHeight; + this.orthographic.left = -halfHeight * this.camera.aspect; + this.orthographic.right = halfHeight * this.camera.aspect; + this.orthographic.zoom = 1; + this.orthographic.position.copy(this.camera.position); + this.orthographic.quaternion.copy(this.camera.quaternion); + this.orthographic.updateProjectionMatrix(); + } + + visibleHalfHeight(distanceToTarget: number): number { + return this.projection === 'orthographic' + ? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom) + : distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360); + } + onTick(callback: EngineTickCallback): () => void { this.tickCallbacks.add(callback); return () => this.tickCallbacks.delete(callback); 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 ca688bc..f3fb17a 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -4,9 +4,15 @@ import * as THREE from 'three/webgpu'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { dateToJulianDate } from '../../shared/astro/constants'; -import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy'; +import { + GALACTIC_BASIS_EQUATORIAL, + MILKY_WAY_ARMS, + SUN_GALACTOCENTRIC_RADIUS_PC, + galacticCentrePositionPc, + galacticToEquatorial +} from '../../shared/astro/galaxy'; import { DataLoaderService } from '../../core/data/data-loader.service'; -import { EngineService } from '../../core/engine/engine.service'; +import { EngineService, SceneCamera } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; @@ -64,6 +70,13 @@ const LABEL_REACH_NDC = 0.3; /** How long the range control has to be still before the graph is rebuilt at its value. */ const JUMP_LINK_REBUILD_DELAY_MS = 250; +/** + * How far in or out the plan view may be zoomed from the extent its distance frames. Under a + * parallel projection the wheel changes the frame rather than the distance, so the orbit limits + * stop applying and this is what stands in for them. + */ +const PLAN_ZOOM_SPAN = 64; + /** How many matches each routing field offers, and how little may be typed to get any. */ const ROUTE_OPTION_COUNT = 6; const MIN_ROUTE_QUERY_LENGTH = 2; @@ -368,6 +381,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } }); effect(() => this.applyDisplay(this.display())); + effect(() => this.applyProjection(this.display().plan)); // Reads both signals, so flipping the layer on and dragging the range each land here. The // rebuild is a quarter-second of walking the catalogue, and the range control emits per // pixel dragged, so it waits for the hand to settle rather than running once per pixel. @@ -451,9 +465,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const scene = this.engine.getScene(); const camera = this.engine.getCamera(); camera.position.copy(GALAXY_OVERVIEW_POSITION); - camera.near = GALAXY_NEAR_PC; - camera.far = GALAXY_FAR_PC; - camera.updateProjectionMatrix(); + // The perspective camera whichever one is live: it is where the depth range is reasoned, + // and the plan view re-derives its own from it every frame. Writing to the active camera + // put the astronomical-unit range on one that overwrites it, and the system clipped. + const depthCamera = this.engine.getPerspectiveCamera(); + depthCamera.near = GALAXY_NEAR_PC; + depthCamera.far = GALAXY_FAR_PC; + depthCamera.updateProjectionMatrix(); this.controls = new OrbitControls(camera, canvas); this.controls.enableDamping = true; @@ -546,15 +564,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { canvas.addEventListener('pointermove', this.handlePointerMove); this.observeResize(canvas); - this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds)); + // Asked for per frame rather than captured: the projection can be swapped underneath, and a + // frame computed against one camera and drawn through the other puts every label off its star. + this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(this.engine.getCamera(), deltaSeconds)); this.engine.start(); this.ready = true; this.reconcileSelection(this.navigationStore.selectedStarId()); } - private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void { + private tick(camera: SceneCamera, deltaSeconds: number): void { this.rig?.update(deltaSeconds); + this.frameProjection(camera); this.controls?.update(); // Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the @@ -592,12 +613,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * has pulled back from the Sun, so the scale ladder reports where the view already is instead * of switching it. */ - private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void { + private updateGalacticCrossfade(camera: SceneCamera): void { if (!this.milkyWay) { return; } - const distancePc = camera.position.length(); + // How much of the Galaxy is in frame, expressed as the distance a perspective camera would + // have to be at to show that much. Under a plan view the camera's own distance says nothing + // about the extent — the frustum does — so reading `position.length()` there would report a + // fixed scale however far the view was zoomed. + const distancePc = this.effectiveDistance(camera); this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc); // Layer toggles from the dock fold in here rather than as a one-off `visible = false`: @@ -615,7 +640,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // from inside it, so it cannot also be the sky behind a view of the Galaxy from outside. this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0; - this.applyGalaxyDepthRange(camera, distancePc); + this.applyGalaxyDepthRange(distancePc); const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy'; if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) { this.navigationStore.setViewLevel(level); @@ -628,26 +653,47 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection * spanning both has no precision left to separate one spiral arm from the next. */ - private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, distancePc: number): void { + /** + * What "how far back is the camera" means, in either projection. Under perspective it is the + * camera's own distance from the origin; under an orthographic one it is the distance a + * perspective camera would need to frame the same extent, so everything keyed on it — the + * crossfade, the depth range, the scale ladder — goes on meaning what it meant. + */ + private effectiveDistance(camera: SceneCamera): number { + if (this.engine.currentProjection === 'perspective') { + return camera.position.length(); + } + const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)); + return halfHeight / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360); + } + + private applyGalaxyDepthRange(distancePc: number): void { const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC); const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC); + // Written to the perspective camera whichever one is live, because it is the one this range + // is reasoned in and the one `frameOrthographic` reads its own from. Skipping it under a plan + // view left the far plane wherever it was when the projection changed, so flying out to the + // Galaxy from there clipped away most of it. + const perspective = this.engine.getPerspectiveCamera(); // Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection // matrix on every frame of it. - if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) { - camera.near = near; - camera.far = far; - camera.updateProjectionMatrix(); + if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) { + perspective.near = near; + perspective.far = far; + perspective.updateProjectionMatrix(); + // The plan view's own range is symmetric about the camera and derived from this one; see + // `frameOrthographic`. It is re-derived every frame, so there is nothing to do here. } } - private updateLabels(camera: THREE.PerspectiveCamera): void { + private updateLabels(camera: SceneCamera): void { const selectedId = this.navigationStore.selectedStarId(); // Measured from what the camera is looking at, not from where it is. Those differ by the // orbit distance, so a camera-relative rule names the stars closest to the near edge of the // view — a ring of labels around the outside of the thing the user is actually looking at. const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; const { x: cx, y: cy, z: cz } = target; - const orbitDistance = (this.controls ? camera.position.distanceTo(target) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE; + const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE; const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC); const maxDistanceSq = labelRadius * labelRadius; @@ -704,7 +750,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and * its label going missing mid-flight reads as the target having been lost. */ - private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] { + /** The frame's shape, from the canvas rather than the camera: only one of the two has it. */ + private viewportAspect(): number { + const canvas = this.canvasRef().nativeElement; + return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1; + } + + private spreadLabels(candidates: readonly LabeledPoint[], camera: SceneCamera, keepId: number | string | null): LabeledPoint[] { const placed: THREE.Vector2[] = []; const chosen: LabeledPoint[] = []; const projected = new THREE.Vector3(); @@ -721,7 +773,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { continue; } - const point = new THREE.Vector2(projected.x * camera.aspect, projected.y); + const point = new THREE.Vector2(projected.x * this.viewportAspect(), projected.y); if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) { continue; } @@ -750,7 +802,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * their rim to the card's near edge. Screen-space work done here, once per frame, because the * body moves every frame and the card's height depends on its content. */ - private updateSelectionMark(camera: THREE.PerspectiveCamera): void { + private updateSelectionMark(camera: SceneCamera): void { const leader = this.leaderRef().nativeElement; const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined; if (!member) { @@ -765,7 +817,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const card = this.objectCardElement(); const canvas = this.canvasRef().nativeElement; const projected = world.clone().project(camera); - if (!card || projected.z > 1) { + if (!card || projected.z > 1 || projected.z < -1) { leader.setAttribute('visibility', 'hidden'); return; } @@ -844,7 +896,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * moved along the ring where a HUD panel already holds that place. `null` where the whole * neighbourhood of that bearing is covered. */ - private neighbourRingPosition(camera: THREE.PerspectiveCamera, direction: THREE.Vector3): THREE.Vector3 | null { + private neighbourRingPosition(camera: SceneCamera, direction: THREE.Vector3): THREE.Vector3 | null { const bearing = this.ringBearing.copy(direction).applyQuaternion(this.ringInverse.copy(camera.quaternion).invert()); // A neighbour behind the camera keeps the side it is on, which is still the way to turn to // bring it round. @@ -854,6 +906,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { if (!placed) { return null; } + if (this.engine.currentProjection === 'orthographic') { + // A parallel projection has no vanishing point to walk towards: every ray through the + // frame is the view direction, so treating the unprojected offset as one and stepping + // along it throws the sideways part away and pulls the whole ring into the middle. The + // unprojected point is already where the name goes. + return this.ringPoint.set(placed.x, placed.y, 0).unproject(camera); + } const along = this.ringPoint.set(placed.x, placed.y, 0.5).unproject(camera).sub(camera.position).normalize(); return along.multiplyScalar(NEIGHBOUR_DEPTH_AU).add(camera.position); } @@ -872,7 +931,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * happened to be in front and stay silent about the rest. What survives is the half of the * direction a viewer can act on: which way to turn to face it. */ - private neighbourLabels(camera: THREE.PerspectiveCamera): LabeledPoint[] { + private neighbourLabels(camera: SceneCamera): LabeledPoint[] { this.refreshReservedBoxes(); return this.neighbours.flatMap(({ star, distancePc, direction }) => { const position = this.neighbourRingPosition(camera, direction); @@ -899,7 +958,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * — five a second — any camera movement would drag them off the ring and snap them back. This * runs every frame and costs four vector operations. */ - private updateNeighbourRing(camera: THREE.PerspectiveCamera): void { + private updateNeighbourRing(camera: SceneCamera): void { if (!this.systemGroup.visible || this.neighbours.length === 0) { return; } @@ -922,7 +981,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * Moons are left out entirely: they sit within a marker's width of their planet at system * framing, so their labels could only ever print on top of it. */ - private updateSystemLabels(camera: THREE.PerspectiveCamera): void { + private updateSystemLabels(camera: SceneCamera): void { const renderer = this.systemRenderer; if (!renderer) { this.labelOverlay?.update([]); @@ -966,6 +1025,97 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } /** Refreshes the readout panel for whichever scale the view is currently at. */ + /** A zoom carried from one unit space into the other would frame nothing recognisable. */ + private resetZoom(): void { + const camera = this.engine.getCamera(); + if ((camera as THREE.OrthographicCamera).isOrthographicCamera) { + (camera as THREE.OrthographicCamera).zoom = 1; + } + } + + /** + * Keeps the plan view's frame, its zoom limits and its star sizes in step with the camera. + * + * All three are functions of how far the camera is orbiting from its target, which is the one + * thing the flights already animate — so a system entered, left or flown between reframes + * itself under this projection with the easing the perspective flights have, and no camera + * rig knows anything about it. + */ + private frameProjection(camera: SceneCamera): void { + if (!this.controls) { + return; + } + if (this.engine.currentProjection !== 'orthographic') { + this.starField?.setProjection(null); + this.hostRings?.setProjection(null); + return; + } + const distance = camera.position.distanceTo(this.controls.target); + this.engine.frameOrthographic(distance); + // Zoom is what a wheel moves under this projection, so the orbit clamps have to be restated + // as the zoom levels that frame the same extents. + // A plain multiplier on the frame the distance already sets, bounded by a factor rather than + // by the orbit limits: those are in whichever unit space the view is in, and reading them on + // the frame the scene swaps from parsecs to astronomical units pins the zoom at the ratio + // between the two — which is how leaving a system used to land the view three kiloparsecs out. + this.controls.minZoom = 1 / PLAN_ZOOM_SPAN; + this.controls.maxZoom = PLAN_ZOOM_SPAN; + const halfHeight = this.engine.visibleHalfHeight(distance); + this.starField?.setProjection(halfHeight); + this.hostRings?.setProjection(halfHeight); + } + + /** + * Switches between the perspective view and the plan: an orthographic projection looking down + * the plane the current scale is read against — the galactic plane out here, this system's own + * orbital plane inside one. + * + * Both halves matter and neither alone is "2D". The projection is what makes a circle a circle + * wherever it sits in the frame instead of an ellipse that leans away from the centre; the + * swing to face the plane is what makes that worth looking at. Orbiting still works afterwards, + * so the plan is where a plan view starts, not a cage. + */ + private applyProjection(plan: boolean): void { + if (!this.controls || !this.rig || this.engine.currentProjection === (plan ? 'orthographic' : 'perspective')) { + return; + } + const camera = this.engine.getCamera(); + const target = this.controls.target.clone(); + const distance = camera.position.distanceTo(target); + + this.engine.setProjection(plan ? 'orthographic' : 'perspective', distance); + const next = this.engine.getCamera(); + // OrbitControls holds one camera for the lifetime of the gesture state it keeps; handing it + // the other one keeps the target, the damping and the pointer bindings it already has. + this.controls.object = next; + this.rig = new CameraRigController(next, this.controls); + next.position.copy(camera.position); + next.up.copy(camera.up); + + if (plan) { + // Straight down the plane's normal, from where the camera already was. + // Down the normal of the plane this scale is actually read against. Inside a system that + // is the system's own orbital plane; outside it, the galactic plane — whose normal is the + // north galactic pole, not the celestial one. Defaulting to the scene's own z would have + // looked down the Earth's rotation axis and called it the plane of the Galaxy. + const galactic = GALACTIC_BASIS_EQUATORIAL; + const inSystem = this.systemGroup.visible && this.systemRenderer; + const normal = inSystem + ? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer!.referenceFrame) + : new THREE.Vector3(galactic.z.x, galactic.z.y, galactic.z.z); + next.position.copy(target).add(normal.multiplyScalar(distance)); + if (inSystem) { + next.up.set(0, 1, 0).applyQuaternion(this.systemRenderer!.referenceFrame); + } else { + // Towards the galactic centre, so the plan is oriented the way the model is described. + next.up.set(galactic.x.x, galactic.x.y, galactic.x.z); + } + } + next.lookAt(target); + this.controls.update(); + this.applyDisplay(this.display()); + } + /** * Shows or hides the layers that hold still between frames: the label layer and the system * view's orbits and grid. The galaxy grids and deep-sky shell are crossfaded every frame @@ -984,7 +1134,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } } - private updateHud(camera: THREE.PerspectiveCamera): void { + private updateHud(camera: SceneCamera): void { const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId); if (this.systemGroup.visible && star) { @@ -1004,11 +1154,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { ...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []) ]); this.hudNote.set('Orbits propagated from published elements to the current date.'); - this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET))); + this.hudRange.set(formatAu(this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)) / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360))); return; } - this.hudRange.set(formatParsecs(camera.position.length())); + this.hudRange.set(formatParsecs(this.effectiveDistance(camera))); if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) { this.hudEyebrow.set('Galactic Scale'); @@ -1071,13 +1221,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } }; - private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void { + private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: SceneCamera): void { if (!this.starField) { return; } // Screen-space rather than a raycast: the star field billboards in the vertex shader, so // its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`. - const starId = this.starField.pickAt(pointerNdc, camera); + const starId = this.starField.pickAt(pointerNdc, camera, this.viewportAspect()); if (starId !== undefined) { this.navigationStore.selectStar(starId); } @@ -1308,7 +1458,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // the wider of the two — and against the camera this scene actually has, so the margin holds // whatever the window shape. Computed before the star, because how far away the star will be // seen from is what decides how big its halo has to be to stay visible. - const viewport = { fovDegrees: camera.fov, aspect: camera.aspect }; + // Framed against the perspective camera whichever is active: the framing distance is what + // the orthographic frustum is then sized from, so both projections show the same extent. + const framingCamera = this.engine.getPerspectiveCamera(); + const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect }; const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport); const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport); @@ -1339,12 +1492,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // clumped over the system's star. this.labelOverlay?.update([]); - camera.near = SYSTEM_NEAR_AU; - camera.far = SYSTEM_FAR_AU; - camera.updateProjectionMatrix(); + // The perspective camera whichever one is live: it is where the depth range is reasoned, + // and the plan view re-derives its own from it every frame. Writing to the active camera + // put the astronomical-unit range on one that overwrites it, and the system clipped. + const depthCamera = this.engine.getPerspectiveCamera(); + depthCamera.near = SYSTEM_NEAR_AU; + depthCamera.far = SYSTEM_FAR_AU; + depthCamera.updateProjectionMatrix(); this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU; this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU; + this.resetZoom(); this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }); // Arrives along whichever direction the approach came from, then swings round to look down @@ -1389,12 +1547,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // into the next system entered. this.clearObjectCard(); - camera.near = GALAXY_NEAR_PC; - camera.far = GALAXY_FAR_PC; - camera.updateProjectionMatrix(); + // The perspective camera whichever one is live: it is where the depth range is reasoned, + // and the plan view re-derives its own from it every frame. Writing to the active camera + // put the astronomical-unit range on one that overwrites it, and the system clipped. + const depthCamera = this.engine.getPerspectiveCamera(); + depthCamera.near = GALAXY_NEAR_PC; + depthCamera.far = GALAXY_FAR_PC; + depthCamera.updateProjectionMatrix(); this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC; this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC; + this.resetZoom(); this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc }); if (isSwitchingSystems) { diff --git a/src/app/features/galaxy-system/host-star-rings.ts b/src/app/features/galaxy-system/host-star-rings.ts index 7469f17..2b634ae 100644 --- a/src/app/features/galaxy-system/host-star-rings.ts +++ b/src/app/features/galaxy-system/host-star-rings.ts @@ -1,8 +1,8 @@ import * as THREE from 'three/webgpu'; -import { color, float, instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl'; +import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl'; import { StarRecord } from '../../shared/models/star.model'; -import { PIXELS_TO_ANGULAR_SIZE } from './angular-size'; +import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size'; /** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */ const RING_SIZE_PX = 12; @@ -37,6 +37,9 @@ export class HostStarRings { private readonly geometry: THREE.InstancedBufferGeometry; private readonly material: THREE.SpriteNodeMaterial; private readonly opacity = uniform(RING_PEAK_OPACITY); + /** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */ + private readonly perspective = uniform(1); + private readonly orthographicScale = uniform(float(0)); constructor(hosts: readonly StarRecord[], accent: number) { const positions = new Float32Array(hosts.length * 3); @@ -44,9 +47,13 @@ export class HostStarRings { this.geometry = createQuadGeometry(hosts.length); this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false }); - this.material.sizeAttenuation = false; - this.material.positionNode = instancedBufferAttribute(new THREE.InstancedBufferAttribute(positions, 3), 'vec3'); - this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE); + // As in the star field: the angular-to-world conversion is done here rather than by + // `sizeAttenuation: false`, which three.js applies only under a perspective camera. + this.material.sizeAttenuation = true; + const position = instancedBufferAttribute<'vec3'>(new THREE.InstancedBufferAttribute(positions, 3), 'vec3'); + this.material.positionNode = position; + const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate(); + this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE).mul(mix(this.orthographicScale, viewDepth, this.perspective)); this.material.colorNode = color(accent); // Opaque on the ring's centreline, falling to nothing one stroke-width either side. const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs(); @@ -59,6 +66,12 @@ export class HostStarRings { this.count = hosts.length; } + /** Which projection the rings are drawn under; see `StarFieldRenderer.setProjection`. */ + setProjection(halfHeightWorld: number | null): void { + this.perspective.value = halfHeightWorld === null ? 1 : 0; + this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360); + } + /** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */ setStrength(strength: number): void { const clamped = THREE.MathUtils.clamp(strength, 0, 1); diff --git a/src/app/features/galaxy-system/star-field-renderer.spec.ts b/src/app/features/galaxy-system/star-field-renderer.spec.ts index 61eb976..ad56b43 100644 --- a/src/app/features/galaxy-system/star-field-renderer.spec.ts +++ b/src/app/features/galaxy-system/star-field-renderer.spec.ts @@ -152,13 +152,13 @@ describe('StarFieldRenderer', () => { it('finds the star under the pointer', () => { const renderer = new StarFieldRenderer(picked, packPositions(picked)); // Both Near and Far project to the screen centre; either is a correct hit. - expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera)); + expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)); renderer.dispose(); }); it('returns undefined when the pointer is on empty sky', () => { const renderer = new StarFieldRenderer(picked, packPositions(picked)); - expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined(); + expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera, camera.aspect)).toBeUndefined(); renderer.dispose(); }); @@ -168,7 +168,7 @@ describe('StarFieldRenderer', () => { const behind = [star({ id: 7, x: 0, y: 0, z: 10 })]; const renderer = new StarFieldRenderer(behind, packPositions(behind)); - expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined(); + expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined(); renderer.dispose(); }); @@ -182,7 +182,7 @@ describe('StarFieldRenderer', () => { // Aim at where star 2 projects, and confirm we get it rather than its neighbours. const target = new THREE.Vector3(0, 2, -10).project(camera); - expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2); + expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera, camera.aspect)).toBe(2); renderer.dispose(); }); @@ -195,7 +195,7 @@ describe('StarFieldRenderer', () => { // Walk outward from the centre until each stops being pickable. const reach = (renderer: StarFieldRenderer): number => { let offset = 0; - while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) { + while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera, camera.aspect) !== undefined) { offset += 0.001; } return offset; @@ -212,13 +212,13 @@ describe('StarFieldRenderer', () => { const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })]; const renderer = new StarFieldRenderer(faint, packPositions(faint)); - expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5); + expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera, camera.aspect)).toBe(5); renderer.dispose(); }); it('finds nothing in an empty field', () => { const renderer = new StarFieldRenderer([], new Float32Array(0)); - expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined(); + expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined(); renderer.dispose(); }); }); diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 78491f4..8220d43 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -1,9 +1,10 @@ import * as THREE from 'three/webgpu'; -import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl'; +import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl'; import { spectralTypeToColorIndex } from '../../shared/astro/spectral'; -import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size'; +import { SceneCamera } from '../../core/engine/engine.service'; import { StarRecord } from '../../shared/models/star.model'; +import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size'; /** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */ const MIN_POINT_SIZE = 1.5; @@ -160,6 +161,10 @@ export class StarFieldRenderer { /** How many of the catalogue's stars this field actually draws. */ readonly drawnCount: number; + /** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */ + private readonly perspective = uniform(1); + private readonly orthographicScale = uniform(float(0)); + private readonly geometry: THREE.InstancedBufferGeometry; private readonly material: THREE.SpriteNodeMaterial; /** The subset of the catalogue that is drawn, and so the only set that can be clicked. */ @@ -200,10 +205,22 @@ export class StarFieldRenderer { depthWrite: false, blending: THREE.AdditiveBlending }); - this.material.sizeAttenuation = false; - this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3'); - this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float'); - this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3'); + // The compensation that turns an angular size into a world size is done here rather than by + // `sizeAttenuation: false`, which three.js applies only when it is compiling against a + // perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation + // === false`). Under an orthographic one it is silently skipped and every star collapses to + // a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind + // a uniform, lets one material serve both cameras without being recompiled between them. + this.material.sizeAttenuation = true; + const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3'); + const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float'); + this.material.positionNode = position; + // Perspective: a star's world size is its angular size times how far away it is, which is + // exactly what the built-in does. Orthographic: distance does not set apparent size at all, + // the frustum does, so the same angular size is scaled by the frustum instead. + const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate(); + this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective)); + this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3'); // Soft radial falloff so each star is a small bright core inside a halo, rather than a // hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre. const radius = uv().sub(vec2(0.5)).length(); @@ -215,6 +232,19 @@ export class StarFieldRenderer { this.object.frustumCulled = false; } + /** + * Tells the field which projection it is being drawn under. + * + * `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a + * perspective camera, where a star's distance sets its apparent size on its own. + */ + setProjection(halfHeightWorld: number | null): void { + this.perspective.value = halfHeightWorld === null ? 1 : 0; + // The world size that subtends the same share of the viewport an angular size would under + // the reference field of view: `angular * halfHeight / tan(fov/2)`. + this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360); + } + /** Looks up the HYG star id for a given instance index. */ starIdAt(instanceIndex: number): number | undefined { return this.stars[instanceIndex]?.id; @@ -230,8 +260,14 @@ export class StarFieldRenderer { * what the user sees at every zoom level instead of being over-permissive up close and * sub-pixel at the far end of the camera's range. */ - pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined { - const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360); + pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined { + // What a unit of angular size is worth on screen. Under perspective the field of view sets + // it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite + // as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out + // by that same half-height leaves the reference field of view and nothing else. Both cases + // are therefore one formula over a different angle. + const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera; + const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360); const projected = new THREE.Vector3(); let bestIndex: number | undefined; @@ -249,7 +285,7 @@ export class StarFieldRenderer { // A sprite square in view space projects to an ellipse in NDC: the same half-extent in y, // divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular. const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP; - const dx = (projected.x - pointerNdc.x) * camera.aspect; + const dx = (projected.x - pointerNdc.x) * aspect; const dy = projected.y - pointerNdc.y; const score = Math.hypot(dx, dy) / ndcRadius; diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts index 8fdf075..0ced268 100644 --- a/src/app/features/hud/hud-dock.component.spec.ts +++ b/src/app/features/hud/hud-dock.component.spec.ts @@ -141,7 +141,7 @@ describe('HudDockComponent', () => { fixture.componentRef.setInput('defaultTab', 'display'); fixture.detectChanges(); const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`); - expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false']); + expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']); }); it('says how to keep a place, rather than showing an empty list', () => { diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index 6358467..3c9ae97 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -27,9 +27,11 @@ export interface HudDisplay { readonly systems: boolean; /** The graph of crossings within the range the Routes panel is set to. */ readonly jumpLinks: boolean; + /** Drawn flat: an orthographic projection, looking down on the plane. */ + readonly plan: boolean; } -export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false }; +export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false }; const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [ { key: 'labels', label: 'Labels' }, @@ -38,7 +40,8 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [ { key: 'deepSky', label: 'Deep sky' }, { key: 'sky', label: 'Sky' }, { key: 'systems', label: 'Systems' }, - { key: 'jumpLinks', label: 'Jump links' } + { key: 'jumpLinks', label: 'Jump links' }, + { key: 'plan', label: 'Plan view' } ]; export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';