In WebGL 1 every uniform was a separate call on the current program (gl.uniform3fv(location, eye)), stored by the driver per program and lost when you switched programs. WebGL 2 added uniform blocks; WebGPU keeps only the block model and makes it explicit:
| Concern | WebGL uniform*() | WebGPU uniform buffer |
|---|---|---|
| Storage | Hidden, per program | A GPUBuffer you create |
| Update | One call per value | One writeBuffer() for a whole struct or many |
| Sharing | Set again for each program | One bind group used by many pipelines |
The price is that you own the byte layout (Uniform Alignment); the payoff is fewer, larger updates: a camera written once for every pipeline, and per-object data in one large buffer addressed by offset (Dynamic Offsets) or instance index (Instanced Rendering).
<!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="340"></canvas>
<canvas id="labels" width="600" height="340"></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);
}
// Every pipeline starts with the same frame struct at @group(0) @binding(0).
const FRAME = /* wgsl */ `
struct Frame { time: f32, sway: f32, light: vec2f }
@group(0) @binding(0) var<uniform> frame: Frame;
fn corner(v: u32) -> vec2f { return vec2f(f32(v & 1), f32(v >> 1)); }`;
const shaders = {
books: FRAME + /* wgsl */ `
struct Out { @builtin(position) pos: vec4f, @location(0) shade: f32, @location(1) @interpolate(flat) i: u32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let q = corner(v);
let lean = sin(frame.time + f32(i)) * frame.sway * q.y;
return Out(vec4f(-0.9 + f32(i) * 0.26 + q.x * 0.2 + lean, -0.02 + q.y * (0.55 + f32(i % 3) * 0.1), 0, 1), q.x, i);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let tints = array(vec3f(.12, .37, .55), vec3f(.36, .25, .60), vec3f(.88, .60, .06), vec3f(.25, .49, .23), vec3f(.71, .27, .18), vec3f(.16, .62, .56), vec3f(.5, .3, .2));
return vec4f(tints[in.i] * (0.7 + 0.3 * mix(1 - in.shade, in.shade, frame.light.x)), 1);
}`,
plank: FRAME + /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
let q = corner(v);
return vec4f(-0.97 + q.x * 1.94, -0.09 + q.y * 0.07, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(vec3f(0.55, 0.38, 0.22) * (0.8 + 0.2 * frame.light.x), 1); }`,
lamp: FRAME + /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
let q = corner(v) * 2 - 1;
return vec4f(frame.light.x * 1.6 - 0.8 + q.x * 0.08, 0.8 + q.y * 0.14, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(1.0, 0.82, 0.3, 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 });
// One explicit layout for the shared group, so a single bind group fits all three pipelines.
const frameLayout = device.createBindGroupLayout({ entries: [{ binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }] });
const layout = device.createPipelineLayout({ bindGroupLayouts: [frameLayout] });
const pipelines = Object.fromEntries(Object.entries(shaders).map(([name, code]) => {
const module = device.createShaderModule({ code });
return [name, device.createRenderPipeline({ layout, primitive: { topology: 'triangle-strip' },
vertex: { module }, fragment: { module, targets: [{ format }] } })];
}));
const frameBuffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const frameGroup = device.createBindGroup({ layout: frameLayout, entries: [{ binding: 0, resource: { buffer: frameBuffer } }] });
const values = new Float32Array(4);
function frame(now) {
values.set([now / 700, 0.04, 0.5 + 0.5 * Math.sin(now / 1300), 0]);
device.queue.writeBuffer(frameBuffer, 0, values); // one write per frame, for every pipeline
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.setBindGroup(0, frameGroup); // bound once; survives setPipeline()
pass.setPipeline(pipelines.lamp); pass.draw(4);
pass.setPipeline(pipelines.plank); pass.draw(4);
pass.setPipeline(pipelines.books); pass.draw(4, 7);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
const rows = [['', 'WebGL uniform*()', 'WebGPU uniform buffer'], ['Storage', 'hidden, per program', 'a GPUBuffer you create'],
['Update', 'one call per value', 'one writeBuffer() per struct'], ['Sharing', 'set again per program', 'one bind group, many pipelines'],
['This frame', '3 programs x 3 values = 9 calls', '1 writeBuffer(), 1 setBindGroup()']];
rows.forEach((r, i) => {
ink.font = `${i === 0 ? 'bold ' : ''}12px system-ui, sans-serif`; ink.fillStyle = i === 4 ? '#1f4f8a' : '#222';
ink.fillText(r[0], 12, 236 + i * 20); ink.fillText(r[1], 110, 236 + i * 20); ink.fillText(r[2], 330, 236 + i * 20);
});
requestAnimationFrame(frame);
}
main();
</script>