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.
The Idea
A point is in shadow if something is between it and the light. To find out, draw the scene twice:
- 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.
- 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.
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.
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.
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.
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.
magFilter: "linear" and minFilter: "linear". The GPU then blends four comparisons for every sample for free, which makes PCF even smoother.
0.007 instead of 0.003.
Key Objects
| Object | Where | Purpose |
|---|---|---|
| Light view-projection | JavaScript | orthographic() × lookAt() from the light |
| Shadow map | JavaScript | A depth32float texture with RENDER_ATTACHMENT | TEXTURE_BINDING |
| Shadow pipeline | JavaScript | Vertex stage and depthStencil only, no fragment stage |
| Comparison sampler | JavaScript | createSampler({ compare: "less" }) |
texture_depth_2d | WGSL | The shadow map as seen by the shader |
sampler_comparison | WGSL | The comparison sampler as seen by the shader |
textureSampleCompare() | WGSL | Returns 1.0 if lit, 0.0 if in shadow |
| Depth bias | WGSL or pipeline | Stops 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
textureSampleComparetell you if a pixel is lit. - A small depth bias fixes shadow acne. PCF averages samples for soft edges.
Official documentation
Go deeper with the specifications and reference documentation:
- w3.org
WebGPU specification: sampler compare
Comparison samplers, which turn a depth texture into a shadow test.
- w3.org
WGSL specification: textureSampleCompare
The built-in that compares a reference depth against the shadow map.
- developer.mozilla.org
MDN: GPUDevice.createSampler()
Setting compare and filtering for smooth shadow edges.
- webgpu.github.io
WebGPU Samples
The official sample collection, including a complete shadow mapping demo.