Depth Compare and Bias

Depth Compare Functions and Depth Bias

depthCompare is never, less, equal, less-equal, greater, not-equal, greater-equal or always: a fragment passes when its depth compares true against the stored value. With the usual clear value of 1, less keeps the nearest surface; less-equal lets a second pass redraw the same geometry, and equal after a depth pre-pass shades each pixel once.

A perspective projection spends most depth values near the camera, so distant surfaces z-fight. Reversed depth maps near to 1 and far to 0, clears to 0 and compares with greater, so depth32float's extra precision near 0 lands where the projection lacks it. For coplanar surfaces, such as a price sticker on a cover or a shadow receiver, depth bias pushes one away: depthBias (a constant in depth units), depthBiasSlopeScale (scaled by the triangle's slope) and depthBiasClamp; bias has no effect on points and lines.

A price sticker on a cover: z-fighting without bias, clean with depthBias, and the same scene with reversed depthHTMLLive
<!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="300"></canvas>
  <canvas id="labels" width="600" height="300"></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);
}

// A large cover lying on a table far from the camera; instance 1 is a sticker in exactly the same plane.
const code = (reversed) => /* wgsl */ `
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let q = vec2f(f32(v & 1), f32(v >> 1));
  let rect = select(vec4f(-3, -4, 6, 8), vec4f(-1.6, -2.2, 3.2, 3.0), i == 1u);   // x, t, width, depth
  let t = rect.y + q.y * rect.w;                           // distance along the tilted table
  let p = vec3f(rect.x + q.x * rect.z, -1.5 + t * 0.34, 16 + t * 0.94);
  let near = 0.5;  let far = 40.0;
  var depth = far / (far - near) * (p.z - near);           // 0 at near, p.z at far (before the divide)
  ${reversed ? 'depth = p.z - depth;                       // reversed: near maps to 1, far to 0' : ''}
  let color = select(vec3f(0.08, 0.40, 0.75), vec3f(0.98, 0.85, 0.20), i == 1u);
  return Out(vec4f(p.x * 3.2, p.y * 3.2 + p.z * 0.25, depth, p.z), color);
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 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 make = (reversed, depthFormat, compare, bias) => {
    const module = device.createShaderModule({ code: code(reversed) });
    return device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
      vertex: { module }, fragment: { module, targets: [{ format }] },
      depthStencil: { format: depthFormat, depthWriteEnabled: true, depthCompare: compare, ...bias } });
  };
  const panels = [
    ["'less', no bias", make(false, 'depth24plus', 'less', {}), 'depth24plus', 1, null],
    ["'less', depthBias -40", make(false, 'depth24plus', 'less', {}), 'depth24plus', 1,
      make(false, 'depth24plus', 'less', { depthBias: -40, depthBiasSlopeScale: -2 })],
    ["reversed: 'greater', clear 0", make(true, 'depth32float', 'greater', {}), 'depth32float', 0,
      make(true, 'depth32float', 'greater', { depthBias: 40, depthBiasSlopeScale: 2 })]];

  const encoder = device.createCommandEncoder();
  const view = context.getCurrentTexture().createView();
  panels.forEach(([, coverPipeline, depthFormat, clear, stickerPipeline], i) => {
    const depth = device.createTexture({ size: [600, 300], format: depthFormat, usage: GPUTextureUsage.RENDER_ATTACHMENT });
    const pass = encoder.beginRenderPass({
      colorAttachments: [{ view, clearValue: [0.97, 0.96, 0.93, 1], loadOp: i === 0 ? 'clear' : 'load', storeOp: 'store' }],
      depthStencilAttachment: { view: depth.createView(), depthClearValue: clear, depthLoadOp: 'clear', depthStoreOp: 'discard' } });
    pass.setViewport(i * 200, 40, 200, 220, 0, 1);
    pass.setPipeline(coverPipeline);
    pass.draw(4, 1, 0, 0);                          // the cover
    pass.setPipeline(stickerPipeline ?? coverPipeline);
    pass.draw(4, 1, 0, 1);                          // the sticker, exactly coplanar
    pass.end();
  });
  device.queue.submit([encoder.finish()]);

  ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222'; ink.textAlign = 'center';
  panels.forEach(([name], i) => ink.fillText(name, 100 + i * 200, 24));
  ink.font = '11.5px system-ui, sans-serif'; ink.fillStyle = '#444';
  ink.fillText('Without bias the coplanar sticker loses the depth test, wholly or in speckles; depthBias fixes it.', 300, 280);
  ink.fillText('Reversed depth (near 1, far 0, compare greater) puts float precision where perspective lacks it.', 300, 296);
}
main();
</script>