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.
This commit is contained in:
German Khokhlov committed 2026-10-30 17:24:29 +04:00
1 parent 15f1f9f5b6
commit dc7a7c4d53
5 files changed
+203 -11

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+50
View File
@@ -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;
}
};
}
+144
View File
@@ -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);
});
+7 -11
View File
@@ -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')