Keep the star visible at the distance that frames its system
Framing the whole system pushed the camera far enough back that the star at the centre became a speck — about a pixel across for the Sun. The cause is a constraint that cannot be tuned away. A star is sized against its system's innermost orbit, because it must never swallow its closest planet, while the camera is placed to frame the outermost ring. In the solar system those differ by a factor of a hundred: at the distance that fits Pluto in view, a disc that stays clear of Mercury is a pixel across. No radius satisfies both, because the information genuinely does not fit on one screen at that zoom. So the disc stays honest to the orbits and the halo carries the visibility. Light is not a surface: a glow that reaches past the innermost orbit says the star is bright, not that it is large. Its extent is still a multiple of the star — so a compact system keeps exactly the corona it had — but floored against the framed radius, which is what the wide systems needed. The disc grows a little too: it may now reach 45% of the innermost orbit rather than 35%, which still leaves clear space between the star's limb and the closest orbit. Also makes createGlowSprite take the extent it will draw rather than a radius and a multiplier. The two were only ever multiplied together, and how large a star's halo should be is not a property of the star — it depends on how its system is framed, which is a decision that belongs with the framing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -311,3 +311,4 @@ Real photography where it exists, and a surface reasoned from measurements where
|
|||||||
- Replace the fixed distance-to-outermost-orbit multiple with a distance derived from the camera's vertical field of view and aspect, so what fits is a radius on screen rather than a guess.
|
- Replace the fixed distance-to-outermost-orbit multiple with a distance derived from the camera's vertical field of view and aspect, so what fits is a radius on screen rather than a guess.
|
||||||
- Frame against the reference grid's outer ring, which is always wider than the outermost orbit, and leave an explicit margin around it.
|
- Frame against the reference grid's outer ring, which is always wider than the outermost orbit, and leave an explicit margin around it.
|
||||||
- Raise the framing ceiling far enough to hold the solar system out to Pluto in a portrait window; only companions hundreds of AU out reach it now.
|
- Raise the framing ceiling far enough to hold the solar system out to Pluto in a portrait window; only companions hundreds of AU out reach it now.
|
||||||
|
- Floor the star's halo against the framed radius, so a star sized against its innermost orbit still reads at the distance that frames its outermost one.
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ instead of having to be inferred from a shape in space. The camera frames that g
|
|||||||
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
|
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
|
||||||
with room around it at any system scale and any window shape.
|
with room around it at any system scale and any window shape.
|
||||||
|
|
||||||
|
The star at the centre is sized against the system's *innermost* orbit, so it can never swallow
|
||||||
|
its closest planet, while the camera is placed to frame the *outermost* ring — and in the solar
|
||||||
|
system those differ by a factor of a hundred. At the distance that fits Pluto in view, a disc
|
||||||
|
that stays clear of Mercury is about a pixel across, and no radius satisfies both. So the disc
|
||||||
|
stays honest to the orbits and the star's halo carries its visibility, floored against the framed
|
||||||
|
radius: light is not a surface, and a glow reaching past the innermost orbit says the star is
|
||||||
|
bright rather than that it is large.
|
||||||
|
|
||||||
**Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet,
|
**Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet,
|
||||||
with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's
|
with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's
|
||||||
own measurements where it does not. See "On surfaces that were never photographed" below.
|
own measurements where it does not. See "On surfaces that were never photographed" below.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { InfoPanelComponent } from './info-panel.component';
|
|||||||
|
|
||||||
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
|
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
|
||||||
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
||||||
|
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
||||||
const GLOW_SCALE = 2.6;
|
const GLOW_SCALE = 2.6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,7 +202,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
||||||
if (atmosphereColor !== undefined) {
|
if (atmosphereColor !== undefined) {
|
||||||
this.glow = createGlowSprite(atmosphereColor, 1, GLOW_SCALE);
|
this.glow = createGlowSprite(atmosphereColor, GLOW_SCALE);
|
||||||
this.scene.add(this.glow);
|
this.scene.add(this.glow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { CameraRigController } from './camera-rig-controller';
|
|||||||
import { DeepSkyRenderer } from './deep-sky-renderer';
|
import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||||
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||||
import { MilkyWayRenderer } from './milky-way-renderer';
|
import { MilkyWayRenderer } from './milky-way-renderer';
|
||||||
import { starMarkerRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
|
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
|
||||||
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
||||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||||
@@ -27,7 +27,8 @@ import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
|||||||
|
|
||||||
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
||||||
const SOL_STAR_ID = 0;
|
const SOL_STAR_ID = 0;
|
||||||
const SUN_GLOW_SCALE = 3.2;
|
/** Stars drawn from a colour rather than a photograph get a more restrained halo. */
|
||||||
|
const DIM_STAR_GLOW_SCALE = 0.6;
|
||||||
|
|
||||||
/** Stars closer than this to the camera get a name label (always includes the selection). */
|
/** Stars closer than this to the camera get a name label (always includes the selection). */
|
||||||
const LABEL_MAX_DISTANCE_PC = 20;
|
const LABEL_MAX_DISTANCE_PC = 20;
|
||||||
@@ -698,6 +699,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
||||||
this.systemGroup.add(this.systemRenderer.object);
|
this.systemGroup.add(this.systemRenderer.object);
|
||||||
|
|
||||||
|
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
|
||||||
|
// the wider of the two — and against the camera this scene actually has, so the margin holds
|
||||||
|
// whatever the window shape. Computed before the star, because how far away the star will be
|
||||||
|
// seen from is what decides how big its halo has to be to stay visible.
|
||||||
|
const viewport = { fovDegrees: camera.fov, aspect: camera.aspect };
|
||||||
|
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
|
||||||
|
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
|
||||||
|
|
||||||
// Sized against this system's innermost orbit, so the star never swallows its own planets.
|
// Sized against this system's innermost orbit, so the star never swallows its own planets.
|
||||||
const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
|
const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
|
||||||
this.starMarkerGeometry?.dispose();
|
this.starMarkerGeometry?.dispose();
|
||||||
@@ -710,10 +719,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// other point in the galaxy view is far too distant to be resolved as a disk.
|
// other point in the galaxy view is far too distant to be resolved as a disk.
|
||||||
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
|
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
|
||||||
starMarkerMaterial.color.set(0xffffff);
|
starMarkerMaterial.color.set(0xffffff);
|
||||||
this.starGlow = createGlowSprite(0xfff2c0, starRadiusAu, SUN_GLOW_SCALE);
|
this.starGlow = createGlowSprite(0xfff2c0, starGlowExtentAu(starRadiusAu, frameRadiusAu));
|
||||||
} else {
|
} else {
|
||||||
starMarkerMaterial.color.copy(starColor);
|
starMarkerMaterial.color.copy(starColor);
|
||||||
this.starGlow = createGlowSprite(starColor, starRadiusAu, SUN_GLOW_SCALE * 0.6);
|
this.starGlow = createGlowSprite(starColor, starGlowExtentAu(starRadiusAu, frameRadiusAu, DIM_STAR_GLOW_SCALE));
|
||||||
}
|
}
|
||||||
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
|
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
|
||||||
this.systemGroup.add(this.starMarker, this.starGlow);
|
this.systemGroup.add(this.starMarker, this.starGlow);
|
||||||
@@ -733,10 +742,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
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) });
|
||||||
|
|
||||||
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
|
|
||||||
// the wider of the two — and against the camera this scene actually has, so the margin holds
|
|
||||||
// whatever the window shape.
|
|
||||||
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect });
|
|
||||||
// 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
|
||||||
// on this system's own orbital plane as it settles — so the swap stays continuous but the
|
// on this system's own orbital plane as it settles — so the swap stays continuous but the
|
||||||
// system is not presented edge-on. See `systemViewDirection`.
|
// system is not presented edge-on. See `systemViewDirection`.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/co
|
|||||||
import {
|
import {
|
||||||
bodyMarkerRadiusAu,
|
bodyMarkerRadiusAu,
|
||||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||||
|
starGlowExtentAu,
|
||||||
starMarkerRadiusAu,
|
starMarkerRadiusAu,
|
||||||
systemFrameRadiusAu,
|
systemFrameRadiusAu,
|
||||||
systemFramingDistanceAu,
|
systemFramingDistanceAu,
|
||||||
@@ -112,6 +113,75 @@ describe('systemFramingDistanceAu', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('starGlowExtentAu', () => {
|
||||||
|
/** Apparent size on screen, as a fraction of the frame's half-height. */
|
||||||
|
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||||
|
const rings = systemGridRingsAu(outermostAu);
|
||||||
|
const distance = systemFramingDistanceAu(rings[rings.length - 1]);
|
||||||
|
const frame = systemFrameRadiusAu(distance);
|
||||||
|
// The sprite's extent is its full width, so half of it is what reaches out from the star.
|
||||||
|
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frame, glowScale) / 2 / frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('scales with the star for a compact system, where the star is already big enough', () => {
|
||||||
|
// A tight frame relative to the star, so the star's own multiple is what decides.
|
||||||
|
const marker = 0.02;
|
||||||
|
const tightFrame = 0.5;
|
||||||
|
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
|
||||||
|
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors against the frame once the star would otherwise vanish into it', () => {
|
||||||
|
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
|
||||||
|
// the multiple of the star is nothing, so the frame decides instead.
|
||||||
|
const tinyStar = 0.001;
|
||||||
|
const wideFrame = 56;
|
||||||
|
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Sun visible at the distance that frames the solar system', () => {
|
||||||
|
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
|
||||||
|
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
|
||||||
|
expect(apparentFraction(0.387, 39.288)).toBeGreaterThan(0.015);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the floor across every system scale the datasets contain', () => {
|
||||||
|
// A compact system's star is genuinely large relative to its own system and keeps the bigger
|
||||||
|
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
|
||||||
|
for (const [innermost, outermost] of [
|
||||||
|
[0.387, 39.288],
|
||||||
|
[0.035, 0.204],
|
||||||
|
[0.01154, 0.06189],
|
||||||
|
[1.2, 12.4]
|
||||||
|
]) {
|
||||||
|
expect(apparentFraction(innermost, outermost)).toBeGreaterThan(0.015);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not blot out the system it sits in', () => {
|
||||||
|
for (const [innermost, outermost] of [
|
||||||
|
[0.387, 39.288],
|
||||||
|
[0.035, 0.204],
|
||||||
|
[0.01154, 0.06189]
|
||||||
|
]) {
|
||||||
|
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
|
||||||
|
// Above the floor the multiplier applies...
|
||||||
|
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
|
||||||
|
// ...and at the floor it cannot dim a star into invisibility.
|
||||||
|
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the star alone when there is no frame to measure against', () => {
|
||||||
|
for (const frame of [0, -1, Number.NaN]) {
|
||||||
|
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the grid and the framing together', () => {
|
describe('the grid and the framing together', () => {
|
||||||
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
||||||
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
||||||
|
|||||||
@@ -25,7 +25,25 @@ export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
|
|||||||
* space between the star's limb and the closest orbit, rather than the orbit grazing or
|
* space between the star's limb and the closest orbit, rather than the orbit grazing or
|
||||||
* disappearing inside it.
|
* disappearing inside it.
|
||||||
*/
|
*/
|
||||||
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.35;
|
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halo extent as a multiple of the star's own radius, and the floor on that extent as a
|
||||||
|
* fraction of the framed radius.
|
||||||
|
*
|
||||||
|
* The floor is what keeps a star visible. A system's star is sized against its *innermost*
|
||||||
|
* orbit — it must never swallow its closest planet — while the camera is placed to frame the
|
||||||
|
* *outermost* ring, and those differ by a factor of a hundred in the solar system. At the
|
||||||
|
* distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel
|
||||||
|
* across; there is no radius that satisfies both, because the information genuinely does not
|
||||||
|
* fit on one screen at that zoom.
|
||||||
|
*
|
||||||
|
* The halo resolves it, because light is not a surface: a glow that reaches past the innermost
|
||||||
|
* orbit does not claim the star is that large, it claims the star is bright. So the disc stays
|
||||||
|
* honest to the orbits and the halo is floored against the frame.
|
||||||
|
*/
|
||||||
|
const STAR_GLOW_TO_MARKER = 3.2;
|
||||||
|
const MIN_STAR_GLOW_TO_FRAME = 0.035;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||||
@@ -125,6 +143,20 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
|
|||||||
return distanceAu * tightHalfExtent(viewport);
|
return distanceAu * tightHalfExtent(viewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
||||||
|
*
|
||||||
|
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
||||||
|
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
||||||
|
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
||||||
|
* caller dim the halo for stars drawn without a real photograph.
|
||||||
|
*/
|
||||||
|
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
||||||
|
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
||||||
|
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
||||||
|
return Math.max(fromStar, fromFrame);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||||
* around it.
|
* around it.
|
||||||
|
|||||||
@@ -88,12 +88,16 @@ export function createGlowTexture(color: THREE.ColorRepresentation, profile: Glo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a soft additive-blended glow halo (used for planetary atmospheres and the Sun's
|
* Builds a soft additive-blended glow halo (used for planetary atmospheres and the Sun's
|
||||||
* corona) sized relative to the given object radius. Cheap billboard-sprite approximation
|
* corona), `extent` across in world units. Cheap billboard-sprite approximation rather than a
|
||||||
* rather than a view-angle-correct Fresnel shader, chosen to stay within built-in material
|
* view-angle-correct Fresnel shader, chosen to stay within built-in material types the WebGPU
|
||||||
* types the WebGPU backend renders natively (see plan risk on TSL/shader maturity). Falls back
|
* backend renders natively (see plan risk on TSL/shader maturity). Falls back to a
|
||||||
* to a flat-colored (gradient-less) sprite if canvas rendering is unavailable.
|
* flat-colored (gradient-less) sprite if canvas rendering is unavailable.
|
||||||
|
*
|
||||||
|
* Takes the finished extent rather than a radius and a multiplier: how big a star's halo should
|
||||||
|
* be is not a fixed multiple of the star, it depends on how the system is framed, and that
|
||||||
|
* decision belongs with the framing (see `starGlowExtentAu`).
|
||||||
*/
|
*/
|
||||||
export function createGlowSprite(color: THREE.ColorRepresentation, radius: number, scale: number): THREE.Sprite {
|
export function createGlowSprite(color: THREE.ColorRepresentation, extent: number): THREE.Sprite {
|
||||||
const material = new THREE.SpriteMaterial({
|
const material = new THREE.SpriteMaterial({
|
||||||
map: createGlowTexture(color),
|
map: createGlowTexture(color),
|
||||||
color: color,
|
color: color,
|
||||||
@@ -102,6 +106,6 @@ export function createGlowSprite(color: THREE.ColorRepresentation, radius: numbe
|
|||||||
blending: THREE.AdditiveBlending
|
blending: THREE.AdditiveBlending
|
||||||
});
|
});
|
||||||
const sprite = new THREE.Sprite(material);
|
const sprite = new THREE.Sprite(material);
|
||||||
sprite.scale.setScalar(radius * scale);
|
sprite.scale.setScalar(extent);
|
||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user