From 8fe0b7500f46c6745e401a36c2dc5ee0fda4d4d9 Mon Sep 17 00:00:00 2001 From: Ruben Marcus Date: Fri, 2 Oct 2026 10:23:19 +0100 Subject: [PATCH] WebGLRenderer: Reuse programs when only output color space or tone mapping differ --- src/renderers/WebGLRenderer.js | 55 +++++-- .../unit/src/renderers/WebGLRenderer.tests.js | 140 ++++++++++++++++++ test/unit/three.source.unit.js | 1 + 3 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 test/unit/src/renderers/WebGLRenderer.tests.js diff --git a/src/renderers/WebGLRenderer.js b/src/renderers/WebGLRenderer.js index 86c767fff5de0f..8af30b162266bf 100644 --- a/src/renderers/WebGLRenderer.js +++ b/src/renderers/WebGLRenderer.js @@ -2201,12 +2201,17 @@ class WebGLRenderer { } - function getProgram( material, scene, object ) { + function getProgram( material, scene, object, isOutputVariantLookup = false ) { if ( scene.isScene !== true ) scene = _emptyScene; // scene could be a Mesh, Line, Points, ... const materialProperties = properties.get( material ); + // a lookup for any other reason than filling the output variant map may change any of + // the state a remembered output variant program was found for, so drop the remembered + // programs: they no longer necessarily match the current state + if ( isOutputVariantLookup === false && materialProperties.outputPrograms !== undefined ) materialProperties.outputPrograms.clear(); + const lights = currentRenderState.state.lights; const shadowsArray = currentRenderState.state.shadowsArray; @@ -2446,10 +2451,6 @@ class WebGLRenderer { needsProgramChange = true; - } else if ( materialProperties.outputColorSpace !== colorSpace ) { - - needsProgramChange = true; - } else if ( object.isBatchedMesh && materialProperties.batching === false ) { needsProgramChange = true; @@ -2532,10 +2533,6 @@ class WebGLRenderer { needsProgramChange = true; - } else if ( materialProperties.toneMapping !== toneMapping ) { - - needsProgramChange = true; - } else if ( materialProperties.morphTargetsCount !== morphTargetsCount ) { needsProgramChange = true; @@ -2569,6 +2566,46 @@ class WebGLRenderer { } + } else if ( material.isNodeMaterial !== true && ( materialProperties.outputColorSpace !== colorSpace || materialProperties.toneMapping !== toneMapping ) ) { + + // Only the output color space or the tone mapping differs from the state the current + // program was found for. This happens when a transmissive object makes the transmission + // pass draw the opaque objects into a render target (working color space, no tone + // mapping) before the main pass draws them with the renderer's output settings. Switch + // to the program remembered for this output variant instead of doing a full program lookup. + + const outputKey = colorSpace + ':' + toneMapping; + + let outputPrograms = materialProperties.outputPrograms; + + if ( outputPrograms === undefined ) { + + outputPrograms = new Map(); + materialProperties.outputPrograms = outputPrograms; + + } + + const outputProgram = outputPrograms.get( outputKey ); + + if ( outputProgram !== undefined ) { + + program = outputProgram; + + materialProperties.currentProgram = program; + materialProperties.uniformsList = null; // rebuilt for the switched program + + materialProperties.outputColorSpace = colorSpace; + materialProperties.toneMapping = toneMapping; + + } else { + + // first draw with this output variant: do a full lookup and remember the program + + program = getProgram( material, scene, object, true ); + outputPrograms.set( outputKey, program ); + + } + } let refreshProgram = false; diff --git a/test/unit/src/renderers/WebGLRenderer.tests.js b/test/unit/src/renderers/WebGLRenderer.tests.js new file mode 100644 index 00000000000000..1bf349ba595b1b --- /dev/null +++ b/test/unit/src/renderers/WebGLRenderer.tests.js @@ -0,0 +1,140 @@ +import { ACESFilmicToneMapping } from '../../../../src/constants.js'; +import { PerspectiveCamera } from '../../../../src/cameras/PerspectiveCamera.js'; +import { DirectionalLight } from '../../../../src/lights/DirectionalLight.js'; +import { BoxGeometry } from '../../../../src/geometries/BoxGeometry.js'; +import { SphereGeometry } from '../../../../src/geometries/SphereGeometry.js'; +import { Mesh } from '../../../../src/objects/Mesh.js'; +import { MeshPhysicalMaterial } from '../../../../src/materials/MeshPhysicalMaterial.js'; +import { MeshStandardMaterial } from '../../../../src/materials/MeshStandardMaterial.js'; +import { Scene } from '../../../../src/scenes/Scene.js'; +import { WebGLRenderer } from '../../../../src/renderers/WebGLRenderer.js'; + + +function createTransmissionScene() { + + // one opaque and one transmissive object: the transmission pass draws the opaque + // object into a render target (working color space, no tone mapping) before the + // main pass draws it with the renderer's output settings + + const scene = new Scene(); + + const material = new MeshStandardMaterial(); + + // getParameters() runs material.customProgramCacheKey() once per program lookup + const customProgramCacheKey = material.customProgramCacheKey; + let programLookups = 0; + + material.customProgramCacheKey = function () { + + programLookups ++; + return customProgramCacheKey.call( this ); + + }; + + const mesh = new Mesh( new BoxGeometry( 1, 1, 1 ), material ); + mesh.position.z = - 2; + scene.add( mesh ); + + const transmissiveMesh = new Mesh( new SphereGeometry( 0.5, 16, 8 ), new MeshPhysicalMaterial( { transmission: 1 } ) ); + transmissiveMesh.position.z = - 2; + scene.add( transmissiveMesh ); + + return { + scene, + countProgramLookups: function () { + + return programLookups; + + }, + resetProgramLookups: function () { + + programLookups = 0; + + } + }; + +} + +function createRenderer() { + + const canvas = document.createElement( 'canvas' ); + + const renderer = new WebGLRenderer( { canvas } ); + renderer.setSize( 64, 64, false ); + renderer.toneMapping = ACESFilmicToneMapping; + + return renderer; + +} + +export default QUnit.module( 'Renderers', () => { + + QUnit.module( 'WebGLRenderer', () => { + + QUnit.test( 'no repeated program lookups with a transmissive object in view', ( assert ) => { + + const setup = createTransmissionScene(); + const renderer = createRenderer(); + const camera = new PerspectiveCamera(); + + // warm up until both output variants of the opaque material have their programs + // (render target pass and canvas pass) + + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + + setup.resetProgramLookups(); + + // steady frame: the opaque material is drawn once with the render target's + // output settings and once with the canvas' output settings + + renderer.render( setup.scene, camera ); + + assert.equal( setup.countProgramLookups(), 0, 'no program lookups in a steady frame' ); + + renderer.dispose(); + + } ); + + QUnit.test( 'changing the lights state runs program lookups again', ( assert ) => { + + const setup = createTransmissionScene(); + const renderer = createRenderer(); + const camera = new PerspectiveCamera(); + + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + + // a lights state change must not keep serving remembered programs + + setup.scene.add( new DirectionalLight( 0xffffff, 1 ) ); + + setup.resetProgramLookups(); + renderer.render( setup.scene, camera ); + + assert.ok( setup.countProgramLookups() > 0, 'program lookups after a lights change' ); + + // each output variant re-runs one lookup for the new lights state; let the + // state settle, then a steady frame has no lookups again + + setup.resetProgramLookups(); + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + renderer.render( setup.scene, camera ); + + setup.resetProgramLookups(); + renderer.render( setup.scene, camera ); + + assert.equal( setup.countProgramLookups(), 0, 'no program lookups once the new state settled' ); + + renderer.dispose(); + + } ); + + } ); + +} ); diff --git a/test/unit/three.source.unit.js b/test/unit/three.source.unit.js index b27ad294c47e31..6907936d6aab39 100644 --- a/test/unit/three.source.unit.js +++ b/test/unit/three.source.unit.js @@ -230,6 +230,7 @@ import './src/objects/Sprite.tests.js'; import './src/renderers/WebGL3DRenderTarget.tests.js'; import './src/renderers/WebGLArrayRenderTarget.tests.js'; import './src/renderers/WebGLCubeRenderTarget.tests.js'; +import './src/renderers/WebGLRenderer.tests.js'; import './src/renderers/WebGLRenderTarget.tests.js'; //src/renderers/shaders