Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 46 additions & 26 deletions js/scene/Places.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}


Expand Down Expand Up @@ -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)
}
Expand Down
56 changes: 46 additions & 10 deletions js/scene/Places.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,62 @@ 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)
})
})


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)
Expand Down
48 changes: 31 additions & 17 deletions js/scene/places.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading