diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 61149f281..c3218943a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1080,5 +1080,6 @@ "src/globe/navara/lines/lineMesh.test.mjs", "src/globe/pickDelivery.test.mjs", "src/globe/navara/picking.test.mjs", - "src/data/selectionDispatcher.test.mjs" + "src/data/selectionDispatcher.test.mjs", + "src/globe/navara/cascadedLightCopy.test.mjs" ] diff --git a/src/globe/navara/cascadedLightCopy.js b/src/globe/navara/cascadedLightCopy.js new file mode 100644 index 000000000..dce4273de --- /dev/null +++ b/src/globe/navara/cascadedLightCopy.js @@ -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; +} diff --git a/src/globe/navara/cascadedLightCopy.test.mjs b/src/globe/navara/cascadedLightCopy.test.mjs new file mode 100644 index 000000000..fa901a9c1 --- /dev/null +++ b/src/globe/navara/cascadedLightCopy.test.mjs @@ -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); +}); diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 2600ca908..d2ffaa156 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -6,7 +6,11 @@ import ThreeView from '@navaramap/three'; import * as geo from '../../geo/index.js'; import { DefaultPlugin } from '@navaramap/three-default-plugin'; import { createNavaraEngine } from './createNavaraEngine.js'; -import { forEachLuminanceUniform, navaraHooks } from './internals.js'; +import { + forEachLuminanceUniform, + keepCascadedLightIds, + navaraHooks, +} from './internals.js'; import { buildNavaraLook } from './look.js'; import { createNavaraSharpen } from './sharpen.js'; import { createNavaraMovers } from './movers/index.js'; @@ -112,6 +116,8 @@ export async function createNavaraGlobe({ () => engine.clock.frameTime(), { forEachLuminanceUniform }, ); + // The look's shadowed sun leaked a light per frame (internals.js). + keepCascadedLightIds(view); engine = createNavaraEngine(view, { hooks: navaraHooks(view), container, diff --git a/src/globe/navara/internals.js b/src/globe/navara/internals.js index 571894777..f97cb31d8 100644 --- a/src/globe/navara/internals.js +++ b/src/globe/navara/internals.js @@ -5,6 +5,7 @@ // found it and the public API that would replace it upstream. import { ModelMesh } from '@navaramap/three'; +import { patchCascadedLightCopy } from './cascadedLightCopy.js'; 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. */ export const navaraHooks = (view) => ({ retainTileGeometry,