Skip to content

WebGLRenderer: Reuse programs when only output color space or tone mapping differ (#34654) - #34738

Closed
rubenmarcus wants to merge 1 commit into
mrdoob:devfrom
rubenmarcus:fix/program-colorspace-cached-lookup
Closed

rubenmarcus wants to merge 1 commit into
mrdoob:devfrom
rubenmarcus:fix/program-colorspace-cached-lookup

Conversation

@rubenmarcus

Copy link
Copy Markdown

Fixes #34654.

When a transmissive object is in view, renderTransmissionPass() draws every opaque object into a render target (working color space, NoToneMapping) before the main pass draws it onto the canvas (renderer.outputColorSpace, renderer.toneMapping). At each switch, setProgram() hit the materialProperties.outputColorSpace !== colorSpace link of its else if chain, so getProgram() ran twice per material per frame. Nothing compiles on those lookups, but getParameters() runs on each one and calls customProgramCacheKey(), which is onBeforeCompile.toString(). The reporter measured 600 lookups per frame and 36% of page JS time with 300 materials; this happens with default renderer settings because sRGB output alone differs from the render target.

The two output checks are now taken out of the chain and compared only when the rest of the chain found no difference. When only the output color space or the tone mapping differs, a small per-material map from colorSpace + ':' + toneMapping to the program switches to the program found earlier instead of calling getProgram(). The uniform list is rebuilt for the switched program while materialProperties.uniforms stays the object it was built from. The map is cleared whenever getProgram() runs for any other reason (material version, lights state, object features, environment, fog, clipping), so a remembered program always matches the rest of the current state. Node materials keep the existing path.

This is the small fix for the case where the opaque pass is still rendered twice; #28423 would remove the second draw altogether.

Verification (macOS, arm64, headless Chrome WebGL2):

$ node test/unit/puppeteer.unit.js --testPage=UnitTestsTmp.html --mode=headless   # new tests only
# pristine upstream: # fail 2
#   not ok - Renderers > WebGLRenderer > no repeated program lookups with a transmissive object in view
#   not ok - Renderers > WebGLRenderer > changing the lights state runs program lookups again
# with this change: # pass 2, # fail 0

Per-frame program lookups with the change (60 unique materials, one transmissive sphere, ACES; light added after frame 4), counted via a customProgramCacheKey spy as in the issue:

FRAMES 0:2 1:1 2:0 3:0 4:0 5:2 6:1 7:0 8:0 9:0

Steady frames are lookup-free; a real state change re-runs one lookup per output variant, then settles.

Pixel comparison of patched vs pristine dev on the issue scenario plus the three edge cases from the discussion (material in two scenes with different lights, normal-mapped material shared by meshes with and without tangents, instance colors added after the first frames), FNV-1a hash of the canvas after 6 renders each:

PIX grid=10b546a2 twoScenes=e196691d,3ff53f tangents=fcd3f1 instanceColors=3a68da2f   # identical on both builds
$ npm run test-unit          # 1..1524, # pass 1523, # todo 1, # fail 0
$ npm run test-e2e -- webgl_loader_gltf_transmission.html webgl_materials_physical_transmission.html webgl_materials_physical_transmission_alpha.html
# webgl_materials_physical_transmission: Diff 0.0% (pass)
# webgl_loader_gltf_transmission and webgl_materials_physical_transmission_alpha: Diff 0.1% (fail)
# both fail identically on pristine upstream on this machine (verified by stashing the src change), pre-existing
$ npx eslint src/renderers/WebGLRenderer.js test/unit/src/renderers/WebGLRenderer.tests.js   # clean

Tradeoff: after any real program change (lights, version bump, features), each output variant re-runs one full lookup once because the map is cleared with the chain; the frames after that are lookup-free again. Materials that alternate between more than two output settings (e.g. multiple render targets with different texture color spaces) keep one map entry per variant, mirroring what materialProperties.programs already holds.

Prepared with AI assistance (GLM 5.3 via Oh My Pi) and reviewed before submission.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
Core 390.08
102.3
390.08
102.3
+0 B
+0 B
WebGL 372.73
87.14
373.05
87.24
+324 B
+93 B
WebGPU 429.27
115.67
429.27
115.67
+0 B
+0 B
WebGPU Nodes 716.94
196.98
716.94
196.98
+0 B
+0 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 517.46
124.27
517.79
124.4
+324 B
+130 B
WebGPU 769.54
206.2
769.54
206.2
+0 B
+0 B
WebGPU Nodes 715.39
192.75
715.39
192.75
+0 B
+0 B

@Mugen87 Mugen87 closed this Oct 2, 2026
@rubenmarcus

Copy link
Copy Markdown
Author

The red CI was mine: the two new unit tests call new WebGLRenderer({ canvas }), so they need a real WebGL context. The unit job runs without a GPU, and context creation failed (Could not create a WebGL context in the log) before the assertions. On a machine with a GPU the tests pass, which is how this shipped looking green.

The unit suite never creates a real context; the existing renderer tests work against a mock GL object (see test/unit/src/renderers/webgl/WebGLExtensions.tests.js). I'll rewrite the two tests that way and open a fresh PR against #34654, which is still open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebGLRenderer: with a transmissive object in view, every opaque material looks its program up twice a frame

2 participants