A sampler created with compare (for example device.createSampler({ compare: 'less', magFilter: 'linear', minFilter: 'linear' })) is a sampler_comparison, usable only with depth textures. Instead of returning a depth, textureSampleCompare() compares a reference value with the stored depths and returns the fraction that passed:
@group(0) @binding(0) var shadowMap: texture_depth_2d;
@group(0) @binding(1) var shadowSampler: sampler_comparison; // compare: 'less'
fn lit(uv: vec2f, depthFromLight: f32) -> f32 { // 0 = shadowed, 1 = lit
return textureSampleCompare(shadowMap, shadowSampler, uv, depthFromLight);
}With nearest filtering the result is 0 or 1 per texel, so shadow edges show the shadow map's texel steps. With linear filtering the hardware compares the four nearest texels and blends the four results: percentage-closer filtering (PCF) for free, the same 2 x 2 PCF that WebGL 2's sampler2DShadow gives (PCF Soft Shadows), with the comparison moved from the texture to the sampler. Tested on a 32 x 32 map, the linear sampler turned a disc's stair-stepped shadow edge into a one-texel gradient. A shadow map is rendered by a depth-only pipeline (no fragment stage, an empty colorAttachments array) and bound with a texture: { sampleType: 'depth' } layout entry and a sampler: { type: 'comparison' } entry. Add depth bias (Depth Compare and Bias) against shadow acne, and use textureSampleCompareLevel() outside fragment shaders.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="320"></canvas>
<canvas id="labels" width="600" height="320"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
// Seen from above: a disc floating over the ground. The light shines at an angle, so in
// light space the disc sits shifted by its height times the light's slant.
const code = /* wgsl */ `
@group(0) @binding(0) var<uniform> disc: vec2f; // the disc's centre on the ground
const SHIFT = vec2f(0.35, -0.3); // light slant x the disc's height
fn discVertex(v: u32) -> vec2f { // a 32-triangle fan
let k = v / 3; let corner = v % 3;
if (corner == 0) { return disc; }
let a = f32(k + corner - 1) / 32 * 6.2832;
return disc + vec2f(cos(a), sin(a)) * 0.32;
}
@vertex fn shadowVs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
return vec4f(discVertex(v) + SHIFT, 0.5, 1); // depth from the light: 0.5 (ground: 1)
}
@group(0) @binding(1) var shadowMap: texture_depth_2d;
@group(0) @binding(2) var shadowSampler: sampler_comparison; // compare: 'less'
struct Out { @builtin(position) pos: vec4f, @location(0) ground: vec2f }
@vertex fn groundVs(@builtin(vertex_index) v: u32) -> Out {
let p = vec2f(f32(v & 1), f32(v >> 1)) * 2 - 1;
return Out(vec4f(p, 0, 1), p);
}
fn lit(uv: vec2f, depthFromLight: f32) -> f32 { // 0 = shadowed, 1 = lit
return textureSampleCompare(shadowMap, shadowSampler, uv, depthFromLight);
}
@fragment fn groundFs(in: Out) -> @location(0) vec4f {
let uv = vec2f(in.ground.x + 1, 1 - in.ground.y) / 2;
let light = lit(uv, 0.99); // the ground is at depth 1, minus a bias
let boards = 0.93 + 0.07 * step(0.5, fract(in.ground.y * 4));
return vec4f(mix(vec3f(0.45, 0.40, 0.35), vec3f(0.93, 0.87, 0.76) * boards, light), 1);
}
@vertex fn discVs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
return vec4f(discVertex(v), 0, 1);
}
@fragment fn discFs() -> @location(0) vec4f { return vec4f(0.08, 0.40, 0.75, 1); }`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const module = device.createShaderModule({ code });
const shadowMap = device.createTexture({ size: [32, 32], format: 'depth32float',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
// A depth-only pipeline: no fragment stage, no colour targets.
const shadowPipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module, entryPoint: 'shadowVs' },
depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' } });
const groundPipeline = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
vertex: { module, entryPoint: 'groundVs' }, fragment: { module, entryPoint: 'groundFs', targets: [{ format }] } });
const discPipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module, entryPoint: 'discVs' },
fragment: { module, entryPoint: 'discFs', targets: [{ format }] } });
const discBuffer = device.createBuffer({ size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const bind = (pipeline, entries) => device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries });
const discEntry = { binding: 0, resource: { buffer: discBuffer } };
const shadowGroup = bind(shadowPipeline, [discEntry]);
const discGroup = bind(discPipeline, [discEntry]);
const groundGroups = ['nearest', 'linear'].map((filter) => bind(groundPipeline, [
{ binding: 1, resource: shadowMap.createView() },
{ binding: 2, resource: device.createSampler({ compare: 'less', magFilter: filter, minFilter: filter }) }]));
ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
ink.fillText("compare: 'less', nearest: texel steps", 12, 18);
ink.fillText("compare: 'less', linear: 2 x 2 PCF", 312, 18);
function frame(now) {
const t = now / 1600;
device.queue.writeBuffer(discBuffer, 0, new Float32Array([Math.cos(t) * 0.25 - 0.15, Math.sin(t) * 0.2 + 0.1]));
const encoder = device.createCommandEncoder();
const shadow = encoder.beginRenderPass({ colorAttachments: [],
depthStencilAttachment: { view: shadowMap.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'store' } });
shadow.setPipeline(shadowPipeline);
shadow.setBindGroup(0, shadowGroup);
shadow.draw(96);
shadow.end();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
groundGroups.forEach((group, i) => {
pass.setViewport(10 + i * 300, 26, 280, 286, 0, 1);
pass.setPipeline(groundPipeline); pass.setBindGroup(0, group); pass.draw(4);
pass.setPipeline(discPipeline); pass.setBindGroup(0, discGroup); pass.draw(96);
});
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
main();
</script>