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); });