You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
PassNode: compileAsync() prepares a render context the pass is never rendered in (regression since r183) #34681
PassNode.compileAsync() (#31501) exists so that "when the animation loop starts and the pass is
rendered for the first time, the frame time should be lower due to the pre-compilation". Since r183
it no longer does that: the pass's first render builds every object of its scene again,
synchronously, on the main thread, and an InstancedMesh also compiles a second shader and render
pipeline. It affects the WebGPU backend and the WebGL 2 backend alike.
Expected: after await scenePass.compileAsync( renderer ), the first renderPipeline.render()
builds nothing for the scene's objects — only the RenderPipeline's own full-screen quad, which
nothing precompiles.
Actual: the first render creates a new render object and runs a new node build for every object
in the scene; for an InstancedMesh the new build emits different code, so a new shader module
and a new render pipeline are created, synchronously.
Cause
Render contexts are keyed by call depth, and the precompile and the render ask for different
depths:
render() asks for its context at this._callDepth
(Renderer.js#L1733),
which is -1 at rest and raised by each nested render()
(#L1683).
A pass is rendered from PassNode.updateBefore()
(PassNode.js#L909),
that is, inside the render of the node that reads it — for a RenderPipeline, inside this._quadMesh.render( renderer )
(RenderPipeline.js#L149).
So the pass renders at call depth 1.
Renderer.compileAsync() asks for its context with no depth, so 0
(Renderer.js#L933),
and PassNode.compileAsync() calls it from the top level
(PassNode.js#L791).
So the precompile fills render objects for a depth-0 context the pass never uses, and the first
render builds the depth-1 ones. compileAsync() itself intends the keys to match ("This ensures
cache keys match between compileAsync and render", Renderer.js#L925).
An InstancedMesh shows it most: its vertex shader names its matrix buffer's node, so the second
build emits different code. The two shaders of the reproduction below differ only in that:
A plain mesh builds the same code again, so its pipeline is reused, but its render object and
node build are still redone.
On dev (eccc422), #34506 made compileAsync() raise _callDepth as render() does
(_beginPreCompile(), Renderer.js#L1133;
the context at #L943).
A top-level call still lands at depth 0, so the pass is still affected. The depth is also read
after await this.init()
(#L900),
which matters for the fix below.
Same scene, camera, render target and MRT in every row; only the call depth of the precompile and
of the render differ. WebGPU backend; node builds counted with renderer.debug.onNodeBuilderCreated.
three
Precompiled at
Rendered at
The first render rebuilds
r186
0: renderer.compileAsync() with the pass's target set
0: renderer.render() into that target
nothing
r186
0: scenePass.compileAsync()
1: renderPipeline.render()
box, instances
r186 + fix
1: scenePass.compileAsync()
1: renderPipeline.render()
nothing
r186 + fix
1: scenePass.compileAsync()
0: renderer.render() into the pass's target
box, instances
The rebuild follows the depth in both directions. The same holds on dev.
Measurements (the reproduction below)
After await scenePass.compileAsync(), the first renderPipeline.render()
r186.1
r186 + fix
Node builds, WebGPU
Render Pipeline, box, instances
Render Pipeline
Render pipelines created, WebGPU
2, sync: instances (rgba16float) and the quad (bgra8unorm)
1, sync: the quad
Shader modules created, WebGPU
3
2
Node builds, WebGL 2 backend
Render Pipeline, box, instances
Render Pipeline
The precompile itself is the same in both columns: two node builds (box, instances), two
asynchronous pipelines, four shader modules. r182 and r183 have no onNodeBuilderCreated; counting
pipelines only, r182's first render creates only the quad's, and r183's creates a second instances pipeline, as r186 does.
In a larger scene the cost scales with it: in one with many instanced meshes, rendered through a RenderPipeline and precompiled with scenePass.compileAsync(), the first frames created 323
render pipelines on WebGPU (197 when the scene was rendered directly) and the WebGL 2 backend's warm
start to the first complete frame went from 12.9–21.2 s to 21.9–23.8 s; with the fix below, 205
pipelines and 12.9–16.7 s.
Proposed fix
A pass precompiles for the depth it is rendered at, one below the render that reads it; and compileAsync() takes the depth a render issued at the moment of the call would take, captured
before its first await. Against dev:
--- a/src/renderers/common/Renderer.js+++ b/src/renderers/common/Renderer.js
@@ async compileAsync( scene, camera, targetScene = null, onProgress = null ) {
+ // The call depth a render issued now would take, 0 at the top level: render contexts are+ // keyed by it, and a precompile keyed otherwise builds render objects no render uses.++ const callDepth = this._callDepth + 1;+
if ( this._isDeviceLost === true ) return;
@@
- const renderContext = this._renderContexts.get( renderTarget, this._mrt, this._callDepth );+ const renderContext = this._renderContexts.get( renderTarget, this._mrt, callDepth );
--- a/src/nodes/display/PassNode.js+++ b/src/nodes/display/PassNode.js
@@ async compileAsync( renderer ) {
renderer.setRenderTarget( this.renderTarget );
renderer.setMRT( this._mrt );
- await renderer.compileAsync( this.scene, this.camera );+ // A pass is rendered inside the render of the node that reads it, one call depth below, and+ // render contexts are keyed by call depth: compile at that depth, or the first render of the+ // pass builds every object again.++ renderer._callDepth ++;++ const compilation = renderer.compileAsync( this.scene, this.camera );++ renderer._callDepth --;++ await compilation;
Both hunks are needed on dev: with the PassNode hunk alone, a renderer that is not yet
initialized still precompiles at depth 0, because compileAsync() awaits init() before it reads
the depth (the test below fails its two "not yet initialized" cases). At the top level the Renderer hunk changes nothing (-1 + 1 = 0). The same change against r186 is what we run as a
local patch.
A pass read inside another pass renders two levels down and is covered by neither hunk; keying
render objects independently of the call depth where the attachment state is equal would cover
it, at a larger cost to review.
Regression test
A unit test in the style of test/unit/addons/tsl/ (a real WebGPURenderer, both backends,
QUnit in the browser). Run against each build, headless Chromium 153 on macOS:
import{BoxGeometry,DirectionalLight,InstancedMesh,Mesh,MeshStandardNodeMaterial,PerspectiveCamera,RenderPipeline,Scene,WebGPURenderer}from'three/webgpu';import{pass}from'three/tsl';// PassNode.compileAsync() must prepare what the pass's first render uses. The pass renders inside// the render that reads it (a RenderPipeline's quad), one call depth below, and render contexts are// keyed by call depth, so a precompile at the top level builds render objects no render uses.constBACKENDS={webgpu: {},webgl: {forceWebGL: true}};functionsetup(){constscene=newScene();scene.add(newDirectionalLight());constbox=newMesh(newBoxGeometry(),newMeshStandardNodeMaterial());box.name='box';constinstances=newInstancedMesh(newBoxGeometry(),newMeshStandardNodeMaterial(),3);instances.name='instances';scene.add(box,instances);constcamera=newPerspectiveCamera();camera.position.z=5;return{ scene, camera };}asyncfunctionfirstRenderBuilds(options,initFirst){constrenderer=newWebGPURenderer({antialias: false, ...options});if(initFirst)awaitrenderer.init();const{ scene, camera }=setup();constscenePass=pass(scene,camera);constrenderPipeline=newRenderPipeline(renderer,scenePass);constbuilt=[];renderer.debug.onNodeBuilderCreated=(builder,renderObject)=>built.push(renderObject.object.name);awaitscenePass.compileAsync(renderer);// initializes the renderer when it has not beenconstcompiled=built.splice(0);renderPipeline.render();renderer.dispose();return{ compiled,rendered: built};}exportdefaultQUnit.module('Nodes',()=>{QUnit.module('PassNode',()=>{for(const[backend,options]ofObject.entries(BACKENDS)){for(constinitFirstof[true,false]){QUnit.test(`compileAsync() prepares the first render of the pass (${backend}, renderer ${initFirst ? 'initialized' : 'not yet initialized'})`,async(assert)=>{const{ compiled, rendered }=awaitfirstRenderBuilds(options,initFirst);assert.deepEqual(compiled.sort(),['box','instances'],'compileAsync() builds the scene');assert.notOk(rendered.includes('box')||rendered.includes('instances'),`the first render builds nothing of the scene again (built: ${rendered.join(', ')})`);});}}});});
Reproduction steps
Open the live example in a browser with WebGPU.
It builds a Mesh and an InstancedMesh (count 3), precompiles them with scenePass.compileAsync( renderer ), renders the RenderPipeline once, and prints the node
builds and render pipelines of each phase — on the WebGPU backend, then on the WebGL 2 backend.
The first render builds box and instances again on both backends.
Code
The live example's code (three r186.1 from jsDelivr)
<scripttype="importmap">{"imports": {"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js","three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.webgpu.js","three/tsl": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.tsl.js"}}</script><preid="out">running...</pre><scripttype="module">import*asTHREEfrom"three/webgpu";import{pass}from"three/tsl";// Render pipelines as the WebGPU device creates them, by phase.letphase="setup";letpipelines=[];const{ createRenderPipeline, createRenderPipelineAsync }=GPUDevice.prototype;GPUDevice.prototype.createRenderPipeline=function(d){pipelines.push(`${phase}: sync ${d.fragment?.targets?.[0]?.format}`);returncreateRenderPipeline.call(this,d);};GPUDevice.prototype.createRenderPipelineAsync=function(d){pipelines.push(`${phase}: async ${d.fragment?.targets?.[0]?.format}`);returncreateRenderPipelineAsync.call(this,d);};// A mesh and an instanced mesh, precompiled with scenePass.compileAsync(), then rendered once// through a RenderPipeline. Returns what each phase built.asyncfunctionrun(forceWebGL){phase="setup";pipelines=[];constrenderer=newTHREE.WebGPURenderer({ forceWebGL });renderer.setSize(256,256);awaitrenderer.init();constbuilds=[];renderer.debug.onNodeBuilderCreated=(builder,renderObject)=>builds.push(`${phase}: ${renderObject.object.name}`);constscene=newTHREE.Scene();scene.add(newTHREE.DirectionalLight(0xffffff,2));constbox=newTHREE.Mesh(newTHREE.BoxGeometry(),newTHREE.MeshStandardNodeMaterial());box.name="box";constinstances=newTHREE.InstancedMesh(newTHREE.BoxGeometry(0.3,0.3,0.3),newTHREE.MeshStandardNodeMaterial(),3);instances.name="instances";scene.add(box,instances);constcamera=newTHREE.PerspectiveCamera(50,1,0.1,100);camera.position.z=5;constscenePass=pass(scene,camera);constrenderPipeline=newTHREE.RenderPipeline(renderer);renderPipeline.outputNode=scenePass;phase="compileAsync";awaitscenePass.compileAsync(renderer);phase="first render";renderPipeline.render();renderer.dispose();return[`three r${THREE.REVISION}, ${forceWebGL ? "WebGL 2" : "WebGPU"} backend`,"node builds:", ...builds.map((b)=>" "+b),
...(forceWebGL ? [] : ["render pipelines:", ...pipelines.map((p)=>" "+p)]),"",];}constreport=[...(awaitrun(false)), ...(awaitrun(true))];document.getElementById("out").textContent=report.join("\n");console.log(report.join("\n"));</script>
Live example
https://jsfiddle.net/vanf8bs0/ (three r186.1: prints the node builds and render pipelines of the precompile and of the first render, on the WebGPU backend and then the WebGL 2 backend)
Screenshots
No response
Version
r186.1 (regression since r183; dev at eccc422 is affected)
The suggested fix does not handle the use case of nested render passes. Also, it's not ideal if PassNode directly access _callDepth. That's an internal information that should not leak in other components. I'm looking for a different solution.
A data point for the nested case. In my app the scene pass sits under FXAA, and fxaa() wraps its input in convertToTexture(), so the scene pass is drawn at call depth 2, not 1. The context it actually renders into is keyed 1:1023:1016:0:true:false-default-2. A fix that assumes "one level below the top-level render" would still miss this setup.
Impact in a real scene (r186, WebGPU backend, a few hundred materials): the first RenderPipeline.render() ran 294 synchronous node builds, about 1.7 s of main-thread time on an M4 Max.
What I'm doing until there's a supported way, in case it helps someone who lands here: set the pass's render target, and for that one compileAsync() call wrap renderer._renderContexts.get so it asks for the depth the pass is really rendered at. Synchronous builds on the first frame went from 294 to 8. It reads internals and hard-codes the depth, so it breaks as soon as the post chain changes: http://github.com/billpwchan/neon-zenith/blob/27ad04ef04aa9e1e1366fccc63e15a379b999f01/src/main.js#L243-L256
Something like a way to ask "precompile this pass as the pipeline will render it" (or resolving the depth from the node graph at precompile time) would cover both the plain and the nested case.
Description
PassNode.compileAsync()(#31501) exists so that "when the animation loop starts and the pass isrendered for the first time, the frame time should be lower due to the pre-compilation". Since r183
it no longer does that: the pass's first render builds every object of its scene again,
synchronously, on the main thread, and an
InstancedMeshalso compiles a second shader and renderpipeline. It affects the WebGPU backend and the WebGL 2 backend alike.
Expected: after
await scenePass.compileAsync( renderer ), the firstrenderPipeline.render()builds nothing for the scene's objects — only the
RenderPipeline's own full-screen quad, whichnothing precompiles.
Actual: the first render creates a new render object and runs a new node build for every object
in the scene; for an
InstancedMeshthe new build emits different code, so a new shader moduleand a new render pipeline are created, synchronously.
Cause
Render contexts are keyed by call depth, and the precompile and the render ask for different
depths:
Since RenderContext: Refactor context key. #32546 (r183) a render context's key is
attachmentState-mrtState-callDepth(RenderContexts.js#L67),
and a render object is keyed by its render context
(RenderObjects.js#L107).
render()asks for its context atthis._callDepth(Renderer.js#L1733),
which is
-1at rest and raised by each nestedrender()(#L1683).
A pass is rendered from
PassNode.updateBefore()(PassNode.js#L909),
that is, inside the render of the node that reads it — for a
RenderPipeline, insidethis._quadMesh.render( renderer )(RenderPipeline.js#L149).
So the pass renders at call depth 1.
Renderer.compileAsync()asks for its context with no depth, so 0(Renderer.js#L933),
and
PassNode.compileAsync()calls it from the top level(PassNode.js#L791).
So the precompile fills render objects for a depth-0 context the pass never uses, and the first
render builds the depth-1 ones.
compileAsync()itself intends the keys to match ("This ensurescache keys match between compileAsync and render",
Renderer.js#L925).
An
InstancedMeshshows it most: its vertex shader names its matrix buffer's node, so the secondbuild emits different code. The two shaders of the reproduction below differ only in that:
A plain mesh builds the same code again, so its pipeline is reused, but its render object and
node build are still redone.
On
dev(eccc422), #34506 madecompileAsync()raise_callDepthasrender()does(
_beginPreCompile(),Renderer.js#L1133;
the context at
#L943).
A top-level call still lands at depth 0, so the pass is still affected. The depth is also read
after
await this.init()(#L900),
which matters for the fix below.
Affected versions
callDepthto the render-context key without passing it fromcompileAsync()), r186, r186.1,devat eccc422.Changing only the depth changes the rebuild
Same scene, camera, render target and MRT in every row; only the call depth of the precompile and
of the render differ. WebGPU backend; node builds counted with
renderer.debug.onNodeBuilderCreated.renderer.compileAsync()with the pass's target setrenderer.render()into that targetscenePass.compileAsync()renderPipeline.render()box,instancesscenePass.compileAsync()renderPipeline.render()scenePass.compileAsync()renderer.render()into the pass's targetbox,instancesThe rebuild follows the depth in both directions. The same holds on
dev.Measurements (the reproduction below)
await scenePass.compileAsync(), the firstrenderPipeline.render()Render Pipeline,box,instancesRender Pipelineinstances(rgba16float) and the quad (bgra8unorm)Render Pipeline,box,instancesRender PipelineThe precompile itself is the same in both columns: two node builds (
box,instances), twoasynchronous pipelines, four shader modules. r182 and r183 have no
onNodeBuilderCreated; countingpipelines only, r182's first render creates only the quad's, and r183's creates a second
instancespipeline, as r186 does.In a larger scene the cost scales with it: in one with many instanced meshes, rendered through a
RenderPipelineand precompiled withscenePass.compileAsync(), the first frames created 323render pipelines on WebGPU (197 when the scene was rendered directly) and the WebGL 2 backend's warm
start to the first complete frame went from 12.9–21.2 s to 21.9–23.8 s; with the fix below, 205
pipelines and 12.9–16.7 s.
Proposed fix
A pass precompiles for the depth it is rendered at, one below the render that reads it; and
compileAsync()takes the depth a render issued at the moment of the call would take, capturedbefore its first
await. Againstdev:Both hunks are needed on
dev: with thePassNodehunk alone, a renderer that is not yetinitialized still precompiles at depth 0, because
compileAsync()awaitsinit()before it readsthe depth (the test below fails its two "not yet initialized" cases). At the top level the
Rendererhunk changes nothing (-1 + 1 = 0). The same change against r186 is what we run as alocal patch.
A pass read inside another pass renders two levels down and is covered by neither hunk; keying
render objects independently of the call depth where the attachment state is equal would cover
it, at a larger cost to review.
Regression test
A unit test in the style of
test/unit/addons/tsl/(a realWebGPURenderer, both backends,QUnit in the browser). Run against each build, headless Chromium 153 on macOS:
dev(eccc422)dev+ thePassNodehunk onlydev+ both hunksPassNode.compileAsync.tests.js
Reproduction steps
Meshand anInstancedMesh(count 3), precompiles them withscenePass.compileAsync( renderer ), renders theRenderPipelineonce, and prints the nodebuilds and render pipelines of each phase — on the WebGPU backend, then on the WebGL 2 backend.
boxandinstancesagain on both backends.Code
The live example's code (three r186.1 from jsDelivr)
Live example
https://jsfiddle.net/vanf8bs0/ (three r186.1: prints the node builds and render pipelines of the precompile and of the first render, on the WebGPU backend and then the WebGL 2 backend)
Screenshots
No response
Version
r186.1 (regression since r183;
devat eccc422 is affected)Device
Desktop
Browser
Chrome
OS
MacOS