From 5188e8869dadd410afd9742d7d052f3ed5d36708 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH] fix(globe): the cinematic look's atmosphere scale, warm-up and sunset clouds - The takram uniform paths move to internals.js (forEachLuminanceUniform, loud in dev when a path is gone); look.js sets each factor to scale x the recorded base value, so shared or copied vectors are never scaled twice, and the stars keep the base value (own vector when shared). Stars at intensity 120: a starfield shows from space. - Both night-side shader variants compile on the first two frames. - The clouds hide once the sun sets at the camera (dark puffs at dusk). - No allocation on look frames that change nothing (frozen updates, a scratch camera place from livePosition). Gallery regenerated. --- scripts/format-scope.json | 1 - scripts/package-boundaries.json | 1 - src/globe/navara/atmosphereLuminance.js | 61 ----- src/globe/navara/index.js | 50 +++-- src/globe/navara/internals.js | 65 ++++++ src/globe/navara/look.js | 143 +++++++++--- src/globe/navara/look.test.mjs | 281 ++++++++++++++---------- src/globe/navara/viewOptions.js | 22 +- src/globe/navara/viewOptions.test.mjs | 14 +- 9 files changed, 410 insertions(+), 228 deletions(-) delete mode 100644 src/globe/navara/atmosphereLuminance.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8e0aa8922..5e1a231f6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1033,7 +1033,6 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/contract.js", - "src/globe/navara/atmosphereLuminance.js", "src/globe/navara/cameraMath.js", "src/globe/navara/cameraMath.test.mjs", "src/globe/navara/cameraModel.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 47d7c5305..2b6f6e582 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1760,7 +1760,6 @@ "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 deleted file mode 100644 index 57199a5c2..000000000 --- a/src/globe/navara/atmosphereLuminance.js +++ /dev/null @@ -1,61 +0,0 @@ -// 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 805a49c8d..39f451e8a 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -6,7 +6,7 @@ import ThreeView from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { createNavaraEngine } from './createNavaraEngine.js'; -import { navaraHooks } from './internals.js'; +import { forEachLuminanceUniform, navaraHooks } from './internals.js'; import { buildNavaraLook } from './look.js'; import { createNavaraSharpen } from './sharpen.js'; import { @@ -33,28 +33,22 @@ 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's place for the look, refreshed in place each frame (no +// allocation): degrees for the sun's elevation, and the height. +const lookCarto = { longitude: 0, latitude: 0, height: 0 }; +const lookPlace = { lat: 0, lng: 0 }; -/** The camera height, or NaN while the camera has no position yet. */ -function cameraHeight(camera) { - try { - return camera.height(); - } catch { - return Number.NaN; - } +/** Fills lookPlace from the camera; false while it has no position yet. */ +function readCameraPlace(camera) { + const place = geo.cartographic.fromCartesian( + camera.livePosition, + geo.ellipsoid.WGS84, + lookCarto, + ); + if (!place) return false; + lookPlace.lat = geo.math.toDegrees(place.latitude); + lookPlace.lng = geo.math.toDegrees(place.longitude); + return true; } // The height of the ground in view, for the look's cloud rule: the depth of @@ -103,7 +97,9 @@ export async function createNavaraGlobe({ const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { mobile: Boolean(view.isMobileOptimized?.()), }); - const look = buildNavaraLook(view, plugin, plan); + const look = buildNavaraLook(view, plugin, plan, undefined, { + forEachLuminanceUniform, + }); engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, @@ -133,8 +129,14 @@ export async function createNavaraGlobe({ engine.frames.preRender.addEventListener(() => { if (!moving && !groundSettled && engine.surface.tilesLoaded()) readGround(); + const placed = readCameraPlace(camera); if ( - look.frame(cameraHeight(camera), moving, ground, cameraPlace(camera)) + look.frame( + placed ? lookCarto.height : Number.NaN, + moving, + ground, + placed ? lookPlace : null, + ) ) engine.render.request('navara-look'); }), diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index f79ed824d..571894777 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -65,6 +65,71 @@ export function isIdle(view) { return view._isIdle === true; } +/** + * The cinematic look's atmosphere scale (look.js createLuminanceScaler): + * the radiance-to-luminance factor uniforms of the takram materials behind + * the look's sky, stars, aerial perspective and clouds handles. Private: + * - sky, stars: handle.ref.raw (the three object) .material.uniforms; + * - aerial perspective: handle.ref.raw.rawEffect.uniforms (a Map); + * - clouds: handle.ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms + * (the clouds swap that material when their quality changes). + * Calls `visit(key, uniform, scaled)` for each, scaled false for the stars. + * A handle whose object is built but has lost a path fails loudly: it + * throws in the dev server and warns once in a build. Upstream ask: a sky + * luminance or atmosphere exposure that leaves unlit content alone. + */ +export const LUMINANCE_UNIFORMS = Object.freeze([ + 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', + 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE', +]); +const DEV = Boolean(import.meta.env?.DEV); +const reported = new Set(); + +function luminancePathLost(name) { + const message = `Navara ${NAVARA_INTERNALS_VERSION}: the ${name} luminance uniforms are not where internals.js expects them`; + if (DEV) throw new Error(message); + if (reported.has(name)) return; + reported.add(name); + console.warn(`[globe] ${message}; the atmosphere stays unscaled there`); +} + +function visitLuminance(name, raw, uniforms, scaled, visit) { + if (!raw) return; // not built yet + const get = (key) => + uniforms instanceof Map ? uniforms.get(key) : uniforms?.[key]; + const sun = get(LUMINANCE_UNIFORMS[0]); + const sky = get(LUMINANCE_UNIFORMS[1]); + if (!sun?.value?.isVector3 || !sky?.value?.isVector3) { + luminancePathLost(name); + return; + } + visit(LUMINANCE_UNIFORMS[0], sun, scaled); + visit(LUMINANCE_UNIFORMS[1], sky, scaled); +} + +export function forEachLuminanceUniform(handles, visit) { + const sky = handles.sky?.ref?.raw; + visitLuminance('sky', sky, sky?.material?.uniforms, true, visit); + const stars = handles.stars?.ref?.raw; + visitLuminance('stars', stars, stars?.material?.uniforms, false, visit); + const aerial = handles.aerialPerspective?.ref?.raw; + visitLuminance( + 'aerial perspective', + aerial, + aerial?.rawEffect?.uniforms, + true, + visit, + ); + const clouds = handles.clouds?.ref?.raw; + visitLuminance( + 'clouds', + clouds, + clouds?.rawEffect?.cloudsPass?.currentMaterial?.uniforms, + true, + visit, + ); +} + /** The hooks createNavaraEngine takes. */ export const navaraHooks = (view) => ({ retainTileGeometry, diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index c319b4f5e..4f8265619 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -2,12 +2,14 @@ // 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, 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. +// views over low ground and while the sun is up, brightens the atmosphere +// (createLuminanceScaler, uniforms from internals.js), compiles both +// night-side shader variants at startup 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 { + cloudsSunlitAt, cloudsVisibleAt, globeLightingAt, starsVisibleAt, @@ -23,9 +25,20 @@ import { * 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. + * the caller draws one more frame. It allocates nothing on a frame that + * changes nothing. + * @param {() => number} [now] the wall clock (ms), a test seam + * @param {{ forEachLuminanceUniform?: (handles: object, visit: (key: string, uniform: { value: object }, scaled: boolean) => void) => void }} [internals] + * the takram uniforms behind the atmosphere scale (internals.js); without + * it the scale does nothing. */ -export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { +export function buildNavaraLook( + view, + plugin, + plan, + now = () => Date.now(), + { forEachLuminanceUniform = null } = {}, +) { const handles = []; if (plan.pluginDefault) { const scene = plugin.addDefaultPhotorealScene(); @@ -48,28 +61,47 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { 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); + const brighten = createLuminanceScaler( + plan.atmosphereLuminance ?? 1, + forEachLuminanceUniform, + { + sky: mesh('sky'), + stars: mesh('stars'), + aerialPerspective: effect('aerialPerspective'), + clouds, + }, + ); let globeLit = false; + // Both aerial-perspective variants (with and without the night side) are + // compiled on the first two frames, during startup, so a later switch + // finds its shader compiled (a cold switch stalled a frame for up to + // 0.8 s, mid-flight on the way down from space). + let warmUp = aerial ? 2 : 0; let cloudsShown = true; + let cloudsSunlit = true; let starsShown = true; const frame = (height, moving = false, ground = Number.NaN, place = null) => { // Every drawn frame: a material Navara rebuilds comes back unscaled. - let changed = brighten(atmosphereHandles); + let changed = brighten(); const atmosphere = view.atmosphere; if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) { atmosphere.date = new Date(now()); changed = true; } + const elevation = + place && typeof atmosphere?.getSunElevation === 'function' + ? atmosphere.getSunElevation(place) + : Number.NaN; + if (warmUp) { + warmUp -= 1; + aerial.update(warmUp ? IRRADIANCE_ON : IRRADIANCE_OFF); + return true; + } if (aerial) { const next = globeLightingAt(height, globeLit); - // Switching recompiles the aerial perspective, a frame of about 0.8 s - // headless: it switches on only at rest (a flight from city to city + // Switching rebuilds the aerial perspective's shader (warmed up + // above): it switches on only at rest (a flight from city to city // through space never switches) and off as soon as the camera comes // down, before the tiles would show relit. if (next !== globeLit && !(next && moving)) { @@ -77,30 +109,28 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { // Only the aerial perspective changes: the tiles already draw plain // albedo, so view.lit stays (toggling it recompiles every tile // material, a frame of about 0.8 s mid-flight). - aerial.update({ aerialPerspective: { irradiance: next } }); + aerial.update(next ? IRRADIANCE_ON : IRRADIANCE_OFF); changed = true; } } - // Clouds only for a camera below them over low ground; switching them - // costs no recompile, so it follows the camera in motion too. + // Clouds only for a camera below them over low ground, and while the + // sun is up at the camera (after sunset they read as dark puffs); + // switching them costs no recompile, so it follows the camera in motion. if (clouds) { - const next = cloudsVisibleAt(height, ground, cloudsShown); + cloudsSunlit = cloudsSunlitAt(elevation, cloudsSunlit); + const next = cloudsSunlit && cloudsVisibleAt(height, ground, cloudsShown); if (next !== cloudsShown) { cloudsShown = next; - clouds.update({ visible: next }); + clouds.update(next ? VISIBLE : HIDDEN); parkCloudLayers(view.atmosphere, next); 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 }); + stars.update(next ? VISIBLE : HIDDEN); changed = true; } } @@ -109,6 +139,69 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { return { handles, frame }; } +const IRRADIANCE_ON = Object.freeze({ + aerialPerspective: Object.freeze({ irradiance: true }), +}); +const IRRADIANCE_OFF = Object.freeze({ + aerialPerspective: Object.freeze({ irradiance: false }), +}); +const VISIBLE = Object.freeze({ visible: true }); +const HIDDEN = Object.freeze({ visible: false }); + +/** + * The atmosphere scale (viewOptions.js ATMOSPHERE_LUMINANCE_SCALE): each + * luminance factor is set to `scale` times the base value recorded the + * first time it is seen, never multiplied in place, so a vector Navara + * shares between materials, or copies into a rebuilt one, is never scaled + * twice. The stars keep the base value: when they share the sky's vector, + * they get their own. Returns `apply()`, true when it changed a uniform. + */ +export function createLuminanceScaler(scale, forEachUniform, handles) { + if (scale === 1 || !forEachUniform) return () => false; + const bases = new Map(); // key -> { base, target } + const scaledVectors = new WeakSet(); + let changed = false; + const entry = (key, value) => { + let found = bases.get(key); + if (!found) { + found = { + base: value.clone(), + target: value.clone().multiplyScalar(scale), + }; + bases.set(key, found); + } + return found; + }; + const record = (key, uniform) => entry(key, uniform.value); + const scaleOne = (key, uniform, scaled) => { + if (!scaled) return; + const { target } = entry(key, uniform.value); + scaledVectors.add(uniform.value); + if (uniform.value.equals(target)) return; + uniform.value.copy(target); + changed = true; + }; + const keepBase = (key, uniform, scaled) => { + if (scaled) return; + const { base } = entry(key, uniform.value); + if (scaledVectors.has(uniform.value)) { + uniform.value = base.clone(); + changed = true; + } else if (!uniform.value.equals(base)) { + uniform.value.copy(base); + changed = true; + } + }; + return () => { + changed = false; + // Bases first, from the values as Navara built them. + forEachUniform(handles, record); + forEachUniform(handles, scaleOne); + forEachUniform(handles, keepBase); + return changed; + }; +} + // The clouds hand the aerial perspective their last output through the // atmosphere (overlay, shadow, shadow length) once, when they first draw. // A hidden clouds pass leaves those in place, and the aerial perspective diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index fd99924b1..6d2a12974 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -37,6 +37,49 @@ const plugin = (calls) => ({ }, }); +/** A cinematic look past its two warm-up frames (updates cleared). */ +function warmCinematic(view, internals) { + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + internals, + ); + look.frame(900); + look.frame(900); + for (const entry of view.added) entry.updates.length = 0; + return look; +} + +test('the night-side shader variants are both compiled on the first two frames', () => { + const view = fakeView(0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); + const aerial = view.added.find(({ desc }) => + Object.hasOwn(desc, 'aerialPerspective'), + ); + assert.equal(look.frame(900), true); + assert.equal(look.frame(900), true); + assert.deepEqual(aerial.updates, [ + { aerialPerspective: { irradiance: true } }, + { aerialPerspective: { irradiance: false } }, + ]); + assert.equal(look.frame(900), false, 'then the usual rules'); + const minimal = fakeView(0); + const plain = buildNavaraLook( + minimal, + plugin([]), + navaraScenePlan('minimal'), + () => 0, + ); + assert.equal(plain.frame(900), false, 'no aerial perspective: no warm-up'); +}); + test('the photoreal look is the default plugin scene, untouched', () => { const calls = []; const view = fakeView(0); @@ -68,12 +111,7 @@ 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 look = warmCinematic(view); const effect = (name) => view.added.find(({ desc }) => Object.hasOwn(desc, name)); assert.equal(look.frame(900), false, 'nothing changes at street level'); @@ -92,12 +130,7 @@ 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 = warmCinematic(view); 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); @@ -119,12 +152,7 @@ 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 look = warmCinematic(view); const aerial = view.added.find(({ desc }) => Object.hasOwn(desc, 'aerialPerspective'), ); @@ -162,7 +190,9 @@ const vec = (x, y, z) => ({ y, z, copy(v) { - Object.assign(this, { x: v.x, y: v.y, z: v.z }); + this.x = v.x; + this.y = v.y; + this.z = v.z; return this; }, multiplyScalar(k) { @@ -174,103 +204,142 @@ const vec = (x, y, z) => ({ clone() { return vec(this.x, this.y, this.z); }, + equals(v) { + return v.x === this.x && v.y === this.y && v.z === 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) }, +const SUN = 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE'; +const SKY = 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE'; +const factors = () => ({ + [SUN]: { value: vec(1.3, 0.9, 0.9) }, + [SKY]: { 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 } } }, +/** A stand-in for internals.js forEachLuminanceUniform over fake materials. */ +function fakeInternals(materials) { + return { + forEachLuminanceUniform(handles, visit) { + for (const [name, scaled] of [ + ['sky', true], + ['stars', false], + ['aerialPerspective', true], + ['clouds', true], + ]) { + if (!handles[name]) continue; + const uniforms = materials[name]; + if (!uniforms) continue; + for (const key of [SUN, SKY]) visit(key, uniforms[key], scaled); + } }, }; - return { sky, aerial, clouds, stars }; } -test('the cinematic atmosphere is brighter, the stars and tiles are not', () => { +const k = navaraScenePlan('cinematic').atmosphereLuminance; +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-12, `${a} vs ${b}`); + +test('the atmosphere factors are set to scale × base, never compounded', () => { + const materials = { + sky: factors(), + stars: factors(), + aerialPerspective: factors(), + clouds: factors(), + }; 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, + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + fakeInternals(materials), ); - 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 }; + look.frame(900); + near(materials.sky[SKY].value.x, 1.5 * k); + near(materials.sky[SUN].value.x, 1.3 * k); + near(materials.aerialPerspective[SKY].value.z, 0.8 * k); + near(materials.clouds[SKY].value.x, 1.5 * k); + assert.equal(materials.stars[SKY].value.x, 1.5, 'the stars keep the base'); + look.frame(900); + look.frame(900); + assert.equal(look.frame(900), false, 'nothing to change once applied'); + near(materials.sky[SKY].value.x, 1.5 * k); + // A rebuilt material copying the scaled vector is not scaled again. + materials.clouds = { + [SUN]: { value: materials.sky[SUN].value.clone() }, + [SKY]: { value: materials.sky[SKY].value.clone() }, + }; + assert.equal(look.frame(900), false); + near(materials.clouds[SKY].value.x, 1.5 * k); + // One built from scratch is. + materials.clouds = factors(); assert.equal(look.frame(900), true); - assert.ok(Math.abs(sky(fresh).x - 1.5 * k) < 1e-12); + near(materials.clouds[SKY].value.x, 1.5 * k); }); -test('a look without the scale and an atmosphere not built yet are left alone', () => { +test('vectors shared between materials are scaled once; stars sharing them get their own', () => { + // Separate uniforms holding one vector each, as Navara 0.2.1 builds them. + const shared = factors(); + const sharing = () => ({ + [SUN]: { value: shared[SUN].value }, + [SKY]: { value: shared[SKY].value }, + }); + const materials = { + sky: shared, + stars: sharing(), + aerialPerspective: sharing(), + clouds: sharing(), + }; + const view = fakeView(0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + fakeInternals(materials), + ); + look.frame(900); + look.frame(900); + near(shared[SKY].value.x, 1.5 * k); + assert.notEqual(materials.stars[SKY].value, shared[SKY].value); + assert.equal(materials.stars[SKY].value.x, 1.5); + assert.equal(materials.stars[SUN].value.x, 1.3); +}); + +test('a look without the scale, or without internals, leaves the factors alone', () => { + const materials = { sky: factors(), stars: factors() }; const view = fakeView(0); const look = buildNavaraLook( view, plugin([]), navaraScenePlan('minimal'), () => 0, + fakeInternals(materials), ); - 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, + assert.equal(materials.sky[SKY].value.x, 1.5); + const other = fakeView(0); + const cinematic = buildNavaraLook( + other, plugin([]), navaraScenePlan('cinematic'), () => 0, ); - assert.equal(other.frame(900), false, 'no raw objects yet: nothing to scale'); + cinematic.frame(900); + cinematic.frame(900); + assert.equal(cinematic.frame(900), false, 'no internals: no 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 look = warmCinematic(view); 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); + look.frame(900, false, 0, place); assert.deepEqual(stars.updates.at(-1), { visible: true }); elevation = 40; look.frame(900, false, 0, place); @@ -279,11 +348,31 @@ test('the cinematic stars hide in a daylit sky and show at night and from space' 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'); + look.frame(900, false, 0, place); assert.deepEqual(stars.updates.at(-1), { visible: false }); }); -test('the minimal look keeps its stars whatever the sun', () => { +test('the cinematic clouds hide after sunset at the camera', () => { + const view = fakeView(0); + let elevation = 20; + view.atmosphere.getSunElevation = () => elevation; + const look = warmCinematic(view); + const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); + const place = { lat: 40.7, lng: -74 }; + assert.equal(look.frame(900, false, 0, place), true, 'the stars hide'); + assert.deepEqual(clouds.updates, []); + elevation = -1; + assert.equal(look.frame(900, false, 0, place), true); + assert.deepEqual(clouds.updates, [{ visible: false }]); + elevation = 1; + look.frame(900, false, 0, place); + assert.deepEqual(clouds.updates, [{ visible: false }], 'hysteresis'); + elevation = 3; + look.frame(900, false, 0, place); + assert.deepEqual(clouds.updates.at(-1), { visible: true }); +}); + +test('the minimal look keeps its stars and clouds whatever the sun', () => { const view = fakeView(0); view.atmosphere.getSunElevation = () => 40; const look = buildNavaraLook( @@ -295,31 +384,3 @@ test('the minimal look keeps its stars whatever the sun', () => { 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 521d56f9b..7474fb8dc 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -100,11 +100,10 @@ export function cloudsVisibleAt(height, ground, current = true) { // 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 +// perspective; not the stars) 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 @@ -114,6 +113,19 @@ export const STARS_HIDE_SUN_ABOVE_DEG = -4; export const STARS_SPACE_ENTER_M = 100_000; export const STARS_SPACE_EXIT_M = 80_000; +// The cinematic clouds hide once the sun at the camera sets (below 0°): +// unlit, they read as dark puffs over a dusk sky. They show again above 2°. +export const CLOUDS_HIDE_SUN_BELOW_DEG = 0; +export const CLOUDS_SHOW_SUN_ABOVE_DEG = 2; + +/** Whether the sun is up enough for the cinematic clouds; NaN keeps `current`. */ +export function cloudsSunlitAt(sunElevation, current = true) { + if (!Number.isFinite(sunElevation)) return current; + return current + ? sunElevation >= CLOUDS_HIDE_SUN_BELOW_DEG + : sunElevation > CLOUDS_SHOW_SUN_ABOVE_DEG; +} + /** * Whether the cinematic stars show for a camera at `height` (metres above * the ellipsoid) with the sun `sunElevation` degrees above its horizon; an @@ -140,13 +152,13 @@ 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: 40, - // divided by the atmosphere scale the stars share. + // globe from space keep a visible starfield next to a sunlit Earth. The + // atmosphere scale leaves the stars at their base factor (look.js). meshes: [ { sky: {} }, { stars: { - intensity: STAR_INTENSITY / ATMOSPHERE_LUMINANCE_SCALE, + intensity: 120, pointSize: 2, }, }, diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index 67d66d05b..3abf113c8 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -22,6 +22,9 @@ import { STARS_SPACE_ENTER_M, STARS_SPACE_EXIT_M, starsVisibleAt, + CLOUDS_HIDE_SUN_BELOW_DEG, + CLOUDS_SHOW_SUN_ABOVE_DEG, + cloudsSunlitAt, } from './viewOptions.js'; test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => { @@ -84,7 +87,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 / ATMOSPHERE_LUMINANCE_SCALE, pointSize: 2 } }, + { stars: { intensity: 120, pointSize: 2 } }, ]); assert.deepEqual(plan.lights, [ { skyLightProbe: {} }, @@ -214,3 +217,12 @@ test('stars show at night and from space, not in a daylit sky, with hysteresis', assert.ok(STARS_SHOW_SUN_BELOW_DEG < STARS_HIDE_SUN_ABOVE_DEG); assert.ok(STARS_SPACE_EXIT_M < STARS_SPACE_ENTER_M); }); + +test('the clouds hide once the sun sets at the camera, with hysteresis', () => { + assert.equal(cloudsSunlitAt(40, true), true); + assert.equal(cloudsSunlitAt(CLOUDS_HIDE_SUN_BELOW_DEG - 0.1, true), false); + assert.equal(cloudsSunlitAt(1, false), false, 'inside the gap'); + assert.equal(cloudsSunlitAt(1, true), true, 'inside the gap'); + assert.equal(cloudsSunlitAt(CLOUDS_SHOW_SUN_ABOVE_DEG + 0.1, false), true); + assert.equal(cloudsSunlitAt(Number.NaN, false), false, 'unknown keeps it'); +});