FBOs and Renderbuffers

Framebuffer Objects and Renderbuffers

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:

Which attachments make a complete framebufferJavaScript
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));
Output
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.

checkFramebufferStatus for four attachment sets, and a book rendered into multisampled renderbuffers then blitted to the canvasHTMLLive
<!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>