A draw call writes its pixels somewhere. By default that is the canvas, but WebGL can be told to write into a texture instead, and then that texture can be sampled by the next draw like any image. This is called render to texture and it is the basis of nearly everything beyond a single pass: shadow maps, reflections, blur, bloom, colour grading.
The place a draw call writes to is a framebuffer. The canvas has one, the default framebuffer, which the browser owns. You can create more. A framebuffer holds no pixels itself: it is a list of attachments, and each attachment is a piece of memory the GPU writes one kind of value to:
An attachment is either a texture or a renderbuffer. A texture can be sampled by a shader afterwards. A renderbuffer is write-only, and in exchange the driver may store it in whatever way is fastest. The colour of a render target is nearly always a texture (that is the point of it); the depth is a renderbuffer unless you need to read depth back, as a shadow map does.
A framebuffer can only be drawn into when it is complete: every attachment
has storage of the same size and a format the GPU can render to. WebGL
reports the answer with checkFramebufferStatus, and an incomplete one
silently draws nothing, so the renderer throws instead.
Two more things to know:
You cannot read from a texture while you draw into it (WebGL raises an error for that feedback loop). To process an image in several passes, draw pass one into target A, pass two reads A and draws into B, pass three reads B and draws into A again. Alternating between two targets like this is called ping-pong.
A RenderTarget is plain data like every other scene object:
const target = new RenderTarget(1024, 512, {
depth: 'renderbuffer',
float: true,
});
renderer.render(scene, camera, target); // draws into the target
renderer.render(pass, camera); // draws to the canvas
width and height are ordinary fields; change them and the renderer
rebuilds the framebuffer at the new size on the next render. depth is one
of DepthAttachment: 'renderbuffer' (the default), 'texture' when
the depth has to be sampled later, or 'none'. float asks for a half
float colour format, which can hold values above 1 and is what the
next page needs; it requires the
EXT_color_buffer_float extension and the renderer throws a clear error
when the browser does not have it.
target.colorAttachment is a normal Texture: put it in a material's
uniforms and it samples what was rendered. That works because the renderer
does what it does for every scene object: the framebuffer, the renderbuffer
and the GPU texture live in the renderer, in a map keyed by the RenderTarget
and the Texture, and the texture is registered where a material will find
it, so the colour is never copied. Rendering into a target does not touch
the canvas's framebuffer or viewport: every render() call binds what it
needs, and rendering without a target goes back to the canvas.
One detail: a Texture normally wraps an image, and an attachment has none.
Texture.empty makes a texture that has only a size (isEmpty); the
renderer allocates storage for it instead of uploading pixels. That also
makes it safe to sample a target's texture before anything has rendered into
it, which is what the first frame of a ping-pong loop does: it reads zeros.
Where it lives:
src/scene/render-target.ts: RenderTarget, its options and the two
attachments.src/scene/constants.ts: the DepthAttachment string union.src/webgl/webgl2-renderer.ts: building, caching, resizing and disposing
the framebuffers; render() takes the target as a third argument.src/test-utils/fake-webgl2.ts: records framebuffer and renderbuffer calls
and can pretend an extension is missing.Render targets
draws a scene into a float target and shows it through a second pass.
Resize the window: the target follows the canvas and the renderer rebuilds
the framebuffer. Then change depth to 'none' in script.js and look at
the spheres.
Depth texture
uses depth: 'texture' and shows the depth attachment as an image next to
the colour. It also shows why depth values are not distances: switch between
the raw depth buffer, which is almost white everywhere, and the linearized
one. Change the range uniform to see closer or farther objects in detail.
Feedback visualizer
is the ping-pong pattern: two float targets take turns, a fullscreen pass
blurs and fades the previous frame into the other one, and a noise curve is
drawn on top before the result is shown. Change the decay and the blur
offset in the feedback shader to make the trails longer or softer.