diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 674d2fb2e..0fc35c83c 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -154,6 +154,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) { styles: createNavaraStyles(view, { shaders: STYLE_SHADERS, onStarsDimmed: (amount) => look.setStarsDimmed(amount), + onActiveChange: (active) => look.setStyled(active), }), movers: createNavaraMovers(view, { overlay, @@ -201,7 +202,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) { const placed = readCameraPlace(camera); const landscape = placed && - plan.globeLighting && + plan.inspectionDaylight && automaticLandscape && worldClock.isLive() && (engine.maps.baseKind === 'sentinel2' || diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index 7f212addf..70eeb4c31 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -1,22 +1,19 @@ // Builds a Navara look (viewOptions.js navaraScenePlan) on an initialised // view and keeps it current while the view renders: the sun follows `now` -// (the world time in the app, shared with the HUD's SUN elevation); the cinematic look -// lights the globe -// from the atmosphere when seen from space, places its clouds above the terrain 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. +// (the world time in the app, shared with the HUD's SUN elevation). The sky +// and clouds keep the cinematic lighting; map colours stay readable at any +// time and height. All of it runs only on frames the view draws anyway, so +// an idle view stays idle. import { cloudsSunlitAt, - ACES_FILMIC, AGX, + LINEAR, + MAP_AERIAL_PERSPECTIVE, cloudTerrainOffset, landscapeSunTime, CUMULUS_BASES, LOCAL_CLOUD_COVERAGE, - globeLightingAt, starsVisibleAt, sunDateStale, } from './viewOptions.js'; @@ -46,15 +43,21 @@ export function buildNavaraLook( { forEachLuminanceUniform = null } = {}, ) { const handles = []; + let scene = null; if (plan.pluginDefault) { - const scene = plugin.addDefaultPhotorealScene(); + scene = plugin.addDefaultPhotorealScene(); handles.push(...Object.values(scene).filter(Boolean)); } else { for (const desc of plan.meshes) handles.push(view.addMesh(desc)); for (const desc of plan.lights) handles.push(view.addLight(desc)); for (const desc of plan.effects) handles.push(view.addEffect(desc)); } + // Set before tiles compile: neither forward nor deferred sunlight should + // relight imagery that already contains its own shadows. + view.lit = false; + view.toneMappingExposure = 1; const effect = (name) => { + if (scene) return scene[name] ?? null; const at = plan.effects.findIndex((desc) => Object.hasOwn(desc, name)); return at < 0 ? null @@ -64,7 +67,10 @@ export function buildNavaraLook( const at = plan.meshes.findIndex((desc) => Object.hasOwn(desc, name)); return at < 0 ? null : handles[at]; }; - const aerial = plan.globeLighting ? effect('aerialPerspective') : null; + if (scene) + effect('aerialPerspective')?.update({ + aerialPerspective: { ...MAP_AERIAL_PERSPECTIVE }, + }); const clouds = effect('clouds'); const stars = plan.daylightHidesStars ? mesh('stars') : null; const brighten = createLuminanceScaler( @@ -77,19 +83,14 @@ export function buildNavaraLook( 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 landscape = false; let daylightTime = null; - let fillLight; - let fill = 0; const tone = effect('toneMapping'); + const toneMode = + plan.effects.find((desc) => desc.toneMapping)?.toneMapping.mode ?? AGX; + let styled = false; let starsShown = true; const frame = ( @@ -100,17 +101,14 @@ export function buildNavaraLook( landscapeRequested = false, ) => { // Every drawn frame: a material Navara rebuilds comes back unscaled. - let changed = brighten(!landscapeRequested); - if (landscapeRequested !== landscape && tone) { + let changed = brighten(); + if (landscapeRequested !== landscape) { landscape = landscapeRequested; daylightTime = null; - view.toneMappingExposure = landscape ? 10 : 1; - tone.update({ toneMapping: { mode: landscape ? AGX : ACES_FILMIC } }); - changed = true; } const atmosphere = view.atmosphere; - // Choose inspection daylight once. Rotating the camera must not rotate - // the terminator across the geography. + // Choose inspection daylight once so rotating the camera leaves the + // sky's sun direction fixed. if (landscape && place && daylightTime === null) daylightTime = landscapeSunTime(now(), place.lng); const time = daylightTime ?? now(); @@ -122,50 +120,6 @@ export function buildNavaraLook( place && typeof atmosphere?.getSunElevation === 'function' ? atmosphere.getSunElevation(place) : Number.NaN; - // Keep the map legible from orbit and when descending into darkness. - // At low altitude the fill increases from sunset to civil dusk. Every - // base map takes the night fill: the tiles are lit by the sun and the - // sky, so a street map turned black after dusk, where Cesium (the - // reference) keeps it legible; the orbital fill stays the landscape's. - const orbitalFill = Number.isFinite(height) - ? Math.min(1, Math.max(0, (height - 50_000) / 250_000)) - : 0; - const nightFill = Number.isFinite(elevation) - ? Math.min(1, Math.max(0, -elevation / 6)) - : 0; - const nextFill = landscape ? Math.max(orbitalFill, nightFill) : nightFill; - if (nextFill !== fill) { - fill = nextFill; - if (fillLight) fillLight.update({ ambient: { intensity: fill } }); - else fillLight = view.addLight({ ambient: { intensity: fill } }); - changed = true; - } - const opacity = aerial?.ref?.raw?.rawEffect?.blendMode.opacity; - const strength = 1 - 0.2 * fill; - if (opacity && opacity.value !== strength) { - opacity.value = strength; - changed = true; - } - if (warmUp) { - warmUp -= 1; - aerial.update(warmUp ? IRRADIANCE_ON : IRRADIANCE_OFF); - return true; - } - if (aerial) { - const next = globeLightingAt(height, globeLit); - // 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)) { - globeLit = next; - // 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(next ? IRRADIANCE_ON : IRRADIANCE_OFF); - changed = true; - } - } // Terrain-relative layers keep distant cumulus above mountain ridges. // Keep the deck visible from aircraft altitude, then clear it for regional views. if (clouds) { @@ -212,6 +166,14 @@ export function buildNavaraLook( return { handles, frame, + // Sensor shaders expect display colours, as on Cesium. A filmic curve + // before their gain/thresholds changes both contrast and clipping. + setStyled(active) { + if (!tone || active === styled) return false; + styled = active; + tone.update({ toneMapping: { mode: styled ? LINEAR : toneMode } }); + return true; + }, resetDaylight() { daylightTime = null; }, @@ -246,12 +208,6 @@ export function buildNavaraLook( /** Navara's own stars intensity, for a look that does not set one. */ const NAVARA_STARS_INTENSITY = 10; -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 }); diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index 54b8cd912..3881ced60 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -1,7 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildNavaraLook } from './look.js'; -import { navaraScenePlan } from './viewOptions.js'; +import { navaraScenePlan, MAP_AERIAL_PERSPECTIVE } from './viewOptions.js'; +import { createStylesPort } from './stylePass.js'; +import { STYLES } from '../../ui/visualPresets.js'; import { createWorldClock } from '../../time/worldClock.js'; function fakeView(dateMs) { @@ -59,7 +61,7 @@ test('the sky follows world time through seek and live without moving live feeds assert.equal(view.atmosphere.date.getTime(), wall); }); -/** A cinematic look past its two warm-up frames (updates cleared). */ +/** A cinematic look after its initial visibility updates (cleared). */ function warmCinematic(view, internals) { const look = buildNavaraLook( view, @@ -74,35 +76,24 @@ function warmCinematic(view, internals) { 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('map colours are unlit before the first frame, with no night-side warm-up', () => { + for (const name of ['minimal', 'cinematic']) { + const view = fakeView(0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan(name), + () => 0, + ); + assert.equal(view.lit, false); + assert.equal(view.toneMappingExposure, 1); + assert.equal(look.frame(900), false); + assert.equal(look.frame(900), false); + assert.ok(view.added.every(({ updates }) => updates.length === 0)); + } }); -test('the photoreal look is the default plugin scene, untouched', () => { +test('the photoreal look keeps its plugin scene with the same map colour rules', () => { const calls = []; const view = fakeView(0); const look = buildNavaraLook( @@ -114,7 +105,7 @@ test('the photoreal look is the default plugin scene, untouched', () => { assert.deepEqual(calls, [[]]); assert.deepEqual(look.handles, [{ id: 'sky' }]); assert.equal(look.frame(8_000_000), false, 'no globe lighting'); - assert.equal(view.lit, true); + assert.equal(view.lit, false); }); test('the cinematic look adds its descriptors in order', () => { @@ -131,23 +122,59 @@ 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', () => { +test('all styles retain map colours across height, sunlight, motion and daylight inspection', () => { const view = fakeView(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'); - assert.equal(look.frame(8_000_000), true); - assert.equal(view.lit, true, 'no tile material recompiles'); - assert.deepEqual(effect('aerialPerspective').updates, [ - { aerialPerspective: { irradiance: true } }, - ]); - assert.equal(look.frame(7_000_000), false, 'no change while it stays up'); - assert.equal(look.frame(1_200_000), false, 'hysteresis'); - assert.equal(look.frame(500_000), true); - assert.deepEqual(effect('aerialPerspective').updates.at(-1), { - aerialPerspective: { irradiance: false }, + let now = 0; + let elevation = 40; + view.atmosphere.getSunElevation = () => elevation; + const look = buildNavaraLook(view, plugin([]), navaraScenePlan(), () => now); + const port = createStylesPort(view, { + shaders: STYLES, + onActiveChange: (active) => look.setStyled(active), }); + const aerial = view.added.find(({ desc }) => desc.aerialPerspective); + const tone = view.added.find(({ desc }) => desc.toneMapping); + assert.deepEqual(aerial.desc.aerialPerspective, MAP_AERIAL_PERSPECTIVE); + for (const style of ['normal', ...Object.keys(STYLES), 'normal']) { + for (const name of Object.keys(STYLES)) + port.set(name, { intensity: name === style ? 1 : 0, uniforms: {} }); + for (const landscape of [true, false]) { + for (elevation of [40, -3, -25, -90]) { + now += 6 * 3600_000; + for (const height of [ + 600, + 12_000, + 100_000, + 1_000_000, + 1_500_000, + 1_700_000, + 8_000_000, + 19_000_000, + Number.NaN, + ]) { + for (const moving of [true, false]) { + look.frame(height, moving, 0, { lat: 40, lng: 10 }, landscape); + assert.equal(view.lit, false); + assert.equal(view.toneMappingExposure, 1); + assert.equal( + aerial.updates.length, + 0, + 'no height or motion can relight the map', + ); + assert.equal( + (tone.updates.at(-1) ?? tone.desc).toneMapping.mode, + style === 'normal' ? 6 : 0, + style, + ); + } + } + } + } + } + assert.ok( + view.added.every(({ desc }) => !desc.ambient), + 'no additive fill can wash out the map', + ); }); test('cumulus rises over mountain terrain and leaves the orbital view clear', () => { @@ -169,89 +196,53 @@ test('cumulus rises over mountain terrain and leaves the orbital view clear', () assert.deepEqual(clouds.updates.at(-1), { visible: true }, 'above the deck'); }); -test('globe lighting switches on only at rest, and off even in motion', () => { +test('plugin tone mapping restores after styles and its aerial pass preserves map colours', () => { const view = fakeView(0); - const look = warmCinematic(view); - const aerial = view.added.find(({ desc }) => - Object.hasOwn(desc, 'aerialPerspective'), + const aerial = view.addEffect({ aerialPerspective: {} }); + const tone = view.addEffect({ toneMapping: { mode: 7 } }); + const look = buildNavaraLook( + view, + { + addDefaultPhotorealScene: () => ({ + aerialPerspective: aerial, + toneMapping: tone, + }), + }, + navaraScenePlan('photoreal'), + () => 0, ); - look.frame(20_000, true); - look.frame(2_300_000, true); - 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, [ + { aerialPerspective: MAP_AERIAL_PERSPECTIVE }, + ]); + assert.equal(look.setStyled(true), true); + assert.equal(look.setStyled(true), false, 'no recompile on uniform updates'); + look.frame(8_000_000); + assert.equal(view.toneMappingExposure, 1); + assert.equal(look.setStyled(false), true); + assert.deepEqual(tone.updates, [ + { toneMapping: { mode: 0 } }, + { toneMapping: { mode: 7 } }, + ]); }); -test('daylight inspection restores live exposure, tone mapping and time when left', () => { +test('daylight inspection changes sky time without changing map exposure or tone mapping', () => { const view = fakeView(0); const look = warmCinematic(view); - const tone = view.added.find(({ desc }) => - Object.hasOwn(desc, 'toneMapping'), - ); - const aerial = view.added.find(({ desc }) => - Object.hasOwn(desc, 'aerialPerspective'), - ); - const opacity = { value: 1 }; - aerial.ref = { raw: { rawEffect: { blendMode: { opacity } } } }; + const tone = view.added.find(({ desc }) => desc.toneMapping); const fuji = { lat: 35.2599, lng: 138.6574 }; look.frame(6508, false, 2600, fuji, true); - assert.equal(view.toneMappingExposure, 10); - assert.equal(opacity.value, 1); - assert.equal(tone.updates.at(-1).toneMapping.mode, 7); + assert.equal(view.toneMappingExposure, 1); assert.ok( Math.abs(view.atmosphere.date.getTime() / 3_600_000 + fuji.lng / 15 - 13) < 1e-6, ); look.frame(750_149, false, 0, fuji, true); - assert.ok(opacity.value < 1); - const ambient = view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient')); - assert.equal(ambient.desc.ambient.intensity, 1); look.frame(6508, false, 2600, fuji, false); - assert.equal(ambient.updates.at(-1).ambient.intensity, 0); assert.equal(view.toneMappingExposure, 1); - assert.equal(opacity.value, 1); - assert.equal(tone.updates.at(-1).toneMapping.mode, 6); + assert.deepEqual(tone.updates, []); assert.equal(view.atmosphere.date.getTime(), 0); }); -test('the dark side retains satellite imagery down to the surface', () => { - const view = fakeView(0); - const look = warmCinematic(view); - const aerial = view.added.find(({ desc }) => - Object.hasOwn(desc, 'aerialPerspective'), - ); - const opacity = { value: 1 }; - aerial.ref = { raw: { rawEffect: { blendMode: { opacity } } } }; - const origin = { lat: 21.975, lng: 20.9038 }; - const fuji = { lat: 35.2599, lng: 138.6574 }; - let elevation = 40; - view.atmosphere.getSunElevation = () => elevation; - look.frame(33_529_370, false, 0, origin, true); - const sun = view.atmosphere.date; - const ambient = view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient')); - elevation = -30; - for (const height of [2_000_000, 100_000, 50_000, 6508, 600]) { - look.frame(height, true, 0, fuji, true); - assert.equal(opacity.value, 0.8); - assert.equal( - ambient.updates.length, - 0, - 'the orbital fill remains at full strength', - ); - assert.equal(view.atmosphere.date, sun); - } - elevation = -3; - look.frame(600, false, 0, fuji, true); - assert.equal(ambient.updates.at(-1).ambient.intensity, 0.5); - elevation = 10; - look.frame(600, false, 0, origin, true); - assert.equal(ambient.updates.at(-1).ambient.intensity, 0); - assert.equal(opacity.value, 1); -}); - test('inspection sunlight stays fixed through orbit, zoom and move end', () => { const view = fakeView(0); const look = warmCinematic(view); @@ -270,29 +261,6 @@ test('inspection sunlight stays fixed through orbit, zoom and move end', () => { assert.equal(view.atmosphere.date, restored); }); -test('a street map keeps the night fill after dusk, as Cesium keeps it lit', () => { - // OSM over Austin at 12 km on the live sun: not a landscape, so the sun - // is the wall clock's and the map has no orbital fill. - const view = fakeView(0); - const look = warmCinematic(view); - const austin = { lat: 30.27, lng: -97.74 }; - let elevation = 40; - view.atmosphere.getSunElevation = () => elevation; - look.frame(12_000, false, 0, austin, false); - const ambient = () => - view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient')); - assert.equal(ambient(), undefined, 'no fill in daylight'); - elevation = -3; - look.frame(12_000, false, 0, austin, false); - assert.equal(ambient().desc.ambient.intensity, 0.5, 'half at dusk'); - elevation = -40; - look.frame(12_000, false, 0, austin, false); - assert.equal(ambient().updates.at(-1).ambient.intensity, 1, 'full at night'); - elevation = 10; - look.frame(12_000, false, 0, austin, false); - assert.equal(ambient().updates.at(-1).ambient.intensity, 0, 'none by day'); -}); - 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); diff --git a/src/globe/navara/stylePass.js b/src/globe/navara/stylePass.js index faf870d30..7a57ce1f9 100644 --- a/src/globe/navara/stylePass.js +++ b/src/globe/navara/stylePass.js @@ -241,9 +241,14 @@ export class GevStylesPass extends Pass { * its Milky Way the same way). * The caller registers the effect (stylePasses.js createNavaraStyles). * @param {object} view an initialised ThreeView - * @param {{ shaders: Record, onStarsDimmed?: (amount: number) => void }} options + * `onActiveChange` selects display tone mapping while any style draws, + * including both sides of a crossfade. + * @param {{ shaders: Record, onStarsDimmed?: (amount: number) => void, onActiveChange?: (active: boolean) => void }} options */ -export function createStylesPort(view, { shaders, onStarsDimmed = null }) { +export function createStylesPort( + view, + { shaders, onStarsDimmed = null, onActiveChange = null }, +) { const order = Object.keys(shaders); for (const name of order) navaraStyleFragmentShader(shaders[name].fragmentShader); @@ -265,6 +270,7 @@ export function createStylesPort(view, { shaders, onStarsDimmed = null }) { }); if (name === 'anime') onStarsDimmed?.(state.intensity); const next = controller.visible().length > 0; + if (next !== shown) onActiveChange?.(next); if (!handle) { if (!next) return; handle = view.addEffect({ @@ -285,6 +291,7 @@ export function createStylesPort(view, { shaders, onStarsDimmed = null }) { destroy() { handle?.delete(); handle = null; + if (shown) onActiveChange?.(false); shown = false; if (states.get('anime')?.intensity) onStarsDimmed?.(0); states.clear(); diff --git a/src/globe/navara/stylePass.test.mjs b/src/globe/navara/stylePass.test.mjs index a987aac5b..c32435b64 100644 --- a/src/globe/navara/stylePass.test.mjs +++ b/src/globe/navara/stylePass.test.mjs @@ -260,3 +260,28 @@ test('the styles port adds the pass once, hides it at 0 and fades the stars with assert.equal(view.deleted, 1); assert.deepEqual(dimmed, [0.5, 0, 0.9, 0], 'destroy brings the stars back'); }); + +test('display tone mapping spans a crossfade and restores only after the last style leaves', () => { + const active = []; + const port = createStylesPort(fakeView(), { + shaders: STYLES, + onActiveChange: (value) => active.push(value), + }); + const set = (name, intensity) => port.set(name, { intensity, uniforms: {} }); + set('retro', 0.001); + assert.deepEqual(active, [], 'below the drawing threshold'); + set('retro', 0.1); + set('surveillance', 0.5); + set('retro', 0); + set('thermal', 0.5); + set('surveillance', 0); + set('thermal', 1); + assert.deepEqual(active, [true], 'uniform changes never recompile the look'); + set('thermal', 0); + assert.deepEqual(active, [true, false]); + set('noir', 1); + port.destroy(); + assert.deepEqual(active, [true, false, true, false]); + port.destroy(); + assert.equal(active.length, 4, 'destroy restores once'); +}); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index 12694e1c0..dfa3a0675 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -61,6 +61,15 @@ export function resolveNavaraLook(search = '') { // postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7). export const ACES_FILMIC = 6; export const AGX = 7; +export const LINEAR = 0; + +// Map textures already encode their lighting. Atmosphere belongs in the sky +// and clouds, not in a second lighting or haze pass over those colours. +export const MAP_AERIAL_PERSPECTIVE = Object.freeze({ + irradiance: false, + transmittance: false, + inscatter: false, +}); // Pick early afternoon at the starting landscape longitude. The look keeps // this timestamp while the user explores; telemetry uses its own clock. @@ -71,25 +80,6 @@ export function landscapeSunTime(now, longitude) { return Math.floor((now + offset) / day) * day + 13 * hour - offset; } -// Camera heights (m) where the cinematic look switches the globe to -// atmosphere lighting (night side and terminator) and back. The gap keeps a -// camera hovering near one height from toggling. -export const GLOBE_LIGHTING_ENTER_M = 1_500_000; -export const GLOBE_LIGHTING_EXIT_M = 1_000_000; - -/** - * Whether the cinematic look lights the globe from the atmosphere (deferred - * irradiance: a real night side) at this camera height. Up close the Google - * tiles keep their baked lighting: relit, they turn grey and dark (their - * shadows and shading would count twice). Hysteresis between the two heights. - */ -export function globeLightingAt(height, current = false) { - if (!Number.isFinite(height)) return current; - return current - ? height > GLOBE_LIGHTING_EXIT_M - : height >= GLOBE_LIGHTING_ENTER_M; -} - // Navara cloud layers use ellipsoid heights. Over uplands raise the two // cumulus layers together; at world scale a single ground sample no longer // represents the whole view. Quantize the offset to avoid tiny layer updates. @@ -100,15 +90,14 @@ export function cloudTerrainOffset(height, ground) { return Math.ceil(Math.max(0, ground - 500) / 500) * 750; } -// Navara's sky, clouds and haze come from the atmosphere model in physical +// Navara's sky and clouds 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; not the stars) by this scale instead of raising -// the exposure, so the sky turns daylight blue and the tiles keep their -// colours. +// the atmosphere's radiance-to-luminance factors (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; // Stars show from space or once the sun is well below the horizon at the @@ -172,11 +161,7 @@ const cinematic = (mobile) => ({ // would cost every frame and darken nothing the tiles show). lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }], effects: [ - // Depth-reconstructed normals: Navara 0.2.1's G-buffer normals are zero - // for Google tiles and the globe, which turns atmosphere lighting black. - // albedoScale brightens that lighting's day side to the photoreal - // look's (it scales the albedo only where the atmosphere lights it). - { aerialPerspective: { useNormalBuffer: false, albedoScale: 2 } }, + { aerialPerspective: { ...MAP_AERIAL_PERSPECTIVE } }, ...(mobile ? [] : [ @@ -203,7 +188,7 @@ const cinematic = (mobile) => ({ { toneMapping: { mode: ACES_FILMIC } }, mobile ? { fxaa: {} } : { smaa: {} }, ], - globeLighting: true, + inspectionDaylight: true, atmosphereLuminance: ATMOSPHERE_LUMINANCE_SCALE, daylightHidesStars: true, }); @@ -215,7 +200,7 @@ const PLANS = { // Match cinematic: Google tiles already carry baked sunlight. lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }], effects: [], - globeLighting: false, + inspectionDaylight: false, atmosphereLuminance: 1, daylightHidesStars: false, }), @@ -224,7 +209,7 @@ const PLANS = { meshes: [], lights: [], effects: [], - globeLighting: false, + inspectionDaylight: false, atmosphereLuminance: 1, daylightHidesStars: false, }), @@ -238,8 +223,7 @@ const PLANS = { * aerial perspective, lens flare, AgX tone mapping, SMAA); * - `cinematic` (the app's default): photoreal plus volumetric clouds for * mountain and coastal views, ACES tone mapping, brighter stars, no sun shadow - * maps, and atmosphere lighting of the globe from space (`globeLighting`, - * see globeLightingAt), a brighter atmosphere (ATMOSPHERE_LUMINANCE_SCALE) + * maps, 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. */ diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index 824c19852..2a875fee2 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -2,12 +2,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_NAVARA_LOOK, - GLOBE_LIGHTING_ENTER_M, - GLOBE_LIGHTING_EXIT_M, NAVARA_CONTEXT_ATTRIBUTES, NAVARA_LOOKS, cloudTerrainOffset, - globeLightingAt, navaraScenePlan, navaraViewOptions, resolveNavaraLook, @@ -61,7 +58,7 @@ test('cinematic is the default look; photoreal is the default plugin scene; mini meshes: [], lights: [], effects: [], - globeLighting: false, + inspectionDaylight: false, atmosphereLuminance: 1, daylightHidesStars: false, }); @@ -70,7 +67,7 @@ test('cinematic is the default look; photoreal is the default plugin scene; mini meshes: [{ sky: {} }, { stars: {} }], lights: [{ skyLightProbe: {} }, { sun: { castShadow: false } }], effects: [], - globeLighting: false, + inspectionDaylight: false, atmosphereLuminance: 1, daylightHidesStars: false, }); @@ -99,10 +96,10 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no 'smaa', ]); const [aerial, clouds, , toneMapping] = plan.effects; - // Forward lighting up close: no irradiance, shadow or specular terms. assert.deepEqual(aerial.aerialPerspective, { - useNormalBuffer: false, - albedoScale: 2, + irradiance: false, + transmittance: false, + inscatter: false, }); assert.equal(clouds.clouds.coverage, 0.36); assert.equal(clouds.clouds.shadows, false, 'no cloud shadow maps'); @@ -119,7 +116,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no 'cloud layers sit above the city views', ); assert.equal(toneMapping.toneMapping.mode, 6, 'ACES filmic'); - assert.equal(plan.globeLighting, true); + assert.equal(plan.inspectionDaylight, true); // Legibility: none of the effects that blur, rain on or grey the map. for (const rejected of ['depthOfField', 'rainDrop', 'ssao', 'ssr']) assert.ok(!names(plan.effects).includes(rejected), rejected); @@ -148,18 +145,6 @@ test('?look= picks a look, anything else the cinematic default', () => { assert.equal(resolveNavaraLook('?look=constructor'), 'cinematic'); }); -test('globe lighting turns on from space and off near the ground, with hysteresis', () => { - assert.equal(globeLightingAt(GLOBE_LIGHTING_ENTER_M - 1, false), false); - assert.equal(globeLightingAt(GLOBE_LIGHTING_ENTER_M, false), true); - assert.equal(globeLightingAt(8_000_000, false), true); - assert.equal(globeLightingAt(1_200_000, true), true, 'inside the gap'); - assert.equal(globeLightingAt(1_200_000, false), false, 'inside the gap'); - assert.equal(globeLightingAt(GLOBE_LIGHTING_EXIT_M, true), false); - assert.equal(globeLightingAt(900, true), false); - assert.equal(globeLightingAt(Number.NaN, true), true, 'unknown keeps it'); - assert.ok(GLOBE_LIGHTING_EXIT_M < GLOBE_LIGHTING_ENTER_M); -}); - test('cumulus layers rise over mountains, with sea-level bases from space', () => { assert.equal(cloudTerrainOffset(900, 10), 0); assert.equal(cloudTerrainOffset(4350, 3100), 4500);