Render to Texture

How to 3D

Chapter 9: Texture Effects

Render to Texture

Normally we render the scene to the framebuffer, which the graphics card displays on the monitor. But we can alternatively render the scene to a texture. Rendering to a texture has several benefits:

This renderer offers an example of post-processing a scene rendered to a texture. In the second pass, the fragment shader reads a neighborhood of texels and averages their color to produce a blurry effect:

WebGL doesn't let us just walk in and render to a texture. We must first set up a framebuffer object (FBO). When we set up a WebGL context, the default framebuffer object is created for us automatically. This default framebuffer object has two attachments: the color framebuffer that is drawn to our screen and a depth buffer that is used to sort fragments. To create our own framebuffer object, we generate our own attachments.

Let's see how to create an FBO with texture attachments. Up till now we've always had a texture to upload to the graphics card, but that's not the case with textures we render into. We only need to allocate space for these textures, which we can do with these two utility functions:

function reserveColorTexture(width: number, height: number, unit: GLenum = gl.TEXTURE0): WebGLTexture {
  gl.activeTexture(unit);
  const texture = gl.createTexture()!;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  return texture;
}

function reserveDepthTexture(width: number, height: number, unit: GLenum = gl.TEXTURE0) {
  gl.activeTexture(unit);
  const texture = gl.createTexture()!;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  return texture;
}
function reserveColorTexture(width: number, height: number, unit: GLenum = gl.TEXTURE0): WebGLTexture {
  gl.activeTexture(unit);
  const texture = gl.createTexture()!;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  return texture;
}

function reserveDepthTexture(width: number, height: number, unit: GLenum = gl.TEXTURE0) {
  gl.activeTexture(unit);
  const texture = gl.createTexture()!;
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, width, height, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  return texture;
}

The depth texture requires several different options than the color texture. For example, we force nearest-neighbor interpolation because lerping depths is not always supported or may produce strange boundaries between objects.

After generating these textures, we create the FBO and attach them with code like this:

function initializeTextureFbo(colorTexture: WebGLTexture, depthTexture: WebGLTexture) {
  const framebuffer = gl.createFramebuffer()!;

  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorTexture, 0);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);

  return framebuffer;
}
function initializeTextureFbo(colorTexture: WebGLTexture, depthTexture: WebGLTexture) {
  const framebuffer = gl.createFramebuffer()!;

  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorTexture, 0);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);

  return framebuffer;
}

We don't always need to read from an attachment during a later draw call. Maybe we want the color attachment applied to a surface but only created the depth attachment in order to sort the geometry. If that's the case, we should use a simpler renderbuffer attachment instead of a wasting a texture. These utility methods create renderbuffers:

function reserveColorRenderbuffer(width: number, height: number) {
  const buffer = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, buffer);
  gl.renderbufferStorage(gl.RENDERBUFFER, gl.RGBA8, width, height);
  return buffer;
}

function reserveDepthRenderbuffer(width: number, height: number) {
  const buffer = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, buffer);
  gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, width, height);
  return buffer;
}
function reserveColorRenderbuffer(width: number, height: number) {
  const buffer = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, buffer);
  gl.renderbufferStorage(gl.RENDERBUFFER, gl.RGBA8, width, height);
  return buffer;
}

function reserveDepthRenderbuffer(width: number, height: number) {
  const buffer = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, buffer);
  gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, width, height);
  return buffer;
}

Attaching renderbuffers is slightly different than attaching textures. This function creates an FBO with two renderbuffer attachments:

function initializeRenderbufferFbo(colorBuffer: WebGLRenderbuffer, depthBuffer: WebGLRenderbuffer) {
  const framebuffer = gl.createFramebuffer()!;

  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, colorBuffer);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);

  return framebuffer;
}
function initializeRenderbufferFbo(colorBuffer: WebGLRenderbuffer, depthBuffer: WebGLRenderbuffer) {
  const framebuffer = gl.createFramebuffer()!;

  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, colorBuffer);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);

  return framebuffer;
}

In practice, most FBOs will be a mixture of texture and renderbuffer attachments. No matter the form of the attachments, drawing into an FBO requires only an extra bind call, like this:

function render() {
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  // draw geometry that needs to be in the texture

  // Restore default framebuffer
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  // draw geometry that relies on the texture
}
function render() {
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  // draw geometry that needs to be in the texture

  // Restore default framebuffer
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  // draw geometry that relies on the texture
}

In a later chapter, we will add shadows by first rendering the scene to a texture.

← Projective Texturing