Matching Layouts

Matching WGSL Bindings to Bind Group Layouts

With an explicit layout (Pipeline Layouts), each binding the entry point uses must exist in the layout, be visible to its stage, match its type and access mode, and have a minBindingSize no smaller than the WGSL type. A sale shader (read prices at binding 0, read_write output at 1, a vec2f uniform at 2) met six layouts in error scopes:

The four layouts that failed
Layout change Chrome 154 1 's message ("..." marks a cut)
Binding 2 as 'storage' The buffer type in the shader (BufferBindingType::Uniform) is not compatible ...
Binding 1 visible to FRAGMENT only Entry point's stage (ShaderStage::Compute) is not in the binding visibility ...
Binding 2 minBindingSize: 4 The shader uses more bytes of the buffer (8) than the layout's minBindingSize (4).
Binding 1 left out Binding doesn't exist in [BindGroupLayoutInternal "sale"].

The correct layout passed, and so did one with an extra binding 3: a layout may offer more than a shader uses. Access modes must match exactly; storage for a read variable failed too.

Six explicit bind group layouts tried against one sale shader in error scopes; the passing layout then computes the barsHTMLLive
<!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="390"></canvas>
  <canvas id="labels" width="600" height="390"></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);
}

const saleShader = /* wgsl */ `
@group(0) @binding(0) var<storage, read> prices: array<f32>;
@group(0) @binding(1) var<storage, read_write> sale: array<f32>;
@group(0) @binding(2) var<uniform> rule: vec2f;           // percent off, minimum price
@compute @workgroup_size(6) fn main(@builtin(local_invocation_index) i: u32) {
  sale[i] = max(prices[i] * (1 - rule.x / 100), rule.y);
}`;

const C = GPUShaderStage.COMPUTE, F = GPUShaderStage.FRAGMENT;
const good = [{ binding: 0, visibility: C, buffer: { type: 'read-only-storage' } },
              { binding: 1, visibility: C, buffer: { type: 'storage' } },
              { binding: 2, visibility: C, buffer: { type: 'uniform', minBindingSize: 8 } }];
const variant = (i, entry) => good.map((e, k) => (k === i ? entry : e)).filter(Boolean);
const layouts = [
  ['The correct layout', good],
  ['Binding 2 as storage', variant(2, { binding: 2, visibility: C, buffer: { type: 'storage' } })],
  ['Binding 1 visible to FRAGMENT only', variant(1, { binding: 1, visibility: F, buffer: { type: 'storage' } })],
  ['Binding 2 minBindingSize: 4', variant(2, { binding: 2, visibility: C, buffer: { type: 'uniform', minBindingSize: 4 } })],
  ['Binding 1 left out', variant(1, null)],
  ['An extra, unused binding 3', [...good, { binding: 3, visibility: C, buffer: { type: 'uniform' } }]]];

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: saleShader });

  const results = [];
  let working = null;
  for (const [name, entries] of layouts) {
    device.pushErrorScope('validation');
    const layout = device.createBindGroupLayout({ label: 'sale', entries });
    const pipeline = device.createComputePipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
      compute: { module } });
    const error = await device.popErrorScope();
    results.push([name, error]);
    if (!error && !working) working = { layout, pipeline };
  }

  // Run the sale with the first layout that passed, then draw prices and sale prices.
  const B = GPUBufferUsage;
  const prices = device.createBuffer({ size: 24, usage: B.STORAGE | B.COPY_DST });
  device.queue.writeBuffer(prices, 0, new Float32Array([14.99, 39.50, 24.00, 18.75, 16.20, 21.30]));
  const sale = device.createBuffer({ size: 24, usage: B.STORAGE });
  const rule = device.createBuffer({ size: 8, usage: B.UNIFORM | B.COPY_DST });
  device.queue.writeBuffer(rule, 0, new Float32Array([30, 12]));          // 30% off, never below $12
  const bars = device.createShaderModule({ code: `
    @group(0) @binding(0) var<storage> prices: array<f32>;
    @group(0) @binding(1) var<storage> sale: array<f32>;
    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 book = i / 2;  let isSale = i % 2 == 1;
      let h = select(prices[book], sale[book], isSale) / 40;
      let x = -0.9 + f32(book) * 0.3 + select(0.0, 0.11, isSale);
      return Out(vec4f(x + q.x * 0.1, -0.95 + q.y * h * 0.5, 0, 1), select(vec3f(0.72, 0.70, 0.66), vec3f(0.16, 0.56, 0.30), isSale));
    }
    @fragment fn fs(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }` });
  const render = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module: bars }, fragment: { module: bars, targets: [{ format }] } });

  const encoder = device.createCommandEncoder();
  const cp = encoder.beginComputePass();
  cp.setPipeline(working.pipeline);
  cp.setBindGroup(0, device.createBindGroup({ layout: working.layout, entries: [
    { binding: 0, resource: { buffer: prices } }, { binding: 1, resource: { buffer: sale } }, { binding: 2, resource: { buffer: rule } }] }));
  cp.dispatchWorkgroups(1);
  cp.end();
  const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
    clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
  pass.setPipeline(render);
  pass.setBindGroup(0, device.createBindGroup({ layout: render.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: prices } }, { binding: 1, resource: { buffer: sale } }] }));
  pass.draw(4, 12);
  pass.end();
  device.queue.submit([encoder.finish()]);

  results.forEach(([name, error], i) => {
    const y = 6 + i * 44;
    ink.fillStyle = error ? '#fbe3dd' : '#e2f1e5'; ink.beginPath(); ink.roundRect(8, y, 584, 40, 8); ink.fill();
    ink.fillStyle = error ? '#b03a28' : '#2a8f4d'; ink.font = 'bold 13px system-ui, sans-serif';
    ink.fillText(`${error ? 'fails' : 'passes'}: ${name}`, 18, y + 17);
    ink.fillStyle = '#444'; ink.font = '11px ui-monospace, monospace';
    ink.fillText(error ? error.message.split('\n')[0].slice(0, 86) : 'createComputePipeline() succeeded', 18, y + 33);
  });
  ink.fillStyle = '#333'; ink.font = '12px system-ui, sans-serif';
  ink.fillText('Run with the correct layout: grey price, green sale (30% off, at least $12)', 8, 282);
}
main();
</script>