A framebuffer object holds no pixels itself: it is a set of attachment points (COLOR_ATTACHMENT0 to 7, DEPTH_ATTACHMENT, STENCIL_ATTACHMENT, DEPTH_STENCIL_ATTACHMENT) into which you plug textures, with framebufferTexture2D(), or renderbuffers, with framebufferRenderbuffer(). A renderbuffer is storage you can render into but never sample, the right choice for a depth buffer that only serves the depth test. Before drawing, checkFramebufferStatus() says whether the combination works:
const status = (color, depth) => {
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
const slots = [[gl.COLOR_ATTACHMENT0, color], [gl.DEPTH_ATTACHMENT, depth]];
for (const [attachment, format] of slots) {
if (!format) continue;
const buffer = gl.createRenderbuffer(); // storage without a texture
gl.bindRenderbuffer(gl.RENDERBUFFER, buffer);
gl.renderbufferStorage(gl.RENDERBUFFER, format, 256, 256);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, attachment, gl.RENDERBUFFER, buffer);
}
const code = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return Object.keys(WebGL2RenderingContext).find((k) => k.startsWith('FRAMEBUFFER_') &&
WebGL2RenderingContext[k] === code);
};
console.log('RGBA8 + DEPTH_COMPONENT24:', status(gl.RGBA8, gl.DEPTH_COMPONENT24));
console.log('DEPTH_COMPONENT24 only: ', status(null, gl.DEPTH_COMPONENT24));
console.log('nothing attached: ', status(null, null));
console.log('RGBA32F (no extension): ', status(gl.RGBA32F, null));RGBA8 + DEPTH_COMPONENT24: FRAMEBUFFER_COMPLETE DEPTH_COMPONENT24 only: FRAMEBUFFER_COMPLETE nothing attached: FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT WebGL: INVALID_ENUM: renderbufferStorage: EXT_color_buffer_float not enabled RGBA32F (no extension): FRAMEBUFFER_INCOMPLETE_ATTACHMENT
Float targets need EXT_color_buffer_float; with it enabled, the same framebuffer is complete. WebGL 1 had only 16-bit color, DEPTH_COMPONENT16 and STENCIL_INDEX8 renderbuffers; WebGL 2 adds the sized formats, DEPTH24_STENCIL8, and multisampled renderbuffers that blitFramebuffer() resolves into a texture.
<!doctype html>
<style>
body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
.row { display: flex; gap: 10px; max-width: 600px; align-items: flex-start; }
canvas { width: 300px; height: 240px; flex: none; }
table { border-collapse: collapse; flex: 1; font-family: monospace; font-size: 10px; }
td { padding: 5px 4px; border-bottom: 1px solid #e3ddd0; }
.ok { color: #3f7d3a; } .bad { color: #b5452f; }
</style>
<div class="row"><canvas id="c" width="300" height="240"></canvas><table id="t"></table></div>
<script>
const gl = document.getElementById('c').getContext('webgl2', { antialias: false });
// A framebuffer is only attachment points: plug renderbuffers in and ask whether the set works.
function status(color, depth) {
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
for (const [attachment, format] of [[gl.COLOR_ATTACHMENT0, color], [gl.DEPTH_ATTACHMENT, depth]]) {
if (!format) continue;
const buffer = gl.createRenderbuffer(); // storage you render into, never sample
gl.bindRenderbuffer(gl.RENDERBUFFER, buffer);
gl.renderbufferStorage(gl.RENDERBUFFER, format, 256, 256);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, attachment, gl.RENDERBUFFER, buffer);
}
const code = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return Object.keys(WebGL2RenderingContext).find((k) => k.startsWith('FRAMEBUFFER_') && WebGL2RenderingContext[k] === code);
}
const rows = [['RGBA8 + DEPTH_COMPONENT24', status(gl.RGBA8, gl.DEPTH_COMPONENT24)],
['DEPTH_COMPONENT24 only', status(null, gl.DEPTH_COMPONENT24)],
['nothing attached', status(null, null)],
['RGBA32F, no extension', status(gl.RGBA32F, null)]];
gl.getExtension('EXT_color_buffer_float');
rows.push(['RGBA32F + EXT_color_buffer_float', status(gl.RGBA32F, null)]);
document.getElementById('t').innerHTML = rows.map(([name, s]) =>
`<tr><td>${name}</td><td class="${s === 'FRAMEBUFFER_COMPLETE' ? 'ok' : 'bad'}">${s.replace('FRAMEBUFFER_', '')}</td></tr>`).join('');
// Render a turning book into 4x multisampled renderbuffers, then resolve them onto the canvas.
const W = 300, H = 240;
const msaa = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, msaa);
for (const [attachment, format] of [[gl.COLOR_ATTACHMENT0, gl.RGBA8], [gl.DEPTH_ATTACHMENT, gl.DEPTH_COMPONENT24]]) {
const rb = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
gl.renderbufferStorageMultisample(gl.RENDERBUFFER, 4, format, W, H);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, attachment, gl.RENDERBUFFER, rb);
}
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
uniform float uAngle; out vec3 vColor;
void main() {
// A box of 36 vertices generated from gl_VertexID: 6 faces x 2 triangles.
int face = gl_VertexID / 6, corner = gl_VertexID % 6;
vec2 q = vec2[6](vec2(-1), vec2(1, -1), vec2(1), vec2(-1), vec2(1), vec2(-1, 1))[corner];
vec3 n = vec3[6](vec3(0,0,1), vec3(0,0,-1), vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0))[face];
vec3 u = vec3[6](vec3(1,0,0), vec3(-1,0,0), vec3(0,0,-1), vec3(0,0,1), vec3(1,0,0), vec3(1,0,0))[face];
vec3 p = (n + q.x * u + q.y * cross(n, u)) * vec3(0.3, 0.42, 0.08);
float c = cos(uAngle), s = sin(uAngle);
mat3 turn = mat3(c, 0.0, -s, 0.0, 1.0, 0.0, s, 0.0, c) * mat3(1.0, 0.0, 0.0, 0.0, 0.96, 0.28, 0.0, -0.28, 0.96);
vec3 wn = turn * n;
vColor = (face == 0 ? vec3(0.12, 0.37, 0.55) : vec3(0.85, 0.8, 0.7)) * (0.4 + 0.6 * max(dot(wn, normalize(vec3(0.3, 0.6, 0.8))), 0.0));
vec3 wp = turn * p;
gl_Position = vec4(wp.x * 1.25, wp.y * 1.5, -wp.z * 0.5, 1.0); // nearer (+z) = smaller depth
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 c; void main() { c = vec4(vColor, 1.0); }`]]) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindVertexArray(gl.createVertexArray());
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
function frame(ms) {
gl.bindFramebuffer(gl.FRAMEBUFFER, msaa); // draw into the renderbuffers
gl.viewport(0, 0, W, H);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1f(gl.getUniformLocation(program, 'uAngle'), ms * 0.0008);
gl.drawArrays(gl.TRIANGLES, 0, 36);
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, msaa); // resolve the samples onto the canvas
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
gl.blitFramebuffer(0, 0, W, H, 0, 0, W, H, gl.COLOR_BUFFER_BIT, gl.NEAREST);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>