Draw it flat: an orthographic plan view

A perspective camera leans everything away from the centre of the frame. In a
system that means the orbits are ellipses whose shape depends on where they
happen to sit on screen, so two planets on the same circular orbit do not look
like they are on the same circle. Plan view, in the Display panel, swaps the
projection for a parallel one and swings to look down the plane the current
scale is read against — the galactic plane out in the field, this system's own
orbital plane inside one. Circles are circles again, wherever they are.

Both halves are the feature and neither alone is it. The projection is what
makes the shape honest; the swing is what makes it worth looking at. Orbiting
still works afterwards, so a plan is where the view starts rather than a cage.

The engine now holds both cameras and keeps them in step, rather than making
one on demand: a camera that exists only while it is being looked through is a
camera whose pose is always one swap out of date. The orthographic frustum is
derived, never stored — it is the perspective camera's own frustum at the
current orbit distance, made parallel — which is why the camera flights work
through it untouched. They move the camera; the frame follows.

Three things had to be taught that a projection had changed.

Sprites. three.js turns an angular size into a world size only when it is
compiling against a perspective camera (SpriteNodeMaterial: `camera
.isPerspectiveCamera && sizeAttenuation === false`). Under a parallel one that
step is silently skipped and every star in the field collapses to a thousandth
of a parsec. The same arithmetic is now done in the node graph behind a
uniform, so one material serves both cameras without being recompiled — and
picking follows it exactly, since a star has to be clickable where it is drawn.

Depth. A parallel camera does not back away as its frame grows, so at galactic
framing the backdrop shell and half the Milky Way sit behind its own plane. Its
depth range is symmetric about it instead, which a linear depth buffer can
afford and a perspective one could not.

And distance. Half the map was keyed on how far back the camera was pulled —
the scale ladder, the crossfade, the label radius, the range readout — which
under a parallel projection says nothing at all, because the frustum sets the
extent. They all read one honest equivalent now: the distance a perspective
camera would need to frame the same thing.

Two defects found while verifying, both mine, both from this change:

The per-frame work was computed against the camera captured at bootstrap while
the renderer drew through the other one, so after a swap every label was
projected by a camera nobody was looking through.

And the zoom limits were derived from the orbit limits, which are in whichever
unit space the view is in. Reading them on the frame the scene swaps parsecs
for astronomical units pinned the zoom at the ratio between the two, and
leaving a system landed the view three kiloparsecs out. Zoom is a plain
multiplier on a frame the distance already sets, so it is bounded by a factor.

Verified: build clean, 595/595 unit including a new spec for the projection
arithmetic, 13/13 end-to-end including two that flatten a system and check the
ladder still knows how far out it is, design detector clean, screenshots of
both scales in both projections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-20 20:11:31 +02:00
co-authored by Claude Fable 5
parent 307fd41be8
commit d9bd913458
11 changed files with 524 additions and 57 deletions
+109
View File
@@ -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);
});
});
+96 -10
View File
@@ -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 } {