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:
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).
<!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>