The G-Buffer

What to Store in the G-Buffer and Where

The surface data lives in the geometry buffer, or G-buffer, named by Saito and Takahashi in 1990 (Deering and colleagues proposed shading after visibility in 1988). Every byte costs bandwidth per pixel:

BookNest's G-buffer layout
Target Format Channels Bytes per pixel
COLOR_ATTACHMENT0 RGBA8 albedo (sRGB), roughness 4
COLOR_ATTACHMENT1 RGBA16F world normal, emissive flag 8
COLOR_ATTACHMENT2 RGBA16F world position, coverage flag 8
DEPTH_ATTACHMENT DEPTH_COMPONENT24 depth for the depth test 4

Engines squeeze harder: they rebuild the position from depth, pack the normal into two channels (octahedral encoding) and fit metallic and material IDs into spare bytes. This layout keeps the lighting pass plain.

Every channel of BookNest's G-buffer after one geometry pass: albedo, roughness, normal, position, emissive and coverage flags, depthHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font: 11px monospace; color: #333; }
  .names div { flex: 1; padding: 4px 2px; }
  p { max-width: 600px; margin: 4px 0; font: 11px monospace; color: #333; }
</style>
<canvas id="c" width="1240" height="440"></canvas>
<div class="names"><div id="table" style="text-align:left"></div></div>
<script>
const m4 = {
  perspective(fovy, aspect, near, far) {
    const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
    return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
  },
  lookAt(eye, target, up = [0, 1, 0]) {
    const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
    const unit = (v) => v.map((c) => c / Math.hypot(...v));
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
    return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
  },
  multiply(a, b) {
    const out = new Array(16).fill(0);
    for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
    return out;
  },
  model(x, y, z, sx, sy, sz, turn = 0) {        // T * Ry(turn) * S
    const c = Math.cos(turn), s = Math.sin(turn);
    return [c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s * sz, 0, c * sz, 0, x, y, z, 1];
  },
  normal(m) {
    const [a, b, c, , d, e, f, , g, h, i] = m;
    const n = [e * i - f * h, f * g - d * i, d * h - e * g, c * h - b * i, a * i - c * g, b * g - a * h, b * f - c * e, c * d - a * f, a * e - b * d];
    const det = a * n[0] + b * n[1] + c * n[2];
    return n.map((v) => v / det);
  },
};
function cube() {                                 // position, normal, uv: 36 vertices
  const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
                 [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
  const out = [];
  for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
    out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n, (a + 1) / 2, (b + 1) / 2);
  return new Float32Array(out);
}
function program(vs, fs) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
    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(p, s);
  }
  gl.linkProgram(p);
  return p;
}
const gl = document.getElementById('c').getContext('webgl2');
gl.getExtension('EXT_color_buffer_float');              // RGBA16F render targets
const u = (p, n) => gl.getUniformLocation(p, n);

// The geometry pass: no lighting, three outputs, one per render target.
const geometry = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aUV;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix; out vec3 vPosition, vNormal; out vec2 vUV;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal;
  vUV = vec2(aUV.x, 1.0 - aUV.y); gl_Position = uViewProjection * w; }`, `#version 300 es
precision highp float;
in vec3 vPosition, vNormal; in vec2 vUV;
layout(location = 0) out vec4 gAlbedo;     // rgb albedo (sRGB), a roughness
layout(location = 1) out vec4 gNormal;     // xyz world normal, w 1 for emissive surfaces
layout(location = 2) out vec4 gPosition;   // xyz world position, w 1 where geometry is
uniform sampler2D uCover; uniform mat3 uNormalMatrix; uniform vec3 uColor;
uniform float uTextured, uRoughness, uEmissive;
void main() {
  vec3 n = normalize(vNormal);
  float front = uTextured * step(0.99, dot(n, normalize(uNormalMatrix * vec3(0, 0, 1))));
  gAlbedo = vec4(mix(uColor, texture(uCover, vUV).rgb, front), uRoughness);
  gNormal = vec4(n, uEmissive);
  gPosition = vec4(vPosition, 1.0);
}`);
// The lighting pass: one oversized triangle, the BRDF for up to 64 point lights per pixel.
const lighting = program(`#version 300 es
out vec2 vUV;
void main() { vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0; gl_Position = vec4(vUV * 2.0 - 1.0, 0.0, 1.0); }`,
`#version 300 es
precision highp float;
in vec2 vUV; out vec4 outColor;
uniform sampler2D uAlbedo, uNormal, uPosition;
uniform vec3 uLightPosition[64], uLightColor[64];
uniform int uLightCount, uView;
uniform vec3 uEye, uAmbient; uniform float uRadius;
const float PI = 3.14159265;
vec3 brdf(vec3 n, vec3 v, vec3 l, vec3 albedo, float roughness) {
  vec3 h = normalize(v + l);
  float nl = max(dot(n, l), 0.0), nv = max(dot(n, v), 1e-4), nh = max(dot(n, h), 0.0), a2 = pow(roughness, 4.0);
  float d = a2 / (PI * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0)), k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
  float g = nl / (nl * (1.0 - k) + k) * nv / (nv * (1.0 - k) + k);
  vec3 f = vec3(0.04) + 0.96 * pow(1.0 - max(dot(h, v), 0.0), 5.0);
  return ((1.0 - f) * albedo / PI + d * g * f / (4.0 * nv * max(nl, 1e-4))) * nl;
}
void main() {
  vec4 a = texture(uAlbedo, vUV), n = texture(uNormal, vUV), p = texture(uPosition, vUV);
  if (p.w < 0.5) discard;                            // nothing drawn here
  vec3 debug[4] = vec3[](a.rgb, a.rgb, n.xyz * 0.5 + 0.5, p.xyz * 0.3 + 0.5);
  if (uView > 0 || n.w > 0.5) { outColor = vec4(debug[uView], 1.0); return; }   // a G-buffer view, or emissive
  vec3 albedo = pow(a.rgb, vec3(2.2)), color = uAmbient * albedo;
  vec3 v = normalize(uEye - p.xyz), normal = normalize(n.xyz);
  for (int i = 0; i < uLightCount; i++) {
    vec3 toLight = uLightPosition[i] - p.xyz;
    float d = length(toLight);
    if (d > uRadius) continue;                       // out of this light's reach: skipped
    float fade = pow(1.0 - pow(d / uRadius, 4.0), 2.0) / (d * d + 0.01);
    color += brdf(normal, v, toLight / d, albedo, a.a) * fade * uLightColor[i];
  }
  color = color / (1.0 + color);                     // Reinhard tone mapping
  outColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}`);

// Geometry: a cube (books, plank, wall) and a low-poly sphere (bulbs), in one buffer.
const verts = [...cube()];
const ring = (i, j) => { const t = i / 6 * Math.PI, p = j / 8 * 2 * Math.PI; return [Math.sin(t) * Math.cos(p), Math.cos(t), -Math.sin(t) * Math.sin(p)]; };
for (let i = 0; i < 6; i++) for (let j = 0; j < 8; j++)
  for (const [di, dj] of [[0, 0], [1, 0], [0, 1], [0, 1], [1, 0], [1, 1]]) { const q = ring(i + di, j + dj); verts.push(...q, ...q, 0, 0); }
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); gl.enableVertexAttribArray(2);
const BULB_FIRST = 36, BULB_COUNT = 6 * 8 * 6;

// Covers drawn on 2D canvases.
const titles = [['#1f5f8b', 'The Quiet', 'Harbor'], ['#5b3f99', "Clockmaker's", 'Paradox'], ['#e09a10', 'Salt and', 'Saffron'],
                ['#3f7d3a', 'Small Steps', 'Big Summits'], ['#b5452f', 'Paper', 'Lanterns'], ['#2a9d8f', 'Tide', 'Tables']];
const covers = titles.map(([color, a, b]) => {
  const c = Object.assign(document.createElement('canvas'), { width: 128, height: 192 }), g = c.getContext('2d');
  g.fillStyle = color; g.fillRect(0, 0, 128, 192);
  g.fillStyle = '#f2ead8'; g.font = 'bold 17px Georgia'; g.textAlign = 'center'; g.fillText(a, 64, 120); g.fillText(b, 64, 142);
  g.strokeStyle = '#f2ead8'; g.lineWidth = 3; g.strokeRect(8, 8, 112, 176);
  const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, c); gl.generateMipmap(gl.TEXTURE_2D);
  return t;
});
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const scene = [...titles.map(([hex], i) => ({ color: rgb(hex), texture: covers[i], roughness: 0.45,
    model: m4.model(-1.3 + i * 0.52, 0.5, 0, 0.42, 0.98, 0.12, (30 - i * 12) * Math.PI / 180) })),
  { color: rgb('#8a6240'), roughness: 0.8, model: m4.model(0, -0.03, 0, 3.4, 0.06, 0.7) },
  { color: rgb('#cdbfa8'), roughness: 0.8, model: m4.model(0, 0.55, -0.45, 3.5, 1.3, 0.04) }];

// Two strings of 24 coloured lights: on the wall, and along the plank's front edge.
const lights = [], palette = [...titles.map(([hex]) => hex), '#ffd27a'].map(rgb);
for (let i = 0; i < 48; i++) {
  const t = (i % 24) / 23, color = palette[(i * 5) % 7], sag = 0.25 * Math.sin(Math.PI * t);
  lights.push({ color: color.map((c) => c / Math.max(...color)),
    position: i < 24 ? [-1.45 + 2.9 * t, 1.1 - sag, -0.36] : [-1.6 + 3.2 * t, 0.02, 0.5] });
}

// The G-buffer: three colour targets (attached in order) and a depth texture.
function makeGBuffer(w, h) {
  const fbo = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  const textures = [gl.RGBA8, gl.RGBA16F, gl.RGBA16F].map((format, i) => {
    const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
    gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t, 0);
    return t;
  });
  const depth = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, depth);
  gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, w, h);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depth, 0);
  gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2]);   // output i -> attachment i
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  return { fbo, textures, depth };
}
function geometryPass(gbuffer, w, h, viewProjection, bulbs = true) {
  gl.bindFramebuffer(gl.FRAMEBUFFER, gbuffer.fbo);
  gl.viewport(0, 0, w, h);
  gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);   // w = 0 marks the background
  gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
  gl.useProgram(geometry);
  gl.uniformMatrix4fv(u(geometry, 'uViewProjection'), false, viewProjection);
  gl.uniform1f(u(geometry, 'uEmissive'), 0);
  gl.activeTexture(gl.TEXTURE0);
  for (const { color, texture, roughness, model } of scene) {
    gl.bindTexture(gl.TEXTURE_2D, texture || null);
    gl.uniform1f(u(geometry, 'uTextured'), texture ? 1 : 0);
    gl.uniform1f(u(geometry, 'uRoughness'), roughness);
    gl.uniformMatrix4fv(u(geometry, 'uModel'), false, model);
    gl.uniformMatrix3fv(u(geometry, 'uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u(geometry, 'uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
  if (bulbs) {                                          // the bulbs: small unlit (emissive) spheres
    gl.uniform1f(u(geometry, 'uEmissive'), 1); gl.uniform1f(u(geometry, 'uTextured'), 0);
    for (const { position, color } of lights) {
      const model = m4.model(...position, 0.025, 0.025, 0.025);
      gl.uniformMatrix4fv(u(geometry, 'uModel'), false, model);
      gl.uniformMatrix3fv(u(geometry, 'uNormalMatrix'), false, [1, 0, 0, 0, 1, 0, 0, 0, 1]);
      gl.uniform3fv(u(geometry, 'uColor'), color);
      gl.drawArrays(gl.TRIANGLES, BULB_FIRST, BULB_COUNT);
    }
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
function lightingPass(gbuffer, view, eye, count = lights.length) {
  gl.disable(gl.DEPTH_TEST);
  gl.useProgram(lighting);
  gbuffer.textures.forEach((t, i) => { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, t); });
  gl.activeTexture(gl.TEXTURE0);
  ['uAlbedo', 'uNormal', 'uPosition'].forEach((name, i) => gl.uniform1i(u(lighting, name), i));
  gl.uniform3fv(u(lighting, 'uEye'), eye); gl.uniform3f(u(lighting, 'uAmbient'), 0.06, 0.06, 0.07);
  gl.uniform1f(u(lighting, 'uRadius'), 1.2); gl.uniform1i(u(lighting, 'uLightCount'), count);
  gl.uniform3fv(u(lighting, 'uLightPosition'), lights.flatMap((x) => x.position));
  gl.uniform3fv(u(lighting, 'uLightColor'), lights.flatMap((x) => x.color.map((c) => c * 0.8)));
  gl.uniform1i(u(lighting, 'uView'), view);
  gl.drawArrays(gl.TRIANGLES, 0, 3);                    // no attributes are read: gl_VertexID only
}


const W = 400, H = 220, eye = [0.5, 1.0, 2.6];
const gbuffer = makeGBuffer(W, H);
geometryPass(gbuffer, W, H, m4.multiply(m4.perspective(40, W / H, 0.1, 20), m4.lookAt(eye, [0, 0.45, 0])));
// A viewer for one channel group of one target.
const show = program(`#version 300 es
out vec2 vUV;
void main() { vUV = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0; gl_Position = vec4(vUV * 2.0 - 1.0, 0.0, 1.0); }`,
`#version 300 es
precision highp float; in vec2 vUV; uniform sampler2D uTex; uniform int uMode; out vec4 c;
void main() {
  vec4 t = texture(uTex, vUV);
  vec3 color = uMode == 0 ? t.rgb                       // RGBA8: albedo
             : uMode == 1 ? vec3(t.a)                    // RGBA8 alpha: roughness
             : uMode == 2 ? t.xyz * 0.5 + 0.5            // RGBA16F: world normal
             : uMode == 3 ? vec3(t.w, t.w * 0.6, 0.1)    // RGBA16F w: emissive flag (the bulbs)
             : uMode == 4 ? t.xyz * 0.3 + 0.5            // RGBA16F: world position
             : vec3(pow(t.r, 40.0));                     // depth, stretched
  c = vec4(color, 1.0);
}`);
const tiles = [[0, 0, 'albedo (RGBA8 rgb)'], [0, 1, 'roughness (RGBA8 a)'], [1, 2, 'normal (RGBA16F xyz)'],
               [1, 3, 'emissive flag (RGBA16F w)'], [2, 4, 'position (RGBA16F xyz)'], [3, 5, 'depth (DEPTH_COMPONENT24)']];
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(show);
gl.disable(gl.DEPTH_TEST);
tiles.forEach(([target, mode], i) => {
  gl.bindTexture(gl.TEXTURE_2D, target === 3 ? gbuffer.depth : gbuffer.textures[target]);
  if (target === 3) { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); }
  gl.uniform1i(u(show, 'uMode'), mode);
  gl.viewport((i % 3) * 413 + 3, i < 3 ? 220 : 0, W, H);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
});
// Bytes per pixel and memory, for this layout at 1920 x 1080.
const rows = [['COLOR_ATTACHMENT0', 'RGBA8', 'albedo (sRGB), roughness', 4], ['COLOR_ATTACHMENT1', 'RGBA16F', 'world normal, emissive flag', 8],
              ['COLOR_ATTACHMENT2', 'RGBA16F', 'world position, coverage flag', 8], ['DEPTH_ATTACHMENT', 'DEPTH_COMPONENT24', 'depth test', 4]];
const total = rows.reduce((s, r) => s + r[3], 0);
document.getElementById('table').innerHTML = 'tiles, left to right: ' + tiles.map(t => t[2]).join(' | ') + '<br>' +
  rows.map(r => `${r[0]} ${r[1]}: ${r[2]}, ${r[3]} B`).join('<br>') +
  `<br>total ${total} bytes per pixel = ${(1920 * 1080 * total / 1048576).toFixed(1)} MiB at 1920 x 1080`;
</script>