From 3a2a53aef99aae2e20acb8b82e9231abec3e8ff8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 17:26:34 +0400 Subject: [PATCH] Anchor Navara inspection sunlight to the restored view --- src/app/navaraMode.js | 5 +++++ src/globe/navara/index.js | 16 +++++++------- src/globe/navara/look.js | 37 ++++++++++++++++++++++++++++++--- src/globe/navara/look.test.mjs | 30 ++++++++++++++++++++++++++ src/globe/navara/viewOptions.js | 4 ++-- 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 96c39669d..d837853d6 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -199,6 +199,7 @@ export async function createNavaraScene({ const { engine } = globe; navaraViews.set(engine, { view: globe.view, + alignDaylightToRestoredView: globe.alignDaylightToRestoredView, MeshDesc: globe.MeshDesc, EffectDesc: globe.EffectDesc, NavaraEffect: globe.NavaraEffect, @@ -264,6 +265,10 @@ export function createViewerlessTools({ defer, }) { const { dataManager } = data; + void styleManager.initialRestorePromise?.then(() => { + if (!signal.aborted) + navaraViews.get(engine)?.alignDaylightToRestoredView?.(); + }); const recentImagery = dataManager.layers.get('recent-imagery')?.module; if (recentImagery) { const tool = initNavaraImageryBoxTool({ diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 2d02da876..49f7dd4d3 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -109,6 +109,7 @@ export async function createNavaraGlobe({ let view; let engine; let overlay = null; + let look; let removeLook = null; let removePostSampling = null; try { @@ -123,13 +124,9 @@ export async function createNavaraGlobe({ }); // The sun follows the engine clock (one time source for layers and the // look); the look only runs in preRender, after the engine exists. - const look = buildNavaraLook( - view, - plugin, - plan, - () => engine.clock.frameTime(), - { forEachLuminanceUniform }, - ); + look = buildNavaraLook(view, plugin, plan, () => engine.clock.frameTime(), { + forEachLuminanceUniform, + }); // The look's shadowed sun leaked a light per frame (internals.js). keepCascadedLightIds(view); removePostSampling = createPostSampling(view); @@ -213,6 +210,11 @@ export async function createNavaraGlobe({ NavaraEffect, DrapedMesh, setupMaterialForDrape, + // Startup can fly from the engine's placeholder camera into a shared view. + alignDaylightToRestoredView() { + look.resetDaylight(); + engine.render.request('restored-daylight'); + }, setRenderLoop: (enabled) => setRenderLoopEnabled(view, enabled), destroy() { if (destroyed) return; diff --git a/src/globe/navara/look.js b/src/globe/navara/look.js index 020e106f1..97624cf23 100644 --- a/src/globe/navara/look.js +++ b/src/globe/navara/look.js @@ -86,6 +86,9 @@ export function buildNavaraLook( let cloudsShown = true; let cloudsSunlit = true; let landscape = false; + let daylightTime = null; + let fillLight; + let fill = 0; const tone = effect('toneMapping'); let starsShown = true; @@ -100,13 +103,35 @@ export function buildNavaraLook( let changed = brighten(!landscapeRequested); if (landscapeRequested !== landscape && tone) { landscape = landscapeRequested; + daylightTime = null; view.toneMappingExposure = landscape ? 10 : 1; tone.update({ toneMapping: { mode: landscape ? AGX : ACES_FILMIC } }); changed = true; } + // In orbit, retain some of the underlying imagery in the atmospheric + // shadow. Fade up with altitude so ground views keep their original light. + const nextFill = + landscape && Number.isFinite(height) + ? Math.min(1, Math.max(0, (height - 50_000) / 250_000)) + : 0; + if (nextFill !== fill) { + fill = nextFill; + if (fillLight) fillLight.update({ ambient: { intensity: fill } }); + else fillLight = view.addLight({ ambient: { intensity: fill } }); + changed = true; + } + const opacity = aerial?.ref?.raw?.rawEffect?.blendMode.opacity; + const strength = 1 - 0.2 * fill; + if (opacity && opacity.value !== strength) { + opacity.value = strength; + changed = true; + } const atmosphere = view.atmosphere; - const time = - landscape && place ? landscapeSunTime(now(), place.lng) : now(); + // Choose inspection daylight once. Rotating the camera must not rotate + // the terminator across the geography. + if (landscape && place && daylightTime === null) + daylightTime = landscapeSunTime(now(), place.lng); + const time = daylightTime ?? now(); if (atmosphere && sunDateStale(atmosphere.date.getTime(), time)) { atmosphere.date = new Date(time); changed = true; @@ -170,7 +195,13 @@ export function buildNavaraLook( } return changed; }; - return { handles, frame }; + return { + handles, + frame, + resetDaylight() { + daylightTime = null; + }, + }; } const IRRADIANCE_ON = Object.freeze({ diff --git a/src/globe/navara/look.test.mjs b/src/globe/navara/look.test.mjs index 66060ac45..d751da061 100644 --- a/src/globe/navara/look.test.mjs +++ b/src/globe/navara/look.test.mjs @@ -167,20 +167,50 @@ test('daylight inspection restores live exposure, tone mapping and time when lef const tone = view.added.find(({ desc }) => Object.hasOwn(desc, 'toneMapping'), ); + const aerial = view.added.find(({ desc }) => + Object.hasOwn(desc, 'aerialPerspective'), + ); + const opacity = { value: 1 }; + aerial.ref = { raw: { rawEffect: { blendMode: { opacity } } } }; const fuji = { lat: 35.2599, lng: 138.6574 }; look.frame(6508, false, 2600, fuji, true); assert.equal(view.toneMappingExposure, 10); + assert.equal(opacity.value, 1); assert.equal(tone.updates.at(-1).toneMapping.mode, 7); assert.ok( Math.abs(view.atmosphere.date.getTime() / 3_600_000 + fuji.lng / 15 - 13) < 1e-6, ); + look.frame(750_149, false, 0, fuji, true); + assert.ok(opacity.value < 1); + const ambient = view.added.find(({ desc }) => Object.hasOwn(desc, 'ambient')); + assert.equal(ambient.desc.ambient.intensity, 1); look.frame(6508, false, 2600, fuji, false); + assert.equal(ambient.updates.at(-1).ambient.intensity, 0); assert.equal(view.toneMappingExposure, 1); + assert.equal(opacity.value, 1); assert.equal(tone.updates.at(-1).toneMapping.mode, 6); assert.equal(view.atmosphere.date.getTime(), 0); }); +test('inspection sunlight stays fixed through orbit, zoom and move end', () => { + const view = fakeView(0); + const look = warmCinematic(view); + const origin = { lat: -45.9847, lng: 67.8795 }; + const rotated = { lat: -20, lng: -158 }; + look.frame(50_151_111, false, 0, origin, true); + const sunDate = view.atmosphere.date; + look.frame(50_151_111, true, 0, rotated, true); + look.frame(750_149, false, 0, rotated, true); + assert.equal(view.atmosphere.date, sunDate); + look.resetDaylight(); + look.frame(750_149, false, 0, rotated, true); + assert.notEqual(view.atmosphere.date.getTime(), sunDate.getTime()); + const restored = view.atmosphere.date; + look.frame(50_151_111, false, 0, origin, true); + assert.equal(view.atmosphere.date, restored); +}); + test('the sun follows the wall clock, a minute at most behind, only on drawn frames', () => { let now = 1_000_000; const view = fakeView(now); diff --git a/src/globe/navara/viewOptions.js b/src/globe/navara/viewOptions.js index 4500cd035..bb4c75066 100644 --- a/src/globe/navara/viewOptions.js +++ b/src/globe/navara/viewOptions.js @@ -52,8 +52,8 @@ export function resolveNavaraLook(search = '') { export const ACES_FILMIC = 6; export const AGX = 7; -// The Sentinel-2 landscape uses early afternoon at the camera longitude, -// independently of the telemetry clock. Only the displayed sunlight moves. +// Pick early afternoon at the starting landscape longitude. The look keeps +// this timestamp while the user explores; telemetry uses its own clock. export function landscapeSunTime(now, longitude) { const hour = 3_600_000; const day = 24 * hour;