Bind a TSL texture() node to a live IUniform slot.
texture() captures the THREE.Texture handed to it at factory-build
time, while the host re-points uniforms.uInput.value at a different
texture between passes (BloomChain walks the mip pyramid; FxaaPass
builds with null and assigns the LDR target per render) — so the node
has to re-resolve the texture on every render or it samples the
placeholder forever and the pass outputs an empty image under WebGPU.
The swap MUST happen in updateBefore, not in update
(.onUpdate(…, 'render')). Every node.sample(…) tap is a CLONE of
this node that reads the texture through referenceNode, and each
clone derives its own render-target Y-flip uniform from that texture
inside TextureNode.update() — three's WebGL-backend emulation of
WebGPU's top-down framebuffer convention. Node updates run in graph
order with no guarantee that the bound node comes first, so swapping
during update lets the taps that were already updated derive their
flip from the PREVIOUS texture. On the first bloom downsample pass that
predecessor is the non-render-target placeholder, so one of the four
taps sampled un-flipped while the other three sampled flipped, and the
box average collapsed to half the vertical gradient (#2584). The
5-tap upsample tent is worse, because its first-built tap is the CENTRE
(c.mul(0.5) is the outer .add's left operand, so the setup-stage
depth-first walk registers it ahead of the four 0.125 taps): the stale
weight is exactly 0.5, and a 0.5/0.5 mix of a gradient with its own
mirror collapses to a spatially CONSTANT contribution equal to the
centre-row value — verbatim #2584's levels: 2 decomposition. The
renderer runs the whole graph's updateBefore ahead of any update,
so binding there makes every tap see the same texture.
The mismatch only bites while the predecessor texture differs in
isRenderTargetTexture, i.e. on the first render after each material
build; from the second frame on every tap already agrees. A RESIZE is
not such a build: BloomChain.setSize/setLevels only dispose and
reallocate the mip targets, while the three NodeMaterials and their
bound texture nodes are constructed once and survive, so a resize just
rebinds one render-target mip over another. The materials are built by
buildBloomChain, which runs when bloom goes from disabled to enabled
(PostProcessingManager.setBloomEnabled(true) — including the first
enable that allocates the chain) and on WebGL context restore. That is
still enough to matter — every session that shows bloom pays it once —
and it is why the fix is applied to FXAA too even though its
parity-harness entry supplies a real texture at build time and so never
exercises the swap.
Bind a TSL
texture()node to a liveIUniformslot.texture()captures the THREE.Texture handed to it at factory-build time, while the host re-pointsuniforms.uInput.valueat a different texture between passes (BloomChain walks the mip pyramid; FxaaPass builds withnulland assigns the LDR target per render) — so the node has to re-resolve the texture on every render or it samples the placeholder forever and the pass outputs an empty image under WebGPU.The swap MUST happen in
updateBefore, not inupdate(.onUpdate(…, 'render')). Everynode.sample(…)tap is a CLONE of this node that reads the texture throughreferenceNode, and each clone derives its own render-target Y-flip uniform from that texture insideTextureNode.update()— three's WebGL-backend emulation of WebGPU's top-down framebuffer convention. Node updates run in graph order with no guarantee that the bound node comes first, so swapping duringupdatelets the taps that were already updated derive their flip from the PREVIOUS texture. On the first bloom downsample pass that predecessor is the non-render-target placeholder, so one of the four taps sampled un-flipped while the other three sampled flipped, and the box average collapsed to half the vertical gradient (#2584). The 5-tap upsample tent is worse, because its first-built tap is the CENTRE (c.mul(0.5)is the outer.add's left operand, so the setup-stage depth-first walk registers it ahead of the four 0.125 taps): the stale weight is exactly 0.5, and a 0.5/0.5 mix of a gradient with its own mirror collapses to a spatially CONSTANT contribution equal to the centre-row value — verbatim #2584'slevels: 2decomposition. The renderer runs the whole graph'supdateBeforeahead of anyupdate, so binding there makes every tap see the same texture.The mismatch only bites while the predecessor texture differs in
isRenderTargetTexture, i.e. on the first render after each material build; from the second frame on every tap already agrees. A RESIZE is not such a build:BloomChain.setSize/setLevelsonly dispose and reallocate the mip targets, while the three NodeMaterials and their bound texture nodes are constructed once and survive, so a resize just rebinds one render-target mip over another. The materials are built bybuildBloomChain, which runs when bloom goes from disabled to enabled (PostProcessingManager.setBloomEnabled(true)— including the first enable that allocates the chain) and on WebGL context restore. That is still enough to matter — every session that shows bloom pays it once — and it is why the fix is applied to FXAA too even though its parity-harness entry supplies a real texture at build time and so never exercises the swap.