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:
@@ -1,7 +1,8 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
|
||||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { SceneCamera } from '../../core/engine/engine.service';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||
@@ -169,6 +170,10 @@ export class StarFieldRenderer {
|
||||
/** How many of the catalogue's stars this field actually draws. */
|
||||
readonly drawnCount: number;
|
||||
|
||||
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||
private readonly perspective = uniform(1);
|
||||
private readonly orthographicScale = uniform(float(0));
|
||||
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
||||
@@ -209,10 +214,22 @@ export class StarFieldRenderer {
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.material.sizeAttenuation = false;
|
||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||
// The compensation that turns an angular size into a world size is done here rather than by
|
||||
// `sizeAttenuation: false`, which three.js applies only when it is compiling against a
|
||||
// perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
|
||||
// === false`). Under an orthographic one it is silently skipped and every star collapses to
|
||||
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
||||
// a uniform, lets one material serve both cameras without being recompiled between them.
|
||||
this.material.sizeAttenuation = true;
|
||||
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3');
|
||||
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float');
|
||||
this.material.positionNode = position;
|
||||
// Perspective: a star's world size is its angular size times how far away it is, which is
|
||||
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
|
||||
// the frustum does, so the same angular size is scaled by the frustum instead.
|
||||
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3');
|
||||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||
const radius = uv().sub(vec2(0.5)).length();
|
||||
@@ -224,6 +241,19 @@ export class StarFieldRenderer {
|
||||
this.object.frustumCulled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the field which projection it is being drawn under.
|
||||
*
|
||||
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
|
||||
* perspective camera, where a star's distance sets its apparent size on its own.
|
||||
*/
|
||||
setProjection(halfHeightWorld: number | null): void {
|
||||
this.perspective.value = halfHeightWorld === null ? 1 : 0;
|
||||
// The world size that subtends the same share of the viewport an angular size would under
|
||||
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
|
||||
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||
}
|
||||
|
||||
/** Looks up the HYG star id for a given instance index. */
|
||||
starIdAt(instanceIndex: number): number | undefined {
|
||||
return this.stars[instanceIndex]?.id;
|
||||
@@ -239,8 +269,14 @@ export class StarFieldRenderer {
|
||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||
* sub-pixel at the far end of the camera's range.
|
||||
*/
|
||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
||||
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||||
// What a unit of angular size is worth on screen. Under perspective that is set by the
|
||||
// field of view; under an orthographic camera the same size was already turned into a world
|
||||
// size by `setProjection`, so it is the frustum that converts it back.
|
||||
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||||
const orthographic = camera as THREE.OrthographicCamera;
|
||||
const halfHeightWorld = perspective ? 0 : (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
|
||||
const tanHalfFov = perspective ? Math.tan(((camera as THREE.PerspectiveCamera).fov * Math.PI) / 360) : 0;
|
||||
const projected = new THREE.Vector3();
|
||||
|
||||
let bestIndex: number | undefined;
|
||||
@@ -257,8 +293,12 @@ export class StarFieldRenderer {
|
||||
|
||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||
const dx = (projected.x - pointerNdc.x) * camera.aspect;
|
||||
const ndcRadius =
|
||||
(perspective
|
||||
? (0.5 * this.angularSizes[index]) / tanHalfFov
|
||||
: // The world size the star is drawn at, as a fraction of the frustum's half-height.
|
||||
(0.5 * this.angularSizes[index] * (halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360))) / halfHeightWorld) + PICK_NDC_SLOP;
|
||||
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||
const dy = projected.y - pointerNdc.y;
|
||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user