diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2b6f6e582..47d7c5305 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1760,6 +1760,7 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/cesium/engineFromViewer.js", + "src/globe/navara/atmosphereLuminance.js", "src/globe/navara/cameraMath.js", "src/globe/navara/cameraModel.js", "src/globe/navara/createNavaraEngine.js", diff --git a/src/globe/navara/atmosphereLuminance.js b/src/globe/navara/atmosphereLuminance.js new file mode 100644 index 000000000..57199a5c2 --- /dev/null +++ b/src/globe/navara/atmosphereLuminance.js @@ -0,0 +1,61 @@ +// The cinematic look's brighter atmosphere (viewOptions.js +// ATMOSPHERE_LUMINANCE_SCALE): the radiance-to-luminance factors of the +// atmosphere's materials (sky, aerial perspective, clouds), multiplied by a +// scale. Navara 0.2.1 has no public setting for them; they are uniforms of +// the takram materials its descriptors build, reached through each handle's +// public `ref.raw`, then fields of those objects that are not documented: +// +// - sky: ref.raw (the three Mesh) .material.uniforms; +// - aerial perspective: ref.raw (Navara's Effect) .rawEffect.uniforms (a Map); +// - clouds: ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms (the +// clouds swap that material when their quality changes). +// +// Re-check each path before moving to another Navara version (as for +// internals.js). Upstream ask: a sky luminance or atmosphere exposure that +// leaves unlit content (the baked tiles) alone. + +export const LUMINANCE_UNIFORMS = Object.freeze([ + 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', + 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE', +]); + +const uniformsOf = { + sky: (raw) => raw?.material?.uniforms, + aerialPerspective: (raw) => raw?.rawEffect?.uniforms, + clouds: (raw) => raw?.rawEffect?.cloudsPass?.currentMaterial?.uniforms, +}; + +const get = (uniforms, name) => + uniforms instanceof Map ? uniforms.get(name) : uniforms?.[name]; + +/** + * Scales the atmosphere's luminance factors to `scale` times the values + * their materials were built with. Idempotent: each factor's vector is + * scaled once (Navara 0.2.1 shares one vector between the sky, the aerial + * perspective and the clouds), and a material Navara rebuilds with a new + * vector is scaled when it first shows. + * @returns {(handles: { sky?: object, aerialPerspective?: object, clouds?: object }) => boolean} + * applies the scale; true when it changed a uniform. + */ +export function createAtmosphereLuminance(scale) { + const scaled = new WeakSet(); + return (handles) => { + if (scale === 1) return false; + let changed = false; + for (const [name, read] of Object.entries(uniformsOf)) { + const uniforms = read(handles[name]?.ref?.raw); + if (!uniforms) continue; + for (const key of LUMINANCE_UNIFORMS) { + const uniform = get(uniforms, key); + const value = uniform?.value; + if (!value || typeof value.multiplyScalar !== 'function') continue; + // Navara's materials share one vector per factor: scale it once. + if (scaled.has(value)) continue; + value.multiplyScalar(scale); + scaled.add(value); + changed = true; + } + } + return changed; + }; +} diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index d7455c7ce..805a49c8d 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -33,6 +33,21 @@ function preservedCanvas(container) { return canvas; } +/** The camera's place in degrees (for the sun's elevation), or null. */ +function cameraPlace(camera) { + try { + const place = camera.pose().cartographic; + return place + ? { + lat: geo.math.toDegrees(place.latitude), + lng: geo.math.toDegrees(place.longitude), + } + : null; + } catch { + return null; + } +} + /** The camera height, or NaN while the camera has no position yet. */ function cameraHeight(camera) { try { @@ -118,7 +133,9 @@ export async function createNavaraGlobe({ engine.frames.preRender.addEventListener(() => { if (!moving && !groundSettled && engine.surface.tilesLoaded()) readGround(); - if (look.frame(cameraHeight(camera), moving, ground)) + if ( + look.frame(cameraHeight(camera), moving, ground, cameraPlace(camera)) + ) engine.render.request('navara-look'); }), ]; diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index 9c655e5fb..c319b4f5e 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -1,13 +1,16 @@ // Builds a Navara look (viewOptions.js navaraScenePlan) on an initialised // view and keeps it current while the view renders: the sun follows the wall // clock as the HUD's SUN elevation does; the cinematic look lights the globe -// from the atmosphere when seen from space and shows its clouds only for low -// views over low ground. All of it runs only on frames the view draws +// from the atmosphere when seen from space, shows its clouds only for low +// views over low ground, brightens the atmosphere (atmosphereLuminance.js) +// and shows its stars only at night or from space. All of it runs only on frames the view draws // anyway, so an idle view stays idle. +import { createAtmosphereLuminance } from './atmosphereLuminance.js'; import { cloudsVisibleAt, globeLightingAt, + starsVisibleAt, sunDateStale, } from './viewOptions.js'; @@ -15,11 +18,12 @@ import { * @param {object} view an initialised ThreeView with the default plugin * @param {object} plugin its DefaultPlugin * @param {ReturnType} plan - * @returns {{ handles: object[], frame: (height: number, moving?: boolean, ground?: number) => boolean }} + * @returns {{ handles: object[], frame: (height: number, moving?: boolean, ground?: number, place?: { lat: number, lng: number } | null) => boolean }} * `frame` is called before each frame with the camera height, whether the - * camera is moving and the height of the ground in view (metres above the - * ellipsoid, NaN when unknown); it returns true when it changed the scene, - * so the caller draws one more frame. + * camera is moving, the height of the ground in view (metres above the + * ellipsoid, NaN when unknown) and the camera's place (degrees, for the + * sun's elevation there); it returns true when it changed the scene, so + * the caller draws one more frame. */ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { const handles = []; @@ -37,13 +41,26 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { ? null : handles[plan.meshes.length + plan.lights.length + at]; }; + const mesh = (name) => { + const at = plan.meshes.findIndex((desc) => Object.hasOwn(desc, name)); + return at < 0 ? null : handles[at]; + }; const aerial = plan.globeLighting ? effect('aerialPerspective') : null; const clouds = effect('clouds'); + const stars = plan.daylightHidesStars ? mesh('stars') : null; + const atmosphereHandles = { + sky: mesh('sky'), + aerialPerspective: effect('aerialPerspective'), + clouds, + }; + const brighten = createAtmosphereLuminance(plan.atmosphereLuminance ?? 1); let globeLit = false; let cloudsShown = true; + let starsShown = true; - const frame = (height, moving = false, ground = Number.NaN) => { - let changed = false; + const frame = (height, moving = false, ground = Number.NaN, place = null) => { + // Every drawn frame: a material Navara rebuilds comes back unscaled. + let changed = brighten(atmosphereHandles); const atmosphere = view.atmosphere; if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) { atmosphere.date = new Date(now()); @@ -75,6 +92,18 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { changed = true; } } + if (stars) { + const elevation = + place && typeof atmosphere?.getSunElevation === 'function' + ? atmosphere.getSunElevation(place) + : Number.NaN; + const next = starsVisibleAt(height, elevation, starsShown); + if (next !== starsShown) { + starsShown = next; + stars.update({ visible: next }); + changed = true; + } + } return changed; }; return { handles, frame }; diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index ac53a0e0e..fd99924b1 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -6,7 +6,12 @@ import { navaraScenePlan } from './viewOptions.js'; function fakeView(dateMs) { const added = []; const handle = (kind, desc) => { - const entry = { kind, desc, updates: [], update: (u) => entry.updates.push(u) }; + const entry = { + kind, + desc, + updates: [], + update: (u) => entry.updates.push(u), + }; added.push(entry); return entry; }; @@ -35,7 +40,12 @@ const plugin = (calls) => ({ test('the photoreal look is the default plugin scene, untouched', () => { const calls = []; const view = fakeView(0); - const look = buildNavaraLook(view, plugin(calls), navaraScenePlan('photoreal'), () => 0); + const look = buildNavaraLook( + view, + plugin(calls), + navaraScenePlan('photoreal'), + () => 0, + ); assert.deepEqual(calls, [[]]); assert.deepEqual(look.handles, [{ id: 'sky' }]); assert.equal(look.frame(8_000_000), false, 'no globe lighting'); @@ -58,8 +68,14 @@ test('the cinematic look adds its descriptors in order', () => { test('from space the globe is lit by the atmosphere; near the ground the tiles keep their own light', () => { const view = fakeView(0); - const look = buildNavaraLook(view, plugin([]), navaraScenePlan('cinematic'), () => 0); - const effect = (name) => view.added.find(({ desc }) => Object.hasOwn(desc, name)); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); + const effect = (name) => + view.added.find(({ desc }) => Object.hasOwn(desc, name)); assert.equal(look.frame(900), false, 'nothing changes at street level'); assert.equal(look.frame(8_000_000), true); assert.equal(view.lit, true, 'no tile material recompiles'); @@ -76,13 +92,22 @@ test('from space the globe is lit by the atmosphere; near the ground the tiles k test('the clouds show only below their tops, switching in motion too', () => { const view = fakeView(0); - const look = buildNavaraLook(view, plugin([]), navaraScenePlan('cinematic'), () => 0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); assert.equal(look.frame(900, true), false, 'shown at street level'); assert.equal(look.frame(20_000, true), true); assert.deepEqual(clouds.updates, [{ visible: false }]); assert.equal(look.frame(3_800, true), false, 'hysteresis'); - assert.equal(view.atmosphere.overlay.value, null, 'no ghost of the last clouds'); + assert.equal( + view.atmosphere.overlay.value, + null, + 'no ghost of the last clouds', + ); assert.equal(view.atmosphere.shadowLength.value, null); assert.equal(look.frame(3_000, true), true); assert.deepEqual(clouds.updates.at(-1), { visible: true }); @@ -94,20 +119,34 @@ test('the clouds show only below their tops, switching in motion too', () => { test('globe lighting switches on only at rest, and off even in motion', () => { const view = fakeView(0); - const look = buildNavaraLook(view, plugin([]), navaraScenePlan('cinematic'), () => 0); - const aerial = view.added.find(({ desc }) => Object.hasOwn(desc, 'aerialPerspective')); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); + const aerial = view.added.find(({ desc }) => + Object.hasOwn(desc, 'aerialPerspective'), + ); look.frame(20_000, true); // the clouds hide on the way up assert.equal(look.frame(2_300_000, true), false, 'a flight through space'); assert.deepEqual(aerial.updates, []); assert.equal(look.frame(8_000_000, false), true, 'at rest'); assert.equal(look.frame(500_000, true), true, 'coming down'); - assert.deepEqual(aerial.updates.at(-1), { aerialPerspective: { irradiance: false } }); + assert.deepEqual(aerial.updates.at(-1), { + aerialPerspective: { irradiance: false }, + }); }); test('the sun follows the wall clock, a minute at most behind, only on drawn frames', () => { let now = 1_000_000; const view = fakeView(now); - const look = buildNavaraLook(view, plugin([]), navaraScenePlan('photoreal'), () => now); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('photoreal'), + () => now, + ); now += 30_000; assert.equal(look.frame(900), false); assert.equal(view.atmosphere.date.getTime(), 1_000_000); @@ -116,3 +155,171 @@ test('the sun follows the wall clock, a minute at most behind, only on drawn fra assert.equal(view.atmosphere.date.getTime(), now); assert.equal(look.frame(900), false); }); + +// A uniform as takram's materials hold it: { value: Vector3-like }. +const vec = (x, y, z) => ({ + x, + y, + z, + copy(v) { + Object.assign(this, { x: v.x, y: v.y, z: v.z }); + return this; + }, + multiplyScalar(k) { + this.x *= k; + this.y *= k; + this.z *= k; + return this; + }, + clone() { + return vec(this.x, this.y, this.z); + }, +}); +const luminanceUniforms = () => ({ + SUN_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.3, 0.9, 0.9) }, + SKY_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.5, 0.9, 0.8) }, +}); + +/** Gives the cinematic handles the raw objects Navara 0.2.1 builds. */ +function withRawAtmosphere(view) { + const find = (name) => + view.added.find(({ desc }) => Object.hasOwn(desc, name)); + const sky = luminanceUniforms(); + const aerial = new Map(Object.entries(luminanceUniforms())); + const clouds = luminanceUniforms(); + const stars = luminanceUniforms(); + find('sky').ref = { raw: { material: { uniforms: sky } } }; + find('stars').ref = { raw: { material: { uniforms: stars } } }; + find('aerialPerspective').ref = { raw: { rawEffect: { uniforms: aerial } } }; + find('clouds').ref = { + raw: { + rawEffect: { cloudsPass: { currentMaterial: { uniforms: clouds } } }, + }, + }; + return { sky, aerial, clouds, stars }; +} + +test('the cinematic atmosphere is brighter, the stars and tiles are not', () => { + const view = fakeView(0); + const plan = navaraScenePlan('cinematic'); + const look = buildNavaraLook(view, plugin([]), plan, () => 0); + const raw = withRawAtmosphere(view); + assert.equal(look.frame(900), true, 'the first frame applies the scale'); + const k = plan.atmosphereLuminance; + const sky = (u) => u.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value; + const sun = (u) => u.SUN_SPECTRAL_RADIANCE_TO_LUMINANCE.value; + assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12); + assert.ok(Math.abs(sun(raw.sky).x - 1.3 * k) < 1e-12); + assert.ok( + Math.abs( + raw.aerial.get('SKY_SPECTRAL_RADIANCE_TO_LUMINANCE').value.x - 1.5 * k, + ) < 1e-12, + ); + assert.ok(Math.abs(sky(raw.clouds).z - 0.8 * k) < 1e-12); + assert.equal( + sky(raw.stars).x, + 1.5, + 'only the sky, aerial perspective and clouds are walked', + ); + assert.equal(look.frame(900), false, 'applied once, not compounded'); + assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12); + // Navara rebuilds a material (a cloud quality change): the new one is scaled. + const fresh = luminanceUniforms(); + view.added.find(({ desc }) => + Object.hasOwn(desc, 'clouds'), + ).ref.raw.rawEffect.cloudsPass.currentMaterial = { uniforms: fresh }; + assert.equal(look.frame(900), true); + assert.ok(Math.abs(sky(fresh).x - 1.5 * k) < 1e-12); +}); + +test('a look without the scale and an atmosphere not built yet are left alone', () => { + const view = fakeView(0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('minimal'), + () => 0, + ); + const sky = luminanceUniforms(); + view.added[0].ref = { raw: { material: { uniforms: sky } } }; + assert.equal(look.frame(900), false); + assert.equal(sky.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x, 1.5); + const cinematic = fakeView(0); + const other = buildNavaraLook( + cinematic, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); + assert.equal(other.frame(900), false, 'no raw objects yet: nothing to scale'); +}); + +test('the cinematic stars hide in a daylit sky and show at night and from space', () => { + const view = fakeView(0); + let elevation = 40; + view.atmosphere.getSunElevation = () => elevation; + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); + const stars = view.added.find(({ desc }) => Object.hasOwn(desc, 'stars')); + const place = { lat: 40.7, lng: -74 }; + assert.equal(look.frame(900, false, 0, place), true); + assert.deepEqual(stars.updates, [{ visible: false }]); + assert.equal(look.frame(900, false, 0, place), false); + elevation = -10; + assert.equal(look.frame(900, false, 0, place), true); + assert.deepEqual(stars.updates.at(-1), { visible: true }); + elevation = 40; + look.frame(900, false, 0, place); + assert.equal(look.frame(8_000_000, false, 0, place), true, 'from space'); + assert.deepEqual(stars.updates.at(-1), { visible: true }); + const count = stars.updates.length; + look.frame(900, false, 0); + assert.equal(stars.updates.length, count, 'no place keeps them'); + assert.equal(look.frame(900, false, 0, place), true, 'back down by day'); + assert.deepEqual(stars.updates.at(-1), { visible: false }); +}); + +test('the minimal look keeps its stars whatever the sun', () => { + const view = fakeView(0); + view.atmosphere.getSunElevation = () => 40; + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('minimal'), + () => 0, + ); + look.frame(900, false, 0, { lat: 0, lng: 0 }); + assert.deepEqual(view.added[1].updates, []); +}); + +test('a factor vector the materials share is scaled once', () => { + const view = fakeView(0); + const plan = navaraScenePlan('cinematic'); + const look = buildNavaraLook(view, plugin([]), plan, () => 0); + const shared = luminanceUniforms(); + const find = (name) => + view.added.find(({ desc }) => Object.hasOwn(desc, name)); + find('sky').ref = { raw: { material: { uniforms: shared } } }; + find('aerialPerspective').ref = { + raw: { rawEffect: { uniforms: new Map(Object.entries(shared)) } }, + }; + find('clouds').ref = { + raw: { + rawEffect: { + cloudsPass: { currentMaterial: { uniforms: { ...shared } } }, + }, + }, + }; + look.frame(900); + look.frame(900); + assert.ok( + Math.abs( + shared.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x - + 1.5 * plan.atmosphereLuminance, + ) < 1e-12, + ); +}); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index 26390a31b..521d56f9b 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -94,6 +94,41 @@ export function cloudsVisibleAt(height, ground, current = true) { : height < CLOUDS_SHOW_BELOW_M && low < CLOUDS_SHOW_OVER_GROUND_M; } +// Navara's sky, clouds and haze come from the atmosphere model in physical +// radiance, scaled for a tone-mapping exposure near 10, while the Google +// tiles carry baked colours that need exposure 1. At exposure 1 the noon sky +// came out dark navy (sRGB about 39, 58, 90 at Manhattan facing the +// horizon), and the stars showed through it. The cinematic look multiplies +// the atmosphere's radiance-to-luminance factors (sky, clouds, aerial +// perspective; Navara's stars share them) by this scale instead of raising +// the exposure, so the sky turns daylight blue and the tiles keep their +// colours. +export const ATMOSPHERE_LUMINANCE_SCALE = 3; +const STAR_INTENSITY = 40; + +// Stars show from space or once the sun is well below the horizon at the +// camera (civil twilight), and hide in a daylit sky. Gaps keep a camera +// near a limit from flickering them. +export const STARS_SHOW_SUN_BELOW_DEG = -6; +export const STARS_HIDE_SUN_ABOVE_DEG = -4; +export const STARS_SPACE_ENTER_M = 100_000; +export const STARS_SPACE_EXIT_M = 80_000; + +/** + * Whether the cinematic stars show for a camera at `height` (metres above + * the ellipsoid) with the sun `sunElevation` degrees above its horizon; an + * unknown height or sun elevation keeps `current`. + */ +export function starsVisibleAt(height, sunElevation, current = true) { + if (!Number.isFinite(height)) return current; + if (current ? height > STARS_SPACE_EXIT_M : height >= STARS_SPACE_ENTER_M) + return true; + if (!Number.isFinite(sunElevation)) return current; + return current + ? sunElevation <= STARS_HIDE_SUN_ABOVE_DEG + : sunElevation < STARS_SHOW_SUN_BELOW_DEG; +} + /** * Whether the atmosphere's sun date has drifted from the wall clock (the * HUD's SUN elevation uses `new Date()`) by more than a minute. @@ -105,8 +140,17 @@ export function sunDateStale(atmosphereMs, nowMs, toleranceMs = 60_000) { const cinematic = (mobile) => ({ pluginDefault: false, // Brighter, larger stars (defaults 10 and 1 px) so the night sky and the - // globe from space keep a visible starfield next to a sunlit Earth. - meshes: [{ sky: {} }, { stars: { intensity: 40, pointSize: 2 } }], + // globe from space keep a visible starfield next to a sunlit Earth: 40, + // divided by the atmosphere scale the stars share. + meshes: [ + { sky: {} }, + { + stars: { + intensity: STAR_INTENSITY / ATMOSPHERE_LUMINANCE_SCALE, + pointSize: 2, + }, + }, + ], // The tiles carry baked sunlight, so the sun casts no shadow maps (CSM // would cost every frame and darken nothing the tiles show). lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }], @@ -140,6 +184,8 @@ const cinematic = (mobile) => ({ mobile ? { fxaa: {} } : { smaa: {} }, ], globeLighting: true, + atmosphereLuminance: ATMOSPHERE_LUMINANCE_SCALE, + daylightHidesStars: true, }); const PLANS = { @@ -149,6 +195,8 @@ const PLANS = { lights: [{ skyLightProbe: {} }, { sun: {} }], effects: [], globeLighting: false, + atmosphereLuminance: 1, + daylightHidesStars: false, }), photoreal: () => ({ pluginDefault: true, @@ -156,6 +204,8 @@ const PLANS = { lights: [], effects: [], globeLighting: false, + atmosphereLuminance: 1, + daylightHidesStars: false, }), cinematic, }; @@ -168,7 +218,8 @@ const PLANS = { * - `cinematic` (the app's default): photoreal plus volumetric clouds for * low views over low ground (cloudsVisibleAt), ACES tone mapping, brighter stars, no sun shadow * maps, and atmosphere lighting of the globe from space (`globeLighting`, - * see globeLightingAt). On a mobile-optimized + * see globeLightingAt), a brighter atmosphere (ATMOSPHERE_LUMINANCE_SCALE) + * and stars only at night or from space (starsVisibleAt). On a mobile-optimized * view it drops the clouds and the lens flare and uses FXAA. */ export function navaraScenePlan( diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index 6dba12acb..67d66d05b 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -16,6 +16,12 @@ import { navaraViewOptions, resolveNavaraLook, sunDateStale, + ATMOSPHERE_LUMINANCE_SCALE, + STARS_HIDE_SUN_ABOVE_DEG, + STARS_SHOW_SUN_BELOW_DEG, + STARS_SPACE_ENTER_M, + STARS_SPACE_EXIT_M, + starsVisibleAt, } from './viewOptions.js'; test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => { @@ -55,6 +61,8 @@ test('cinematic is the default look; photoreal is the default plugin scene; mini lights: [], effects: [], globeLighting: false, + atmosphereLuminance: 1, + daylightHidesStars: false, }); assert.deepEqual(navaraScenePlan('minimal'), { pluginDefault: false, @@ -62,6 +70,8 @@ test('cinematic is the default look; photoreal is the default plugin scene; mini lights: [{ skyLightProbe: {} }, { sun: {} }], effects: [], globeLighting: false, + atmosphereLuminance: 1, + daylightHidesStars: false, }); assert.throws(() => navaraScenePlan('cine'), RangeError); assert.throws(() => navaraScenePlan('toString'), RangeError); @@ -74,7 +84,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no assert.equal(plan.pluginDefault, false); assert.deepEqual(plan.meshes, [ { sky: {} }, - { stars: { intensity: 40, pointSize: 2 } }, + { stars: { intensity: 40 / ATMOSPHERE_LUMINANCE_SCALE, pointSize: 2 } }, ]); assert.deepEqual(plan.lights, [ { skyLightProbe: {} }, @@ -173,3 +183,34 @@ test('the sun date is stale once it drifts a minute from the clock', () => { assert.equal(sunDateStale(120_000, 0), true, 'either direction'); assert.equal(sunDateStale(Number.NaN, 0), true); }); + +test('the cinematic look brightens the atmosphere, not the tiles, and hides stars by day', () => { + const plan = navaraScenePlan('cinematic'); + assert.equal(plan.atmosphereLuminance, ATMOSPHERE_LUMINANCE_SCALE); + assert.ok(ATMOSPHERE_LUMINANCE_SCALE > 1); + assert.equal(plan.daylightHidesStars, true); + for (const look of ['minimal', 'photoreal']) { + assert.equal(navaraScenePlan(look).atmosphereLuminance, 1, look); + assert.equal(navaraScenePlan(look).daylightHidesStars, false, look); + } +}); + +test('stars show at night and from space, not in a daylit sky, with hysteresis', () => { + assert.equal(starsVisibleAt(900, 40, true), false, 'noon'); + assert.equal(starsVisibleAt(900, 40, false), false); + assert.equal(starsVisibleAt(900, STARS_SHOW_SUN_BELOW_DEG - 1, false), true); + assert.equal(starsVisibleAt(900, -5, false), false, 'inside the gap'); + assert.equal(starsVisibleAt(900, -5, true), true, 'inside the gap'); + assert.equal(starsVisibleAt(900, STARS_HIDE_SUN_ABOVE_DEG + 1, true), false); + assert.equal(starsVisibleAt(STARS_SPACE_ENTER_M, 40, false), true, 'space'); + assert.equal(starsVisibleAt(90_000, 40, true), true, 'inside the gap'); + assert.equal(starsVisibleAt(STARS_SPACE_EXIT_M - 1, 40, true), false); + assert.equal(starsVisibleAt(900, Number.NaN, true), true, 'unknown keeps it'); + assert.equal( + starsVisibleAt(Number.NaN, 40, false), + false, + 'unknown keeps it', + ); + assert.ok(STARS_SHOW_SUN_BELOW_DEG < STARS_HIDE_SUN_ABOVE_DEG); + assert.ok(STARS_SPACE_EXIT_M < STARS_SPACE_ENTER_M); +});