Merge pull request #11 from avalon-vanguard/feat/hud-2d
Draw it flat: an orthographic plan view
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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