Under the Hood
Webgpu

Render pipelines: baking all the state up front

A GPURenderPipeline bundles every piece of fixed draw state — both shaders, the vertex layout, primitive topology, depth/stencil, and blend — into one immutable object that WebGPU validates and translates to native code exactly once, at creation, which is the direct answer to WebGL's per-draw validation cost.

Render pipelines: baking all the state up front

The flagship lesson named the villain: WebGL re-derives your entire draw configuration — shaders, vertex layout, blend state, depth state, all of it — on every single drawArrays call, because nothing about that state is settled until the moment of the draw. It also named the fix in one sentence, without showing you the object. This lesson is that object. The GPURenderPipeline is the single most important thing you'll create in WebGPU, and once you've seen everything that goes into one, the rest of the API's shape — bind groups, command encoders — makes a lot more sense as supporting cast.

What a pipeline actually bundles

A render pipeline is not a handle to "the current shader" the way a WebGL program is. It is a complete, frozen description of a draw: both shader stages, how vertex data is laid out in memory, what primitive shape the vertices form, how depth and stencil testing behave, and how the fragment output blends into the color target. Every one of those settings lives on this one object, and none of them can change without creating a different pipeline.

Walk through each piece, because each one is a direct stand-in for something WebGL used to configure with a separate, stateful call.

Shader modules. Before you can build a pipeline you need compiled shader code. device.createShaderModule({ code }) takes a string of WGSL source (the language lesson 5 covers) and hands back a GPUShaderModule — the WebGPU analog of a compiled-but-not-yet-linked GLSL shader. A single module can hold both a vertex entry point and a fragment entry point in one file, which is common in WebGPU code even though the pipeline can also pull them from two separate modules.

vertex. This is the module, the name of its entry-point function, and — the part with no direct WebGL equivalent as a single object — the vertex buffer layout: for each vertex buffer you'll bind at draw time, the arrayStride (how many bytes apart consecutive vertices are), and a list of attributes, each with a format ("float32x3" for a vec3 position, "float32x2" for a UV, and so on), an offset within the stride, and a shaderLocation matching the @location(n) the vertex shader expects. In WebGL this was vertexAttribPointer, called once per attribute, every time you switched geometry. Here it's declared once, as data, inside the pipeline.

fragment. The module, its entry point, and the targets array — one entry per color attachment the fragment shader writes, each specifying the output format (must match the texture format you'll render into) and an optional blend state (how the new fragment color combines with what's already in the target — alpha blending, additive, or none). Blend state used to be a handful of separate gl.blendFunc / gl.enable(gl.BLEND) global toggles; here it's frozen per target, per pipeline.

primitive. topology ("triangle-list", "line-list", "point-list", and a couple of strip variants), cullMode ("none", "front", or "back"), and frontFace (which vertex winding counts as front-facing). This is the geometry-assembly and culling configuration from a step early in how a triangle becomes pixels, now pinned to the pipeline instead of set as loose global state.

depthStencil. Whether depth testing is enabled, whether passing fragments write depth, the comparison function, and the format of the depth/stencil attachment. Same idea: a handful of WebGL globals (gl.enable(gl.DEPTH_TEST), gl.depthFunc) collapsed into fields on one object.

layout. A reference to one or more GPUBindGroupLayout objects (or the string "auto", letting WebGPU infer them from the shader code) describing what resources — uniform buffers, textures, samplers — the shaders expect to be bound where. This is the pipeline's contract with bind groups, lesson 6: the pipeline doesn't hold the actual resources, just the shape they must come in. You'll see the other half of that handshake there.

The code

A minimal but complete createRenderPipeline call, alongside the shader modules it references:

const shaderModule = device.createShaderModule({
  code: wgslSource, // vertex + fragment entry points, see lesson 5
});

const pipeline = device.createRenderPipeline({
  layout: "auto", // infer bind group layouts from the shader code
  vertex: {
    module: shaderModule,
    entryPoint: "vs_main",
    buffers: [
      {
        arrayStride: 6 * 4, // 6 floats per vertex, 4 bytes each
        attributes: [
          { shaderLocation: 0, offset: 0, format: "float32x3" }, // position
          { shaderLocation: 1, offset: 12, format: "float32x3" }, // normal
        ],
      },
    ],
  },
  fragment: {
    module: shaderModule,
    entryPoint: "fs_main",
    targets: [{ format: "bgra8unorm" }], // must match the canvas/texture format
  },
  primitive: {
    topology: "triangle-list",
    cullMode: "back",
  },
  depthStencil: {
    format: "depth24plus",
    depthWriteEnabled: true,
    depthCompare: "less",
  },
});

Nothing here touches the GPU driver at draw time. Every field above is consumed once, when createRenderPipeline runs.

Validated and translated once — this is the whole point

Here is the mechanical payoff, and it's worth stating plainly because it's the entire reason this object exists: when createRenderPipeline is called, WebGPU checks that everything is internally consistent — do the vertex buffer layout's shader locations match what the WGSL vertex entry point actually declares? Does the fragment target format match what the shader outputs? — and then lowers this whole configuration to the native driver's pipeline object one time. On Metal that's an MTLRenderPipelineState; on Vulkan, a VkPipeline; on D3D12, a PSO. Whichever backend the browser is running on, the expensive work — consistency checking, shader linking, native compilation — happens exactly once, at creation, off the frame's hot path.

Recall from the flagship lesson that this is a direct answer to Problem 2: WebGL had to re-derive and re-validate an equivalent configuration on every drawArrays call, because nothing was settled until draw time. A GPURenderPipeline inverts that — everything is settled before the first draw, so a draw itself has nothing left to figure out.

That shows up at draw time as almost nothing:

pass.setPipeline(pipeline);
pass.draw(vertexCount);

setPipeline just tells the encoder which already-baked native pipeline object to reference in the recorded command stream. There's no validation, no translation, no re-derivation — the cost was already paid.

Not blocking the main thread: createRenderPipelineAsync

Compiling and linking shaders and lowering a pipeline to native code is real CPU work, and for a complex shader it can take long enough to cause a visible hitch if done synchronously in the middle of a frame. device.createRenderPipelineAsync(descriptor) takes the exact same descriptor and returns a promise instead of blocking, letting the browser do the compilation off the main thread and your app keep rendering with whatever pipeline it already has until the new one resolves. For load-time pipeline creation this matters less; for compiling a shader variant mid-session, it's the difference between a frame drop and nothing.

Where this goes next

You now know the object that WebGL's per-draw state machine was replaced with, and why baking it once is what makes a WebGPU draw call cheap. Two things were mentioned here but not opened up: the WGSL source the shader modules actually contain, and the bind group layouts that connect a pipeline to real buffers and textures. WGSL: the shading language WebGPU speaks is next — it's the language every code string passed to createShaderModule above is written in.

Go deeper

  • W3C WebGPU spec — GPURenderPipeline The normative list of every field a render pipeline descriptor accepts, including the vertex buffer layout and primitive state details this lesson summarized.
  • WebGPU Fundamentals Walks creating a first render pipeline end to end with runnable code, including the vertex buffer layout math worked out step by step.
  • MDN — GPUDevice.createRenderPipeline() Reference for the descriptor shape and for createRenderPipelineAsync, with browser support notes.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. List the pieces of fixed draw state a GPURenderPipeline bundles into one object, and name the WebGL mechanism each one replaces.
  2. What exactly happens once, at createRenderPipeline time, that WebGL had to redo on every drawArrays call instead?
  3. What does the vertex buffer layout's arrayStride and shaderLocation each describe, and how do they connect to the WGSL vertex shader's inputs?
  4. Why can't you flip a single blend setting on an existing pipeline object? What do you create instead?
  5. What does the pipeline's layout field describe, and which later lesson supplies the other half of that contract?
  6. What does pass.setPipeline() cost at draw time, and why is that cost so low compared to a WebGL state change?
  7. When would you reach for createRenderPipelineAsync instead of createRenderPipeline, and what problem does it avoid?