From ac081a008ddc65d034222800b9164654323d25d0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 02:29:46 +0400 Subject: [PATCH] feat(globe): Navara's cinematic look by default navaraScenePlan gains a pure `cinematic` plan, now the default of the app's Navara mode (`?look=photoreal|minimal` picks the others): photoreal plus volumetric clouds (low quality, layers from 1,500 m, no ground shadows), ACES tone mapping, brighter stars and no sun shadow maps. From space, at rest, the aerial perspective lights the globe from the atmosphere for a real night side and terminator; it switches off below 1,000 km so the Google tiles keep their baked light. look.js builds the plan and keeps it current on frames Navara draws anyway: the atmosphere's sun date follows the wall clock (the HUD's SUN elevation), and the clouds hide above 4 km or over high ground in view, parking their last image so the aerial perspective shows no ghosts. --- scripts/package-boundaries.json | 1 + src/app/navaraMode.js | 2 + src/app/navaraScene.test.mjs | 4 + src/globe/navara/index.js | 86 ++++++++++++-- src/globe/navara/look.js | 108 +++++++++++++++++ src/globe/navara/look.test.mjs | 118 ++++++++++++++++++ src/globe/navara/viewOptions.js | 164 +++++++++++++++++++++++--- src/globe/navara/viewOptions.test.mjs | 140 +++++++++++++++++++++- 8 files changed, 589 insertions(+), 34 deletions(-) create mode 100644 src/globe/navara/look.js create mode 100644 src/globe/navara/look.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0d21800c7..0487148a2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1768,6 +1768,7 @@ "src/globe/navara/index.js", "src/globe/navara/input.js", "src/globe/navara/internals.js", + "src/globe/navara/look.js", "src/globe/navara/maps.js", "src/globe/navara/surface.js", "src/globe/navara/viewOptions.js", diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 679a14d1a..848201580 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -55,6 +55,8 @@ export async function createNavaraScene({ signal.throwIfAborted(); const globe = await createNavaraGlobe({ container: document.getElementById('cesiumContainer'), + // The cinematic look unless `?look=photoreal|minimal` asks otherwise. + search: globalThis.location?.search ?? '', }); defer(() => globe.destroy()); const { engine } = globe; diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index cac0abb3c..be543c4a5 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -15,6 +15,7 @@ function installDom() { window: globalThis.window, document: globalThis.document, CustomEvent: globalThis.CustomEvent, + location: globalThis.location, }; const container = { id: 'cesiumContainer' }; const appended = []; @@ -27,6 +28,7 @@ function installDom() { }, body: { appendChild: (node) => appended.push(node) }, }; + globalThis.location = { search: '?engine=navara&look=photoreal' }; const events = []; globalThis.window = { dispatchEvent: (event) => events.push(event), @@ -125,6 +127,8 @@ test('the Navara scene builds the engine on the app container and no viewer', as assert.equal(run.scene.engine.kind, 'navara'); assert.equal(run.loads.length, 1); assert.equal(run.loads[0].container, run.dom.container); + // The globe picks its look from the page's ?look= (cinematic by default). + assert.equal(run.loads[0].search, '?engine=navara&look=photoreal'); assert.ok(run.scene.operations.searchAndFlyTo); assert.equal(globalThis.window.__GOOGLE_MAPS_API_KEY__, 'google-test'); // Data layers are off: only the credits of what the shell itself uses diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 35d45e0fc..5d3abb9a2 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -3,13 +3,16 @@ // dynamic import() so Navara stays out of the Cesium-mode bundle. 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 { buildNavaraLook } from './look.js'; import { NAVARA_CONTEXT_ATTRIBUTES, navaraScenePlan, navaraViewOptions, + resolveNavaraLook, } from './viewOptions.js'; // Navara 0.2.1 has no preserveDrawingBuffer option but takes a canvas whose @@ -29,36 +32,98 @@ function preservedCanvas(container) { return canvas; } +/** The camera height, or NaN while the camera has no position yet. */ +function cameraHeight(camera) { + try { + return camera.height(); + } catch { + return Number.NaN; + } +} + +// The height of the ground in view, for the look's cloud rule: the depth of +// the last drawn frame halfway down the lower half (the near ground, not the +// horizon: over a canyon the rim, not the far floor). A depth read stalls for +// 1.5 to 4 ms, so it runs only when the camera comes to rest (and once more +// when the tiles in view have loaded). +function groundInView(engine) { + const { width, height } = engine.host.size(); + const point = engine.picker.pickPosition({ + x: width / 2, + y: height * 0.75, + }); + const place = + point && geo.cartographic.fromCartesian(point, geo.ellipsoid.WGS84); + return place ? place.height : Number.NaN; +} + /** * @param {{ * container: HTMLElement, - * effects?: 'minimal' | 'photoreal', + * effects?: 'minimal' | 'photoreal' | 'cinematic', + * search?: string, * pixelRatio?: number, - * }} options `pixelRatio` only for measurements; the product uses the device's. + * }} options `effects` names the look (cinematic by default); `search`, the + * page's query string, picks it from `?look=` instead (the app's way). + * `pixelRatio` only for measurements; the product uses the device's. * @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>} */ -export async function createNavaraGlobe({ container, effects, pixelRatio }) { +export async function createNavaraGlobe({ + container, + effects, + search, + pixelRatio, +}) { if (!container) throw new TypeError('createNavaraGlobe needs a container'); - const plan = navaraScenePlan(effects); const canvas = preservedCanvas(container); let view; let engine; + let removeLook = null; try { view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio })); const plugin = new DefaultPlugin(); view.addPlugin(plugin); await view.init(); - if (plan.pluginDefault) plugin.addDefaultPhotorealScene(); - else { - for (const name of plan.meshes) view.addMesh({ [name]: {} }); - for (const name of plan.lights) view.addLight({ [name]: {} }); - for (const name of plan.effects) view.addEffect({ [name]: {} }); - } + const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { + mobile: Boolean(view.isMobileOptimized?.()), + }); + const look = buildNavaraLook(view, plugin, plan); engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, }); + const camera = engine.camera; + let ground = Number.NaN; + // Whether `ground` was read from a frame with all its tiles: a read + // while they stream in is taken again once they have loaded. + let groundSettled = false; + const readGround = () => { + groundSettled = engine.surface.tilesLoaded(); + ground = groundInView(engine); + }; + let moving = false; + const removers = [ + camera.moveStart.addEventListener(() => { + moving = true; + }), + camera.moveEnd.addEventListener(() => { + moving = false; + readGround(); + engine.render.request('navara-look'); + }), + // After the engine's camera sync: before the first frame the camera + // has no position yet (camera.height() throws). + engine.frames.preRender.addEventListener(() => { + if (!moving && !groundSettled && engine.surface.tilesLoaded()) + readGround(); + if (look.frame(cameraHeight(camera), moving, ground)) + engine.render.request('navara-look'); + }), + ]; + removeLook = () => removers.forEach((remove) => remove()); } catch (error) { + removeLook?.(); + engine?.destroy(); view?.dispose(); canvas.remove(); throw error; @@ -70,6 +135,7 @@ export async function createNavaraGlobe({ container, effects, pixelRatio }) { destroy() { if (destroyed) return; destroyed = true; + removeLook(); engine.destroy(); view.dispose(); canvas.remove(); diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js new file mode 100644 index 000000000..9c655e5fb --- /dev/null +++ b/src/globe/navara/look.js @@ -0,0 +1,108 @@ +// 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 +// anyway, so an idle view stays idle. + +import { + cloudsVisibleAt, + globeLightingAt, + sunDateStale, +} from './viewOptions.js'; + +/** + * @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 }} + * `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. + */ +export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { + const handles = []; + if (plan.pluginDefault) { + const 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)); + } + const effect = (name) => { + const at = plan.effects.findIndex((desc) => Object.hasOwn(desc, name)); + return at < 0 + ? null + : handles[plan.meshes.length + plan.lights.length + at]; + }; + const aerial = plan.globeLighting ? effect('aerialPerspective') : null; + const clouds = effect('clouds'); + let globeLit = false; + let cloudsShown = true; + + const frame = (height, moving = false, ground = Number.NaN) => { + let changed = false; + const atmosphere = view.atmosphere; + if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) { + atmosphere.date = new Date(now()); + changed = 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 + // 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({ aerialPerspective: { irradiance: next } }); + 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. + if (clouds) { + const next = cloudsVisibleAt(height, ground, cloudsShown); + if (next !== cloudsShown) { + cloudsShown = next; + clouds.update({ visible: next }); + parkCloudLayers(view.atmosphere, next); + changed = true; + } + } + return changed; + }; + return { handles, frame }; +} + +// 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 +// keeps compositing the last clouds as grey ghosts. Hiding parks them; +// showing puts them back. +const CLOUD_OUTPUTS = ['overlay', 'shadow', 'shadowLength']; +const parked = new WeakMap(); + +function parkCloudLayers(atmosphere, shown) { + if (!atmosphere) return; + if (!shown) { + const outputs = {}; + for (const name of CLOUD_OUTPUTS) { + if (!atmosphere[name]) continue; + outputs[name] = atmosphere[name].value; + atmosphere[name].value = null; + } + parked.set(atmosphere, outputs); + return; + } + const outputs = parked.get(atmosphere); + if (!outputs) return; + parked.delete(atmosphere); + for (const [name, value] of Object.entries(outputs)) + atmosphere[name].value = value; +} diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs new file mode 100644 index 000000000..ac53a0e0e --- /dev/null +++ b/src/globe/navara/look.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildNavaraLook } from './look.js'; +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) }; + added.push(entry); + return entry; + }; + return { + added, + lit: true, + atmosphere: { + date: new Date(dateMs), + overlay: { value: 'overlay' }, + shadow: { value: null }, + shadowLength: { value: 'shafts' }, + }, + addMesh: (desc) => handle('mesh', desc), + addLight: (desc) => handle('light', desc), + addEffect: (desc) => handle('effect', desc), + }; +} + +const plugin = (calls) => ({ + addDefaultPhotorealScene: (...args) => { + calls.push(args); + return { sky: { id: 'sky' }, lensFlare: undefined }; + }, +}); + +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); + 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); +}); + +test('the cinematic look adds its descriptors in order', () => { + const view = fakeView(0); + const plan = navaraScenePlan('cinematic'); + buildNavaraLook(view, plugin([]), plan, () => 0); + assert.deepEqual( + view.added.map(({ kind, desc }) => [kind, desc]), + [ + ...plan.meshes.map((desc) => ['mesh', desc]), + ...plan.lights.map((desc) => ['light', desc]), + ...plan.effects.map((desc) => ['effect', desc]), + ], + ); +}); + +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)); + 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 }, + }); +}); + +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 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.shadowLength.value, null); + assert.equal(look.frame(3_000, true), true); + assert.deepEqual(clouds.updates.at(-1), { visible: true }); + assert.equal(view.atmosphere.overlay.value, 'overlay', 'put back'); + assert.equal(view.atmosphere.shadowLength.value, 'shafts'); + assert.equal(look.frame(3_000, false, 2_100), true, 'over high ground'); + assert.deepEqual(clouds.updates.at(-1), { visible: false }); +}); + +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')); + 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 } }); +}); + +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); + now += 30_000; + assert.equal(look.frame(900), false); + assert.equal(view.atmosphere.date.getTime(), 1_000_000); + now += 31_000; + assert.equal(look.frame(900), true); + assert.equal(view.atmosphere.date.getTime(), now); + assert.equal(look.frame(900), false); +}); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index c97047ad2..26390a31b 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -2,10 +2,11 @@ // engine's render.setContinuous turns animation on), MSAA 4 as Cesium's // msaaSamples, the device pixel ratio unless a measurement pins one, a canvas // whose WebGL2 context keeps its drawing buffer (Cesium's -// contextOptions.webgl.preserveDrawingBuffer, for captures), and Navara's -// `photoreal` look by default: its own photoreal scene with aerial -// perspective, tone mapping and SMAA, about 5 ms a frame (S5). `minimal` -// (sky, stars, sky light and sun without post effects) stays for benches. +// contextOptions.webgl.preserveDrawingBuffer, for captures), and a look: +// `cinematic` by default (navaraScenePlan below), Navara's own `photoreal` +// scene (aerial perspective, tone mapping and SMAA, about 5 ms a frame, S5) +// with `?look=photoreal`, and `minimal` (sky, stars, sky light and sun +// without post effects) for benches. /** WebGL2 attributes Navara 0.2.1 asks for, plus preserveDrawingBuffer. */ export const NAVARA_CONTEXT_ATTRIBUTES = Object.freeze({ @@ -31,23 +32,150 @@ export function navaraViewOptions({ container, canvas, pixelRatio } = {}) { }; } +/** The looks the view can build; the app takes `?look=` (cinematic by default). */ +export const NAVARA_LOOKS = Object.freeze([ + 'minimal', + 'photoreal', + 'cinematic', +]); +export const DEFAULT_NAVARA_LOOK = 'cinematic'; + +/** The look named by `?look=` in `search`, else the default (cinematic). */ +export function resolveNavaraLook(search = '') { + const value = String(new URLSearchParams(search).get('look') ?? '') + .trim() + .toLowerCase(); + return NAVARA_LOOKS.includes(value) ? value : DEFAULT_NAVARA_LOOK; +} + +// postprocessing's ToneMappingMode.ACES_FILMIC (Navara's default is AGX, 7). +const ACES_FILMIC = 6; + +// 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; +} + +// Where the cinematic clouds show. Their layers span 1,500 to 3,000 m above +// the ellipsoid. Above 4 km the camera would look down on them, and they +// would hide the map under clouds that are a backdrop, not weather; over +// ground above 1,000 m (canyons, the Alps) they would cut into the terrain +// as dark puffs. +// Each limit has a gap so a camera near it does not flicker them. +export const CLOUDS_HIDE_ABOVE_M = 4_000; +export const CLOUDS_SHOW_BELOW_M = 3_500; +export const CLOUDS_HIDE_OVER_GROUND_M = 1_000; +export const CLOUDS_SHOW_OVER_GROUND_M = 800; + +/** + * Whether the cinematic clouds show for a camera at `height` looking at + * ground at `ground` (both metres above the ellipsoid; an unknown ground + * counts as low). + */ +export function cloudsVisibleAt(height, ground, current = true) { + if (!Number.isFinite(height)) return current; + const low = Number.isFinite(ground) ? ground : 0; + return current + ? height <= CLOUDS_HIDE_ABOVE_M && low <= CLOUDS_HIDE_OVER_GROUND_M + : height < CLOUDS_SHOW_BELOW_M && low < CLOUDS_SHOW_OVER_GROUND_M; +} + +/** + * 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. + */ +export function sunDateStale(atmosphereMs, nowMs, toleranceMs = 60_000) { + return !(Math.abs(nowMs - atmosphereMs) <= toleranceMs); +} + +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 } }], + // 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 } }], + 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 } }, + ...(mobile + ? [] + : [ + { + clouds: { + // 'low' looks the same here and costs about 1 ms less than + // 'medium' (clouds cost about 3 ms a frame at 1280×800). + qualityPreset: 'low', + coverage: 0.2, + // No cloud shadows on the ground: dark patches over the map + // read as features (and cost nothing measurable either way). + shadows: false, + // The two cumulus layers start at 1,500 m instead of 750 and + // 1,000 m, above the city views (Austin's 600 m, Manhattan's + // 900 m): clouds fill the sky without hiding the streets. + cloudLayers: [{ altitude: 1500 }, { altitude: 1800 }], + }, + }, + { lensFlare: {} }, + ]), + { toneMapping: { mode: ACES_FILMIC } }, + mobile ? { fxaa: {} } : { smaa: {} }, + ], + globeLighting: true, +}); + const PLANS = { - minimal: { pluginDefault: false, effects: [] }, - photoreal: { pluginDefault: true, effects: [] }, + minimal: () => ({ + pluginDefault: false, + meshes: [{ sky: {} }, { stars: {} }], + lights: [{ skyLightProbe: {} }, { sun: {} }], + effects: [], + globeLighting: false, + }), + photoreal: () => ({ + pluginDefault: true, + meshes: [], + lights: [], + effects: [], + globeLighting: false, + }), + cinematic, }; /** - * What the view adds for a look: descriptor names, or the default plugin's - * own addDefaultPhotorealScene (sky, sun, aerial perspective, lens flare, - * tone mapping, SMAA). + * What the view adds for a look, as Navara descriptors ({ name: config }): + * - `minimal`: sky, stars, sky light and sun, no post effects (benches); + * - `photoreal`: the default plugin's addDefaultPhotorealScene (sky, sun, + * aerial perspective, lens flare, AgX tone mapping, SMAA); + * - `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 + * view it drops the clouds and the lens flare and uses FXAA. */ -export function navaraScenePlan(effects = 'photoreal') { - if (!Object.hasOwn(PLANS, effects)) - throw new RangeError(`Unknown Navara effects: ${effects}`); - return { - pluginDefault: PLANS[effects].pluginDefault, - meshes: ['sky', 'stars'], - lights: ['skyLightProbe', 'sun'], - effects: [...PLANS[effects].effects], - }; +export function navaraScenePlan( + look = DEFAULT_NAVARA_LOOK, + { mobile = false } = {}, +) { + if (!Object.hasOwn(PLANS, look)) + throw new RangeError(`Unknown Navara look: ${look}`); + return PLANS[look](mobile); } diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index 92a1cce3d..6dba12acb 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -1,9 +1,21 @@ 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, + CLOUDS_HIDE_ABOVE_M, + CLOUDS_HIDE_OVER_GROUND_M, + CLOUDS_SHOW_BELOW_M, + CLOUDS_SHOW_OVER_GROUND_M, + cloudsVisibleAt, + globeLightingAt, navaraScenePlan, navaraViewOptions, + resolveNavaraLook, + sunDateStale, } from './viewOptions.js'; test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => { @@ -34,14 +46,130 @@ test('the canvas context keeps its drawing buffer, with the attributes Navara as }); }); -test('effects=photoreal is the default look; minimal is sky, stars and sun light, no post effects (S5)', () => { - assert.equal(navaraScenePlan().pluginDefault, true); +test('cinematic is the default look; photoreal is the default plugin scene; minimal has no post effects (S5)', () => { + assert.equal(DEFAULT_NAVARA_LOOK, 'cinematic'); + assert.deepEqual(navaraScenePlan(), navaraScenePlan('cinematic')); + assert.deepEqual(navaraScenePlan('photoreal'), { + pluginDefault: true, + meshes: [], + lights: [], + effects: [], + globeLighting: false, + }); assert.deepEqual(navaraScenePlan('minimal'), { pluginDefault: false, - meshes: ['sky', 'stars'], - lights: ['skyLightProbe', 'sun'], + meshes: [{ sky: {} }, { stars: {} }], + lights: [{ skyLightProbe: {} }, { sun: {} }], effects: [], + globeLighting: false, }); - assert.equal(navaraScenePlan('photoreal').pluginDefault, true); - assert.throws(() => navaraScenePlan('cinematic'), RangeError); + assert.throws(() => navaraScenePlan('cine'), RangeError); + assert.throws(() => navaraScenePlan('toString'), RangeError); +}); + +const names = (descs) => descs.map((desc) => Object.keys(desc)[0]); + +test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no relighting of the tiles', () => { + const plan = navaraScenePlan('cinematic'); + assert.equal(plan.pluginDefault, false); + assert.deepEqual(plan.meshes, [ + { sky: {} }, + { stars: { intensity: 40, pointSize: 2 } }, + ]); + assert.deepEqual(plan.lights, [ + { skyLightProbe: {} }, + { sun: { castShadow: false } }, + ]); + assert.deepEqual(names(plan.effects), [ + 'aerialPerspective', + 'clouds', + 'lensFlare', + 'toneMapping', + '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, + }); + assert.equal(clouds.clouds.coverage, 0.2); + assert.equal(clouds.clouds.shadows, false, 'no cloud shadow maps'); + assert.ok( + clouds.clouds.cloudLayers.every(({ altitude }) => altitude >= 1000), + 'cloud layers sit above the city views', + ); + assert.equal(toneMapping.toneMapping.mode, 6, 'ACES filmic'); + assert.equal(plan.globeLighting, 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); +}); + +test('a mobile-optimized view drops clouds and lens flare and uses FXAA', () => { + assert.deepEqual( + names(navaraScenePlan('cinematic', { mobile: true }).effects), + ['aerialPerspective', 'toneMapping', 'fxaa'], + ); +}); + +test('each call returns a fresh plan', () => { + const plan = navaraScenePlan('cinematic'); + plan.effects.length = 0; + assert.equal(navaraScenePlan('cinematic').effects.length, 5); +}); + +test('?look= picks a look, anything else the cinematic default', () => { + assert.deepEqual(NAVARA_LOOKS, ['minimal', 'photoreal', 'cinematic']); + assert.equal(resolveNavaraLook(''), 'cinematic'); + assert.equal(resolveNavaraLook('?engine=navara'), 'cinematic'); + assert.equal(resolveNavaraLook('?engine=navara&look=photoreal'), 'photoreal'); + assert.equal(resolveNavaraLook('?look=%20Minimal%20'), 'minimal'); + assert.equal(resolveNavaraLook('?look=rain'), 'cinematic'); + 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('the clouds show for low views over low ground, with hysteresis', () => { + assert.equal(cloudsVisibleAt(900, 10, true), true); + assert.equal(cloudsVisibleAt(900, Number.NaN, true), true, 'unknown ground'); + assert.equal(cloudsVisibleAt(CLOUDS_HIDE_ABOVE_M, 0, true), true); + assert.equal(cloudsVisibleAt(CLOUDS_HIDE_ABOVE_M + 1, 0, true), false); + assert.equal(cloudsVisibleAt(3_800, 0, false), false, 'inside the gap'); + assert.equal(cloudsVisibleAt(CLOUDS_SHOW_BELOW_M - 1, 0, false), true); + assert.equal(cloudsVisibleAt(3_000, 2_100, true), false, 'over a canyon rim'); + assert.equal(cloudsVisibleAt(3_000, 900, false), false, 'inside the gap'); + assert.equal( + cloudsVisibleAt(3_000, CLOUDS_SHOW_OVER_GROUND_M - 1, false), + true, + ); + assert.equal(cloudsVisibleAt(CLOUDS_HIDE_OVER_GROUND_M, 0, true), true); + assert.equal( + cloudsVisibleAt(Number.NaN, 0, false), + false, + 'unknown keeps it', + ); + const layers = navaraScenePlan('cinematic').effects[1].clouds.cloudLayers; + assert.ok( + layers[0].altitude - CLOUDS_HIDE_OVER_GROUND_M >= 500, + 'cloud base well above the highest ground they show over', + ); +}); + +test('the sun date is stale once it drifts a minute from the clock', () => { + assert.equal(sunDateStale(0, 60_000), false); + assert.equal(sunDateStale(0, 60_001), true); + assert.equal(sunDateStale(120_000, 0), true, 'either direction'); + assert.equal(sunDateStale(Number.NaN, 0), true); });