From 15f1f9f5b65d5e56a3733a818e664acc6dd4eb2c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 14:42:31 +0400 Subject: [PATCH] perf(navara): submit stencil stages in one render Preserve the native stencil draws and geometry MSAA while avoiding two intermediate MRT resolves per volume. Verify original pixels, holes, overlaps, ownership and controlled comparison modes. --- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 1 + .../installations/render/navaraGeometry.js | 5 +- .../render/navaraStencilBatch.js | 85 +++++++ .../render/navaraStencilBatch.test.mjs | 212 ++++++++++++++++++ 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 src/layers/installations/render/navaraStencilBatch.js create mode 100644 src/layers/installations/render/navaraStencilBatch.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8f795d0ea..0cef3dd6f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1272,5 +1272,8 @@ "src/ui/imageryBoxNavara.test.mjs", "scripts/qa-w2-imagery.mjs", "src/globe/navara/postSamplingPass.test.mjs", - "scripts/qa-post-sampling.mjs" + "scripts/qa-post-sampling.mjs", + "scripts/qa-installation-stencil.mjs", + "scripts/qa-installation-stencil-entry.mjs", + "src/layers/installations/render/navaraStencilBatch.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 86b8a6a5e..f9c83a63f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2705,6 +2705,7 @@ "src/layers/bikeshare/render/navara.js", "src/layers/installations/render/navaraGeometry.js", "src/layers/installations/render/navaraOutlineBatch.js", + "src/layers/installations/render/navaraStencilBatch.js", "src/layers/firms/rectangle.js", "src/layers/firms/render/cesium.js", "src/layers/radio/render/cesium.js", diff --git a/src/layers/installations/render/navaraGeometry.js b/src/layers/installations/render/navaraGeometry.js index 2fa710429..4a11bcf98 100644 --- a/src/layers/installations/render/navaraGeometry.js +++ b/src/layers/installations/render/navaraGeometry.js @@ -6,6 +6,7 @@ import { Shape, } from 'three'; import * as geo from '../../../geo/index.js'; +import { batchStencilDraws } from './navaraStencilBatch.js'; export const INSTALLATION_DRAPE_DEPTH_M = 40000; @@ -135,7 +136,7 @@ export function createInstallationDrapedMesh( side: DoubleSide, }); setupMaterialForDrape?.(material, globeNormal, globeDepth); - const mesh = new DrapedMesh(geometry, material); + const mesh = batchStencilDraws(new DrapedMesh(geometry, material)); mesh.matrix.fromArray(frame); mesh.matrixAutoUpdate = false; mesh.matrixWorldNeedsUpdate = true; @@ -190,7 +191,7 @@ export function createInstallationOutlineDrapedMesh( side: DoubleSide, }); setupMaterialForDrape?.(material, globeNormal, globeDepth); - const mesh = new DrapedMesh(geometry, material); + const mesh = batchStencilDraws(new DrapedMesh(geometry, material)); mesh.matrix.fromArray(frame); mesh.matrixAutoUpdate = false; mesh.matrixWorldNeedsUpdate = true; diff --git a/src/layers/installations/render/navaraStencilBatch.js b/src/layers/installations/render/navaraStencilBatch.js new file mode 100644 index 000000000..6b1210bbe --- /dev/null +++ b/src/layers/installations/render/navaraStencilBatch.js @@ -0,0 +1,85 @@ +import { Material, Mesh } from 'three'; + +const stateKeys = [ + 'side', + 'colorWrite', + 'depthWrite', + 'depthTest', + 'stencilWrite', + 'stencilFunc', + 'stencilRef', + 'stencilFuncMask', + 'stencilWriteMask', + 'stencilFail', + 'stencilZFail', + 'stencilZPass', +]; + +// Keep Navara's three stencil draws, but submit them together. Three resolves +// the entire multisampled MRT after each render(), even for colorWrite=false. +// Use the public DrapedMesh.process callback to obtain the SDK's exact states. +export function batchStencilDraws(mesh) { + const process = mesh.process; + if (!process || Array.isArray(mesh.material)) return mesh; + const source = mesh.material; + let stages = null, + hidden = null, + version = -1, + disposed = false; + const dispose = () => { + disposed = true; + source.removeEventListener('dispose', dispose); + for (const stage of stages ?? []) stage.material.dispose(); + hidden?.dispose(); + stages = null; + }; + source.addEventListener('dispose', dispose); + mesh.process = function (render) { + if (disposed) return; + if (this.material !== source || this.children.length) + return process.call(this, render); + let index = 0; + process.call(this, () => { + if (!stages) { + hidden = new Material(); + hidden.visible = false; + stages = Array.from({ length: 3 }, (_, order) => { + const stage = new Mesh(this.geometry, source.clone()); + stage.renderOrder = order; + return stage; + }); + } + const stage = stages[index++]; + if (!stage) return; + const material = stage.material; + if (version !== source.version) { + material.copy(source); + material.defines = { ...source.defines }; + material.onBeforeCompile = source.onBeforeCompile; + material.customProgramCacheKey = source.customProgramCacheKey; + material.needsUpdate = true; + } + for (const key of stateKeys) material[key] = source[key]; + material.color = source.color; + material.emissive = source.emissive; + material.opacity = source.opacity; + material.visible = source.visible; + material.transparent = source.transparent; + stage.geometry = this.geometry; + stage.frustumCulled = this.frustumCulled; + stage.layers.mask = this.layers.mask; + }); + if (index === 0) return; + if (index !== 3) return process.call(this, render); + version = source.version; + this.material = hidden; + this.add(...stages); + try { + render(); + } finally { + this.remove(...stages); + this.material = source; + } + }; + return mesh; +} diff --git a/src/layers/installations/render/navaraStencilBatch.test.mjs b/src/layers/installations/render/navaraStencilBatch.test.mjs new file mode 100644 index 000000000..706703af3 --- /dev/null +++ b/src/layers/installations/render/navaraStencilBatch.test.mjs @@ -0,0 +1,212 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + AlwaysStencilFunc, + BackSide, + FrontSide, + KeepStencilOp, + IncrementWrapStencilOp, + DecrementWrapStencilOp, + NotEqualStencilFunc, + ZeroStencilOp, + BufferGeometry, + Mesh, + MeshLambertMaterial, + Group, +} from 'three'; +import { batchStencilDraws } from './navaraStencilBatch.js'; + +const keys = [ + 'side', + 'colorWrite', + 'depthWrite', + 'depthTest', + 'stencilWrite', + 'stencilFunc', + 'stencilRef', + 'stencilFuncMask', + 'stencilWriteMask', + 'stencilFail', + 'stencilZFail', + 'stencilZPass', +]; +const snapshot = (m) => Object.fromEntries(keys.map((k) => [k, m[k]])); +class Drape extends Mesh { + enabled() { + return this.visible; + } + process(render) { + if (!this.enabled()) return; + const m = this.material, + saved = snapshot(m); + try { + Object.assign(m, { + side: BackSide, + colorWrite: false, + depthWrite: false, + depthTest: true, + stencilWrite: true, + stencilFunc: AlwaysStencilFunc, + stencilFail: KeepStencilOp, + stencilZFail: IncrementWrapStencilOp, + stencilZPass: KeepStencilOp, + }); + render(); + m.side = FrontSide; + m.stencilZFail = DecrementWrapStencilOp; + render(); + Object.assign(m, { + side: BackSide, + colorWrite: true, + depthTest: false, + stencilFunc: NotEqualStencilFunc, + stencilFail: ZeroStencilOp, + stencilZFail: ZeroStencilOp, + stencilZPass: ZeroStencilOp, + }); + render(); + } finally { + Object.assign(m, saved); + } + } +} +function fixture() { + const material = new MeshLambertMaterial({ + color: 0x74aaff, + opacity: 0.85, + transparent: true, + }); + material.defines = { NVR_TEST: 1 }; + material.onBeforeCompile = (shader) => { + shader.uniforms.shared = { value: 123 }; + }; + material.customProgramCacheKey = () => 'native-drape'; + return new Drape(new BufferGeometry(), material); +} + +test('three native stencil states render in one submission with the same geometry, transforms and shader hooks', () => { + const mesh = fixture(), + material = mesh.material, + expected = []; + mesh.process(() => expected.push(snapshot(material))); + mesh.position.set(100, 200, 300); + mesh.layers.set(2); + batchStencilDraws(mesh); + let calls = 0; + mesh.process(() => { + calls++; + assert.equal(mesh.material.visible, false); + mesh.updateMatrixWorld(true); + assert.deepEqual( + mesh.children.map((m) => snapshot(m.material)), + expected, + ); + for (const [i, stage] of mesh.children.entries()) { + assert.equal(stage.renderOrder, i); + assert.equal(stage.geometry, mesh.geometry); + assert.deepEqual(stage.matrixWorld.elements, mesh.matrixWorld.elements); + assert.equal(stage.layers.mask, mesh.layers.mask); + assert.equal(stage.material.onBeforeCompile, material.onBeforeCompile); + assert.equal( + stage.material.customProgramCacheKey(), + material.customProgramCacheKey(), + ); + assert.deepEqual(stage.material.defines, material.defines); + } + }); + assert.equal(calls, 1); + assert.equal(mesh.material, material); + assert.equal(mesh.children.length, 0); + mesh.geometry.dispose(); + material.dispose(); +}); + +test('stages track replaced batch geometry, shader versions and live colour/opacity without recompiling steady frames', () => { + const mesh = batchStencilDraws(fixture()), + source = mesh.material; + let previous, versions; + mesh.process(() => { + previous = mesh.children.slice(); + versions = previous.map((s) => s.material.version); + }); + const old = mesh.geometry; + mesh.geometry = new BufferGeometry(); + old.dispose(); + source.color.setHex(0xffcc00); + source.opacity = 0.22; + source.stencilRef = 2; + source.stencilFuncMask = 0x0f; + source.stencilWriteMask = 0x0f; + mesh.process(() => { + assert.deepEqual(mesh.children, previous); + assert.deepEqual( + mesh.children.map((s) => s.material.version), + versions, + ); + for (const s of mesh.children) { + assert.equal(s.geometry, mesh.geometry); + assert.equal(s.material.color, source.color); + assert.equal(s.material.opacity, 0.22); + assert.equal(s.material.stencilRef, 2); + assert.equal(s.material.stencilFuncMask, 0x0f); + assert.equal(s.material.stencilWriteMask, 0x0f); + } + }); + source.defines.NVR_NORMAL = 1; + source.needsUpdate = true; + mesh.process(() => { + for (const [i, s] of mesh.children.entries()) { + assert.equal(s.material.defines.NVR_NORMAL, 1); + assert.ok(s.material.version > versions[i]); + } + }); + mesh.geometry.dispose(); + source.dispose(); +}); + +test('render exceptions restore original ownership and stencil state; material disposal releases only owned resources', () => { + const mesh = batchStencilDraws(fixture()), + source = mesh.material, + saved = snapshot(source); + let disposed = 0, + geometryDisposed = 0; + mesh.geometry.addEventListener('dispose', () => geometryDisposed++); + assert.throws( + () => + mesh.process(() => { + mesh.material.addEventListener('dispose', () => disposed++); + for (const s of mesh.children) + s.material.addEventListener('dispose', () => disposed++); + throw new Error('draw failure'); + }), + /draw failure/, + ); + assert.equal(mesh.material, source); + assert.equal(mesh.children.length, 0); + assert.deepEqual(snapshot(source), saved); + source.dispose(); + assert.equal(disposed, 4); + assert.equal(geometryDisposed, 0); + mesh.process(() => assert.fail('disposed owner must not allocate or draw')); + source.dispose(); + assert.equal(disposed, 4); + mesh.geometry.dispose(); +}); + +test('hidden meshes allocate nothing; meshes with children keep native behavior', () => { + const mesh = batchStencilDraws(fixture()); + mesh.visible = false; + mesh.process(() => assert.fail('hidden draw')); + assert.equal(mesh.children.length, 0); + mesh.visible = true; + const child = new Group(); + mesh.add(child); + let calls = 0; + mesh.process(() => { + calls++; + assert.deepEqual(mesh.children, [child]); + }); + assert.equal(calls, 3); + mesh.material.dispose(); + mesh.geometry.dispose(); +});