Draw it flat: an orthographic plan view
A perspective camera leans everything away from the centre of the frame. In a system that means the orbits are ellipses whose shape depends on where they happen to sit on screen, so two planets on the same circular orbit do not look like they are on the same circle. Plan view, in the Display panel, swaps the projection for a parallel one and swings to look down the plane the current scale is read against — the galactic plane out in the field, this system's own orbital plane inside one. Circles are circles again, wherever they are. Both halves are the feature and neither alone is it. The projection is what makes the shape honest; the swing is what makes it worth looking at. Orbiting still works afterwards, so a plan is where the view starts rather than a cage. The engine now holds both cameras and keeps them in step, rather than making one on demand: a camera that exists only while it is being looked through is a camera whose pose is always one swap out of date. The orthographic frustum is derived, never stored — it is the perspective camera's own frustum at the current orbit distance, made parallel — which is why the camera flights work through it untouched. They move the camera; the frame follows. Three things had to be taught that a projection had changed. Sprites. three.js turns an angular size into a world size only when it is compiling against a perspective camera (SpriteNodeMaterial: `camera .isPerspectiveCamera && sizeAttenuation === false`). Under a parallel one that step is silently skipped and every star in the field collapses to a thousandth of a parsec. The same arithmetic is now done in the node graph behind a uniform, so one material serves both cameras without being recompiled — and picking follows it exactly, since a star has to be clickable where it is drawn. Depth. A parallel camera does not back away as its frame grows, so at galactic framing the backdrop shell and half the Milky Way sit behind its own plane. Its depth range is symmetric about it instead, which a linear depth buffer can afford and a perspective one could not. And distance. Half the map was keyed on how far back the camera was pulled — the scale ladder, the crossfade, the label radius, the range readout — which under a parallel projection says nothing at all, because the frustum sets the extent. They all read one honest equivalent now: the distance a perspective camera would need to frame the same thing. Two defects found while verifying, both mine, both from this change: The per-frame work was computed against the camera captured at bootstrap while the renderer drew through the other one, so after a swap every label was projected by a camera nobody was looking through. And the zoom limits were derived from the orbit limits, which are in whichever unit space the view is in. Reading them on the frame the scene swaps parsecs for astronomical units pinned the zoom at the ratio between the two, and leaving a system landed the view three kiloparsecs out. Zoom is a plain multiplier on a frame the distance already sets, so it is bounded by a factor. Verified: build clean, 595/595 unit including a new spec for the projection arithmetic, 13/13 end-to-end including two that flatten a system and check the ladder still knows how far out it is, design detector clean, screenshots of both scales in both projections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
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.
|
||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||
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();
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, { 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);
|
||||
});
|
||||
});
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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<EngineTickCallback>();
|
||||
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);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { 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 +64,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;
|
||||
@@ -362,6 +369,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.
|
||||
@@ -540,15 +548,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
|
||||
@@ -586,12 +597,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`:
|
||||
@@ -622,7 +637,25 @@ 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(camera: SceneCamera, distancePc: number): void {
|
||||
// The plan view sets its own depth range, symmetric about the camera; see `frameOrthographic`.
|
||||
if (this.engine.currentProjection === 'orthographic') {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
|
||||
@@ -634,14 +667,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -698,7 +731,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();
|
||||
@@ -715,7 +754,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;
|
||||
}
|
||||
@@ -744,7 +783,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) {
|
||||
@@ -759,7 +798,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const card = this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]');
|
||||
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;
|
||||
}
|
||||
@@ -838,7 +877,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.
|
||||
@@ -866,7 +905,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);
|
||||
@@ -893,7 +932,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;
|
||||
}
|
||||
@@ -916,7 +955,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([]);
|
||||
@@ -960,6 +999,84 @@ 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.
|
||||
const normal = this.systemGroup.visible && this.systemRenderer ? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer.referenceFrame) : new THREE.Vector3(0, 0, 1);
|
||||
next.position.copy(target).add(normal.multiplyScalar(distance));
|
||||
next.up.set(0, 1, 0).applyQuaternion(this.systemGroup.visible && this.systemRenderer ? this.systemRenderer.referenceFrame : new THREE.Quaternion());
|
||||
}
|
||||
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
|
||||
@@ -978,7 +1095,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) {
|
||||
@@ -998,11 +1115,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');
|
||||
@@ -1065,13 +1182,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);
|
||||
}
|
||||
@@ -1302,7 +1419,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,6 +1459,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
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,6 +1510,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -40,6 +40,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);
|
||||
@@ -47,9 +50,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();
|
||||
@@ -62,6 +69,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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { SceneCamera } from '../../core/engine/engine.service';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||
@@ -169,6 +170,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. */
|
||||
@@ -209,10 +214,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();
|
||||
@@ -224,6 +241,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;
|
||||
@@ -239,8 +269,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 that is set by the
|
||||
// field of view; under an orthographic camera the same size was already turned into a world
|
||||
// size by `setProjection`, so it is the frustum that converts it back.
|
||||
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||||
const orthographic = camera as THREE.OrthographicCamera;
|
||||
const halfHeightWorld = perspective ? 0 : (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
|
||||
const tanHalfFov = perspective ? Math.tan(((camera as THREE.PerspectiveCamera).fov * Math.PI) / 360) : 0;
|
||||
const projected = new THREE.Vector3();
|
||||
|
||||
let bestIndex: number | undefined;
|
||||
@@ -257,8 +293,12 @@ 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 ndcRadius =
|
||||
(perspective
|
||||
? (0.5 * this.angularSizes[index]) / tanHalfFov
|
||||
: // The world size the star is drawn at, as a fraction of the frustum's half-height.
|
||||
(0.5 * this.angularSizes[index] * (halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360))) / halfHeightWorld) + PICK_NDC_SLOP;
|
||||
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||
const dy = projected.y - pointerNdc.y;
|
||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user