diff --git a/src/globe/navara/frameDescriptor.js b/src/globe/navara/frameDescriptor.js index 771a6bfc2..ca5bed500 100644 --- a/src/globe/navara/frameDescriptor.js +++ b/src/globe/navara/frameDescriptor.js @@ -33,3 +33,12 @@ export function withPassDisposal(Base) { } }; } + +// The base the layers' Navara effects extend (engine.LayerEffectDesc): per +// frame updates on their own callback, and the raw pass disposed when the +// effect is deleted. A layer re-creates its effect on every attach (an +// engine switch), so an undisposed pass kept its material, its shader +// program and the uniforms' data textures alive for the page's lifetime. +export function layerEffectDesc(EffectDesc) { + return withPassDisposal(withFrameUpdates(EffectDesc)); +} diff --git a/src/globe/navara/frameDescriptor.test.mjs b/src/globe/navara/frameDescriptor.test.mjs index 32510815c..3d1f14e63 100644 --- a/src/globe/navara/frameDescriptor.test.mjs +++ b/src/globe/navara/frameDescriptor.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { withFrameUpdates, withPassDisposal } from './frameDescriptor.js'; +import { + layerEffectDesc, + withFrameUpdates, + withPassDisposal, +} from './frameDescriptor.js'; test('deleting a descriptor unsubscribes only that renderer from the warm view', () => { const frames = new Set(); @@ -77,3 +81,46 @@ test('a deleted effect disposes the pass the composer no longer holds', () => { assert.throws(() => new (withPassDisposal(Failing))().onDestroy(), /removal/); assert.deepEqual(events, ['dispose']); }); + +test('a layer effect re-created on every attach leaves no pass, material or frame callback behind', () => { + const frames = new Set(); + const view = { + on: (name, cb) => frames.add(cb), + off: (name, cb) => frames.delete(cb), + }; + const live = new Set(); + class EffectDesc { + view = view; + onCreate() { + const pass = { + material: {}, + dispose: () => live.delete(pass), + }; + live.add(pass); + this._instance = { rawPass: pass }; + } + get raw() { + return this._instance; + } + onDestroy() { + // Navara's removePass: out of the composer, not disposed. + this._instance = undefined; + } + } + // As the earthquakes ground effect: disposes its own effect, then super. + class LayerEffect extends layerEffectDesc(EffectDesc) { + updateFrame() {} + onDestroy() { + this.effectDisposed = true; + super.onDestroy(); + } + } + for (let attach = 0; attach < 50; attach++) { + const effect = new LayerEffect(); + effect.onCreate(); + effect.onDestroy(); + assert.equal(effect.effectDisposed, true); + } + assert.equal(live.size, 0, 'every pass of a deleted effect is disposed'); + assert.equal(frames.size, 0, 'no frame callback outlives its effect'); +}); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 54894c2a2..c6ff0d621 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -2,7 +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 { layerEffectDesc, withFrameUpdates } from './frameDescriptor.js'; import ThreeView, { Color as NavaraColor, MeshDesc, @@ -241,7 +241,7 @@ export async function createNavaraGlobe({ NavaraColor, MeshDesc, EffectDesc, - LayerEffectDesc: withFrameUpdates(EffectDesc), + LayerEffectDesc: layerEffectDesc(EffectDesc), NavaraEffect, DrapedMesh, setupMaterialForDrape,