fix(globe): Navara's shadowed sun leaked a light per frame

Navara 0.2.1's transparent pass refreshes its copy of every scene light
each frame with copy(light, true). For the sun's cascaded shadow lights
(three-csm CascadedDirectionalLights) that copy clones every cascade, so
each frame brought new DirectionalLights with new ids and three's
WebGLLights uniforms cache (keyed by id) grew by about 1.2 KB a frame in
the looks whose sun casts shadows (minimal, photoreal).

src/globe/navara/cascadedLightCopy.js patches the copy to update the
cascades it has in place (pose, colour, intensity, target, shadow
settings and camera) when the counts match, and keeps the rebuild
otherwise; internals.js finds the class among the view's scene lights
(private view._scenes.light) and applies it when the globe is built.
Tests on three-csm's own class: the original copy changes the light ids,
the patched one keeps the lights and takes the values.

Post-GC JS heap (CDP HeapProfiler.collectGarbage every 10 s, the app on
?engine=navara rendering continuously at 60 fps): minimal 4.33 → 0.20
MB/min over 70 s and 0.06 MB/min over 180 s; photoreal 4.34 → 0.19
MB/min; cinematic (no shadow) 0.34. Added to the upstream asks.
This commit is contained in:
German Khokhlov committed 2026-10-16 18:41:20 +04:00
1 parent c327a25744
commit 8812117013
5 files changed
+185 -2

No files matched your search

+2 -1
View File
@@ -1080,5 +1080,6 @@
"src/globe/navara/lines/lineMesh.test.mjs", "src/globe/navara/lines/lineMesh.test.mjs",
"src/globe/pickDelivery.test.mjs", "src/globe/pickDelivery.test.mjs",
"src/globe/navara/picking.test.mjs", "src/globe/navara/picking.test.mjs",
"src/data/selectionDispatcher.test.mjs" "src/data/selectionDispatcher.test.mjs",
"src/globe/navara/cascadedLightCopy.test.mjs"
] ]
+85
View File
@@ -0,0 +1,85 @@
// Navara 0.2.1 leak fix (phase 3 review): Navara's transparent pass keeps a
// copy of each scene light and refreshes it every frame with
// `copy.copy(light, true)`. For the sun's cascaded shadow lights
// (@navaramap/three-csm CascadedDirectionalLights) that copy clones every
// cascade, so each frame brings new DirectionalLight objects with new ids,
// and three's WebGLLights uniforms cache (keyed by light id) grows by about
// 1.2 KB a frame in looks with a shadowed sun. The patched copy updates the
// cascades it already has in place (pose, colour, intensity, target, shadow
// settings and camera) when the cascade counts match, and keeps the original
// rebuild otherwise. internals.js applies it to the class Navara uses.
// Upstream ask: copy cascaded lights in place (or sync without copy()).
const PATCHED = Symbol('gev.cascadedLightCopy');
function copyLightInPlace(target, source) {
target.position.copy(source.position);
target.quaternion.copy(source.quaternion);
target.scale.copy(source.scale);
target.matrix.copy(source.matrix);
target.matrixWorld.copy(source.matrixWorld);
target.matrixAutoUpdate = source.matrixAutoUpdate;
target.visible = source.visible;
target.castShadow = source.castShadow;
target.layers.mask = source.layers.mask;
target.color.copy(source.color);
target.intensity = source.intensity;
const to = target.target;
const from = source.target;
to.position.copy(from.position);
to.quaternion.copy(from.quaternion);
to.matrix.copy(from.matrix);
to.matrixWorld.copy(from.matrixWorld);
const shadow = target.shadow;
const original = source.shadow;
shadow.intensity = original.intensity;
shadow.bias = original.bias;
shadow.normalBias = original.normalBias;
shadow.radius = original.radius;
shadow.blurSamples = original.blurSamples;
shadow.mapSize.copy(original.mapSize);
shadow.camera.copy(original.camera);
shadow.camera.updateProjectionMatrix?.();
}
/**
* Patches CascadedDirectionalLights.prototype.copy (idempotent).
* @returns {() => void} restore the original copy
*/
export function patchCascadedLightCopy(proto) {
if (proto.copy[PATCHED]) return proto.copy[PATCHED];
const original = proto.copy;
if (typeof original !== 'function')
throw new Error('CascadedDirectionalLights.copy not found');
function copyInPlace(source, recursive) {
const mine = this.cascadedLights;
const theirs = source?.cascadedLights;
if (
!Array.isArray(mine) ||
!Array.isArray(theirs) ||
mine.length === 0 ||
mine.length !== theirs.length
)
return original.call(this, source, recursive);
// Object3D's own fields, without children (as the original's
// super.copy(source, false)).
this.name = source.name;
this.position.copy(source.position);
this.quaternion.copy(source.quaternion);
this.scale.copy(source.scale);
this.matrix.copy(source.matrix);
this.matrixWorld.copy(source.matrixWorld);
this.visible = source.visible;
this.direction?.copy?.(source.direction);
for (let index = 0; index < mine.length; index++)
copyLightInPlace(mine[index], theirs[index]);
return this;
}
copyInPlace.gevInPlace = true;
const restore = () => {
if (proto.copy === copyInPlace) proto.copy = original;
};
copyInPlace[PATCHED] = restore;
proto.copy = copyInPlace;
return restore;
}
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import csm from '@navaramap/three-csm';
import { patchCascadedLightCopy } from './cascadedLightCopy.js';
const { CascadedDirectionalLights } = csm;
function sun(count = 3) {
const light = new CascadedDirectionalLights(0xffffff, 2);
light.setCount(count);
light.cascadedLights.forEach((cascade, index) => {
cascade.position.set(index, 2, 3);
cascade.target.position.set(index, 0, 0);
cascade.updateMatrixWorld(true);
cascade.target.updateMatrixWorld(true);
cascade.shadow.bias = -0.001 * (index + 1);
});
return light;
}
test("Navara 0.2.1's copy clones new lights every call (the leak)", () => {
const source = sun();
const copy = new CascadedDirectionalLights();
copy.copy(source, true);
const ids = copy.cascadedLights.map((light) => light.id);
copy.copy(source, true);
assert.notDeepEqual(
copy.cascadedLights.map((light) => light.id),
ids,
);
});
test('patched, a copy keeps its lights and takes the values', () => {
const restore = patchCascadedLightCopy(CascadedDirectionalLights.prototype);
try {
const source = sun();
const copy = new CascadedDirectionalLights();
copy.copy(source, true); // first copy: builds the lights
const lights = [...copy.cascadedLights];
const targets = lights.map((light) => light.target);
source.cascadedLights[1].intensity = 5;
source.cascadedLights[1].color.set(0xff0000);
source.cascadedLights[2].position.set(9, 9, 9);
source.cascadedLights[2].target.position.set(7, 0, 0);
source.cascadedLights[2].updateMatrixWorld(true);
source.cascadedLights[2].target.updateMatrixWorld(true);
source.cascadedLights[0].shadow.camera.left = -77;
copy.copy(source, true);
assert.deepEqual(copy.cascadedLights, lights, 'the same light objects');
assert.deepEqual(
copy.cascadedLights.map((light) => light.target),
targets,
);
assert.equal(copy.cascadedLights[1].intensity, 5);
assert.equal(copy.cascadedLights[1].color.getHex(), 0xff0000);
assert.deepEqual(copy.cascadedLights[2].position.toArray(), [9, 9, 9]);
assert.equal(copy.cascadedLights[2].target.matrixWorld.elements[12], 7);
assert.equal(copy.cascadedLights[0].shadow.camera.left, -77);
assert.equal(copy.cascadedLights[2].shadow.bias, -0.003);
// A different cascade count rebuilds, as before.
source.setCount(4);
copy.copy(source, true);
assert.equal(copy.cascadedLights.length, 4);
assert.equal(
patchCascadedLightCopy(CascadedDirectionalLights.prototype),
restore,
'idempotent',
);
} finally {
restore();
}
assert.equal(CascadedDirectionalLights.prototype.copy.gevInPlace, undefined);
});
+7 -1
View File
@@ -6,7 +6,11 @@ import ThreeView from '@navaramap/three';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { DefaultPlugin } from '@navaramap/three-default-plugin';
import { createNavaraEngine } from './createNavaraEngine.js'; import { createNavaraEngine } from './createNavaraEngine.js';
import { forEachLuminanceUniform, navaraHooks } from './internals.js'; import {
forEachLuminanceUniform,
keepCascadedLightIds,
navaraHooks,
} from './internals.js';
import { buildNavaraLook } from './look.js'; import { buildNavaraLook } from './look.js';
import { createNavaraSharpen } from './sharpen.js'; import { createNavaraSharpen } from './sharpen.js';
import { createNavaraMovers } from './movers/index.js'; import { createNavaraMovers } from './movers/index.js';
@@ -112,6 +116,8 @@ export async function createNavaraGlobe({
() => engine.clock.frameTime(), () => engine.clock.frameTime(),
{ forEachLuminanceUniform }, { forEachLuminanceUniform },
); );
// The look's shadowed sun leaked a light per frame (internals.js).
keepCascadedLightIds(view);
engine = createNavaraEngine(view, { engine = createNavaraEngine(view, {
hooks: navaraHooks(view), hooks: navaraHooks(view),
container, container,
+18
View File
@@ -5,6 +5,7 @@
// found it and the public API that would replace it upstream. // found it and the public API that would replace it upstream.
import { ModelMesh } from '@navaramap/three'; import { ModelMesh } from '@navaramap/three';
import { patchCascadedLightCopy } from './cascadedLightCopy.js';
export const NAVARA_INTERNALS_VERSION = '0.2.1'; export const NAVARA_INTERNALS_VERSION = '0.2.1';
@@ -130,6 +131,23 @@ export function forEachLuminanceUniform(handles, visit) {
); );
} }
/**
* Phase 3 review (leak): the sun's cascaded shadow lights are re-cloned by
* the transparent pass every frame (cascadedLightCopy.js). Finds the
* CascadedDirectionalLights class among the view's scene lights (private:
* view._scenes.light) and patches its copy to update in place. Returns the
* restore function, or null when the look has no cascaded light (the
* cinematic sun casts no shadow). Upstream ask: copy cascaded lights in
* place.
*/
export function keepCascadedLightIds(view) {
const lights = view._scenes?.light?.children ?? [];
const cascaded = lights.find((light) => Array.isArray(light?.cascadedLights));
return cascaded
? patchCascadedLightCopy(Object.getPrototypeOf(cascaded))
: null;
}
/** The hooks createNavaraEngine takes. */ /** The hooks createNavaraEngine takes. */
export const navaraHooks = (view) => ({ export const navaraHooks = (view) => ({
retainTileGeometry, retainTileGeometry,