diff --git a/js/scene/Places.js b/js/scene/Places.js index 9cfef86..e50d007 100644 --- a/js/scene/Places.js +++ b/js/scene/Places.js @@ -6,22 +6,19 @@ import {latLngAltToBodyFixed} from '../coords.js' import {labelTextColor} from '../shared.js' -// Tier reveal thresholds: planet apparent RADIUS in viewport pixels. -// T0 marquee names appear when the body is a small recognizable disc; -// finer tiers reveal as the camera closes in. Indexed by entry's `t` field. +// Tier reveal thresholds: planet apparent DIAMETER as a fraction of +// viewport height. Viewport-relative so the visual size at which each +// tier reveals stays consistent across 720p / 1080p / 4K / 8K monitors. // -// At the default FOV of 45° on a 900-pixel-tall viewport, the body -// subtends roughly: -// d=10R → 114 px (only T0) -// d=5.5R → 200 px (T1 reveals) -// d=2.3R → 500 px (T2 reveals — "close orbital view", whole hemisphere) -// d=R → 900 px (camera at the surface) +// The whole reveal sequence sits in the "planet fills most of the +// screen" regime — earlier tunings let T0 fire at fraction ≈ 0.06 +// (planet a small disc), which made even the marquee names crowd the +// view long before the user was close enough to read them. Now nothing +// reveals until the planet is ~3/4 of the screen height. // -// Earlier T2 was 1500 which never fired at the default FOV (max is ~900 -// at d=R), making T2 entries effectively dead unless the user narrowed -// FOV via the `,` key. T3 (8000) remains reserved for a possible future -// zoom-only level. -const DEFAULT_TIER_PX = [30, 200, 500, 8000] +// T0 reveals at d ≈ 3.3 R, T1 at d ≈ 2.4 R, T2 at d ≈ 1.5 R. T3 +// reserved for a future surface-detail level. +const DEFAULT_TIER_FRAC = [0.75, 1.0, 1.5, 8.0] // Per-entry altitude (`a`, in m) is preserved as-is. We don't add a fixed // surface lift any more: the surface-visibility SpriteSheet shader uses @@ -40,17 +37,19 @@ const DEFAULT_TIER_PX = [30, 200, 500, 8000] * raw body-fixed XYZ from latLngAltToBodyFixed, no per-frame transform work. * * LOD: per-tier SpriteSheets are lazy-instantiated the first time their - * reveal threshold (planet apparent radius in pixels) is crossed. Per-frame - * visibility toggling is driven by an invisible placeholder Points whose - * onBeforeRender computes screenPx for the parent body. + * reveal threshold (planet diameter as fraction of viewport height) is + * crossed. Per-frame visibility toggling is driven by an invisible + * placeholder Points whose onBeforeRender computes the apparent diameter + * for the parent body. */ export default class Places extends Group { /** * @param {string} bodyName For node naming + debugging * @param {number} planetRadius Body radius in meters - * @param {number[]} [tierThresholds] Override DEFAULT_TIER_PX + * @param {number[]} [tierThresholds] Override DEFAULT_TIER_FRAC. Each + * entry is planet apparent diameter as a fraction of viewport height. */ - constructor(bodyName, planetRadius, tierThresholds = DEFAULT_TIER_PX) { + constructor(bodyName, planetRadius, tierThresholds = DEFAULT_TIER_FRAC) { super() this.name = `${bodyName}.places` this.bodyName = bodyName @@ -87,6 +86,8 @@ export default class Places extends Group { /** * Compute the body's apparent radius in viewport pixels at the camera. + * Kept for external use (tests, debugging) — internally + * `diameterFraction` is what drives tier reveal. * * @param {object} camera Three.js PerspectiveCamera (uses .fov) * @param {number} viewportH Viewport height in pixels @@ -109,19 +110,38 @@ export default class Places extends Group { /** - * Visibility test for tier `t` at a given screenPx. Exposed for tests so - * we don't need a real renderer/camera to verify the LOD logic. + * Compute the body's apparent DIAMETER as a fraction of viewport height. + * Viewport-relative so a single threshold reads the same on 1080p, 4K, + * 8K, etc — the basis for tier reveal. + * + * @param {object} camera Three.js PerspectiveCamera (uses .fov) + * @param {number} viewportH Viewport height in pixels + * @returns {number} fraction (1.0 means body diameter equals screen height) + */ + diameterFraction(camera, viewportH) { + if (viewportH <= 0) { + return 0 + } + // screenPx is the apparent RADIUS in pixels; diameter / vph = 2*sp/vph. + return (2 * this.screenPx(camera, viewportH)) / viewportH + } + + + /** + * Visibility test for tier `t` at a given diameter fraction. Exposed + * for tests so we don't need a real renderer/camera to verify the LOD + * logic. * * @param {number} t - * @param {number} screenPx + * @param {number} fraction body diameter / viewport height * @returns {boolean} */ - shouldShowTier(t, screenPx) { + shouldShowTier(t, fraction) { const threshold = this.tierThresholds[t] if (threshold === undefined) { return false } - return screenPx >= threshold + return fraction >= threshold } @@ -167,12 +187,12 @@ export default class Places extends Group { // Hook runs only when the placeholder itself isn't culled. It sits at // the parent's origin, so it's visible whenever the planet is in frame. placeholder.onBeforeRender = (renderer, _scene, camera) => { - const px = this.screenPx(camera, renderer.domElement.clientHeight) + const frac = this.diameterFraction(camera, renderer.domElement.clientHeight) for (let t = 0; t < this.tierThresholds.length; t++) { if (!this.byTier[t]) { continue } - const want = this.shouldShowTier(t, px) + const want = this.shouldShowTier(t, frac) if (want && !this.tierGroups[t]) { this._buildTier(t) } diff --git a/js/scene/Places.test.js b/js/scene/Places.test.js index f9ff3cc..fb11c21 100644 --- a/js/scene/Places.test.js +++ b/js/scene/Places.test.js @@ -45,19 +45,21 @@ function placesAt(parentPos = new Vector3(0, 0, 0), radius = EARTH_R) { // ─── tests ──────────────────────────────────────────────────────────────── describe('Places.shouldShowTier', () => { + // Thresholds are body-diameter / viewport-height fractions; the + // default table is DEFAULT_TIER_FRAC = [0.75, 1.0, 1.5]. const {places} = placesAt() - it('reveals T0 above 30 px', () => { - expect(places.shouldShowTier(0, 29)).toBe(false) - expect(places.shouldShowTier(0, 30)).toBe(true) - expect(places.shouldShowTier(0, 1000)).toBe(true) + it('reveals T0 above 0.75 (planet ≥ 75% of screen)', () => { + expect(places.shouldShowTier(0, 0.749)).toBe(false) + expect(places.shouldShowTier(0, 0.75)).toBe(true) + expect(places.shouldShowTier(0, 5)).toBe(true) }) - it('reveals T1 above 200 px', () => { - expect(places.shouldShowTier(1, 199)).toBe(false) - expect(places.shouldShowTier(1, 200)).toBe(true) + it('reveals T1 above 1.0 (planet just fills the screen)', () => { + expect(places.shouldShowTier(1, 0.999)).toBe(false) + expect(places.shouldShowTier(1, 1.0)).toBe(true) }) - it('reveals T2 above 500 px', () => { - expect(places.shouldShowTier(2, 499)).toBe(false) - expect(places.shouldShowTier(2, 500)).toBe(true) + it('reveals T2 above 1.5 (planet 1.5× screen — close zoom)', () => { + expect(places.shouldShowTier(2, 1.499)).toBe(false) + expect(places.shouldShowTier(2, 1.5)).toBe(true) }) it('returns false for a tier with no threshold', () => { expect(places.shouldShowTier(99, 1e9)).toBe(false) @@ -65,6 +67,40 @@ describe('Places.shouldShowTier', () => { }) +describe('Places.diameterFraction', () => { + // The metric that actually drives tier reveal — verifies it's viewport- + // relative so a single threshold reads identically across screen sizes. + const cam = new PerspectiveCamera(45, 16 / 9, 1, 1e12) + + it('returns 0 when viewport height is zero or negative', () => { + const {places} = placesAt() + expect(places.diameterFraction(cam, 0)).toBe(0) + expect(places.diameterFraction(cam, -1)).toBe(0) + }) + + it('reads the same fraction on 1080p and 4K at the same camera distance', () => { + // Regression: an earlier absolute-pixel threshold gave wildly different + // visual sizes across viewports. With the fraction-based metric, + // the visual size at which a tier reveals is viewport-independent. + const {places} = placesAt(new Vector3(0, 0, 0)) + cam.position.set(0, 0, EARTH_R * 3) + cam.updateMatrixWorld(true) + const frac1080 = places.diameterFraction(cam, 1080) + const frac2160 = places.diameterFraction(cam, 2160) + expect(frac1080).toBeCloseTo(frac2160, 4) + }) + + it('= 2 × screenPx / viewportH', () => { + const {places} = placesAt(new Vector3(0, 0, 0)) + cam.position.set(0, 0, EARTH_R * 5) + cam.updateMatrixWorld(true) + const vph = 1080 + expect(places.diameterFraction(cam, vph)).toBeCloseTo( + (2 * places.screenPx(cam, vph)) / vph, 6) + }) +}) + + describe('Places.screenPx', () => { // 1080p viewport, 45 deg FOV const cam = new PerspectiveCamera(45, 16 / 9, 1, 1e12) diff --git a/js/scene/places.md b/js/scene/places.md index 4cf6df3..30cd30a 100644 --- a/js/scene/places.md +++ b/js/scene/places.md @@ -51,25 +51,39 @@ of homogeneous coordinates. Visibility = f(tier, planet apparent screen radius in pixels). Computed per-frame in `Places._installLODHook`'s `onBeforeRender`: -| Tier | screenPx ≥ | UX intent | +| Tier | diameterFraction ≥ | UX intent | |---|---|---| -| T0 | 30 | small recognizable disc → only marquee names | -| T1 | 200 | planet fills ~half the screen | -| T2 | 500 | close orbital view — whole hemisphere visible | -| T3 | (Phase 2 lazy chunks) | — | - -`screenPx` is the body's apparent *radius* in viewport pixels (see -`Places.screenPx`). At the default FOV (45°) and a 900-px-tall viewport, -the camera distance → screenPx mapping is roughly: - -| Camera distance | screenPx | Visible tiers | +| T0 | 0.75 | planet fills ~3/4 of the screen — marquee names appear | +| T1 | 1.00 | planet fills the screen — major cities add | +| T2 | 1.50 | planet 1.5× screen — close-zoom picking, all cities | +| T3 | (8.0, reserved) | — | + +`diameterFraction` is the body's apparent diameter as a fraction of +viewport height, e.g. `0.75` means the body's screen diameter ≈ 75 % +of the viewport's vertical extent. Viewport-relative on purpose: an +earlier absolute-pixel threshold (T1 = 400 px radius) gave a "planet +fills 74 %" reveal on 1080p but only ~18 % on 8K, so users on larger +displays saw T1 names at the initial d=10R fly-in. + +The whole reveal sequence is intentionally in the "planet is large" +regime — at the default FOV (45°), the camera-distance → +diameter-fraction mapping is: + +| Camera distance | diameter / vph | Visible tiers | |---|---|---| -| 10 R | 114 | T0 | -| 5.5 R | 200 | T0, T1 | -| 2.3 R | 500 | T0, T1, T2 | -| R (surface) | 900 | T0, T1, T2 | - -(Where `R` is the body's surface radius.) +| 10 R (initial fly-in) | 0.25 | (none) | +| 3.3 R | 0.75 | T0 | +| 2.4 R | 1.00 | T0, T1 | +| 1.5 R | 1.50 | T0, T1, T2 | +| R (surface) | 2.0 | T0, T1, T2 | + +(Where `R` is the body's surface radius.) The mapping is independent +of viewport size — `Places.diameterFraction` divides by viewport +height, so the table holds for 720p / 1080p / 4K / 8K alike. + +`Places.screenPx` is still exposed for tests and debugging (it returns +the absolute pixel-radius), but tier reveal goes through +`diameterFraction`. Per-tier SpriteSheets are lazy-instantiated the first time their threshold is crossed — most users browsing the solar system will never