diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ae4ebb159..8f795d0ea 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1270,5 +1270,7 @@ "src/layers/recentImagery/render/tiles.test.mjs", "src/layers/recentImagery/render/navara.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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c90ac4412..86b8a6a5e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2669,6 +2669,8 @@ "src/globe/navara/movers/pack.js", "src/globe/navara/movers/frameCamera.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/modelTint.js", "src/globe/navara/movers/index.js", diff --git a/src/globe/navara/index.js b/src/globe/navara/index.js index 8a6a886c2..0ae575f10 100644 --- a/src/globe/navara/index.js +++ b/src/globe/navara/index.js @@ -22,6 +22,7 @@ import { createNavaraSharpen } from './sharpen.js'; import { createNavaraMovers } from './movers/index.js'; import { createNavaraLines } from './lines/index.js'; import { createNavaraOverlay } from './overlay.js'; +import { createPostSampling } from './postSampling.js'; import { NAVARA_CONTEXT_ATTRIBUTES, ACES_FILMIC, @@ -108,6 +109,7 @@ export async function createNavaraGlobe({ let engine; let overlay = null; let removeLook = null; + let removePostSampling = null; try { view = new ThreeView(navaraViewOptions({ container, canvas, pixelRatio })); const plugin = new DefaultPlugin(); @@ -127,6 +129,7 @@ export async function createNavaraGlobe({ ); // The look's shadowed sun leaked a light per frame (internals.js). keepCascadedLightIds(view); + removePostSampling = createPostSampling(view); // Screen overlays and model display colour after tone mapping (overlay.js). overlay = createNavaraOverlay(view); engine = createNavaraEngine(view, { @@ -182,6 +185,7 @@ export async function createNavaraGlobe({ removeLook = () => removers.forEach((remove) => remove()); } catch (error) { removeLook?.(); + removePostSampling?.(); engine?.destroy(); overlay?.destroy(); view?.dispose(); @@ -201,6 +205,7 @@ export async function createNavaraGlobe({ if (destroyed) return; destroyed = true; removeLook(); + removePostSampling(); engine.destroy(); overlay.destroy(); view.dispose(); diff --git a/src/globe/navara/postSampling.js b/src/globe/navara/postSampling.js new file mode 100644 index 000000000..74a459de0 --- /dev/null +++ b/src/globe/navara/postSampling.js @@ -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(); +} diff --git a/src/globe/navara/postSamplingPass.js b/src/globe/navara/postSamplingPass.js new file mode 100644 index 000000000..95197da5f --- /dev/null +++ b/src/globe/navara/postSamplingPass.js @@ -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(); + } +} diff --git a/src/globe/navara/postSamplingPass.test.mjs b/src/globe/navara/postSamplingPass.test.mjs new file mode 100644 index 000000000..f56ac1ba4 --- /dev/null +++ b/src/globe/navara/postSamplingPass.test.mjs @@ -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]); +});