perf(navara): resolve MSAA at geometry stages

Keep four-sample geometry and depth while using single-sample postprocessing buffers. Preserve forward and overlay coverage, skip empty transfers, and add matched pixel and regression checks.
This commit is contained in:
German Khokhlov committed 2026-10-30 12:52:50 +04:00
1 parent 8279af2e6d
commit c76872dfaa
6 files changed
+306 -1

No files matched your search

+3 -1
View File
@@ -1270,5 +1270,7 @@
"src/layers/recentImagery/render/tiles.test.mjs", "src/layers/recentImagery/render/tiles.test.mjs",
"src/layers/recentImagery/render/navara.test.mjs", "src/layers/recentImagery/render/navara.test.mjs",
"src/ui/imageryBoxNavara.test.mjs", "src/ui/imageryBoxNavara.test.mjs",
"scripts/qa-w2-imagery.mjs" "scripts/qa-w2-imagery.mjs",
"src/globe/navara/postSamplingPass.test.mjs",
"scripts/qa-post-sampling.mjs"
] ]
+2
View File
@@ -2669,6 +2669,8 @@
"src/globe/navara/movers/pack.js", "src/globe/navara/movers/pack.js",
"src/globe/navara/movers/frameCamera.js", "src/globe/navara/movers/frameCamera.js",
"src/globe/navara/overlayPass.js", "src/globe/navara/overlayPass.js",
"src/globe/navara/postSampling.js",
"src/globe/navara/postSamplingPass.js",
"src/globe/navara/movers/modelMover.js", "src/globe/navara/movers/modelMover.js",
"src/globe/navara/movers/modelTint.js", "src/globe/navara/movers/modelTint.js",
"src/globe/navara/movers/index.js", "src/globe/navara/movers/index.js",
+5
View File
@@ -22,6 +22,7 @@ import { createNavaraSharpen } from './sharpen.js';
import { createNavaraMovers } from './movers/index.js'; import { createNavaraMovers } from './movers/index.js';
import { createNavaraLines } from './lines/index.js'; import { createNavaraLines } from './lines/index.js';
import { createNavaraOverlay } from './overlay.js'; import { createNavaraOverlay } from './overlay.js';
import { createPostSampling } from './postSampling.js';
import { import {
NAVARA_CONTEXT_ATTRIBUTES, NAVARA_CONTEXT_ATTRIBUTES,
ACES_FILMIC, ACES_FILMIC,
@@ -108,6 +109,7 @@ export async function createNavaraGlobe({
let engine; let engine;
let overlay = null; let overlay = null;
let removeLook = null; let removeLook = null;
let removePostSampling = null;
try { try {
view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio })); view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio }));
const plugin = new DefaultPlugin(); const plugin = new DefaultPlugin();
@@ -127,6 +129,7 @@ export async function createNavaraGlobe({
); );
// The look's shadowed sun leaked a light per frame (internals.js). // The look's shadowed sun leaked a light per frame (internals.js).
keepCascadedLightIds(view); keepCascadedLightIds(view);
removePostSampling = createPostSampling(view);
// Screen overlays and model display colour after tone mapping (overlay.js). // Screen overlays and model display colour after tone mapping (overlay.js).
overlay = createNavaraOverlay(view); overlay = createNavaraOverlay(view);
engine = createNavaraEngine(view, { engine = createNavaraEngine(view, {
@@ -182,6 +185,7 @@ export async function createNavaraGlobe({
removeLook = () => removers.forEach((remove) => remove()); removeLook = () => removers.forEach((remove) => remove());
} catch (error) { } catch (error) {
removeLook?.(); removeLook?.();
removePostSampling?.();
engine?.destroy(); engine?.destroy();
overlay?.destroy(); overlay?.destroy();
view?.dispose(); view?.dispose();
@@ -201,6 +205,7 @@ export async function createNavaraGlobe({
if (destroyed) return; if (destroyed) return;
destroyed = true; destroyed = true;
removeLook(); removeLook();
removePostSampling();
engine.destroy(); engine.destroy();
overlay.destroy(); overlay.destroy();
view.dispose(); view.dispose();
+18
View File
@@ -0,0 +1,18 @@
import { EffectDesc, Pass as NavaraPass } from '@navaramap/three';
import { GeometrySamplingPass } from './postSamplingPass.js';
const KEY = 'gevPostSampling';
class PostSamplingDesc extends EffectDesc {
static key = KEY;
static insertBefore = ['mrt'];
createPass() {
return new NavaraPass(new GeometrySamplingPass(this.ctx), null);
}
}
export function createPostSampling(view) {
view.registerEffect(KEY, PostSamplingDesc);
const handle = view.addEffect({ [KEY]: {}, visible: true });
return () => handle.delete();
}
+106
View File
@@ -0,0 +1,106 @@
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;
}
// Navara 0.2.1 multisamples every composer target, including full-screen
// effects. Resolve geometry once; post effects need one sample per pixel.
// Keep forward meshes and the final overlay multisampled too, not just MRT.
export class GeometrySamplingPass extends Pass {
constructor(ctx) {
super('GevGeometrySampling');
this.needsSwap = false;
this.ctx = ctx;
this.samples = ctx.getInputBuffer().samples;
this.buffers = new Map();
this.wrapped = new Map();
this.target = null;
this.copy = new ShaderMaterial({
vertexShader:
'varying vec2 vUv; void main(){vUv=uv;gl_Position=vec4(position.xy,0.,1.);}',
fragmentShader:
'uniform sampler2D image; uniform sampler2D depth; varying vec2 vUv; void main(){gl_FragColor=texture2D(image,vUv);gl_FragDepth=texture2D(depth,vUv).r;}',
uniforms: { image: { value: null }, depth: { value: null } },
depthTest: true,
depthFunc: AlwaysDepth,
depthWrite: true,
toneMapped: false,
});
this.background = new Mesh(new PlaneGeometry(2, 2), this.copy);
this.background.frustumCulled = false;
this.background.renderOrder = -Infinity;
}
render(renderer, input, output) {
if (!this.samples) return;
for (const buffer of [input, output]) {
if (!this.buffers.has(buffer)) this.buffers.set(buffer, buffer.samples);
this.setSamples(buffer, 0);
}
// G-buffer attachments can be replaced when an effect is added/removed.
this.setSamples(this.ctx.getRenderTarget(), this.samples);
for (const key of ['mrt', 'transparent', 'gevOverlay']) {
const pass = this.ctx.getPass(key);
if (!pass || this.wrapped.has(pass)) continue;
const original = pass.render;
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);
const target = (owner.target ??= input.clone());
owner.setSamples(target, owner.samples);
target.setSize(input.width, input.height);
const autoClear = renderer.autoClear;
renderer.autoClear = false;
try {
if (key !== 'mrt') {
owner.copy.uniforms.image.value = input.texture;
owner.copy.uniforms.depth.value = input.depthTexture;
// Copy and geometry share one render() and therefore one MSAA
// resolve. The opaque background sorts before transparent glyphs.
this.scene.add(owner.background);
}
original.call(this, renderer, target, output, ...rest);
renderer.initRenderTarget(input);
renderer.copyTextureToTexture(target.texture, input.texture);
if (key === 'mrt')
renderer.copyTextureToTexture(
target.depthTexture,
input.depthTexture,
);
} finally {
owner.background.removeFromParent();
renderer.setRenderTarget(input);
renderer.autoClear = autoClear;
}
};
this.wrapped.set(pass, original);
}
}
setSamples(target, samples) {
if (target.samples === samples) return;
target.dispose();
target.samples = samples;
}
dispose() {
for (const [pass, render] of this.wrapped) pass.render = render;
for (const [buffer, samples] of this.buffers)
this.setSamples(buffer, samples);
this.wrapped.clear();
this.buffers.clear();
this.background.geometry.dispose();
super.dispose();
}
}
+172
View File
@@ -0,0 +1,172 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DepthTexture, Group, Mesh, Scene, WebGLRenderTarget } from 'three';
import { GeometrySamplingPass } from './postSamplingPass.js';
function fixture() {
const target = () => {
const buffer = new WebGLRenderTarget(32, 24, { samples: 4 });
buffer.depthTexture = new DepthTexture(32, 24);
return buffer;
};
const input = target(),
output = target();
let mrt = target();
const calls = [],
copies = [],
draws = [];
const scenes = { opaque: new Scene(), transparent: new Scene() };
const passes = Object.fromEntries(
['mrt', 'transparent', 'gevOverlay'].map((key) => [
key,
{
scene: new Scene(),
overlay: { hasDrawables: () => true },
render(renderer, input, output, ...rest) {
calls.push({
key,
receiver: this,
input,
output,
rest,
background: this.scene.children[0],
});
},
},
]),
);
const originals = Object.fromEntries(
Object.entries(passes).map(([key, pass]) => [key, pass.render]),
);
const renderer = {
autoClear: true,
setRenderTarget(target) {
this.target = target;
},
render(scene) {
draws.push(scene);
},
initRenderTarget() {},
copyTextureToTexture(from, to) {
copies.push([from, to]);
},
};
const ctx = {
scenes,
getInputBuffer: () => input,
getRenderTarget: () => mrt,
getPass: (key) => passes[key],
};
const policy = new GeometrySamplingPass(ctx);
return {
policy,
input,
output,
scenes,
passes,
originals,
renderer,
calls,
copies,
draws,
replaceMrt() {
mrt = target();
mrt.samples = 0;
return mrt;
},
};
}
test('post targets use one sample; replacement MRT and actual forward geometry retain four', () => {
const f = fixture();
try {
f.scenes.opaque.add(new Mesh());
f.policy.render(f.renderer, f.input, f.output);
assert.equal(f.input.samples, 0);
assert.equal(f.output.samples, 0);
const mrt = f.replaceMrt();
f.policy.render(f.renderer, f.input, f.output);
assert.equal(mrt.samples, 4);
f.passes.mrt.render(f.renderer, f.input, f.output, 0.5, false);
const call = f.calls[0];
assert.equal(call.receiver, f.passes.mrt);
assert.equal(call.input.samples, 4);
assert.notEqual(call.input, f.input);
assert.deepEqual(call.rest, [0.5, false]);
assert.deepEqual(f.copies, [
[call.input.texture, f.input.texture],
[call.input.depthTexture, f.input.depthTexture],
]);
assert.equal(f.renderer.autoClear, true);
} finally {
f.policy.dispose();
}
});
test('empty forward scenes avoid copies, including hidden geometry', () => {
const f = fixture();
try {
const hidden = new Group();
hidden.visible = false;
hidden.add(new Mesh());
f.scenes.opaque.add(hidden);
f.passes.gevOverlay.overlay.hasDrawables = () => false;
f.policy.render(f.renderer, f.input, f.output);
for (const pass of Object.values(f.passes))
pass.render(f.renderer, f.input, f.output);
assert.deepEqual(
f.calls.map((call) => call.key),
['mrt'],
);
assert.equal(f.calls[0].input, f.input);
assert.equal(f.copies.length, 0);
assert.equal(f.policy.target, null);
hidden.visible = true;
f.passes.mrt.render(f.renderer, f.input, f.output);
assert.equal(f.calls[1].input.samples, 4);
} finally {
f.policy.dispose();
}
});
test('transparent and overlay draws copy the current ping-pong input and keep MSAA after resize', () => {
const f = fixture();
try {
f.scenes.transparent.add(new Mesh());
f.policy.render(f.renderer, f.input, f.output);
f.output.setSize(64, 48);
for (const key of ['transparent', 'gevOverlay']) {
f.passes[key].render(f.renderer, f.output, f.input);
assert.equal(f.policy.copy.uniforms.image.value, f.output.texture);
assert.equal(f.policy.copy.uniforms.depth.value, f.output.depthTexture);
assert.equal(f.calls.at(-1).input.samples, 4);
assert.equal(f.calls.at(-1).input.width, 64);
assert.equal(f.calls.at(-1).input.height, 48);
assert.deepEqual(f.copies.at(-1), [
f.policy.target.texture,
f.output.texture,
]);
assert.equal(f.calls.at(-1).background, f.policy.background);
assert.equal(f.passes[key].scene.children.length, 0);
}
assert.equal(f.draws.length, 0);
assert.equal(f.renderer.autoClear, true);
} finally {
f.policy.dispose();
}
});
test('disposing restores public passes and buffer sampling without per-frame reallocations', () => {
const f = fixture();
let disposals = 0;
f.input.addEventListener('dispose', () => disposals++);
f.policy.render(f.renderer, f.input, f.output);
f.policy.render(f.renderer, f.input, f.output);
assert.equal(disposals, 1);
f.policy.dispose();
assert.equal(disposals, 2);
assert.equal(f.input.samples, 4);
assert.equal(f.output.samples, 4);
for (const key of Object.keys(f.passes))
assert.equal(f.passes[key].render, f.originals[key]);
});