Dynamic Offsets

Dynamic Offsets for Per-Object Uniforms

Bind Groups introduced dynamic offsets: a buffer binding declared hasDynamicOffset: true in an explicit layout (auto layouts never have them) takes its offset at setBindGroup() time. For per-object uniforms, give each object a slot of device.limits.minUniformBufferOffsetAlignment bytes (256 on this device), write all slots with one writeBuffer(), and bind one group:

Per-object uniforms in 256-byte slots of one bufferJavaScript
const SLOT = device.limits.minUniformBufferOffsetAlignment;   // 256
const offsets = new Uint32Array(objects.length).map((_, i) => i * SLOT);
for (let i = 0; i < objects.length; i++) {
  pass.setBindGroup(1, group, offsets, i, 1);          // offsets[i], no new array per draw
  pass.draw(6);
}

The binding's size is the struct size (12 bytes for Instanced Shelf's shelf), not the slot size, and each offset plus that size must stay inside the buffer. The five-argument form of setBindGroup() reads offsets from a Uint32Array without allocating an array per call (Bind Group Trade-offs measures the difference).

Six books with per-object uniforms in 256-byte slots of one buffer, selected per draw with a dynamic offsetHTMLLive
<!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="330"></canvas>
  <canvas id="labels" width="600" height="330"></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 code = /* wgsl */ `
struct Object { color: vec4f, offset: vec2f, scale: vec2f }    // 32 bytes of data per 256-byte slot
@group(0) @binding(0) var<uniform> object: Object;              // moved by the dynamic offset
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
  let q = vec2f(f32(v & 1), f32(v >> 1)) - vec2f(0.5, 0);
  return vec4f(object.offset + q * object.scale, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return object.color; }`;
const colors = [[0.12, 0.37, 0.55], [0.36, 0.25, 0.60], [0.88, 0.60, 0.06], [0.25, 0.49, 0.23], [0.71, 0.27, 0.18], [0.16, 0.62, 0.56]];

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 SLOT = device.limits.minUniformBufferOffsetAlignment;   // 256 on most devices
  const COUNT = colors.length;

  // Dynamic offsets need an explicit layout with hasDynamicOffset: true (auto layouts never have them).
  const layout = device.createBindGroupLayout({ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
    buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: 32 } }] });
  const module = device.createShaderModule({ code });
  const pipeline = device.createRenderPipeline({ layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
    primitive: { topology: 'triangle-strip' }, vertex: { module }, fragment: { module, targets: [{ format }] } });
  const buffer = device.createBuffer({ size: SLOT * COUNT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  const group = device.createBindGroup({ layout, entries: [{ binding: 0, resource: { buffer, size: 32 } }] });   // binding size = struct size
  const data = new Float32Array(SLOT * COUNT / 4);
  const offsets = new Uint32Array(COUNT).map((_, i) => i * SLOT);

  ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
  ink.fillText(`one ${SLOT * COUNT}-byte buffer, ${COUNT} slots of ${SLOT} bytes, one bind group`, 10, 20);
  ink.fillText('for (i) { pass.setBindGroup(0, group, offsets, i, 1); pass.draw(4); }', 10, 38);
  function frame(now) {
    for (let i = 0; i < COUNT; i++) {             // fill every slot, then upload once
      const bounce = Math.abs(Math.sin(now / 500 + i * 0.7));
      data.set([...colors[i], 1, -0.78 + i * 0.31, -0.8 + bounce * 0.15, 0.22, 0.9 + (i % 3) * 0.12], i * SLOT / 4);
    }
    device.queue.writeBuffer(buffer, 0, data);
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(pipeline);
    for (let i = 0; i < COUNT; i++) {
      pass.setBindGroup(0, group, offsets, i, 1);   // offsets[i], without a new array per draw
      pass.draw(4);
    }
    pass.end();
    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>