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:
- It can save time and memory. A model may be very expensive to render, especially if we have to render it many times. An alternative is to render it just once, either per run or per frame, into a texture and then apply the texture to much cheaper geometry. Billboards, portals, and skyboxes might be generated in this way.
- It provides an opportunity for post-processing a scene with effects like blurring, color manipulation, and edge enhancement. The scene is essentially rendered in two passes. In the first pass, the scene is rendered normally with transformations and shading, but to a texture. In the second pass, an untransformed and viewport-filling quadrilateral is rendered. The vertex shader does hardly any work, but the fragment shader reads in texels from the scene texture and applies the desired effect.
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.