Skip to content
Rendering · FramebuffersIntermediate

Render targets

Draw into a texture instead of the screen, then read that texture in a later pass.

Live demo coming soon

How it works

A fragment shader writes its color somewhere. By default that somewhere is the canvas: the image the browser shows. Every frame you have drawn so far went straight there, and once a pixel is on the canvas, no shader can read it back.

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 banding).
  • RGBA16F: half floats, up to about 65 000, with negative values. This is what HDR 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 of RGBA8.

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.

Further reading