diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md new file mode 100644 index 000000000..08bbbad81 --- /dev/null +++ b/changelog.d/1367-turned-off-layer-memory.md @@ -0,0 +1,4 @@ +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b9bc87ad2..b15babbb2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2159,6 +2159,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > always exits non-zero, `1` meaning the control is healthy and `2` meaning it > is not. Plus the unit pins above. +> **2026-10-24 — a turned-off layer's memory is released:** Cesium 1.138 +> keeps the draw commands past each frame's count in the opaque and other +> per-pass arrays (`View.frustumCommandsList`) and in the offscreen pick view, +> so a turned-off layer's entities stayed reachable (Data Centers kept 116 +> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`, +> requests one frame through `governorRequestRender` after every layer +> disable and then trims those arrays. It reads private Cesium fields; +> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames +> them or changes which passes it truncates. + > **2026-08-08 — performance waves 1+2:** the app idles via an explicit render > governor (`src/renderGovernor.js` — hold/release from every per-frame > animator; discrete mutators call `governorRequestRender`). Any NEW diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c84a11cad..68a57066a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2574,7 +2574,8 @@ "src/voice/geminiSession.js", "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/sceneCommandRelease.js" ], "external": [ "hls.js", diff --git a/src/app/tools.js b/src/app/tools.js index 7b3ea83c9..7aea39c22 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, getRenderGovernorDiagnostics, @@ -110,6 +111,15 @@ export function createApplicationTools({ // nothing animates per frame. Installed AFTER every module above has had // its chance to register pre-install holds. (perf wave 2) installRenderGovernor(viewer); + // Cesium keeps a turned-off layer's draw commands (and so all its entities) + // alive in reused per-pass arrays; trim them after each disable. + defer( + installLayerReleaseTrim({ + scene: viewer.scene, + dataManager, + requestRender: governorRequestRender, + }), + ); // Install the explicit scope mask used by the DISPLAY controls. installScopeMask(viewer); diff --git a/src/sceneCommandRelease.js b/src/sceneCommandRelease.js new file mode 100644 index 000000000..4d8594401 --- /dev/null +++ b/src/sceneCommandRelease.js @@ -0,0 +1,74 @@ +/** + * Let a turned-off layer's memory go. + * + * Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command + * arrays: every frame resets `frustumCommands.indices[pass]` to 0 and + * overwrites `commands[pass][i]` from the start. `Scene.js` truncates the + * voxel, Gaussian-splat and translucent passes to their counts; the opaque + * and other passes keep the slots past the new count. When a layer is turned + * off the next frame has fewer commands, so that tail keeps the destroyed + * primitives' DrawCommands, and through `_owner` → primitive → entity → + * EntityCollection the whole data source stays reachable. The offscreen view + * `scene.sampleHeight` / `pickFromRay` render into keeps its last list the + * same way, until the next height sample. + * + * Measured (production build, heap after GC, Data Centers on → off): 116 MiB + * kept on main, 13 MiB with this module; heap snapshots traced the entities + * through `View.frustumCommandsList` and `Picking._pickOffscreenView`. + * + * After a layer finishes turning off, this renders one frame, trims the main + * view's arrays to that frame's counts, and empties the offscreen pick view's + * lists (it rebuilds them in full on every pick). It reads private Cesium + * fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed + * or if Cesium changes which passes it truncates, and once Cesium clears the + * tail itself this module can go. + */ + +/** Drop command references a view no longer draws. */ +function trimView(view, { keepCurrent }) { + for (const frustum of view?.frustumCommandsList || []) { + const { commands, indices } = frustum; + for (let pass = 0; pass < commands.length; pass += 1) { + const keep = keepCurrent ? indices[pass] : 0; + if (commands[pass].length > keep) commands[pass].length = keep; + if (!keepCurrent) indices[pass] = 0; + } + } + if (!keepCurrent) + for (const extent of view?._commandExtents || []) + extent.command = undefined; +} + +/** + * Release stale command references in the scene's main view (keeping what + * the last frame drew) and in its offscreen pick view (all of it). + * @param {object} scene Cesium Scene, right after a render. + */ +export function trimStaleSceneCommands(scene) { + trimView(scene._defaultView, { keepCurrent: true }); + trimView(scene._picking?._pickOffscreenView, { keepCurrent: false }); +} + +/** + * After every layer disable, render one frame and trim once it is drawn. + * @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps + * @returns {() => void} uninstall + */ +export function installLayerReleaseTrim({ scene, dataManager, requestRender }) { + let removeOneShot = null; + const unsubscribe = dataManager.subscribe((change) => { + if (change?.type !== 'visibility' || change.enabled !== false) return; + if (removeOneShot) return; // a trim is already waiting for its frame + removeOneShot = scene.postRender.addEventListener(() => { + removeOneShot(); + removeOneShot = null; + trimStaleSceneCommands(scene); + }); + requestRender('layer-release-trim'); + }); + return () => { + unsubscribe(); + removeOneShot?.(); + removeOneShot = null; + }; +} diff --git a/src/sceneCommandRelease.test.mjs b/src/sceneCommandRelease.test.mjs new file mode 100644 index 000000000..d88037872 --- /dev/null +++ b/src/sceneCommandRelease.test.mjs @@ -0,0 +1,137 @@ +// A turned-off layer's draw commands must not stay reachable through Cesium's +// reused per-pass command arrays (src/sceneCommandRelease.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + installLayerReleaseTrim, + trimStaleSceneCommands, +} from './sceneCommandRelease.js'; + +const frustum = (lists, counts) => ({ + commands: lists.map((list) => [...list]), + indices: [...counts], +}); + +test('the main view keeps what the last frame drew and drops the stale tail', () => { + const view = { + frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])], + }; + trimStaleSceneCommands({ _defaultView: view }); + assert.deepEqual(view.frustumCommandsList[0].commands, [ + ['a', 'b'], + ['c'], + [], + ]); + assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]); +}); + +test('the offscreen pick view is emptied, since every pick rebuilds it', () => { + const extents = [{ command: 'x' }, { command: 'y' }]; + const pick = { + frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])], + _commandExtents: extents, + }; + trimStaleSceneCommands({ + _defaultView: { frustumCommandsList: [] }, + _picking: { _pickOffscreenView: pick }, + }); + assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]); + assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]); + assert.deepEqual( + extents.map((e) => e.command), + [undefined, undefined], + ); +}); + +test('a layer disable renders one frame and trims after it', () => { + const listeners = new Set(); + const postRender = new Set(); + const requests = []; + const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] }; + const scene = { + _defaultView: view, + postRender: { + addEventListener(fn) { + postRender.add(fn); + return () => postRender.delete(fn); + }, + }, + }; + const uninstall = installLayerReleaseTrim({ + scene, + dataManager: { + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }, + requestRender: (reason) => requests.push(reason), + }); + const emit = (change) => listeners.forEach((fn) => fn(change)); + + emit({ type: 'visibility', layerId: 'x', enabled: true }); + assert.equal(requests.length, 0, 'enabling trims nothing'); + emit({ type: 'visibility', layerId: 'x', enabled: false }); + emit({ type: 'visibility', layerId: 'y', enabled: false }); + assert.deepEqual( + requests, + ['layer-release-trim'], + 'one frame for back-to-back disables', + ); + assert.equal( + view.frustumCommandsList[0].commands[0].length, + 2, + 'not before the frame', + ); + postRender.forEach((fn) => fn()); + assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]); + assert.equal(postRender.size, 0, 'the frame listener is one-shot'); + uninstall(); + assert.equal(listeners.size, 0); +}); + +// The workaround reads private Cesium fields. Pin the shape it relies on, and +// fail when Cesium starts clearing the tail itself so the module can go. +test('Cesium still reuses per-pass command arrays without clearing their tail', () => { + const require = createRequire(import.meta.url); + const enginePkg = require.resolve('@cesium/engine/package.json'); + const read = (rel) => + readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8'); + const scene = read('./Source/Scene/Scene.js'); + const view = read('./Source/Scene/View.js'); + const frustumCommands = read('./Source/Scene/FrustumCommands.js'); + const picking = read('./Source/Scene/Picking.js'); + // Every private field the trim reads. A rename would otherwise turn the + // trim into a silent no-op (it reaches them through `?.` and `|| []`). + assert.match(scene, /this\._picking = new Picking\(this\);/); + assert.match(scene, /this\._defaultView = new View\(/); + assert.match(picking, /this\._pickOffscreenView = new View\(/); + assert.match(view, /this\.frustumCommandsList = \[\];/); + assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/); + assert.match(view, /this\._commandExtents = \[\];/); + assert.match( + frustumCommands, + /this\.commands = commands;\s*this\.indices = indices;/, + ); + // Cesium truncates only the voxel, Gaussian-splat and translucent passes, + // each in its own draw step; the opaque and other passes keep their tail. + // A change here means Cesium's handling moved: re-check whether this + // module is still needed. + assert.doesNotMatch( + view, + /\.length\s*=\s*[^;]*indices\[/, + 'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js', + ); + assert.deepEqual( + [ + ...scene.matchAll( + /commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g, + ), + ].map((match) => match[1]), + ['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'], + 'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js', + ); +});