Comparison Samplers

Comparison Samplers for Shadow Sampling

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:

Shadow lookup with a comparison samplerJavaScript
@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.

A disc's shadow from a 32 x 32 shadow map, read with a nearest and with a linear comparison sampler (free 2 x 2 PCF)HTMLLive
<!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>