From 05b646daa3e0c0d4940e1b711d3b8332ff726259 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 05:16:20 +0400 Subject: [PATCH] Unsubscribe removed Navara effects and models from warm view frames --- scripts/package-boundaries.json | 1 + src/app/navaraMode.js | 4 +- src/globe/navara/frameDescriptor.js | 18 ++++++++ src/globe/navara/frameDescriptor.test.mjs | 43 +++++++++++++++++++ src/globe/navara/index.js | 2 + src/globe/navara/movers/index.js | 5 ++- src/globe/navara/movers/moverMesh.test.mjs | 10 +++-- src/globe/navara/overlay.js | 5 ++- src/globe/navara/sharpen.js | 5 ++- src/layers/earthquakes/render/navara.js | 2 +- src/layers/installations/render/navara.js | 2 +- .../recentImagery/render/navaraGround.js | 2 +- src/layers/shared/render/navaraGroundCells.js | 2 +- src/layers/transit/render/navaraTrail.js | 2 +- src/layers/weather/render/navaraSurface.js | 2 +- src/standalone/rendererCache.js | 4 +- 16 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 src/globe/navara/frameDescriptor.js create mode 100644 src/globe/navara/frameDescriptor.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dfaab3c28..f1fd57bad 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3616,6 +3616,7 @@ "src/geo/developerError.js", "src/time/clock.js", "src/globe/navara/overlay.js", + "src/globe/navara/frameDescriptor.js", "src/globe/cesium/clock.js", "src/globe/pickDelivery.js", "src/globe/navara/clock.js", diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 2b652f165..b88aec6a1 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -273,7 +273,7 @@ export async function createNavaraScene({ alignDaylightToRestoredView: globe.alignDaylightToRestoredView, MeshDesc: globe.MeshDesc, NavaraColor: globe.NavaraColor, - EffectDesc: globe.EffectDesc, + EffectDesc: globe.LayerEffectDesc, NavaraEffect: globe.NavaraEffect, DrapedMesh: globe.DrapedMesh, setupMaterialForDrape: globe.setupMaterialForDrape, @@ -441,7 +441,7 @@ export function createViewerlessTools({ if (window.__godsEyeView === debug) delete window.__godsEyeView; }); // Voice on the engine (phase 3, Task 9): the W1 tools drive the engine; - // the viewer-only ones (director, annotations…) refuse. + // the Director uses the shared session; viewer-only tools declare availability. const voiceCommands = initGevVoiceCommands({ ...voice, annotationResolver: operations.annotationResolver, diff --git a/src/globe/navara/frameDescriptor.js b/src/globe/navara/frameDescriptor.js new file mode 100644 index 000000000..a45f976cf --- /dev/null +++ b/src/globe/navara/frameDescriptor.js @@ -0,0 +1,18 @@ +// Navara auto-binds descriptor.update on creation but does not unregister +// that bound function when its handle is deleted. GEV's synchronous effects +// and meshes subscribe with their own callback so a parked view can be reused. +export function withFrameUpdates(Base) { + return class extends Base { + #frame = (time) => this.updateFrame(time); + + onCreate() { + super.onCreate(); + this.view.on('preRender', this.#frame); + } + + onDestroy() { + this.view.off('preRender', this.#frame); + super.onDestroy(); + } + }; +} diff --git a/src/globe/navara/frameDescriptor.test.mjs b/src/globe/navara/frameDescriptor.test.mjs new file mode 100644 index 000000000..45142194b --- /dev/null +++ b/src/globe/navara/frameDescriptor.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { withFrameUpdates } from './frameDescriptor.js'; + +test('deleting a descriptor unsubscribes only that renderer from the warm view', () => { + const frames = new Set(); + const view = { + on(name, cb) { + assert.equal(name, 'preRender'); + frames.add(cb); + }, + off(name, cb) { + assert.equal(name, 'preRender'); + frames.delete(cb); + }, + }; + class Base { + view = view; + onCreate() {} + onDestroy() { + this.destroyed = true; + } + } + class Layer extends withFrameUpdates(Base) { + ticks = []; + updateFrame(time) { + this.ticks.push(time); + } + } + const a = new Layer(), + b = new Layer(); + a.onCreate(); + b.onCreate(); + assert.equal(typeof a.update, 'undefined', 'no duplicate SDK subscription'); + for (const cb of frames) cb(1); + a.onDestroy(); + for (const cb of frames) cb(2); + assert.deepEqual(a.ticks, [1]); + assert.deepEqual(b.ticks, [1, 2]); + assert.equal(a.destroyed, true); + b.onDestroy(); + assert.equal(frames.size, 0); +}); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 4b733ec45..0f0e1045e 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -2,6 +2,7 @@ // the product way (viewOptions.js) and the GlobeEngine on it. Load it with a // dynamic import() so Navara stays out of the Cesium-mode bundle. +import { withFrameUpdates } from './frameDescriptor.js'; import ThreeView, { Color as NavaraColor, MeshDesc, @@ -209,6 +210,7 @@ export async function createNavaraGlobe({ NavaraColor, MeshDesc, EffectDesc, + LayerEffectDesc: withFrameUpdates(EffectDesc), NavaraEffect, DrapedMesh, setupMaterialForDrape, diff --git a/src/globe/navara/movers/index.js b/src/globe/navara/movers/index.js index 5cd5f3768..ce21e5710 100644 --- a/src/globe/navara/movers/index.js +++ b/src/globe/navara/movers/index.js @@ -9,6 +9,7 @@ // asks for one frame (view.forceUpdate); per-frame work runs only on frames // the view draws anyway (S5), so an idle view stays idle. +import { withFrameUpdates } from '../frameDescriptor.js'; import { MeshDesc, PickableInstancedMeshWrapper, @@ -25,7 +26,7 @@ import { createNavaraOverlay } from '../overlay.js'; const DESC_NAME = 'gevMoverSet'; const registered = new WeakSet(); -class GevMoverSetDesc extends MeshDesc { +class GevMoverSetDesc extends withFrameUpdates(MeshDesc) { constructor(view, ctx, config) { super(view, ctx, config); this.gev = config[DESC_NAME]; @@ -59,7 +60,7 @@ class GevMoverSetDesc extends MeshDesc { return group; } - update(time) { + updateFrame(time) { updateMover(this.gev.readFrameCamera, this.ctx, this.gev.mover, time); } diff --git a/src/globe/navara/movers/moverMesh.test.mjs b/src/globe/navara/movers/moverMesh.test.mjs index d8a5990d8..0ff503c07 100644 --- a/src/globe/navara/movers/moverMesh.test.mjs +++ b/src/globe/navara/movers/moverMesh.test.mjs @@ -27,15 +27,19 @@ test('disposing a sprite atlas drops decoded images and detaches pending load ca const previous = globalThis.Image; let image; globalThis.Image = class { - constructor() { image = this; } + constructor() { + image = this; + } }; t.after(() => { if (previous) globalThis.Image = previous; else delete globalThis.Image; }); const options = normalizeMoverSetOptions({ - ...spriteOptions, sprite: { - sizePx: 24, atlas: { url: '/bracket.svg', cells: { a: [0, 0, 1, 1] } }, + ...spriteOptions, + sprite: { + sizePx: 24, + atlas: { url: '/bracket.svg', cells: { a: [0, 0, 1, 1] } }, }, }); for (const loaded of [false, true]) { diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 919017608..5b0281691 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -12,6 +12,7 @@ // FIRMS opts into a separate native-stage overlay before sharpening/AA. // Only that stage owns an encoded-RGBA8 blend target; W1 stays in place. +import { withFrameUpdates } from './frameDescriptor.js'; import { EffectDesc, Pass as NavaraPass } from '@navaramap/three'; import { GevOverlayPass } from './overlayPass.js'; import { RGBADepthPacking, Scene } from 'three'; @@ -20,7 +21,7 @@ export const OVERLAY_EFFECT_KEY = 'gevOverlay'; const PRE_SHARPEN_KEY = 'gevPreSharpenOverlay'; const registeredViews = new WeakMap(); -class GevOverlayEffectDesc extends EffectDesc { +class GevOverlayEffectDesc extends withFrameUpdates(EffectDesc) { static key = OVERLAY_EFFECT_KEY; // Navara takes the first existing anchor, so the latest pass comes first. static insertAfter = ['fxaa', 'smaa', 'gevSharpen', 'toneMapping']; @@ -56,7 +57,7 @@ class GevOverlayEffectDesc extends EffectDesc { // Each drawn frame: the scene depth the overlays test against, then each // overlay's own per-frame work (pixel footprint, origin, viewport). - update(time) { + updateFrame(time) { const mrt = this.find('mrt'); this.overlay.uniforms.tGevSceneDepth.value = mrt?.depthBufferPacking === RGBADepthPacking ? mrt.depthBuffer : null; diff --git a/src/globe/navara/sharpen.js b/src/globe/navara/sharpen.js index 02c1ea806..e83b810ff 100644 --- a/src/globe/navara/sharpen.js +++ b/src/globe/navara/sharpen.js @@ -20,6 +20,7 @@ // has no public call to merge effects. A disabled pass is skipped: off costs // nothing. +import { withFrameUpdates } from './frameDescriptor.js'; import { Effect as NavaraEffect, EffectDesc } from '@navaramap/three'; import { Effect, EffectAttribute } from 'postprocessing'; import { RGBADepthPacking, Uniform } from 'three'; @@ -86,7 +87,7 @@ class UnsharpMaskEffect extends Effect { } /** config.gevSharpen: { amount } (Cesium's `amount` uniform). */ -export class SharpenEffectDesc extends EffectDesc { +export class SharpenEffectDesc extends withFrameUpdates(EffectDesc) { static key = SHARPEN_EFFECT_KEY; static insertAfter = ['toneMapping']; static insertBefore = ['smaa', 'fxaa', 'gevOverlay', 'final']; @@ -105,7 +106,7 @@ export class SharpenEffectDesc extends EffectDesc { // Each frame: the scene depth for the sky mask, when Navara packs it as // RGBA (0.2.1 does); otherwise the whole frame is sharpened. - update() { + updateFrame() { if (!this.effect) return; const mrt = this.find('mrt'); const depth = diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js index 5741c4cc3..e7848a2c9 100644 --- a/src/layers/earthquakes/render/navara.js +++ b/src/layers/earthquakes/render/navara.js @@ -230,7 +230,7 @@ function groundEffectClass(EffectDesc, NavaraEffect) { }); return new NavaraEffect(this.view.camera.raw, this.effect); } - update() { + updateFrame() { const state = this.owner.state, camera = this.view.camera.raw, mrt = this.find('mrt'); diff --git a/src/layers/installations/render/navara.js b/src/layers/installations/render/navara.js index a6888c357..9ccc6cebb 100644 --- a/src/layers/installations/render/navara.js +++ b/src/layers/installations/render/navara.js @@ -40,7 +40,7 @@ function createDrapeEffectClass(EffectDesc, NavaraEffect) { return new NavaraEffect(this.view.camera.raw, this.effect); } - update() { + updateFrame() { const mrt = this.find('mrt'); this.owner.globeNormal.value = mrt?.globeNormalBuffer ?? null; this.owner.globeDepth.value = mrt?.globeDepthBuffer ?? null; diff --git a/src/layers/recentImagery/render/navaraGround.js b/src/layers/recentImagery/render/navaraGround.js index 3bfd0b57b..46528c3a8 100644 --- a/src/layers/recentImagery/render/navaraGround.js +++ b/src/layers/recentImagery/render/navaraGround.js @@ -108,7 +108,7 @@ export function createImageryGround( }); return new NavaraEffect(this.view.camera.raw, this.effect); } - update() { + updateFrame() { if (!this.effect) return; const camera = this.view.camera.raw, u = this.effect.uniforms, diff --git a/src/layers/shared/render/navaraGroundCells.js b/src/layers/shared/render/navaraGroundCells.js index ba0d34acd..c2a28b0bb 100644 --- a/src/layers/shared/render/navaraGroundCells.js +++ b/src/layers/shared/render/navaraGroundCells.js @@ -244,7 +244,7 @@ function groundEffectClass(EffectDesc, NavaraEffect) { }); return new NavaraEffect(this.view.camera.raw, this.effect); } - update() { + updateFrame() { const state = this.owner.state, camera = this.view.camera.raw; if (!this.effect || !state || !camera) return; diff --git a/src/layers/transit/render/navaraTrail.js b/src/layers/transit/render/navaraTrail.js index bd3e72d76..239725469 100644 --- a/src/layers/transit/render/navaraTrail.js +++ b/src/layers/transit/render/navaraTrail.js @@ -232,7 +232,7 @@ function descriptor(EffectDesc, NavaraEffect) { }); return new NavaraEffect(this.view.camera.raw, this.effect); } - update() { + updateFrame() { if (!this.effect) return; const { state, displayT, active, color, pixelRatio } = this.owner; const camera = this.view.camera.raw; diff --git a/src/layers/weather/render/navaraSurface.js b/src/layers/weather/render/navaraSurface.js index c9d166703..a2ea37dd1 100644 --- a/src/layers/weather/render/navaraSurface.js +++ b/src/layers/weather/render/navaraSurface.js @@ -118,7 +118,7 @@ function weatherPass(engine, { view, EffectDesc, NavaraEffect }) { }); return new NavaraEffect(this.view.camera.raw, this.effect); } - update() { + updateFrame() { if (!this.effect) return; const camera = this.view.camera.raw, u = this.effect.uniforms; diff --git a/src/standalone/rendererCache.js b/src/standalone/rendererCache.js index 4b4bb8c9f..49db694df 100644 --- a/src/standalone/rendererCache.js +++ b/src/standalone/rendererCache.js @@ -1,5 +1,5 @@ -// Only the renderer and its map survive a switch. The outgoing UI, data feeds, -// tools and their subscriptions are still disposed with their application. +// This cache owns the parked renderer and its map. The layer session owns +// feeds, selection, media and the Director across both renderer attachments. export function createRendererCache() { const idle = new Map(); let retainKind;