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(); +});