From 26968f4903b59e8fa5528b6197ac181f1ce86ce0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 14:34:17 +0400 Subject: [PATCH 1/6] fix(ui): the Cesium engine's render.sharpen() mirrors the sharpen stage VisualEffects sends its sharpen state to engine.render.setSharpen when a viewer exists too; the Cesium engine keeps it as state, and the Cesium stage itself is unchanged. --- src/ui/visualEffects.js | 10 +++++----- src/ui/visualEffects.test.mjs | 26 ++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index b23f9ebbf..461276e20 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -32,12 +32,11 @@ export class VisualEffects { createStage = (options) => new PostProcessStage(options), }) { this.viewer = viewer; - // Cesium sharpens with its own post stage below; an engine without a - // viewer (Navara) sharpens in its render pipeline. + // Cesium sharpens with its own post stage below, and the engine's + // render.sharpen() mirrors that stage (the Cesium engine keeps the state + // only); an engine without a viewer (Navara) sharpens in its pipeline. this.engineRender = - !viewer && typeof engine?.render?.setSharpen === 'function' - ? engine.render - : null; + typeof engine?.render?.setSharpen === 'function' ? engine.render : null; this.requestRender = requestRender; this.holdRender = holdRender; this.releaseRender = releaseRender; @@ -118,6 +117,7 @@ export class VisualEffects { }); this.sharpenStage.enabled = false; this.viewer.scene.postProcessStages.add(this.sharpenStage); + this.engineRender?.setSharpen({ enabled: false }); this.applySharpenIntensity(sharpenIntensity); } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index a564008ca..2fbf94378 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -223,14 +223,32 @@ test('without a viewer the engine sharpens with the same amount scale', () => { effects.destroy(); }); -test('with a viewer the Cesium stage sharpens and the engine is left alone', () => { - let calls = 0; - const f = fixture({ render: { setSharpen: () => calls++ } }); +test('with a viewer the Cesium stage sharpens and the engine mirrors its state', () => { + let state = { enabled: false, amount: 1 }; + const engine = { + render: { + setSharpen: (options) => { + state = { ...state, ...options }; + }, + sharpen: () => ({ ...state }), + }, + }; + const f = fixture(engine); f.effects.initPostProcess(0.49); + assert.deepEqual(engine.render.sharpen(), { + enabled: false, + amount: f.effects.sharpenStage.uniforms.amount, + }); f.effects.applySharpenIntensity(0.8); f.effects.setSharpenEnabled(true); - assert.equal(calls, 0); assert.equal(f.effects.sharpenStage.uniforms.amount, sharpenAmount(0.8)); + assert.equal(f.effects.sharpenStage.enabled, true); + assert.deepEqual(engine.render.sharpen(), { + enabled: true, + amount: sharpenAmount(0.8), + }); + f.effects.setSharpenEnabled(false); + assert.equal(engine.render.sharpen().enabled, false); f.effects.destroy(); }); From 5188e8869dadd410afd9742d7d052f3ed5d36708 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH 2/6] fix(globe): the cinematic look's atmosphere scale, warm-up and sunset clouds - The takram uniform paths move to internals.js (forEachLuminanceUniform, loud in dev when a path is gone); look.js sets each factor to scale x the recorded base value, so shared or copied vectors are never scaled twice, and the stars keep the base value (own vector when shared). Stars at intensity 120: a starfield shows from space. - Both night-side shader variants compile on the first two frames. - The clouds hide once the sun sets at the camera (dark puffs at dusk). - No allocation on look frames that change nothing (frozen updates, a scratch camera place from livePosition). Gallery regenerated. --- scripts/format-scope.json | 1 - scripts/package-boundaries.json | 1 - src/globe/navara/atmosphereLuminance.js | 61 ----- src/globe/navara/index.js | 50 +++-- src/globe/navara/internals.js | 65 ++++++ src/globe/navara/look.js | 143 +++++++++--- src/globe/navara/look.test.mjs | 281 ++++++++++++++---------- src/globe/navara/viewOptions.js | 22 +- src/globe/navara/viewOptions.test.mjs | 14 +- 9 files changed, 410 insertions(+), 228 deletions(-) delete mode 100644 src/globe/navara/atmosphereLuminance.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8e0aa8922..5e1a231f6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1033,7 +1033,6 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/contract.js", - "src/globe/navara/atmosphereLuminance.js", "src/globe/navara/cameraMath.js", "src/globe/navara/cameraMath.test.mjs", "src/globe/navara/cameraModel.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 47d7c5305..2b6f6e582 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1760,7 +1760,6 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/cesium/engineFromViewer.js", - "src/globe/navara/atmosphereLuminance.js", "src/globe/navara/cameraMath.js", "src/globe/navara/cameraModel.js", "src/globe/navara/createNavaraEngine.js", diff --git a/src/globe/navara/atmosphereLuminance.js b/src/globe/navara/atmosphereLuminance.js deleted file mode 100644 index 57199a5c2..000000000 --- a/src/globe/navara/atmosphereLuminance.js +++ /dev/null @@ -1,61 +0,0 @@ -// The cinematic look's brighter atmosphere (viewOptions.js -// ATMOSPHERE_LUMINANCE_SCALE): the radiance-to-luminance factors of the -// atmosphere's materials (sky, aerial perspective, clouds), multiplied by a -// scale. Navara 0.2.1 has no public setting for them; they are uniforms of -// the takram materials its descriptors build, reached through each handle's -// public `ref.raw`, then fields of those objects that are not documented: -// -// - sky: ref.raw (the three Mesh) .material.uniforms; -// - aerial perspective: ref.raw (Navara's Effect) .rawEffect.uniforms (a Map); -// - clouds: ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms (the -// clouds swap that material when their quality changes). -// -// Re-check each path before moving to another Navara version (as for -// internals.js). Upstream ask: a sky luminance or atmosphere exposure that -// leaves unlit content (the baked tiles) alone. - -export const LUMINANCE_UNIFORMS = Object.freeze([ - 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', - 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE', -]); - -const uniformsOf = { - sky: (raw) => raw?.material?.uniforms, - aerialPerspective: (raw) => raw?.rawEffect?.uniforms, - clouds: (raw) => raw?.rawEffect?.cloudsPass?.currentMaterial?.uniforms, -}; - -const get = (uniforms, name) => - uniforms instanceof Map ? uniforms.get(name) : uniforms?.[name]; - -/** - * Scales the atmosphere's luminance factors to `scale` times the values - * their materials were built with. Idempotent: each factor's vector is - * scaled once (Navara 0.2.1 shares one vector between the sky, the aerial - * perspective and the clouds), and a material Navara rebuilds with a new - * vector is scaled when it first shows. - * @returns {(handles: { sky?: object, aerialPerspective?: object, clouds?: object }) => boolean} - * applies the scale; true when it changed a uniform. - */ -export function createAtmosphereLuminance(scale) { - const scaled = new WeakSet(); - return (handles) => { - if (scale === 1) return false; - let changed = false; - for (const [name, read] of Object.entries(uniformsOf)) { - const uniforms = read(handles[name]?.ref?.raw); - if (!uniforms) continue; - for (const key of LUMINANCE_UNIFORMS) { - const uniform = get(uniforms, key); - const value = uniform?.value; - if (!value || typeof value.multiplyScalar !== 'function') continue; - // Navara's materials share one vector per factor: scale it once. - if (scaled.has(value)) continue; - value.multiplyScalar(scale); - scaled.add(value); - changed = true; - } - } - return changed; - }; -} diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 805a49c8d..39f451e8a 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -6,7 +6,7 @@ import ThreeView from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { createNavaraEngine } from './createNavaraEngine.js'; -import { navaraHooks } from './internals.js'; +import { forEachLuminanceUniform, navaraHooks } from './internals.js'; import { buildNavaraLook } from './look.js'; import { createNavaraSharpen } from './sharpen.js'; import { @@ -33,28 +33,22 @@ function preservedCanvas(container) { return canvas; } -/** The camera's place in degrees (for the sun's elevation), or null. */ -function cameraPlace(camera) { - try { - const place = camera.pose().cartographic; - return place - ? { - lat: geo.math.toDegrees(place.latitude), - lng: geo.math.toDegrees(place.longitude), - } - : null; - } catch { - return null; - } -} +// The camera's place for the look, refreshed in place each frame (no +// allocation): degrees for the sun's elevation, and the height. +const lookCarto = { longitude: 0, latitude: 0, height: 0 }; +const lookPlace = { lat: 0, lng: 0 }; -/** The camera height, or NaN while the camera has no position yet. */ -function cameraHeight(camera) { - try { - return camera.height(); - } catch { - return Number.NaN; - } +/** Fills lookPlace from the camera; false while it has no position yet. */ +function readCameraPlace(camera) { + const place = geo.cartographic.fromCartesian( + camera.livePosition, + geo.ellipsoid.WGS84, + lookCarto, + ); + if (!place) return false; + lookPlace.lat = geo.math.toDegrees(place.latitude); + lookPlace.lng = geo.math.toDegrees(place.longitude); + return true; } // The height of the ground in view, for the look's cloud rule: the depth of @@ -103,7 +97,9 @@ export async function createNavaraGlobe({ const plan = navaraScenePlan(effects ?? resolveNavaraLook(search), { mobile: Boolean(view.isMobileOptimized?.()), }); - const look = buildNavaraLook(view, plugin, plan); + const look = buildNavaraLook(view, plugin, plan, undefined, { + forEachLuminanceUniform, + }); engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, @@ -133,8 +129,14 @@ export async function createNavaraGlobe({ engine.frames.preRender.addEventListener(() => { if (!moving && !groundSettled && engine.surface.tilesLoaded()) readGround(); + const placed = readCameraPlace(camera); if ( - look.frame(cameraHeight(camera), moving, ground, cameraPlace(camera)) + look.frame( + placed ? lookCarto.height : Number.NaN, + moving, + ground, + placed ? lookPlace : null, + ) ) engine.render.request('navara-look'); }), diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index f79ed824d..571894777 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -65,6 +65,71 @@ export function isIdle(view) { return view._isIdle === true; } +/** + * The cinematic look's atmosphere scale (look.js createLuminanceScaler): + * the radiance-to-luminance factor uniforms of the takram materials behind + * the look's sky, stars, aerial perspective and clouds handles. Private: + * - sky, stars: handle.ref.raw (the three object) .material.uniforms; + * - aerial perspective: handle.ref.raw.rawEffect.uniforms (a Map); + * - clouds: handle.ref.raw.rawEffect.cloudsPass.currentMaterial.uniforms + * (the clouds swap that material when their quality changes). + * Calls `visit(key, uniform, scaled)` for each, scaled false for the stars. + * A handle whose object is built but has lost a path fails loudly: it + * throws in the dev server and warns once in a build. Upstream ask: a sky + * luminance or atmosphere exposure that leaves unlit content alone. + */ +export const LUMINANCE_UNIFORMS = Object.freeze([ + 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE', + 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE', +]); +const DEV = Boolean(import.meta.env?.DEV); +const reported = new Set(); + +function luminancePathLost(name) { + const message = `Navara ${NAVARA_INTERNALS_VERSION}: the ${name} luminance uniforms are not where internals.js expects them`; + if (DEV) throw new Error(message); + if (reported.has(name)) return; + reported.add(name); + console.warn(`[globe] ${message}; the atmosphere stays unscaled there`); +} + +function visitLuminance(name, raw, uniforms, scaled, visit) { + if (!raw) return; // not built yet + const get = (key) => + uniforms instanceof Map ? uniforms.get(key) : uniforms?.[key]; + const sun = get(LUMINANCE_UNIFORMS[0]); + const sky = get(LUMINANCE_UNIFORMS[1]); + if (!sun?.value?.isVector3 || !sky?.value?.isVector3) { + luminancePathLost(name); + return; + } + visit(LUMINANCE_UNIFORMS[0], sun, scaled); + visit(LUMINANCE_UNIFORMS[1], sky, scaled); +} + +export function forEachLuminanceUniform(handles, visit) { + const sky = handles.sky?.ref?.raw; + visitLuminance('sky', sky, sky?.material?.uniforms, true, visit); + const stars = handles.stars?.ref?.raw; + visitLuminance('stars', stars, stars?.material?.uniforms, false, visit); + const aerial = handles.aerialPerspective?.ref?.raw; + visitLuminance( + 'aerial perspective', + aerial, + aerial?.rawEffect?.uniforms, + true, + visit, + ); + const clouds = handles.clouds?.ref?.raw; + visitLuminance( + 'clouds', + clouds, + clouds?.rawEffect?.cloudsPass?.currentMaterial?.uniforms, + true, + visit, + ); +} + /** The hooks createNavaraEngine takes. */ export const navaraHooks = (view) => ({ retainTileGeometry, diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index c319b4f5e..4f8265619 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -2,12 +2,14 @@ // view and keeps it current while the view renders: the sun follows the wall // clock as the HUD's SUN elevation does; the cinematic look lights the globe // from the atmosphere when seen from space, shows its clouds only for low -// views over low ground, brightens the atmosphere (atmosphereLuminance.js) -// and shows its stars only at night or from space. All of it runs only on frames the view draws -// anyway, so an idle view stays idle. +// views over low ground and while the sun is up, brightens the atmosphere +// (createLuminanceScaler, uniforms from internals.js), compiles both +// night-side shader variants at startup and shows its stars only at night +// or from space. All of it runs only on frames the view draws anyway, so an +// idle view stays idle. -import { createAtmosphereLuminance } from './atmosphereLuminance.js'; import { + cloudsSunlitAt, cloudsVisibleAt, globeLightingAt, starsVisibleAt, @@ -23,9 +25,20 @@ import { * camera is moving, the height of the ground in view (metres above the * ellipsoid, NaN when unknown) and the camera's place (degrees, for the * sun's elevation there); it returns true when it changed the scene, so - * the caller draws one more frame. + * the caller draws one more frame. It allocates nothing on a frame that + * changes nothing. + * @param {() => number} [now] the wall clock (ms), a test seam + * @param {{ forEachLuminanceUniform?: (handles: object, visit: (key: string, uniform: { value: object }, scaled: boolean) => void) => void }} [internals] + * the takram uniforms behind the atmosphere scale (internals.js); without + * it the scale does nothing. */ -export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { +export function buildNavaraLook( + view, + plugin, + plan, + now = () => Date.now(), + { forEachLuminanceUniform = null } = {}, +) { const handles = []; if (plan.pluginDefault) { const scene = plugin.addDefaultPhotorealScene(); @@ -48,28 +61,47 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { const aerial = plan.globeLighting ? effect('aerialPerspective') : null; const clouds = effect('clouds'); const stars = plan.daylightHidesStars ? mesh('stars') : null; - const atmosphereHandles = { - sky: mesh('sky'), - aerialPerspective: effect('aerialPerspective'), - clouds, - }; - const brighten = createAtmosphereLuminance(plan.atmosphereLuminance ?? 1); + const brighten = createLuminanceScaler( + plan.atmosphereLuminance ?? 1, + forEachLuminanceUniform, + { + sky: mesh('sky'), + stars: mesh('stars'), + aerialPerspective: effect('aerialPerspective'), + clouds, + }, + ); let globeLit = false; + // Both aerial-perspective variants (with and without the night side) are + // compiled on the first two frames, during startup, so a later switch + // finds its shader compiled (a cold switch stalled a frame for up to + // 0.8 s, mid-flight on the way down from space). + let warmUp = aerial ? 2 : 0; let cloudsShown = true; + let cloudsSunlit = true; let starsShown = true; const frame = (height, moving = false, ground = Number.NaN, place = null) => { // Every drawn frame: a material Navara rebuilds comes back unscaled. - let changed = brighten(atmosphereHandles); + let changed = brighten(); const atmosphere = view.atmosphere; if (atmosphere && sunDateStale(atmosphere.date.getTime(), now())) { atmosphere.date = new Date(now()); changed = true; } + const elevation = + place && typeof atmosphere?.getSunElevation === 'function' + ? atmosphere.getSunElevation(place) + : Number.NaN; + if (warmUp) { + warmUp -= 1; + aerial.update(warmUp ? IRRADIANCE_ON : IRRADIANCE_OFF); + return true; + } if (aerial) { const next = globeLightingAt(height, globeLit); - // Switching recompiles the aerial perspective, a frame of about 0.8 s - // headless: it switches on only at rest (a flight from city to city + // Switching rebuilds the aerial perspective's shader (warmed up + // above): it switches on only at rest (a flight from city to city // through space never switches) and off as soon as the camera comes // down, before the tiles would show relit. if (next !== globeLit && !(next && moving)) { @@ -77,30 +109,28 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { // Only the aerial perspective changes: the tiles already draw plain // albedo, so view.lit stays (toggling it recompiles every tile // material, a frame of about 0.8 s mid-flight). - aerial.update({ aerialPerspective: { irradiance: next } }); + aerial.update(next ? IRRADIANCE_ON : IRRADIANCE_OFF); changed = true; } } - // Clouds only for a camera below them over low ground; switching them - // costs no recompile, so it follows the camera in motion too. + // Clouds only for a camera below them over low ground, and while the + // sun is up at the camera (after sunset they read as dark puffs); + // switching them costs no recompile, so it follows the camera in motion. if (clouds) { - const next = cloudsVisibleAt(height, ground, cloudsShown); + cloudsSunlit = cloudsSunlitAt(elevation, cloudsSunlit); + const next = cloudsSunlit && cloudsVisibleAt(height, ground, cloudsShown); if (next !== cloudsShown) { cloudsShown = next; - clouds.update({ visible: next }); + clouds.update(next ? VISIBLE : HIDDEN); parkCloudLayers(view.atmosphere, next); changed = true; } } if (stars) { - const elevation = - place && typeof atmosphere?.getSunElevation === 'function' - ? atmosphere.getSunElevation(place) - : Number.NaN; const next = starsVisibleAt(height, elevation, starsShown); if (next !== starsShown) { starsShown = next; - stars.update({ visible: next }); + stars.update(next ? VISIBLE : HIDDEN); changed = true; } } @@ -109,6 +139,69 @@ export function buildNavaraLook(view, plugin, plan, now = () => Date.now()) { return { handles, frame }; } +const IRRADIANCE_ON = Object.freeze({ + aerialPerspective: Object.freeze({ irradiance: true }), +}); +const IRRADIANCE_OFF = Object.freeze({ + aerialPerspective: Object.freeze({ irradiance: false }), +}); +const VISIBLE = Object.freeze({ visible: true }); +const HIDDEN = Object.freeze({ visible: false }); + +/** + * The atmosphere scale (viewOptions.js ATMOSPHERE_LUMINANCE_SCALE): each + * luminance factor is set to `scale` times the base value recorded the + * first time it is seen, never multiplied in place, so a vector Navara + * shares between materials, or copies into a rebuilt one, is never scaled + * twice. The stars keep the base value: when they share the sky's vector, + * they get their own. Returns `apply()`, true when it changed a uniform. + */ +export function createLuminanceScaler(scale, forEachUniform, handles) { + if (scale === 1 || !forEachUniform) return () => false; + const bases = new Map(); // key -> { base, target } + const scaledVectors = new WeakSet(); + let changed = false; + const entry = (key, value) => { + let found = bases.get(key); + if (!found) { + found = { + base: value.clone(), + target: value.clone().multiplyScalar(scale), + }; + bases.set(key, found); + } + return found; + }; + const record = (key, uniform) => entry(key, uniform.value); + const scaleOne = (key, uniform, scaled) => { + if (!scaled) return; + const { target } = entry(key, uniform.value); + scaledVectors.add(uniform.value); + if (uniform.value.equals(target)) return; + uniform.value.copy(target); + changed = true; + }; + const keepBase = (key, uniform, scaled) => { + if (scaled) return; + const { base } = entry(key, uniform.value); + if (scaledVectors.has(uniform.value)) { + uniform.value = base.clone(); + changed = true; + } else if (!uniform.value.equals(base)) { + uniform.value.copy(base); + changed = true; + } + }; + return () => { + changed = false; + // Bases first, from the values as Navara built them. + forEachUniform(handles, record); + forEachUniform(handles, scaleOne); + forEachUniform(handles, keepBase); + return changed; + }; +} + // The clouds hand the aerial perspective their last output through the // atmosphere (overlay, shadow, shadow length) once, when they first draw. // A hidden clouds pass leaves those in place, and the aerial perspective diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index fd99924b1..6d2a12974 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -37,6 +37,49 @@ const plugin = (calls) => ({ }, }); +/** A cinematic look past its two warm-up frames (updates cleared). */ +function warmCinematic(view, internals) { + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + internals, + ); + look.frame(900); + look.frame(900); + for (const entry of view.added) entry.updates.length = 0; + return look; +} + +test('the night-side shader variants are both compiled on the first two frames', () => { + const view = fakeView(0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + ); + const aerial = view.added.find(({ desc }) => + Object.hasOwn(desc, 'aerialPerspective'), + ); + assert.equal(look.frame(900), true); + assert.equal(look.frame(900), true); + assert.deepEqual(aerial.updates, [ + { aerialPerspective: { irradiance: true } }, + { aerialPerspective: { irradiance: false } }, + ]); + assert.equal(look.frame(900), false, 'then the usual rules'); + const minimal = fakeView(0); + const plain = buildNavaraLook( + minimal, + plugin([]), + navaraScenePlan('minimal'), + () => 0, + ); + assert.equal(plain.frame(900), false, 'no aerial perspective: no warm-up'); +}); + test('the photoreal look is the default plugin scene, untouched', () => { const calls = []; const view = fakeView(0); @@ -68,12 +111,7 @@ test('the cinematic look adds its descriptors in order', () => { test('from space the globe is lit by the atmosphere; near the ground the tiles keep their own light', () => { const view = fakeView(0); - const look = buildNavaraLook( - view, - plugin([]), - navaraScenePlan('cinematic'), - () => 0, - ); + const look = warmCinematic(view); const effect = (name) => view.added.find(({ desc }) => Object.hasOwn(desc, name)); assert.equal(look.frame(900), false, 'nothing changes at street level'); @@ -92,12 +130,7 @@ test('from space the globe is lit by the atmosphere; near the ground the tiles k test('the clouds show only below their tops, switching in motion too', () => { const view = fakeView(0); - const look = buildNavaraLook( - view, - plugin([]), - navaraScenePlan('cinematic'), - () => 0, - ); + const look = warmCinematic(view); const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); assert.equal(look.frame(900, true), false, 'shown at street level'); assert.equal(look.frame(20_000, true), true); @@ -119,12 +152,7 @@ test('the clouds show only below their tops, switching in motion too', () => { test('globe lighting switches on only at rest, and off even in motion', () => { const view = fakeView(0); - const look = buildNavaraLook( - view, - plugin([]), - navaraScenePlan('cinematic'), - () => 0, - ); + const look = warmCinematic(view); const aerial = view.added.find(({ desc }) => Object.hasOwn(desc, 'aerialPerspective'), ); @@ -162,7 +190,9 @@ const vec = (x, y, z) => ({ y, z, copy(v) { - Object.assign(this, { x: v.x, y: v.y, z: v.z }); + this.x = v.x; + this.y = v.y; + this.z = v.z; return this; }, multiplyScalar(k) { @@ -174,103 +204,142 @@ const vec = (x, y, z) => ({ clone() { return vec(this.x, this.y, this.z); }, + equals(v) { + return v.x === this.x && v.y === this.y && v.z === this.z; + }, }); -const luminanceUniforms = () => ({ - SUN_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.3, 0.9, 0.9) }, - SKY_SPECTRAL_RADIANCE_TO_LUMINANCE: { value: vec(1.5, 0.9, 0.8) }, +const SUN = 'SUN_SPECTRAL_RADIANCE_TO_LUMINANCE'; +const SKY = 'SKY_SPECTRAL_RADIANCE_TO_LUMINANCE'; +const factors = () => ({ + [SUN]: { value: vec(1.3, 0.9, 0.9) }, + [SKY]: { value: vec(1.5, 0.9, 0.8) }, }); -/** Gives the cinematic handles the raw objects Navara 0.2.1 builds. */ -function withRawAtmosphere(view) { - const find = (name) => - view.added.find(({ desc }) => Object.hasOwn(desc, name)); - const sky = luminanceUniforms(); - const aerial = new Map(Object.entries(luminanceUniforms())); - const clouds = luminanceUniforms(); - const stars = luminanceUniforms(); - find('sky').ref = { raw: { material: { uniforms: sky } } }; - find('stars').ref = { raw: { material: { uniforms: stars } } }; - find('aerialPerspective').ref = { raw: { rawEffect: { uniforms: aerial } } }; - find('clouds').ref = { - raw: { - rawEffect: { cloudsPass: { currentMaterial: { uniforms: clouds } } }, +/** A stand-in for internals.js forEachLuminanceUniform over fake materials. */ +function fakeInternals(materials) { + return { + forEachLuminanceUniform(handles, visit) { + for (const [name, scaled] of [ + ['sky', true], + ['stars', false], + ['aerialPerspective', true], + ['clouds', true], + ]) { + if (!handles[name]) continue; + const uniforms = materials[name]; + if (!uniforms) continue; + for (const key of [SUN, SKY]) visit(key, uniforms[key], scaled); + } }, }; - return { sky, aerial, clouds, stars }; } -test('the cinematic atmosphere is brighter, the stars and tiles are not', () => { +const k = navaraScenePlan('cinematic').atmosphereLuminance; +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-12, `${a} vs ${b}`); + +test('the atmosphere factors are set to scale × base, never compounded', () => { + const materials = { + sky: factors(), + stars: factors(), + aerialPerspective: factors(), + clouds: factors(), + }; const view = fakeView(0); - const plan = navaraScenePlan('cinematic'); - const look = buildNavaraLook(view, plugin([]), plan, () => 0); - const raw = withRawAtmosphere(view); - assert.equal(look.frame(900), true, 'the first frame applies the scale'); - const k = plan.atmosphereLuminance; - const sky = (u) => u.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value; - const sun = (u) => u.SUN_SPECTRAL_RADIANCE_TO_LUMINANCE.value; - assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12); - assert.ok(Math.abs(sun(raw.sky).x - 1.3 * k) < 1e-12); - assert.ok( - Math.abs( - raw.aerial.get('SKY_SPECTRAL_RADIANCE_TO_LUMINANCE').value.x - 1.5 * k, - ) < 1e-12, + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + fakeInternals(materials), ); - assert.ok(Math.abs(sky(raw.clouds).z - 0.8 * k) < 1e-12); - assert.equal( - sky(raw.stars).x, - 1.5, - 'only the sky, aerial perspective and clouds are walked', - ); - assert.equal(look.frame(900), false, 'applied once, not compounded'); - assert.ok(Math.abs(sky(raw.sky).x - 1.5 * k) < 1e-12); - // Navara rebuilds a material (a cloud quality change): the new one is scaled. - const fresh = luminanceUniforms(); - view.added.find(({ desc }) => - Object.hasOwn(desc, 'clouds'), - ).ref.raw.rawEffect.cloudsPass.currentMaterial = { uniforms: fresh }; + look.frame(900); + near(materials.sky[SKY].value.x, 1.5 * k); + near(materials.sky[SUN].value.x, 1.3 * k); + near(materials.aerialPerspective[SKY].value.z, 0.8 * k); + near(materials.clouds[SKY].value.x, 1.5 * k); + assert.equal(materials.stars[SKY].value.x, 1.5, 'the stars keep the base'); + look.frame(900); + look.frame(900); + assert.equal(look.frame(900), false, 'nothing to change once applied'); + near(materials.sky[SKY].value.x, 1.5 * k); + // A rebuilt material copying the scaled vector is not scaled again. + materials.clouds = { + [SUN]: { value: materials.sky[SUN].value.clone() }, + [SKY]: { value: materials.sky[SKY].value.clone() }, + }; + assert.equal(look.frame(900), false); + near(materials.clouds[SKY].value.x, 1.5 * k); + // One built from scratch is. + materials.clouds = factors(); assert.equal(look.frame(900), true); - assert.ok(Math.abs(sky(fresh).x - 1.5 * k) < 1e-12); + near(materials.clouds[SKY].value.x, 1.5 * k); }); -test('a look without the scale and an atmosphere not built yet are left alone', () => { +test('vectors shared between materials are scaled once; stars sharing them get their own', () => { + // Separate uniforms holding one vector each, as Navara 0.2.1 builds them. + const shared = factors(); + const sharing = () => ({ + [SUN]: { value: shared[SUN].value }, + [SKY]: { value: shared[SKY].value }, + }); + const materials = { + sky: shared, + stars: sharing(), + aerialPerspective: sharing(), + clouds: sharing(), + }; + const view = fakeView(0); + const look = buildNavaraLook( + view, + plugin([]), + navaraScenePlan('cinematic'), + () => 0, + fakeInternals(materials), + ); + look.frame(900); + look.frame(900); + near(shared[SKY].value.x, 1.5 * k); + assert.notEqual(materials.stars[SKY].value, shared[SKY].value); + assert.equal(materials.stars[SKY].value.x, 1.5); + assert.equal(materials.stars[SUN].value.x, 1.3); +}); + +test('a look without the scale, or without internals, leaves the factors alone', () => { + const materials = { sky: factors(), stars: factors() }; const view = fakeView(0); const look = buildNavaraLook( view, plugin([]), navaraScenePlan('minimal'), () => 0, + fakeInternals(materials), ); - const sky = luminanceUniforms(); - view.added[0].ref = { raw: { material: { uniforms: sky } } }; assert.equal(look.frame(900), false); - assert.equal(sky.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x, 1.5); - const cinematic = fakeView(0); - const other = buildNavaraLook( - cinematic, + assert.equal(materials.sky[SKY].value.x, 1.5); + const other = fakeView(0); + const cinematic = buildNavaraLook( + other, plugin([]), navaraScenePlan('cinematic'), () => 0, ); - assert.equal(other.frame(900), false, 'no raw objects yet: nothing to scale'); + cinematic.frame(900); + cinematic.frame(900); + assert.equal(cinematic.frame(900), false, 'no internals: no scale'); }); test('the cinematic stars hide in a daylit sky and show at night and from space', () => { const view = fakeView(0); let elevation = 40; view.atmosphere.getSunElevation = () => elevation; - const look = buildNavaraLook( - view, - plugin([]), - navaraScenePlan('cinematic'), - () => 0, - ); + const look = warmCinematic(view); const stars = view.added.find(({ desc }) => Object.hasOwn(desc, 'stars')); const place = { lat: 40.7, lng: -74 }; assert.equal(look.frame(900, false, 0, place), true); assert.deepEqual(stars.updates, [{ visible: false }]); assert.equal(look.frame(900, false, 0, place), false); elevation = -10; - assert.equal(look.frame(900, false, 0, place), true); + look.frame(900, false, 0, place); assert.deepEqual(stars.updates.at(-1), { visible: true }); elevation = 40; look.frame(900, false, 0, place); @@ -279,11 +348,31 @@ test('the cinematic stars hide in a daylit sky and show at night and from space' const count = stars.updates.length; look.frame(900, false, 0); assert.equal(stars.updates.length, count, 'no place keeps them'); - assert.equal(look.frame(900, false, 0, place), true, 'back down by day'); + look.frame(900, false, 0, place); assert.deepEqual(stars.updates.at(-1), { visible: false }); }); -test('the minimal look keeps its stars whatever the sun', () => { +test('the cinematic clouds hide after sunset at the camera', () => { + const view = fakeView(0); + let elevation = 20; + view.atmosphere.getSunElevation = () => elevation; + const look = warmCinematic(view); + const clouds = view.added.find(({ desc }) => Object.hasOwn(desc, 'clouds')); + const place = { lat: 40.7, lng: -74 }; + assert.equal(look.frame(900, false, 0, place), true, 'the stars hide'); + assert.deepEqual(clouds.updates, []); + elevation = -1; + assert.equal(look.frame(900, false, 0, place), true); + assert.deepEqual(clouds.updates, [{ visible: false }]); + elevation = 1; + look.frame(900, false, 0, place); + assert.deepEqual(clouds.updates, [{ visible: false }], 'hysteresis'); + elevation = 3; + look.frame(900, false, 0, place); + assert.deepEqual(clouds.updates.at(-1), { visible: true }); +}); + +test('the minimal look keeps its stars and clouds whatever the sun', () => { const view = fakeView(0); view.atmosphere.getSunElevation = () => 40; const look = buildNavaraLook( @@ -295,31 +384,3 @@ test('the minimal look keeps its stars whatever the sun', () => { look.frame(900, false, 0, { lat: 0, lng: 0 }); assert.deepEqual(view.added[1].updates, []); }); - -test('a factor vector the materials share is scaled once', () => { - const view = fakeView(0); - const plan = navaraScenePlan('cinematic'); - const look = buildNavaraLook(view, plugin([]), plan, () => 0); - const shared = luminanceUniforms(); - const find = (name) => - view.added.find(({ desc }) => Object.hasOwn(desc, name)); - find('sky').ref = { raw: { material: { uniforms: shared } } }; - find('aerialPerspective').ref = { - raw: { rawEffect: { uniforms: new Map(Object.entries(shared)) } }, - }; - find('clouds').ref = { - raw: { - rawEffect: { - cloudsPass: { currentMaterial: { uniforms: { ...shared } } }, - }, - }, - }; - look.frame(900); - look.frame(900); - assert.ok( - Math.abs( - shared.SKY_SPECTRAL_RADIANCE_TO_LUMINANCE.value.x - - 1.5 * plan.atmosphereLuminance, - ) < 1e-12, - ); -}); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index 521d56f9b..7474fb8dc 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -100,11 +100,10 @@ export function cloudsVisibleAt(height, ground, current = true) { // came out dark navy (sRGB about 39, 58, 90 at Manhattan facing the // horizon), and the stars showed through it. The cinematic look multiplies // the atmosphere's radiance-to-luminance factors (sky, clouds, aerial -// perspective; Navara's stars share them) by this scale instead of raising +// perspective; not the stars) by this scale instead of raising // the exposure, so the sky turns daylight blue and the tiles keep their // colours. export const ATMOSPHERE_LUMINANCE_SCALE = 3; -const STAR_INTENSITY = 40; // Stars show from space or once the sun is well below the horizon at the // camera (civil twilight), and hide in a daylit sky. Gaps keep a camera @@ -114,6 +113,19 @@ export const STARS_HIDE_SUN_ABOVE_DEG = -4; export const STARS_SPACE_ENTER_M = 100_000; export const STARS_SPACE_EXIT_M = 80_000; +// The cinematic clouds hide once the sun at the camera sets (below 0°): +// unlit, they read as dark puffs over a dusk sky. They show again above 2°. +export const CLOUDS_HIDE_SUN_BELOW_DEG = 0; +export const CLOUDS_SHOW_SUN_ABOVE_DEG = 2; + +/** Whether the sun is up enough for the cinematic clouds; NaN keeps `current`. */ +export function cloudsSunlitAt(sunElevation, current = true) { + if (!Number.isFinite(sunElevation)) return current; + return current + ? sunElevation >= CLOUDS_HIDE_SUN_BELOW_DEG + : sunElevation > CLOUDS_SHOW_SUN_ABOVE_DEG; +} + /** * Whether the cinematic stars show for a camera at `height` (metres above * the ellipsoid) with the sun `sunElevation` degrees above its horizon; an @@ -140,13 +152,13 @@ export function sunDateStale(atmosphereMs, nowMs, toleranceMs = 60_000) { const cinematic = (mobile) => ({ pluginDefault: false, // Brighter, larger stars (defaults 10 and 1 px) so the night sky and the - // globe from space keep a visible starfield next to a sunlit Earth: 40, - // divided by the atmosphere scale the stars share. + // globe from space keep a visible starfield next to a sunlit Earth. The + // atmosphere scale leaves the stars at their base factor (look.js). meshes: [ { sky: {} }, { stars: { - intensity: STAR_INTENSITY / ATMOSPHERE_LUMINANCE_SCALE, + intensity: 120, pointSize: 2, }, }, diff --git a/src/globe/navara/viewOptions.test.mjs b/src/globe/navara/viewOptions.test.mjs index 67d66d05b..3abf113c8 100644 --- a/src/globe/navara/viewOptions.test.mjs +++ b/src/globe/navara/viewOptions.test.mjs @@ -22,6 +22,9 @@ import { STARS_SPACE_ENTER_M, STARS_SPACE_EXIT_M, starsVisibleAt, + CLOUDS_HIDE_SUN_BELOW_DEG, + CLOUDS_SHOW_SUN_ABOVE_DEG, + cloudsSunlitAt, } from './viewOptions.js'; test('the product view renders on demand with MSAA 4 at the native pixel ratio', () => { @@ -84,7 +87,7 @@ test('the cinematic look: photoreal plus clouds and ACES, no sun shadow maps, no assert.equal(plan.pluginDefault, false); assert.deepEqual(plan.meshes, [ { sky: {} }, - { stars: { intensity: 40 / ATMOSPHERE_LUMINANCE_SCALE, pointSize: 2 } }, + { stars: { intensity: 120, pointSize: 2 } }, ]); assert.deepEqual(plan.lights, [ { skyLightProbe: {} }, @@ -214,3 +217,12 @@ test('stars show at night and from space, not in a daylit sky, with hysteresis', assert.ok(STARS_SHOW_SUN_BELOW_DEG < STARS_HIDE_SUN_ABOVE_DEG); assert.ok(STARS_SPACE_EXIT_M < STARS_SPACE_ENTER_M); }); + +test('the clouds hide once the sun sets at the camera, with hysteresis', () => { + assert.equal(cloudsSunlitAt(40, true), true); + assert.equal(cloudsSunlitAt(CLOUDS_HIDE_SUN_BELOW_DEG - 0.1, true), false); + assert.equal(cloudsSunlitAt(1, false), false, 'inside the gap'); + assert.equal(cloudsSunlitAt(1, true), true, 'inside the gap'); + assert.equal(cloudsSunlitAt(CLOUDS_SHOW_SUN_ABOVE_DEG + 0.1, false), true); + assert.equal(cloudsSunlitAt(Number.NaN, false), false, 'unknown keeps it'); +}); From 6dad3894bdcbd79ce130fcccfa6a1ebb1dc3e82b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH 3/6] fix(globe): sharpen sky mask past depth 0.99999, not >= 1 RGBA8 depth packing rounds near the far plane. --- src/globe/navara/sharpen.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js index 034adc686..2ddc7d4d8 100644 --- a/src/globe/navara/sharpen.js +++ b/src/globe/navara/sharpen.js @@ -34,6 +34,7 @@ const fragmentShader = /* glsl */ ` uniform float amount; uniform sampler2D tSceneDepth; uniform bool uSkipSky; +const float SKY_DEPTH = 0.99999; vec3 encodeGamma(vec3 color) { return pow(max(color, vec3(0.0)), vec3(1.0 / 2.2)); @@ -44,7 +45,8 @@ vec3 sampleGamma(vec2 uv) { } void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { - if (uSkipSky && unpackRGBAToDepth(texture2D(tSceneDepth, uv)) >= 1.0) { + // The far plane, with a margin: packing to RGBA8 rounds near 1.0. + if (uSkipSky && unpackRGBAToDepth(texture2D(tSceneDepth, uv)) > SKY_DEPTH) { outputColor = inputColor; return; } From da6141be4b87862752fb752f4b166043fd8d948c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH 4/6] perf(globe): lazy hidden tiles by tile extent; floor resample interval sampleHeight collects hidden tiles only after the shown ones miss, with no per-node allocation, and picks the finest by tile extent (Navara exposes no geometric error), not per-mesh radius. The ground floor resamples on a move only after 100 ms, and reports its own ground and margin (engine.groundFloor, Navara-only diagnostics). --- src/globe/navara/createNavaraEngine.js | 8 +- src/globe/navara/createNavaraEngine.test.mjs | 7 ++ src/globe/navara/groundFloor.js | 24 +++- src/globe/navara/groundFloor.test.mjs | 20 ++++ src/globe/navara/surface.js | 117 ++++++++++++------- src/globe/navara/surface.test.mjs | 25 ++++ 6 files changed, 152 insertions(+), 49 deletions(-) diff --git a/src/globe/navara/createNavaraEngine.js b/src/globe/navara/createNavaraEngine.js index 8e4f3434f..dbd4d5d4a 100644 --- a/src/globe/navara/createNavaraEngine.js +++ b/src/globe/navara/createNavaraEngine.js @@ -18,7 +18,7 @@ import { createNavaraCredits } from './credits.js'; import { createNavaraInput } from './input.js'; import { createNavaraMaps } from './maps.js'; import { createNavaraSurface } from './surface.js'; -import { createGroundFloor } from './groundFloor.js'; +import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js'; const { cartesian3, math } = geo; @@ -231,5 +231,11 @@ export function createNavaraEngine( sharpenPort?.destroy(); }, isDestroyed: () => destroyed, + // Navara only, not contract: the ground the floor last sampled under + // the camera and the margin it keeps above it (QA measures against it). + groundFloor: { + ground: () => groundFloor.ground(), + margin: GROUND_FLOOR.MIN_ABOVE, + }, }; } diff --git a/src/globe/navara/createNavaraEngine.test.mjs b/src/globe/navara/createNavaraEngine.test.mjs index 0f98dad22..2ceab2c31 100644 --- a/src/globe/navara/createNavaraEngine.test.mjs +++ b/src/globe/navara/createNavaraEngine.test.mjs @@ -369,3 +369,10 @@ test('the floor does not fight flights or a lookAt target', () => { near(heightOf(engine), 130, 1e-6, 'lifted once released'); engine.destroy(); }); + +test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => { + const { engine } = setup(); + assert.equal(typeof engine.groundFloor.ground, 'function'); + assert.equal(engine.groundFloor.margin, 30); + assert.equal(engine.groundFloor.ground(), undefined, 'nothing sampled yet'); +}); diff --git a/src/globe/navara/groundFloor.js b/src/globe/navara/groundFloor.js index 53e8e12ea..e670b1a7d 100644 --- a/src/globe/navara/groundFloor.js +++ b/src/globe/navara/groundFloor.js @@ -37,6 +37,11 @@ export const GROUND_FLOOR = Object.freeze({ RESAMPLE_METRES: 20, /** Age (ms) after which the ground is sampled again. */ RESAMPLE_MS: 250, + /** + * Age (ms) before a move resamples: a sample of the ground outside the + * view costs about 1.6 ms (surface.js), too much for every frame. + */ + MIN_RESAMPLE_MS: 100, }); /** @@ -81,12 +86,19 @@ export function createGroundFloor({ 0, WGS84, ); - if ( - sample && - at - sample.at < GROUND_FLOOR.RESAMPLE_MS && - cartesian3.distance(point, sample.point) < GROUND_FLOOR.RESAMPLE_METRES - ) - return sample.height; + // Again after RESAMPLE_MS, or after a RESAMPLE_METRES move once + // MIN_RESAMPLE_MS has passed (a pan does not sample every frame). + if (sample) { + const age = at - sample.at; + const moved = + cartesian3.distance(point, sample.point) >= + GROUND_FLOOR.RESAMPLE_METRES; + if ( + age < GROUND_FLOOR.RESAMPLE_MS && + !(moved && age >= GROUND_FLOOR.MIN_RESAMPLE_MS) + ) + return sample.height; + } const height = surface.sampleHeight(place) ?? surface.terrainHeight?.(place) ?? 0; sample = { point, at, height }; diff --git a/src/globe/navara/groundFloor.test.mjs b/src/globe/navara/groundFloor.test.mjs index 9f80a109b..a5a8ff83b 100644 --- a/src/globe/navara/groundFloor.test.mjs +++ b/src/globe/navara/groundFloor.test.mjs @@ -130,6 +130,26 @@ test('the ground is resampled after a move or a while, and the clamp only on rea state.position = at(2.36, 48.85, 500); state.ground = 140; floor.update(); + assert.equal(state.samples, 2, 'not within MIN_RESAMPLE_MS of the last'); + state.time += GROUND_FLOOR.MIN_RESAMPLE_MS; + floor.update(); assert.equal(state.samples, 3, 'resampled after a move'); assert.equal(state.clamps.length, 2, 'a 40 m change is sent'); }); + +test('a pan resamples at most every MIN_RESAMPLE_MS, however far it moves', () => { + // A sample of the ground outside the view costs about 1.6 ms (surface.js): + // not on every frame of a pan. + assert.ok(GROUND_FLOOR.MIN_RESAMPLE_MS >= 100); + const { floor, state } = setup({ ground: 100 }); + state.position = at(2.35, 48.85, 500); + floor.update(); + for (let frame = 1; frame <= 30; frame++) { + state.time = frame * 16; + state.position = at(2.35 + frame * 0.001, 48.85, 500); // ~73 m a frame + floor.update(); + } + // 480 ms of panning: the first sample and one every 100 ms at most. + assert.ok(state.samples <= 1 + Math.ceil(480 / GROUND_FLOOR.MIN_RESAMPLE_MS)); + assert.ok(state.samples >= 3, 'still follows the pan'); +}); diff --git a/src/globe/navara/surface.js b/src/globe/navara/surface.js index 45fa27f51..67fb3bcbc 100644 --- a/src/globe/navara/surface.js +++ b/src/globe/navara/surface.js @@ -58,25 +58,23 @@ const b = new Vector3(); const c = new Vector3(); const point = new Vector3(); -// Meshes with CPU positions under the tile roots, minus `exclude`: the -// shown ones, and the loaded ones Navara hides (outside the frustum, or -// parents of the tiles it shows), which still hold their geometry. -function tileMeshes(roots, exclude) { - const shown = []; - const hidden = []; - const stack = roots - .filter((root) => !exclude.has(root)) - .map((root) => [root, root.visible]); - while (stack.length) { - const [object, visible] = stack.pop(); - if (object.isMesh && object.geometry?.attributes?.position?.array) - (visible ? shown : hidden).push(object); - for (const child of object.children) - if (!exclude.has(child)) stack.push([child, visible && child.visible]); - } - return { shown, hidden }; +const hasPositions = (object) => + object.isMesh && Boolean(object.geometry?.attributes?.position?.array); + +// The meshes with CPU positions under `object`, minus `exclude`, into +// `out`; with `shownOnly`, only through visible objects. +function collectMeshes(object, exclude, shownOnly, out) { + if (exclude.has(object) || (shownOnly && !object.visible)) return; + if (hasPositions(object)) out.push(object); + const children = object.children; + for (let i = 0; i < children.length; i++) + collectMeshes(children[i], exclude, shownOnly, out); } +// Reused between calls (sampleHeight runs per frame in the ground floor). +const shownMeshes = []; +const tileMeshesScratch = []; + // Nearest hit along worldRay with one mesh, in metres. Not Mesh.raycast: that // interpolates UVs and normals, whose arrays Navara has released. function meshHitDistance(mesh, far) { @@ -137,33 +135,65 @@ function nearestHit(meshes, ray) { return nearest; } -// Among hidden tiles several levels of detail overlap: the hit on the -// finest tiles (bounding radius within 1.5 times the smallest hit: a -// quadtree parent is about twice its child), the top one -// among them. The tiles the ray passes are tried finest first, and coarser -// ones stop being tried once a hit sets the limit, so the coarse parents' -// triangles are never walked. -function finestHit(meshes, ray) { - worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z); - worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z); - const candidates = []; - for (const mesh of meshes) { +// A tile's extent: the bounding sphere around all its meshes, in +// Earth-fixed metres. Navara 0.2.1 exposes no geometric error or level on a +// tile, and a quadtree tile's extent halves with each level, so the extent +// orders them. Tiles do not move once loaded: computed once per tile. +const extents = new WeakMap(); +function tileExtent(root) { + let extent = extents.get(root); + if (extent) return extent; + tileMeshesScratch.length = 0; + collectMeshes(root, NO_EXCLUDE, false, tileMeshesScratch); + if (!tileMeshesScratch.length) return null; + const bounds = new Sphere(); + for (const mesh of tileMeshesScratch) { const geometry = mesh.geometry; if (!geometry.boundingSphere) geometry.computeBoundingSphere(); sphere.copy(geometry.boundingSphere).applyMatrix4(mesh.matrixWorld); - if (worldRay.intersectsSphere(sphere)) - candidates.push([sphere.radius, mesh]); + if (bounds.isEmpty()) bounds.copy(sphere); + else bounds.union(sphere); } - candidates.sort((x, y) => x[0] - y[0]); + extent = bounds; + extents.set(root, extent); + return extent; +} +const NO_EXCLUDE = new Set(); + +// Where the shown tiles miss, the loaded tiles Navara hides (outside the +// frustum, or parents of the tiles it shows) answer. Several levels of +// detail overlap there: the hit on the finest tiles (extent within 1.5 +// times the finest tile hit; a quadtree parent is about twice its child), +// the top one among them. Tiles the ray passes are tried finest first, and +// coarser ones stop being tried once a hit sets the limit, so the coarse +// parents' triangles are never walked. +const hiddenTiles = []; +function finestHiddenHit(roots, exclude, ray) { + worldRay.origin.set(ray.origin.x, ray.origin.y, ray.origin.z); + worldRay.direction.set(ray.direction.x, ray.direction.y, ray.direction.z); + hiddenTiles.length = 0; + for (let i = 0; i < roots.length; i++) { + const root = roots[i]; + if (root.visible || exclude.has(root)) continue; + const extent = tileExtent(root); + if (extent && worldRay.intersectsSphere(extent)) hiddenTiles.push(root); + } + hiddenTiles.sort((x, y) => extents.get(x).radius - extents.get(y).radius); let limit = Infinity; let nearest = Infinity; - for (const [radius, mesh] of candidates) { + for (const root of hiddenTiles) { + const radius = extents.get(root).radius; if (radius > limit) break; - const distance = meshHitDistance(mesh, ray.far); - if (!Number.isFinite(distance)) continue; + tileMeshesScratch.length = 0; + collectMeshes(root, exclude, false, tileMeshesScratch); + let hit = Infinity; + for (const mesh of tileMeshesScratch) + hit = Math.min(hit, meshHitDistance(mesh, ray.far)); + if (!Number.isFinite(hit)) continue; if (limit === Infinity) limit = 1.5 * radius; - nearest = Math.min(nearest, distance); + nearest = Math.min(nearest, hit); } + hiddenTiles.length = 0; return nearest; } @@ -194,14 +224,17 @@ export function createNavaraSurface(view, hooks) { const sampleHeight = (place, exclude) => { if (!supported) return undefined; const ray = surfaceRay(place); - const { shown, hidden } = tileMeshes( - hooks.tileRoots(), - new Set(exclude ?? []), - ); + const roots = hooks.tileRoots(); + const excluded = exclude ? new Set(exclude) : NO_EXCLUDE; // The tiles on screen first; where they do not reach (the ground under - // a camera looking ahead is outside the frustum), the loaded hidden ones. - let distance = nearestHit(shown, ray); - if (!Number.isFinite(distance)) distance = finestHit(hidden, ray); + // a camera looking ahead is outside the frustum), the hidden ones. + shownMeshes.length = 0; + for (let i = 0; i < roots.length; i++) + collectMeshes(roots[i], excluded, true, shownMeshes); + let distance = nearestHit(shownMeshes, ray); + shownMeshes.length = 0; + if (!Number.isFinite(distance)) + distance = finestHiddenHit(roots, excluded, ray); return heightAlongRay(ray, distance) ?? terrainHeight(place); }; return { diff --git a/src/globe/navara/surface.test.mjs b/src/globe/navara/surface.test.mjs index 00b12d866..ffefb80a9 100644 --- a/src/globe/navara/surface.test.mjs +++ b/src/globe/navara/surface.test.mjs @@ -190,3 +190,28 @@ test('tilesLoaded follows Navara idle; a failed geometry patch means unsupported assert.equal(surface.supported, false); assert.equal(surface.clampToHeightSupported, false); }); + +test('among hidden tiles the finest tile wins by its extent, not by a small mesh in a coarse tile', () => { + // A coarse hidden tile (800 m across) carries a small 20 m mesh 5 m above + // the fine hidden tile's ground: by mesh size the coarse tile's small mesh + // would win; by tile extent the fine tile does. + const fine = tileAt(10, 45, 40); + const coarse = tileAt(10, 45, 30, 400); + const coarseRoof = tileAt(10, 45, 45, 10); + // One coarse tile with two meshes, both relative to its own centre. + coarseRoof.tile.remove(coarseRoof.mesh); + coarseRoof.mesh.position + .copy(coarseRoof.tile.position) + .sub(coarse.tile.position); + coarse.tile.add(coarseRoof.mesh); + coarse.tile.updateMatrixWorld(true); + fine.tile.visible = false; + coarse.tile.visible = false; + const { surface } = setup({ tiles: [coarse.tile, fine.tile] }); + near( + surface.sampleHeight(cartographic.fromDegrees(10, 45, 0)), + 40, + 0.05, + 'the fine tile', + ); +}); From ce985768bc0f788c3c4099e9de428e4fbb2871c3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH 5/6] test(qa): wheel floor against the floor's own ground; polite tile cache The Navara wheel check measures clearance against engine.groundFloor's ground (threshold 29 m: the floor keeps 30 m), so tiles streaming under a still camera cannot flake it. The tile cache identifies itself with the repository URL, keeps at most two misses in flight and merges identical ones. --- scripts/qa/tileCache.mjs | 52 ++++++++++++++++++++++++++++++++-- src/tooling/tileCache.test.mjs | 44 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/scripts/qa/tileCache.mjs b/scripts/qa/tileCache.mjs index b345e96e5..ec1aba3ff 100644 --- a/scripts/qa/tileCache.mjs +++ b/scripts/qa/tileCache.mjs @@ -16,6 +16,11 @@ export const CACHED_TILE_HOSTS = Object.freeze([ 'terrain.reearth.land', ]); const RETRIES = 4; +/** Who is asking, with a contact URL (the tile hosts' usage policies). */ +export const QA_USER_AGENT = + 'gods-eye-view-qa/1 (headless tests with a disk cache; https://github.com/bilawalsidhu/gods-eye-view)'; +/** Fetches to the tile hosts in flight at once. */ +export const MAX_MISSES_IN_FLIGHT = 2; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** @@ -59,7 +64,7 @@ export async function fetchTile( for (let attempt = 0; ; attempt++) { const response = await fetchImpl(url, { headers: { - 'User-Agent': 'gods-eye-view-qa (headless test, cached)', + 'User-Agent': QA_USER_AGENT, ...(accept ? { Accept: accept } : {}), }, }); @@ -73,6 +78,48 @@ export async function fetchTile( } } +/** + * Loads missing tiles politely: at most MAX_MISSES_IN_FLIGHT fetches at + * once (the rest queue), and one fetch for identical misses in flight (same + * URL and Accept). `load(url, accept)` resolves to fetchTile's result. + */ +export function createMissLoader({ + fetchImpl = fetch, + wait = sleep, + maxInFlight = MAX_MISSES_IN_FLIGHT, +} = {}) { + const pending = new Map(); + const queue = []; + let running = 0; + const next = () => { + while (running < maxInFlight && queue.length) { + const job = queue.shift(); + running++; + fetchTile(job.url, { accept: job.accept, fetchImpl, wait }) + .then(job.resolve, job.reject) + .finally(() => { + running--; + pending.delete(job.key); + next(); + }); + } + }; + return { + load(url, accept) { + const key = `${url}\n${accept ?? ''}`; + let promise = pending.get(key); + if (!promise) { + promise = new Promise((resolve, reject) => + queue.push({ key, url, accept, resolve, reject }), + ); + pending.set(key, promise); + next(); + } + return promise; + }, + }; +} + /** * Serves the page's requests to CACHED_TILE_HOSTS from the cache (filling * it on a miss). Returns the counts. @@ -84,6 +131,7 @@ export async function installTileCache( fs.mkdirSync(dir, { recursive: true }); const stats = { hits: 0, fetched: 0, cancelled: 0, failed: 0, errors: {} }; const client = await page.createCDPSession(); + const misses = createMissLoader(); client.on('Fetch.requestPaused', async ({ requestId, request }) => { const accept = Object.entries(request.headers ?? {}).find( ([name]) => name.toLowerCase() === 'accept', @@ -101,7 +149,7 @@ export async function installTileCache( }; stats.hits++; } else { - const tile = await fetchTile(request.url, { accept }); + const tile = await misses.load(request.url, accept); // Only successes are cached; a 404 (no tile there) is passed on. if (tile.status === 200) { fs.writeFileSync(file, tile.body); diff --git a/src/tooling/tileCache.test.mjs b/src/tooling/tileCache.test.mjs index 19ffcb879..5674f06b1 100644 --- a/src/tooling/tileCache.test.mjs +++ b/src/tooling/tileCache.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { backoffMs, + createMissLoader, fetchTile, tileCacheFile, } from '../../scripts/qa/tileCache.mjs'; @@ -69,3 +70,46 @@ test('a tile fetch retries 429s, stops after four retries, keeps Accept', async assert.equal(limited.status, 429); assert.equal(calls, 5); }); + +test('misses: at most two fetches in flight, identical ones merged, a contact UA', async () => { + let inFlight = 0; + let most = 0; + const calls = []; + const releases = []; + const loader = createMissLoader({ + fetchImpl: (url, init) => { + calls.push([url, init.headers['User-Agent']]); + inFlight++; + most = Math.max(most, inFlight); + return new Promise((resolve) => + releases.push(() => { + inFlight--; + resolve(response(200, { 'content-type': 'image/png' })); + }), + ); + }, + wait: async () => {}, + }); + const loads = [ + loader.load('a', null), + loader.load('a', null), + loader.load('b', null), + loader.load('c', null), + loader.load('a', 'image/webp'), + ]; + for (let round = 0; round < 10 && releases.length; round++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + releases.splice(0).forEach((release) => release()); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const tiles = await Promise.all(loads); + assert.ok(tiles.every((tile) => tile.status === 200)); + assert.equal(tiles[0], tiles[1], 'one fetch for two identical misses'); + assert.equal(calls.length, 4, 'a, b, c and a with another Accept'); + assert.ok(most <= 2, `${most} fetches in flight`); + assert.ok( + calls.every(([, agent]) => + agent.includes('https://github.com/bilawalsidhu/gods-eye-view'), + ), + ); +}); From 4aaf9746006b7cd9cae10e10dc0ae3511d91fc9d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:06:15 +0400 Subject: [PATCH 6/6] docs(migration): the review fixes, their measurements and QA --- src/globe/contract.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/globe/contract.js b/src/globe/contract.js index 8d07e22d9..f3e51f951 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -167,7 +167,8 @@ * Sharpen slider sets in 0.1..2.1). Omitted fields keep their value; a * non-boolean `enabled` or a negative or non-finite `amount` throws a * TypeError. Off with amount 1 until set. The Cesium engine only keeps the - * state: in Cesium mode the app's own post stage sharpens. + * state: in Cesium mode the app's own post stage sharpens and mirrors its + * state here (src/ui/visualEffects.js). * @property {() => { enabled: boolean, amount: number }} sharpen * The sharpening last set (a copy). *