WebGPU Shadow Mapping

Shadow mapping is the most common way to draw real-time shadows.

The GPU has no idea what a shadow is. You teach it with one simple trick: look at the scene from the light first.

A rotating cube with soft shadows from a moving light.

The Idea

A point is in shadow if something is between it and the light. To find out, draw the scene twice:

  1. Shadow pass: render the scene from the light. Keep only the depth. This depth texture is the shadow map. It stores how far the light can "see" in every direction.
  2. Main pass: render the scene from the camera as usual. For each pixel, work out where it lands in the shadow map. If the pixel is further from the light than the stored depth, something blocks the light. The pixel is in shadow.
Note This lesson builds on the depth buffer and camera matrices. The shadow map is just a depth buffer rendered from a different point of view.

Matrix Helpers

We need a few matrix functions. They use plain JavaScript and no library. Matrices are 16 numbers in column-major order, and depth goes from 0 to 1, as WebGPU expects.

function multiply(a, b) {
  const result = new Float32Array(16);
  for (let column = 0; column < 4; column++) {
    for (let row = 0; row < 4; row++) {
      let sum = 0;
      for (let k = 0; k < 4; k++) {
        sum += a[k * 4 + row] * b[column * 4 + k];
      }
      result[column * 4 + row] = sum;
    }
  }
  return result;
}

function perspective(fieldOfViewRadians, aspect, near, far) {
  const focal = 1 / Math.tan(fieldOfViewRadians / 2);
  const rangeInverse = 1 / (near - far);
  return new Float32Array([
    focal / aspect, 0, 0, 0,
    0, focal, 0, 0,
    0, 0, far * rangeInverse, -1,
    0, 0, near * far * rangeInverse, 0
  ]);
}

function orthographic(left, right, bottom, top, near, far) {
  return new Float32Array([
    2 / (right - left), 0, 0, 0,
    0, 2 / (top - bottom), 0, 0,
    0, 0, 1 / (near - far), 0,
    (left + right) / (left - right), (bottom + top) / (bottom - top), near / (near - far), 1
  ]);
}

function lookAt(eye, target, up) {
  const zAxis = normalize(subtract(eye, target));
  const xAxis = normalize(cross(up, zAxis));
  const yAxis = cross(zAxis, xAxis);
  return new Float32Array([
    xAxis[0], yAxis[0], zAxis[0], 0,
    xAxis[1], yAxis[1], zAxis[1], 0,
    xAxis[2], yAxis[2], zAxis[2], 0,
    -dot(xAxis, eye), -dot(yAxis, eye), -dot(zAxis, eye), 1
  ]);
}

subtract, cross, dot and normalize are the usual small vector functions. You will find them in the full examples.

The Light's View-Projection Matrix

The light needs its own "camera". Sunlight is so far away that its rays are parallel, so we use an orthographic projection. It has no perspective: far things do not get smaller.

const LIGHT_POSITION = [5, 6, 2];
const lightView = lookAt(LIGHT_POSITION, [0, 0, 0], [0, 1, 0]);
const lightProjection = orthographic(-7, 7, -7, 7, 1, 20);
const lightViewProjection = multiply(lightProjection, lightView);

The box from −7 to 7 must cover everything that can cast or receive a shadow. A smaller box gives sharper shadows, because the shadow map pixels are spread over less ground.

Tip For a spotlight or a lamp, use perspective() for the light instead. Its rays spread out from one point, like a camera.

The Shadow Map Texture

The shadow map is a depth texture. It needs two usages: RENDER_ATTACHMENT so the shadow pass can draw into it, and TEXTURE_BINDING so the main pass can read it.

const SHADOW_MAP_SIZE = 2048;

const shadowTexture = device.createTexture({
  size: [SHADOW_MAP_SIZE, SHADOW_MAP_SIZE],
  format: "depth32float",
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});

The Shadow Pass

The shadow pass only needs depth, so its pipeline has no fragment stage and no colour targets. That makes it very fast.

@vertex
fn shadowVertexMain(@location(0) position: vec3f) -> @builtin(position) vec4f {
  return scene.lightViewProjection * object.model * vec4f(position, 1.0);
}
const shadowPipeline = device.createRenderPipeline({
  layout: "auto",
  vertex: { module: shaderModule, entryPoint: "shadowVertexMain", buffers: [vertexBufferLayout] },
  depthStencil: { format: "depth32float", depthWriteEnabled: true, depthCompare: "less" }
});

const shadowPass = encoder.beginRenderPass({
  colorAttachments: [],
  depthStencilAttachment: {
    view: shadowTexture.createView(),
    depthClearValue: 1,
    depthLoadOp: "clear",
    depthStoreOp: "store"
  }
});

Draw every object in the shadow pass, then end it and start the normal camera pass.

The Comparison Sampler

To read the shadow map, use a special comparison sampler. Instead of returning the stored depth, it compares it with a depth you give it and returns 1.0 (lit) or 0.0 (in shadow).

const shadowSampler = device.createSampler({ compare: "less" });

compare: "less" means: return 1 if your depth is less than the stored depth. In other words, nothing is closer to the light than this pixel.

Comparing in WGSL

In WGSL, the shadow map is a texture_depth_2d, and the sampler is a sampler_comparison. Read them with textureSampleCompare.

The vertex shader also moves each point into the light's space. Clip space goes from −1 to 1, but texture coordinates go from 0 to 1 with y pointing down, so we scale by (0.5, -0.5) and add 0.5.

Example

A rotating cube casting a sharp shadow:

@group(0) @binding(0) var<uniform> scene: Scene;
@group(0) @binding(1) var shadowMap: texture_depth_2d;
@group(0) @binding(2) var shadowSampler: sampler_comparison;
@group(1) @binding(0) var<uniform> object: Object;

const SHADOW_BIAS = 0.003;
const AMBIENT_LIGHT = 0.25;

@vertex
fn vertexMain(@location(0) position: vec3f, @location(1) normal: vec3f) -> VertexOutput {
  let worldPosition = object.model * vec4f(position, 1.0);
  let lightSpacePosition = scene.lightViewProjection * worldPosition;
  var output: VertexOutput;
  output.position = scene.cameraViewProjection * worldPosition;
  output.normal = (object.model * vec4f(normal, 0.0)).xyz;
  output.shadowPosition = vec3f(
    lightSpacePosition.xy * vec2f(0.5, -0.5) + 0.5,
    lightSpacePosition.z
  );
  return output;
}

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let visibility = textureSampleCompare(
    shadowMap,
    shadowSampler,
    input.shadowPosition.xy,
    input.shadowPosition.z - SHADOW_BIAS
  );
  let diffuse = max(dot(normalize(input.normal), scene.lightDirection), 0.0);
  let lighting = AMBIENT_LIGHT + diffuse * visibility;
  return vec4f(object.color.rgb * lighting, 1.0);
}
Edit & Run »

The lighting is simple diffuse lighting: surfaces that face the light are bright. The shadow only removes the diffuse part, so shadows are dark but not black, thanks to the AMBIENT_LIGHT.

Note We can skip dividing by w because an orthographic projection always gives w = 1. With a perspective light, divide lightSpacePosition.xyz by lightSpacePosition.w in the fragment shader.

Shadow Acne and Depth Bias

A shadow map has a limited number of pixels, and depth values are not perfectly precise. Without a fix, a surface can shadow itself, giving ugly stripes. This is called shadow acne.

The fix is a small depth bias: pretend each pixel is a tiny bit closer to the light. That is what - SHADOW_BIAS does above.

  • Bias too small: stripes of acne appear.
  • Bias too large: shadows detach from the objects. This is called peter-panning.
Tip You can also set the bias on the shadow pipeline: depthStencil: { ..., depthBias: 2, depthBiasSlopeScale: 2 }. The slope scale adds more bias on surfaces that are steep to the light, where acne is worst.

Soft Shadows with PCF

Each shadow map pixel is either lit or not, so shadow edges look hard and blocky. PCF (Percentage-Closer Filtering) softens them.

The idea is simple: compare several nearby points in the shadow map and average the results. Near an edge, some samples are lit and some are not, which gives a smooth value between 0 and 1.

Example

Average a 5×5 grid of shadow samples:

const SHADOW_MAP_SIZE = 1024.0;
const FILTER_RADIUS = 2;
const FILTER_SPREAD = 2.5;

fn softShadow(shadowPosition: vec3f) -> f32 {
  let texelSize = FILTER_SPREAD / SHADOW_MAP_SIZE;
  let depth = shadowPosition.z - SHADOW_BIAS;
  var total = 0.0;
  var sampleCount = 0.0;
  for (var y = -FILTER_RADIUS; y <= FILTER_RADIUS; y++) {
    for (var x = -FILTER_RADIUS; x <= FILTER_RADIUS; x++) {
      let offset = vec2f(f32(x), f32(y)) * texelSize;
      total += textureSampleCompare(shadowMap, shadowSampler, shadowPosition.xy + offset, depth);
      sampleCount += 1.0;
    }
  }
  return total / sampleCount;
}
Edit & Run »

In this example, the light also circles the scene. Each frame it builds a new lightViewProjection with lookAt() and writes it into the uniform buffer.

Tip Give the comparison sampler magFilter: "linear" and minFilter: "linear". The GPU then blends four comparisons for every sample for free, which makes PCF even smoother.
Warning PCF samples further from the pixel, so it needs a slightly larger bias. The soft example uses 0.007 instead of 0.003.

Key Objects

ObjectWherePurpose
Light view-projectionJavaScriptorthographic() × lookAt() from the light
Shadow mapJavaScriptA depth32float texture with RENDER_ATTACHMENT | TEXTURE_BINDING
Shadow pipelineJavaScriptVertex stage and depthStencil only, no fragment stage
Comparison samplerJavaScriptcreateSampler({ compare: "less" })
texture_depth_2dWGSLThe shadow map as seen by the shader
sampler_comparisonWGSLThe comparison sampler as seen by the shader
textureSampleCompare()WGSLReturns 1.0 if lit, 0.0 if in shadow
Depth biasWGSL or pipelineStops shadow acne

Exercises

1. Which two usage flags does a shadow map texture need?

Show answer

GPUTextureUsage.RENDER_ATTACHMENT (to draw into it) and GPUTextureUsage.TEXTURE_BINDING (to read it in the main pass).

2. Set SHADOW_BIAS to 0.0 in the sharp example. What do you see, and why?

Show answer

Stripes and speckles appear on lit surfaces. This is shadow acne: surfaces shadow themselves because of limited depth precision.

3. Make the shadows lighter, so shadowed areas keep half of the diffuse light.

Show answer
let lighting = AMBIENT_LIGHT + diffuse * mix(0.5, 1.0, visibility);

4. How many shadow map samples does the PCF function take when FILTER_RADIUS is 1?

Show answer

A 3×3 grid: 9 samples.

Summary

  • A shadow map is a depth texture rendered from the light.
  • Use an orthographic projection for sunlight and perspective for spotlights.
  • The shadow pass has no fragment stage. It only writes depth.
  • A comparison sampler and textureSampleCompare tell you if a pixel is lit.
  • A small depth bias fixes shadow acne. PCF averages samples for soft edges.

Official documentation