Merge pull request #11 from avalon-vanguard/feat/hud-2d
Draw it flat: an orthographic plan view
This commit is contained in:
@@ -14,7 +14,10 @@ test.describe('Bookmarks', () => {
|
|||||||
|
|
||||||
// A later visit, on a different page: kept places outlive the one they were kept from.
|
// A later visit, on a different page: kept places outlive the one they were kept from.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||||
|
|
||||||
const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' });
|
const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' });
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ test.describe('Galaxy view', () => {
|
|||||||
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
|
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
|
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
|
||||||
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
|
||||||
await expect(backButtonLocator(page)).toHaveCount(0);
|
await expect(backButtonLocator(page)).toHaveCount(0);
|
||||||
@@ -23,7 +26,7 @@ test.describe('Galaxy view', () => {
|
|||||||
// up to more than the default per-test budget.
|
// up to more than the default per-test budget.
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Milky Way' }).click();
|
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { openSearch } from './support/open-search';
|
||||||
|
|
||||||
|
test.describe('Plan view', () => {
|
||||||
|
test('flattens a system onto its own orbital plane, and unflattens it', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.goto('/?stars=4000');
|
||||||
|
|
||||||
|
const searchInput = await openSearch(page);
|
||||||
|
await searchInput.fill('Sol');
|
||||||
|
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
|
||||||
|
await expect(page.getByTestId('hud-title')).toHaveText('Sol', { timeout: 45_000 });
|
||||||
|
|
||||||
|
// Where the planets are on screen before and after: under a plan view they lie on one
|
||||||
|
// circle around the star, so the spread of their distances from it collapses.
|
||||||
|
const spread = async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const labels = [...document.querySelectorAll('.map-label:not(.map-label--ghost)')];
|
||||||
|
const centre = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||||
|
const radii = labels.map((label) => {
|
||||||
|
const box = label.getBoundingClientRect();
|
||||||
|
return Math.hypot(box.left - centre.x, box.top + box.height / 2 - centre.y);
|
||||||
|
});
|
||||||
|
return radii.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Display' }).click();
|
||||||
|
const plan = page.getByRole('button', { name: 'Plan view', exact: true });
|
||||||
|
await expect(plan).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await plan.click();
|
||||||
|
await expect(plan).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// The scene survives the swap: it is still this system, still labelled, still readable.
|
||||||
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||||
|
await page.getByRole('tab', { name: 'Readout' }).click();
|
||||||
|
await expect(page.getByTestId('hud-title')).toHaveText('Sol');
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Display' }).click();
|
||||||
|
await plan.click();
|
||||||
|
await expect(plan).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the scale ladder honest about how far out the view is', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto('/?stars=4000');
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood', { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Display' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Plan view', exact: true }).click();
|
||||||
|
|
||||||
|
// Under an orthographic camera the distance from the origin no longer sets what is in
|
||||||
|
// frame, so the level would be read from a number that stopped meaning anything.
|
||||||
|
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood');
|
||||||
|
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||||
|
await expect(page.getByTestId('hud-current-level')).toHaveText('Milky Way', { timeout: 45_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,10 @@ test.describe('Route plotting', () => {
|
|||||||
test('chains one star to another through the crossings a chosen range allows', async ({ page }) => {
|
test('chains one star to another through the crossings a chosen range allows', async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Routes' }).click();
|
await page.getByRole('tab', { name: 'Routes' }).click();
|
||||||
|
|
||||||
@@ -32,7 +35,7 @@ test.describe('Route plotting', () => {
|
|||||||
test('says what range a crossing would need, rather than only that there is none', async ({ page }) => {
|
test('says what range a crossing would need, rather than only that there is none', async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Routes' }).click();
|
await page.getByRole('tab', { name: 'Routes' }).click();
|
||||||
await page.locator('#route-from').fill('Sol');
|
await page.locator('#route-from').fill('Sol');
|
||||||
|
|||||||
@@ -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;
|
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,
|
* 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
|
* 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 canvas?: HTMLCanvasElement;
|
||||||
private renderer?: THREE.WebGPURenderer;
|
private renderer?: THREE.WebGPURenderer;
|
||||||
private scene?: THREE.Scene;
|
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;
|
private running = false;
|
||||||
|
|
||||||
constructor(private readonly ngZone: NgZone) {}
|
constructor(private readonly ngZone: NgZone) {}
|
||||||
@@ -33,8 +46,71 @@ export class EngineService {
|
|||||||
return this.requireInitialized(this.scene);
|
return this.requireInitialized(this.scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCamera(): THREE.PerspectiveCamera {
|
/** The camera the scene is currently drawn through. */
|
||||||
return this.requireInitialized(this.camera);
|
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 {
|
getRenderer(): THREE.WebGPURenderer {
|
||||||
@@ -52,8 +128,10 @@ export class EngineService {
|
|||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
||||||
this.camera.position.set(0, 0, 5);
|
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();
|
const { width, height } = this.canvasSize();
|
||||||
this.resize(width, height);
|
this.resize(width, height);
|
||||||
@@ -99,11 +177,18 @@ export class EngineService {
|
|||||||
* Updates the camera aspect ratio and renderer drawing buffer size.
|
* Updates the camera aspect ratio and renderer drawing buffer size.
|
||||||
*/
|
*/
|
||||||
resize(width: number, height: number): void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.camera.aspect = width / height;
|
const aspect = width / height;
|
||||||
this.camera.updateProjectionMatrix();
|
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);
|
this.renderer.setSize(width, height, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +201,8 @@ export class EngineService {
|
|||||||
this.renderer?.dispose();
|
this.renderer?.dispose();
|
||||||
this.renderer = undefined;
|
this.renderer = undefined;
|
||||||
this.scene = undefined;
|
this.scene = undefined;
|
||||||
this.camera = undefined;
|
this.perspective = undefined;
|
||||||
|
this.orthographic = undefined;
|
||||||
this.canvas = undefined;
|
this.canvas = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +214,7 @@ export class EngineService {
|
|||||||
callback(deltaSeconds, elapsedSeconds);
|
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 } {
|
private canvasSize(): { width: number; height: number } {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
|
import { SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
export interface CameraPose {
|
export interface CameraPose {
|
||||||
@@ -27,7 +29,7 @@ export class CameraRigController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly camera: THREE.PerspectiveCamera,
|
private readonly camera: SceneCamera,
|
||||||
private readonly controls: OrbitControls
|
private readonly controls: OrbitControls
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ const EARTH: BodyRecord = {
|
|||||||
class FakeEngineService {
|
class FakeEngineService {
|
||||||
private readonly scene = new THREE.Scene();
|
private readonly scene = new THREE.Scene();
|
||||||
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
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>();
|
private readonly tickCallbacks = new Set<EngineTickCallback>();
|
||||||
|
projection: 'perspective' | 'orthographic' = 'perspective';
|
||||||
|
|
||||||
get isInitialized(): boolean {
|
get isInitialized(): boolean {
|
||||||
return true;
|
return true;
|
||||||
@@ -78,10 +80,37 @@ class FakeEngineService {
|
|||||||
return this.scene;
|
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;
|
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 {
|
onTick(callback: EngineTickCallback): () => void {
|
||||||
this.tickCallbacks.add(callback);
|
this.tickCallbacks.add(callback);
|
||||||
return () => this.tickCallbacks.delete(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 { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
import { dateToJulianDate } from '../../shared/astro/constants';
|
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 { 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 { BodyRecord } from '../../shared/models/body.model';
|
||||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.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. */
|
/** How long the range control has to be still before the graph is rebuilt at its value. */
|
||||||
const JUMP_LINK_REBUILD_DELAY_MS = 250;
|
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. */
|
/** How many matches each routing field offers, and how little may be typed to get any. */
|
||||||
const ROUTE_OPTION_COUNT = 6;
|
const ROUTE_OPTION_COUNT = 6;
|
||||||
const MIN_ROUTE_QUERY_LENGTH = 2;
|
const MIN_ROUTE_QUERY_LENGTH = 2;
|
||||||
@@ -368,6 +381,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
effect(() => this.applyDisplay(this.display()));
|
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
|
// 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
|
// 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.
|
// 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 scene = this.engine.getScene();
|
||||||
const camera = this.engine.getCamera();
|
const camera = this.engine.getCamera();
|
||||||
camera.position.copy(GALAXY_OVERVIEW_POSITION);
|
camera.position.copy(GALAXY_OVERVIEW_POSITION);
|
||||||
camera.near = GALAXY_NEAR_PC;
|
// The perspective camera whichever one is live: it is where the depth range is reasoned,
|
||||||
camera.far = GALAXY_FAR_PC;
|
// and the plan view re-derives its own from it every frame. Writing to the active camera
|
||||||
camera.updateProjectionMatrix();
|
// 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 = new OrbitControls(camera, canvas);
|
||||||
this.controls.enableDamping = true;
|
this.controls.enableDamping = true;
|
||||||
@@ -546,15 +564,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
canvas.addEventListener('pointermove', this.handlePointerMove);
|
canvas.addEventListener('pointermove', this.handlePointerMove);
|
||||||
this.observeResize(canvas);
|
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.engine.start();
|
||||||
|
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.reconcileSelection(this.navigationStore.selectedStarId());
|
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.rig?.update(deltaSeconds);
|
||||||
|
this.frameProjection(camera);
|
||||||
this.controls?.update();
|
this.controls?.update();
|
||||||
|
|
||||||
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
|
// 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
|
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
|
||||||
* of switching it.
|
* of switching it.
|
||||||
*/
|
*/
|
||||||
private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void {
|
private updateGalacticCrossfade(camera: SceneCamera): void {
|
||||||
if (!this.milkyWay) {
|
if (!this.milkyWay) {
|
||||||
return;
|
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);
|
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
|
||||||
|
|
||||||
// Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
|
// 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.
|
// 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.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';
|
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
|
||||||
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
|
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
|
||||||
this.navigationStore.setViewLevel(level);
|
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
|
* 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.
|
* 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 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);
|
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
|
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
|
||||||
// matrix on every frame of it.
|
// 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) {
|
if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) {
|
||||||
camera.near = near;
|
perspective.near = near;
|
||||||
camera.far = far;
|
perspective.far = far;
|
||||||
camera.updateProjectionMatrix();
|
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();
|
const selectedId = this.navigationStore.selectedStarId();
|
||||||
// Measured from what the camera is looking at, not from where it is. Those differ by the
|
// 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
|
// 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.
|
// 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 target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||||
const { x: cx, y: cy, z: cz } = 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 labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
||||||
const maxDistanceSq = labelRadius * labelRadius;
|
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
|
* `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.
|
* 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 placed: THREE.Vector2[] = [];
|
||||||
const chosen: LabeledPoint[] = [];
|
const chosen: LabeledPoint[] = [];
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
@@ -721,7 +773,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
continue;
|
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)) {
|
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
||||||
continue;
|
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
|
* 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.
|
* 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 leader = this.leaderRef().nativeElement;
|
||||||
const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined;
|
const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined;
|
||||||
if (!member) {
|
if (!member) {
|
||||||
@@ -765,7 +817,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
const card = this.objectCardElement();
|
const card = this.objectCardElement();
|
||||||
const canvas = this.canvasRef().nativeElement;
|
const canvas = this.canvasRef().nativeElement;
|
||||||
const projected = world.clone().project(camera);
|
const projected = world.clone().project(camera);
|
||||||
if (!card || projected.z > 1) {
|
if (!card || projected.z > 1 || projected.z < -1) {
|
||||||
leader.setAttribute('visibility', 'hidden');
|
leader.setAttribute('visibility', 'hidden');
|
||||||
return;
|
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
|
* moved along the ring where a HUD panel already holds that place. `null` where the whole
|
||||||
* neighbourhood of that bearing is covered.
|
* 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());
|
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
|
// A neighbour behind the camera keeps the side it is on, which is still the way to turn to
|
||||||
// bring it round.
|
// bring it round.
|
||||||
@@ -854,6 +906,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
if (!placed) {
|
if (!placed) {
|
||||||
return null;
|
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();
|
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);
|
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
|
* 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.
|
* 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();
|
this.refreshReservedBoxes();
|
||||||
return this.neighbours.flatMap(({ star, distancePc, direction }) => {
|
return this.neighbours.flatMap(({ star, distancePc, direction }) => {
|
||||||
const position = this.neighbourRingPosition(camera, 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
|
* — 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.
|
* 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) {
|
if (!this.systemGroup.visible || this.neighbours.length === 0) {
|
||||||
return;
|
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
|
* 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.
|
* 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;
|
const renderer = this.systemRenderer;
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
this.labelOverlay?.update([]);
|
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. */
|
/** 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
|
* 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
|
* 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);
|
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
||||||
|
|
||||||
if (this.systemGroup.visible && star) {
|
if (this.systemGroup.visible && star) {
|
||||||
@@ -1004,11 +1154,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
|
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
|
||||||
]);
|
]);
|
||||||
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hudRange.set(formatParsecs(camera.position.length()));
|
this.hudRange.set(formatParsecs(this.effectiveDistance(camera)));
|
||||||
|
|
||||||
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
||||||
this.hudEyebrow.set('Galactic Scale');
|
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) {
|
if (!this.starField) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
|
// 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`.
|
// 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) {
|
if (starId !== undefined) {
|
||||||
this.navigationStore.selectStar(starId);
|
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
|
// 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
|
// 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.
|
// 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 framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
|
||||||
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
|
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
|
||||||
|
|
||||||
@@ -1339,12 +1492,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// clumped over the system's star.
|
// clumped over the system's star.
|
||||||
this.labelOverlay?.update([]);
|
this.labelOverlay?.update([]);
|
||||||
|
|
||||||
camera.near = SYSTEM_NEAR_AU;
|
// The perspective camera whichever one is live: it is where the depth range is reasoned,
|
||||||
camera.far = SYSTEM_FAR_AU;
|
// and the plan view re-derives its own from it every frame. Writing to the active camera
|
||||||
camera.updateProjectionMatrix();
|
// 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!.minDistance = SYSTEM_MIN_DISTANCE_AU;
|
||||||
this.controls!.maxDistance = SYSTEM_MAX_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) });
|
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
|
// 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.
|
// into the next system entered.
|
||||||
this.clearObjectCard();
|
this.clearObjectCard();
|
||||||
|
|
||||||
camera.near = GALAXY_NEAR_PC;
|
// The perspective camera whichever one is live: it is where the depth range is reasoned,
|
||||||
camera.far = GALAXY_FAR_PC;
|
// and the plan view re-derives its own from it every frame. Writing to the active camera
|
||||||
camera.updateProjectionMatrix();
|
// 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!.minDistance = GALAXY_MIN_DISTANCE_PC;
|
||||||
this.controls!.maxDistance = GALAXY_MAX_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 });
|
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
|
||||||
|
|
||||||
if (isSwitchingSystems) {
|
if (isSwitchingSystems) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
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 { 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. */
|
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
|
||||||
const RING_SIZE_PX = 12;
|
const RING_SIZE_PX = 12;
|
||||||
@@ -37,6 +37,9 @@ export class HostStarRings {
|
|||||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
private readonly opacity = uniform(RING_PEAK_OPACITY);
|
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) {
|
constructor(hosts: readonly StarRecord[], accent: number) {
|
||||||
const positions = new Float32Array(hosts.length * 3);
|
const positions = new Float32Array(hosts.length * 3);
|
||||||
@@ -44,9 +47,13 @@ export class HostStarRings {
|
|||||||
this.geometry = createQuadGeometry(hosts.length);
|
this.geometry = createQuadGeometry(hosts.length);
|
||||||
|
|
||||||
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
|
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
|
||||||
this.material.sizeAttenuation = false;
|
// As in the star field: the angular-to-world conversion is done here rather than by
|
||||||
this.material.positionNode = instancedBufferAttribute(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
|
// `sizeAttenuation: false`, which three.js applies only under a perspective camera.
|
||||||
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE);
|
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);
|
this.material.colorNode = color(accent);
|
||||||
// Opaque on the ring's centreline, falling to nothing one stroke-width either side.
|
// 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();
|
const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs();
|
||||||
@@ -59,6 +66,12 @@ export class HostStarRings {
|
|||||||
this.count = hosts.length;
|
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. */
|
/** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */
|
||||||
setStrength(strength: number): void {
|
setStrength(strength: number): void {
|
||||||
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||||
|
|||||||
@@ -152,13 +152,13 @@ describe('StarFieldRenderer', () => {
|
|||||||
it('finds the star under the pointer', () => {
|
it('finds the star under the pointer', () => {
|
||||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||||
// Both Near and Far project to the screen centre; either is a correct hit.
|
// 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();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined when the pointer is on empty sky', () => {
|
it('returns undefined when the pointer is on empty sky', () => {
|
||||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
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();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ describe('StarFieldRenderer', () => {
|
|||||||
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
||||||
const renderer = new StarFieldRenderer(behind, packPositions(behind));
|
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();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ describe('StarFieldRenderer', () => {
|
|||||||
|
|
||||||
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
|
// 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);
|
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();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ describe('StarFieldRenderer', () => {
|
|||||||
// Walk outward from the centre until each stops being pickable.
|
// Walk outward from the centre until each stops being pickable.
|
||||||
const reach = (renderer: StarFieldRenderer): number => {
|
const reach = (renderer: StarFieldRenderer): number => {
|
||||||
let offset = 0;
|
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;
|
offset += 0.001;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
@@ -212,13 +212,13 @@ describe('StarFieldRenderer', () => {
|
|||||||
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
||||||
const renderer = new StarFieldRenderer(faint, packPositions(faint));
|
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();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finds nothing in an empty field', () => {
|
it('finds nothing in an empty field', () => {
|
||||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
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();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
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 { 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 { 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}. */
|
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||||
const MIN_POINT_SIZE = 1.5;
|
const MIN_POINT_SIZE = 1.5;
|
||||||
@@ -160,6 +161,10 @@ export class StarFieldRenderer {
|
|||||||
/** How many of the catalogue's stars this field actually draws. */
|
/** How many of the catalogue's stars this field actually draws. */
|
||||||
readonly drawnCount: number;
|
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 geometry: THREE.InstancedBufferGeometry;
|
||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
/** 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,
|
depthWrite: false,
|
||||||
blending: THREE.AdditiveBlending
|
blending: THREE.AdditiveBlending
|
||||||
});
|
});
|
||||||
this.material.sizeAttenuation = false;
|
// The compensation that turns an angular size into a world size is done here rather than by
|
||||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
// `sizeAttenuation: false`, which three.js applies only when it is compiling against a
|
||||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
// perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
|
||||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
// === 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
|
// 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.
|
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||||
const radius = uv().sub(vec2(0.5)).length();
|
const radius = uv().sub(vec2(0.5)).length();
|
||||||
@@ -215,6 +232,19 @@ export class StarFieldRenderer {
|
|||||||
this.object.frustumCulled = false;
|
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. */
|
/** Looks up the HYG star id for a given instance index. */
|
||||||
starIdAt(instanceIndex: number): number | undefined {
|
starIdAt(instanceIndex: number): number | undefined {
|
||||||
return this.stars[instanceIndex]?.id;
|
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
|
* 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.
|
* sub-pixel at the far end of the camera's range.
|
||||||
*/
|
*/
|
||||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
// 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();
|
const projected = new THREE.Vector3();
|
||||||
|
|
||||||
let bestIndex: number | undefined;
|
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,
|
// 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.
|
// 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 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 dy = projected.y - pointerNdc.y;
|
||||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ describe('HudDockComponent', () => {
|
|||||||
fixture.componentRef.setInput('defaultTab', 'display');
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
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', () => {
|
it('says how to keep a place, rather than showing an empty list', () => {
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export interface HudDisplay {
|
|||||||
readonly systems: boolean;
|
readonly systems: boolean;
|
||||||
/** The graph of crossings within the range the Routes panel is set to. */
|
/** The graph of crossings within the range the Routes panel is set to. */
|
||||||
readonly jumpLinks: boolean;
|
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 }[] = [
|
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||||
{ key: 'labels', label: 'Labels' },
|
{ key: 'labels', label: 'Labels' },
|
||||||
@@ -38,7 +40,8 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
|||||||
{ key: 'deepSky', label: 'Deep sky' },
|
{ key: 'deepSky', label: 'Deep sky' },
|
||||||
{ key: 'sky', label: 'Sky' },
|
{ key: 'sky', label: 'Sky' },
|
||||||
{ key: 'systems', label: 'Systems' },
|
{ 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';
|
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||||
|
|||||||
Reference in New Issue
Block a user