Render targets
Draw into a texture instead of the screen, then read that texture in a later pass.
How it works
A fragment shaderThe GPU program that runs once for every pixel a triangle covers and decides its color. Fragment shader The GPU program that runs once for every pixel a triangle covers and decides its color.
A render target changes where the output goes. It is a texture you draw into instead of the screen. When the draw is done, the texture holds an image like any other, and a later draw can sample it. That one change, draw now and read later, is what blur, bloom, outlines, mirrors and every other multi-pass effect stand on.
Framebuffers
OpenGL calls the place a draw writes to a framebuffer. The canvas has one built in, the default framebuffer. You can make your own and attach textures to it: a color texture for the fragment shader’s output, and optionally a depth buffer so 3D geometry still sorts correctly. While your framebuffer is bound, every draw lands in its textures.
In WebGL2 that looks like this:
// a texture the size of the canvas, with no data yet
const target = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, target);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, width, height);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// a framebuffer that writes into it
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target, 0);
// pass 1: draw the scene into the texture
drawScene();
// pass 2: back to the canvas, and sample the texture
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, target);
drawFullScreen();
Two details matter. The viewport (gl.viewport) is set per target, so a half-size texture needs a half-size viewport.
And gl.checkFramebufferStatus tells you whether the attachments form a framebuffer the GPU can draw into; a format it
can’t render to fails there, not later.
One target can’t be read and written at once
A pass can’t sample the texture it is drawing into. The GPU runs many fragments in parallel, so a fragment reading its neighbour couldn’t know whether that neighbour was already overwritten. OpenGL calls this a feedback loop and leaves the result undefined; WebGL2 turns it into an error. Effects that need the previous result read one target and write another, then swap.
What a target can hold
The texture’s format decides what survives the pass:
RGBA8: 8 bits per channel, values clamped to 0–1. Always renderable. Fine for a final image, too coarse for linear light in the darks (see bandingBanding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.RenderingBanding & dithering
Banding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.
Sources).RGBA16F: half floats, up to about 65 000, with negative values. This is what HDRRendering keeps light values above 1 in float buffers (HDR), then a tone-mapping curve compresses them into the 0–1 a screen can show.RenderingHDR & tone mapping
Rendering keeps light values above 1 in float buffers (HDR), then a tone-mapping curve compresses them into the 0–1 a screen can show.
Reinhard = c / (1 + c)needs: a bright highlight stays brighter than 1 until the end of the frame.RGBA32F: full floats, for data such as positions. Four times the memory ofRGBA8.
WebGL2 can sample half floats with LINEAR filtering everywhere. Full floats (RGBA32F) need NEAREST, or the
OES_texture_float_linear extension for LINEAR; without it, the snippet above samples black. Rendering into either
needs the EXT_color_buffer_float extension.
Ask for it with gl.getExtension("EXT_color_buffer_float") and keep a fallback for the devices that say no.
Several outputs in one pass
A framebuffer can have several color attachments, and one fragment shader can write all of them. This is called multiple render targets (MRT). Each output gets a location:
#version 300 es
precision highp float;
in vec3 vNormal;
in vec4 vColor;
layout(location = 0) out vec4 outColor;
layout(location = 1) out vec4 outNormal;
void main() {
outColor = vColor;
outNormal = vec4(normalize(vNormal) * 0.5 + 0.5, 1.0);
}
On the JavaScript side, attach a texture to COLOR_ATTACHMENT1 as well and call
gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]). Deferred renderers use this to write albedo, normals
and roughness in one geometry pass.
In Godot
Godot wraps the same idea in a node. A SubViewport renders its children into its own texture, and a
ViewportTexture lets any material sample that image. The engine’s own 3D renderer uses render targets
internally (the scene, depth and normals before post-processing), and a shader reaches the current frame through
hint_screen_texture and hint_depth_texture uniforms rather than through a framebuffer you manage.
On this site
A card with more than one pass lists them in its render.json. Each pass renders into its own half-float target, and
a later pass reads it by name as t_<pass>. The effect pass is always last and the only one that reaches the
screen.