A WebGL draw call uses whatever program, buffers, blend, depth and cull state happen to be bound (Shaders and Data and Depth, Culling, Blending), so the driver must validate the combination, and sometimes recompile shader variants, at draw time. WebGPU moves that cost to object creation: a GPURenderPipeline fixes the shaders, vertex layout, primitive topology, culling, depth-stencil state, blending and target formats in one immutable object, validated once. Drawing needs only setPipeline(), bindings and draw(), and no state leaks from one draw into the next.
This listing draws BookNest's six books as spines sized by page count, with all fixed state in one createRenderPipeline() call and geometry generated from vertex_index (no vertex buffer):
<canvas id="gpu" width="480" height="170"></canvas>
<script type="module">
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const context = gpu.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const module = device.createShaderModule({ code: /* wgsl */ `
const pages = array(312.0, 428.0, 256.0, 198.0, 344.0, 176.0); // BookNest page counts
const tint = array(vec3f(0.08, 0.40, 0.75), vec3f(0.18, 0.49, 0.20), vec3f(0.94, 0.42, 0.0),
vec3f(0.42, 0.11, 0.60), vec3f(0.22, 0.28, 0.31), vec3f(0.78, 0.16, 0.16));
const corner = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 0), vec2f(1, 1),
vec2f(0, 1)); // two triangles per spine
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec3f }
@vertex fn vs(@builtin(vertex_index) i: u32) -> Out {
let book = i / 6;
let c = corner[i % 6];
let x = -0.9 + f32(book) * 0.3 + c.x * 0.24;
return Out(vec4f(x, -0.9 + c.y * pages[book] / 240.0, 0, 1), tint[book]);
}
@fragment fn fs(v: Out) -> @location(0) vec4f { return vec4f(v.color, 1); }` });
const pipeline = device.createRenderPipeline({ layout: 'auto', // all state, validated once
vertex: { module }, fragment: { module, targets: [{ format }] },
primitive: { topology: 'triangle-list', cullMode: 'back' } });
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture()
.createView(), clearValue: [0.96, 0.94, 0.90, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline); // replaces useProgram, enable(CULL_FACE), cullFace and friends
pass.draw(36); // 6 books x 6 vertices
pass.end();
device.queue.submit([encoder.finish()]);
await device.queue.onSubmittedWorkDone();
window.__done = true;
</script>
The price is flexibility: a different cull or blend state needs another pipeline, so applications create a handful at startup (with createRenderPipelineAsync(), Render Pipelines) and switch between them.