From dc7a7c4d53880153411ccac5803cdce526f3931f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 17:24:29 +0400 Subject: [PATCH] perf(navara): skip redundant empty-scene resolves Preserve pending clears, backgrounds, callbacks, shadows and real geometry. Verify exact pixels and the underground depth-clear path; add controlled original-render comparison modes. --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 1 + src/globe/navara/emptySceneRender.js | 50 +++++++ src/globe/navara/emptySceneRender.test.mjs | 144 +++++++++++++++++++++ src/globe/navara/postSamplingPass.js | 18 +-- 5 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/globe/navara/emptySceneRender.js create mode 100644 src/globe/navara/emptySceneRender.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0cef3dd6f..65bfe7b72 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1272,6 +1272,7 @@ "src/ui/imageryBoxNavara.test.mjs", "scripts/qa-w2-imagery.mjs", "src/globe/navara/postSamplingPass.test.mjs", + "src/globe/navara/emptySceneRender.test.mjs", "scripts/qa-post-sampling.mjs", "scripts/qa-installation-stencil.mjs", "scripts/qa-installation-stencil-entry.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f9c83a63f..133555c69 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2671,6 +2671,7 @@ "src/globe/navara/overlayPass.js", "src/globe/navara/postSampling.js", "src/globe/navara/postSamplingPass.js", + "src/globe/navara/emptySceneRender.js", "src/globe/navara/movers/modelMover.js", "src/globe/navara/movers/modelTint.js", "src/globe/navara/movers/index.js", diff --git a/src/globe/navara/emptySceneRender.js b/src/globe/navara/emptySceneRender.js new file mode 100644 index 000000000..0416fbade --- /dev/null +++ b/src/globe/navara/emptySceneRender.js @@ -0,0 +1,50 @@ +import { Scene } from 'three'; + +export function hasGeometry(scene) { + let found = false; + scene.traverseVisible((object) => { + if (object.isMesh || object.isLine || object.isPoints || object.isSprite) + found = true; + }); + return found; +} + +// Navara renders its MRT and forward scenes even when they contain only lights. +// Three resolves the current MSAA target on every render(), including no draws. +// Scope the override to this pass; retain clears, backgrounds and callbacks. +export function skipEmptySceneRenders(render, scenes) { + return function (renderer, ...args) { + const original = renderer.render; + const clear = renderer.clear; + const pendingClears = new Set(); + if (clear) + renderer.clear = function (...args) { + pendingClears.add(this.getRenderTarget()); + return clear.apply(this, args); + }; + renderer.render = function (scene, ...args) { + const shadow = this.shadowMap; + const target = this.getRenderTarget?.(); + if ( + (scene === scenes.mrt || scene === scenes.opaque) && + !this.autoClear && + !pendingClears.has(target) && + !(shadow?.enabled && (shadow.autoUpdate || shadow.needsUpdate)) && + scene.background === null && + scene.onBeforeRender === Scene.prototype.onBeforeRender && + scene.onAfterRender === Scene.prototype.onAfterRender && + !hasGeometry(scene) + ) + return; + const result = original.call(this, scene, ...args); + pendingClears.delete(target); + return result; + }; + try { + return render.call(this, renderer, ...args); + } finally { + renderer.render = original; + if (clear) renderer.clear = clear; + } + }; +} diff --git a/src/globe/navara/emptySceneRender.test.mjs b/src/globe/navara/emptySceneRender.test.mjs new file mode 100644 index 000000000..746a120ff --- /dev/null +++ b/src/globe/navara/emptySceneRender.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Color, DirectionalLight, Group, Mesh, Scene } from 'three'; +import { skipEmptySceneRenders } from './emptySceneRender.js'; + +function fixture() { + const scenes = { mrt: new Scene(), opaque: new Scene() }; + scenes.mrt.add(new DirectionalLight()); + const calls = []; + const renderer = { + autoClear: false, + shadowMap: { enabled: true, autoUpdate: false, needsUpdate: false }, + render(scene, camera) { + calls.push({ scene, camera, receiver: this }); + }, + }; + const original = renderer.render; + return { scenes, renderer, calls, original }; +} + +test('empty MRT/forward scenes skip resolves; real geometry and other scenes retain their ordered draws', () => { + const f = fixture(), + camera = {}, + other = new Scene(), + receiver = {}; + const mesh = new Mesh(); + f.scenes.opaque.add(mesh); + const run = skipEmptySceneRenders(function (renderer, arg) { + assert.equal(this, receiver); + assert.equal(arg, 42); + renderer.render(f.scenes.mrt, camera); + renderer.render(f.scenes.opaque, camera); + renderer.render(other, camera); + return 'result'; + }, f.scenes); + assert.equal(run.call(receiver, f.renderer, 42), 'result'); + assert.deepEqual( + f.calls.map((c) => c.scene), + [f.scenes.opaque, other], + ); + assert.ok( + f.calls.every((c) => c.camera === camera && c.receiver === f.renderer), + ); + assert.equal(f.renderer.render, f.original); +}); + +test('visibility changes are evaluated on each render without stale geometry caching', () => { + const f = fixture(), + group = new Group(); + group.add(new Mesh()); + group.visible = false; + f.scenes.mrt.add(group); + const run = skipEmptySceneRenders( + (renderer) => renderer.render(f.scenes.mrt), + f.scenes, + ); + run(f.renderer); + assert.equal(f.calls.length, 0); + group.visible = true; + run(f.renderer); + assert.equal(f.calls.length, 1); + group.removeFromParent(); + run(f.renderer); + assert.equal(f.calls.length, 1); +}); + +test('clears, backgrounds, callbacks and pending shadow work keep the original render', () => { + for (const configure of [ + (f) => { + f.renderer.autoClear = true; + }, + (f) => { + f.scenes.mrt.background = new Color('red'); + }, + (f) => { + f.scenes.mrt.onBeforeRender = () => {}; + }, + (f) => { + f.scenes.mrt.onAfterRender = () => {}; + }, + (f) => { + f.renderer.shadowMap.autoUpdate = true; + }, + (f) => { + f.renderer.shadowMap.needsUpdate = true; + }, + ]) { + const f = fixture(); + configure(f); + skipEmptySceneRenders( + (renderer) => renderer.render(f.scenes.mrt), + f.scenes, + )(f.renderer); + assert.equal(f.calls.length, 1); + assert.equal(f.renderer.render, f.original); + } +}); + +test('nested passes and exceptions restore the exact borrowed renderer method', () => { + const f = fixture(); + const inner = skipEmptySceneRenders(() => { + throw new Error('render failure'); + }, f.scenes); + const outer = skipEmptySceneRenders((renderer) => { + const wrapped = renderer.render; + assert.throws(() => inner(renderer), /render failure/); + assert.equal(renderer.render, wrapped); + throw new Error('outer failure'); + }, f.scenes); + assert.throws(() => outer(f.renderer), /outer failure/); + assert.equal(f.renderer.render, f.original); + f.renderer.render(f.scenes.mrt); + assert.equal(f.calls.length, 1); +}); + +test('manual clears must resolve on their own target before empty renders may be skipped', () => { + const f = fixture(), + first = {}, + second = {}, + targets = []; + let target = first; + const clear = () => targets.push(target); + f.renderer.clear = clear; + f.renderer.getRenderTarget = () => target; + const run = skipEmptySceneRenders((renderer) => { + renderer.clear(false, true, false); + target = second; + renderer.render(f.scenes.mrt); + assert.equal(f.calls.length, 0); + target = first; + renderer.render(f.scenes.mrt); + assert.equal(f.calls.length, 1, 'depth clear still needs its resolve'); + renderer.render(f.scenes.mrt); + assert.equal(f.calls.length, 1, 'resolved empty target needs no repeat'); + renderer.clear(false, false, true); + renderer.render(f.scenes.opaque); + assert.equal(f.calls.length, 2, 'stencil clear also needs its resolve'); + throw new Error('after clear'); + }, f.scenes); + assert.throws(() => run(f.renderer), /after clear/); + assert.deepEqual(targets, [first, first]); + assert.equal(f.renderer.clear, clear); + assert.equal(f.renderer.render, f.original); +}); diff --git a/src/globe/navara/postSamplingPass.js b/src/globe/navara/postSamplingPass.js index 95197da5f..38d9ed1ad 100644 --- a/src/globe/navara/postSamplingPass.js +++ b/src/globe/navara/postSamplingPass.js @@ -1,14 +1,6 @@ import { Pass } from 'postprocessing'; import { AlwaysDepth, Mesh, PlaneGeometry, ShaderMaterial } from 'three'; - -function hasGeometry(scene) { - let found = false; - scene.traverseVisible((object) => { - if (object.isMesh || object.isLine || object.isPoints || object.isSprite) - found = true; - }); - return found; -} +import { hasGeometry, skipEmptySceneRenders } from './emptySceneRender.js'; // Navara 0.2.1 multisamples every composer target, including full-screen // effects. Resolve geometry once; post effects need one sample per pixel. @@ -50,13 +42,17 @@ export class GeometrySamplingPass extends Pass { const pass = this.ctx.getPass(key); if (!pass || this.wrapped.has(pass)) continue; const original = pass.render; + const draw = + key === 'mrt' + ? skipEmptySceneRenders(original, this.ctx.scenes) + : original; const owner = this; pass.render = function (renderer, input, output, ...rest) { if (key === 'gevOverlay' && !this.overlay.hasDrawables()) return; if (key === 'transparent' && !hasGeometry(owner.ctx.scenes.transparent)) return; if (key === 'mrt' && !hasGeometry(owner.ctx.scenes.opaque)) - return original.call(this, renderer, input, output, ...rest); + return draw.call(this, renderer, input, output, ...rest); const target = (owner.target ??= input.clone()); owner.setSamples(target, owner.samples); target.setSize(input.width, input.height); @@ -70,7 +66,7 @@ export class GeometrySamplingPass extends Pass { // resolve. The opaque background sorts before transparent glyphs. this.scene.add(owner.background); } - original.call(this, renderer, target, output, ...rest); + draw.call(this, renderer, target, output, ...rest); renderer.initRenderTarget(input); renderer.copyTextureToTexture(target.texture, input.texture); if (key === 'mrt')