Depth Textures in WebGL 2

A depth renderbuffer can only be tested against; a depth texture can also be sampled, as shadow mapping needs. WebGL 1 had them only through WEBGL_depth_texture, so older tutorials packed depth into RGBA bytes. WebGL 2 makes them core:

WebGL 2 depth texture formats
Internal format Bits Typical use
DEPTH_COMPONENT16 16 unsigned normalized small maps, mobile
DEPTH_COMPONENT24 24 unsigned normalized general shadow maps (GLH.framebuffer())
DEPTH_COMPONENT32F 32 float large depth ranges, reversed-Z
DEPTH24_STENCIL8 24 + 8 stencil depth plus stencil in one attachment

Two restrictions, both measured here: readPixels() rejects depth (INVALID_ENUM), so debug by drawing the values as gray; and through a plain sampler2D a depth texture needs NEAREST (with LINEAR it sampled 0), while LINEAR works in comparison mode, the mode the next subsections use.

A DEPTH_COMPONENT24 texture rendered from the lamp, sampled as grey with a plain sampler2D and NEAREST, beside the camera viewHTMLLive
<!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; }
</style>
<canvas id="c" width="1200" height="300"></canvas>
<div class="names"><div>the depth texture, linearized to grey (near dark)</div><div id="note"></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() {
  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);
  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');
const depthProgram = program(`#version 300 es
layout(location = 0) in vec3 aPosition; uniform mat4 uViewProjection, uModel;
void main() { gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0); }`, `#version 300 es
precision mediump float; void main() {}`);      // depth only: no colour is written
const litProgram = program(`#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; uniform mat3 uNormalMatrix; out vec3 vPosition, vNormal;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = uNormalMatrix * aNormal; gl_Position = uViewProjection * w; }`,
`#version 300 es
precision highp float;
in vec3 vPosition, vNormal; out vec4 outColor;
uniform highp sampler2DShadow uShadowMap;
uniform mat4 uLightViewProjection;
uniform vec3 uColor, uLamp;
uniform float uBias, uSpread;
void main() {
  vec4 p = uLightViewProjection * vec4(vPosition, 1.0);
  vec3 s = p.xyz / p.w * 0.5 + 0.5;                   // shadow-map u, v and depth, all 0..1
  vec2 texel = uSpread / vec2(textureSize(uShadowMap, 0));
  float lit = 0.0;
  for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++)   // 3 x 3 PCF (all nine alike when uSpread = 0)
    lit += texture(uShadowMap, vec3(s.xy + vec2(x, y) * texel, s.z - uBias));
  vec3 n = normalize(vNormal), l = normalize(uLamp - vPosition);
  outColor = vec4(uColor * (0.3 + 0.9 * max(dot(n, l), 0.0) * lit / 9.0), 1.0);
}`);
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, cube(), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);

const covers = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16) / 255);
const scene = [...covers.map((hex, i) => ({ color: rgb(hex), model: m4.model(-0.81 + i * 0.32, 0, 0, 0.26, 0.9, 0.2, (20 - i * 8) * Math.PI / 180) })),
  { color: rgb('#8a6240'), model: m4.model(0, -0.495, 0, 2.3, 0.08, 0.7) },     // plank
  { color: rgb('#cdbfa8'), model: m4.model(0, 0.13, -0.45, 3.5, 1.3, 0.04) }];  // wall
const lamp = [-0.2, 1.3, 1.1], eye = [0.9, 0.9, 2.9], size = 512;
function drawScene(p, viewProjection) {
  const u = (n) => gl.getUniformLocation(p, n);
  gl.uniformMatrix4fv(u('uViewProjection'), false, viewProjection);
  for (const { color, model } of scene) {
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniformMatrix3fv(u('uNormalMatrix'), false, m4.normal(model));
    gl.uniform3fv(u('uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  }
}
// The shadow map: a depth texture in comparison mode, on a framebuffer with no colour attachment.
const depthTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, size, size);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
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);
const shadowFBO = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFBO);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);
gl.drawBuffers([gl.NONE]);
const light = m4.multiply(m4.perspective(90, 1, 0.5, 5), m4.lookAt(lamp, [0.1, -0.2, -0.3]));


// Render the lamp's depth into the texture (the framebuffer from above is still bound).
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
gl.viewport(0, 0, size, size);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(depthProgram);
drawScene(depthProgram, light);
// readPixels() cannot read a depth attachment: it reports INVALID_ENUM.
gl.readPixels(0, 0, 1, 1, gl.DEPTH_COMPONENT, gl.FLOAT, new Float32Array(1));
const readError = gl.getError();
gl.bindFramebuffer(gl.FRAMEBUFFER, null);

// To look at it, sample it like any texture: comparison off and NEAREST filtering.
gl.bindTexture(gl.TEXTURE_2D, depthTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.NONE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const showDepth = 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; uniform highp sampler2D uDepth; in vec2 vUV; out vec4 c;
void main() {
  float d = texture(uDepth, vUV).r * 2.0 - 1.0;                        // NDC depth, -1..1
  float near = 0.5, far = 5.0;
  float linear = 2.0 * near * far / (far + near - d * (far - near));   // back to a distance
  c = vec4(vec3((linear - near) / (far - near)), 1.0);
}`);
gl.clearColor(0.93, 0.91, 0.87, 1);
gl.enable(gl.SCISSOR_TEST);
gl.viewport(0, 0, 600, 300); gl.scissor(0, 0, 597, 300);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.viewport(150, 0, 300, 300);                                         // the square map, undistorted
gl.useProgram(showDepth);
gl.drawArrays(gl.TRIANGLES, 0, 3);

// The camera's view of the same scene, lit (a huge bias counts everything as lit: no shadows yet).
gl.viewport(600, 0, 600, 300); gl.scissor(603, 0, 597, 300);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
gl.useProgram(litProgram);
gl.uniformMatrix4fv(gl.getUniformLocation(litProgram, 'uLightViewProjection'), false, light);
gl.uniform3fv(gl.getUniformLocation(litProgram, 'uLamp'), lamp);
gl.uniform1f(gl.getUniformLocation(litProgram, 'uBias'), 1.0);
drawScene(litProgram, m4.multiply(m4.perspective(40, 2, 0.1, 20), m4.lookAt(eye, [0.1, -0.2, 0])));
document.getElementById('note').textContent =
  `camera view | readPixels(DEPTH_COMPONENT) -> ${readError === gl.INVALID_ENUM ? 'INVALID_ENUM' : readError}`;
</script>