Lesson 0000 — The WebGPU API Surface, Deep Dive

← index · prerequisite for Lesson 0001

Goal: before writing any code, understand every API call in the triangle program — what object it returns, what it costs, when it validates, and where it maps onto the D3D12 model you already carry. ~30 min of reading.

Contents 0. The lifetime pattern (async boot, sync frames) 1. requestAdapter() 2. requestDevice() 3. canvas.getContext('webgpu') + configure() 4. createShaderModule() 5. createRenderPipeline() + pipeline layout 6. createBuffer() + createBindGroup() 7. Command encoder, render pass, draw 8. queue.submit() / writeBuffer() / the queue model 9. Error handling & validation culture 10. The whole program, annotated

0 · The lifetime pattern

The single most disorienting thing coming from D3D12: WebGPU splits its API into two phases.

PhaseWhenCallsD3D12 analogue
Boot (async)once, at startuprequestAdapter → requestDevice → configure → create*factory → device → PSO/descriptor setup at init
Frame (sync)every framewriteBuffer, createCommandEncoder, beginRenderPass, draw, submityour frame loop / command recording

Every create* and request* returns a Promise. Object creation is expensive and validated eagerly — so you do it at boot, keep the objects forever, and the frame loop is nothing but recording + submit. This is exactly the "build PSOs at startup" discipline D3D12 already taught you, except WebGPU enforces it: there is no runtime PSO recompile API at all.

1 · navigator.gpu.requestAdapter()

const adapter = await navigator.gpu.requestAdapter();
// optional: requestAdapter({ powerPreference: 'high-performance' })
IDXGIFactory::EnumAdapters, collapsed into one call. No LUID juggling, no choosing among adapters yourself — the browser picks. adapter.info (vendor/architecture/device strings) is for telemetry/UX, not for making decisions.

Returns null (not an exception) if WebGPU is unavailable — no WebGPU-capable GPU, browser too old, or — critically — not a secure context. Always:

if (!navigator.gpu) throw new Error('WebGPU unavailable');
The adapter is a handle, not a device. You can't record commands against it. It also never exposes VRAM or feature levels — capability queries happen at the device layer (§2).

2 · adapter.requestDevice()

const device = await adapter.requestDevice({
  requiredFeatures: [],              // opt-in features, e.g. 'timestamp-query'
  requiredLimits: { maxStorageBuffersPerShaderStage: 8 },  // raise limits ABOVE defaults only
});
device.lost.then(info => { /* handle: 'unknown' | 'destroyed' | ... */ });
This is ID3D12Device and the ID3D12CommandQueue (Direct) and the fence semantics, merged into one object. There is exactly one queue per device, and it does graphics + compute. No CreateCommandQueue(D3D12_COMMAND_LIST_TYPE_DIRECT) — the concept doesn't exist separately. No copy/compute/video queues either (use the one queue; cross-queue sync isn't your problem anymore).

Key differences from D3D12 device creation:

All further objects (pipelines, buffers, textures, bind groups, command encoders) are created from the device — and are only valid with that device. There's no sharing across devices, ever.

3 · canvas.getContext('webgpu') + configure()

const canvas = document.getElementById('c');
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();   // 'bgra8unorm' or 'rgba8unorm'
context.configure({ device, format, alphaMode: 'opaque' });
This is the swapchain creation + presentation model in one, except there is no swapchain object you own. No DXGI factory, no buffer count, no tearing flags, no Present(). The canvas element is the swapchain.

What configure actually does: binds this canvas to this device and fixes the texture format the backbuffer will be handed to you in. Per frame, context.getCurrentTexture() hands you the backbuffer — an implicit acquire, no fences, no GetCurrentBackBufferIndex().

Configure can be called again (to re-target the canvas at another device) but a canvas has exactly one webgpu context for its lifetime. getContext('webgpu') twice returns the same context.

4 · device.createShaderModule({ code })

const shader = device.createShaderModule({ code: /* wgsl */`
  struct VSOut {
    @builtin(position) pos: vec4f,   // SV_Position
    @location(0)       color: vec3f, // COLOR0
  };
  @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut { ... }
  @fragment fn fs(in: VSOut) -> @location(0) vec4f { ... }
`});
Equivalent of compiling DXIL blobs — except the string is WGSL source, compiled by the browser at this call. There is no offline compilation step and no binary format.

What's happening under the hood, and why you care:

An invalid shader module does NOT throw. Creation succeeds; the error is reported asynchronously (§9). The pipeline creation that consumes it is what fails loudly. Read validation messages top-down — the first line names the struct/field, the rest explains the rule violated.

5 · device.createRenderPipeline() — the PSO

const pipeline = device.createRenderPipeline({
  layout: 'auto',                                  // ← discussed below
  vertex:  { module: shader, entryPoint: 'vs' },
  fragment:{ module: shader, entryPoint: 'fs',
             targets: [{ format }], },             // must match canvas format!
  primitive: { topology: 'triangle-list' },        // IA stage
  // depthStencil: { ... },                        // absent = no depth attachment
  // multisample: { count: 1 },                    // MSAA, default 1
});
ID3D12GraphicsCommandList*::* PipelineState — the merged blob of IA/VS/PS/OM state — exactly. Immutable after creation, one-shot, meant to be created at startup. Missing states are fixed defaults, not garbage: no depthStencil block = depth test disabled; no blend block = opaque; no viewport block = full attachment; inputLayout absent = no vertex buffers.

layout: 'auto' is the interesting part:

There are no dynamic state objects (no D3D12 dynamic descriptor indexing trick, no SetGraphicsRoot32BitConstant improvisation). Everything is in the pipeline object or in bind groups. Period.

6 · createBuffer() + createBindGroup() — the data path

// 1. The buffer. size in BYTES. usage flags are explicit (like D3D12 heap flags).
const uniformBuf = device.createBuffer({
  size: 16,                                        // ≥ your data, 16 here
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});   // created in the equivalent of READBACK-less default heap; no initial state

// 2. The WGSL side declares the address space:
//    @group(0) @binding(0) var time: f32;

// 3. Bind group = concrete resource bound to a concrete slot
const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),          // root param reflection
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuf },              // whole buffer (offset/size optional)
  }],
});
Buffer creation = CreateCommittedResource(DEFAULT_HEAP). Bind group = a prebaked descriptor set: allocate a heap slice, fill it (CopyDescriptors), done — and because bind groups are immutable, WebGPU validates types/formats/usages once, at creation, instead of at every draw. getBindGroupLayout(0) = reflection over root parameter 0.

The three-step mental model (buffer → binding declaration → bind group) is the data path everything else in this course rides on:

WGSLJS resourceGPUBufferUsageD3D12 analogue
var<uniform>bufferUNIFORM (+COPY_DST)CBV, 256B-aligned offsets
var<storage> (ro)bufferSTORAGESRV structured buffer
var<storage, read_write>bufferSTORAGEUAV
texture_2d<f32>textureTEXTURE_BINDINGSRV tex2d
@builtin(position) output—RENDER_ATTACHMENTRTV/DSV

Writing data: no Map/Unmap in the frame loop. Two mechanisms:

WGSL struct alignment: uniform buffers follow WGSL's alignment rules (vec3f is 16-aligned; a struct {f32, vec3f} is 32 bytes, not 16). Mismatches don't throw — they render wrong. This is the #1 silent failure in WebGPU, and the entire subject of lesson 0002.

7 · Command encoder, render pass, draw

const enc  = device.createCommandEncoder();
const pass = enc.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),  // backbuffer RTV
    clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },
    loadOp: 'clear',                                 // ClearRenderTargetView
    storeOp: 'store',
  }],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
device.queue.submit([enc.finish()]);
Encoder = command allocator + list, with two differences: recording is object-method-chained (no pointer choreography), and finish() = Close(). Render pass = explicit render-target scope: the loadOp/storeOp declarations let the driver skip tile loads on TBDR mobile GPUs — the API is Meta Quest-native, and it shows. submit() = ExecuteCommandLists.

Things that are simply absent vs D3D12, all by design:

Draw calls: draw(vertexCount, instanceCount, firstVertex, firstInstance), plus drawIndexed, drawIndirect. Our draw(3) with no vertex buffers is not a hack — @builtin(vertex_index) procedural geometry is idiomatic WebGPU for full-screen triangles and gizmos.

8 · The queue model

device.queue.submit([enc.finish()]);              // ordered, async
device.queue.writeBuffer(uniformBuf, 0, data);    // also ordered, before submits
device.queue.onSubmittedWorkDone();               // Promise — GPU idle wait
One Direct queue, forever. writeBuffer is a tiny internal staging copy that respects queue order — it lands before any submits recorded after it. There is no Present call: when the pass wrote to getCurrentTexture(), the browser presents on submit automatically.

Concurrency rule: WebGPU commands never execute mid-frame-out-from-under-you. Between submit and pixels-on-screen there are typically 2-3 frames of latency managed by the browser — the compositor throttles against the display, not your code. For a phone browser this is a feature, not a loss.

9 · Error culture — the validation model

WebGPU is aggressively validated, in two distinct channels you must keep straight:

ChannelTimingSurfaces asExamples
Synchronousat the create callJS exception (try/catch works)fragment format ≠ canvas format; bad usage-flag combos
Asynchronousat validation time, latererror-scope / uncaptured error; object becomes an invalid objectshader compile error; bind group layout mismatch
// Global safety net for async errors:
device.addEventListener?.('uncapturederror', e => console.error(e.error.message));

// Targeted — like D3D12 info queues but scoped:
device.pushErrorScope('validation');
const maybeBad = device.createShaderModule({ code: myCode });
maybeBad.compilationInfo().then(info => {
  for (const m of info.messages) console.log(m.lineNum, m.message); // like HLSL warnings
});
device.popErrorScope().then(err => { if (err) console.error(err.message); });

The key behavioral difference vs D3D12: an error doesn't poison the process — it poisons the object. A failed pipeline becomes an invalid pipeline; using it is silently dropped (with a console message). Your app keeps running. That's why lesson pages here always wire the uncapturederror handler — in a canvas app, a poisoned pipeline means "black screen, no exception," which is the #1 way WebGPU debugging goes wrong.

10 · The whole program, annotated

// ---------- BOOT (once, async) ----------
const adapter = await navigator.gpu.requestAdapter();          // pick GPU
const device  = await adapter.requestDevice();                 // device+queue merged
device.addEventListener?.('uncapturederror', e => console.error(e.error.message));

const canvas  = document.getElementById('c');
const context = canvas.getContext('webgpu');                   // swapchain facade
const format  = navigator.gpu.getPreferredCanvasFormat();      // bgra8/rgba8
context.configure({ device, format });                         // bind canvas→device

const shader   = device.createShaderModule({ code: wgslSrc }); // parse+validate now
const pipeline = device.createRenderPipeline({ ... });         // PSO, immutable
const buf      = device.createBuffer({ size:16, usage: UNIFORM|COPY_DST });
const bind     = device.createBindGroup({ ... });              // descriptor set

// ---------- FRAME (every rAF, sync) ----------
device.queue.writeBuffer(buf, 0, timeData);        // upload before/with submit
const enc  = device.createCommandEncoder();        // cheap, per-frame
const pass = enc.beginRenderPass({ ... });         // declare loads/stores
pass.setPipeline(pipeline); pass.setBindGroup(0, bind); pass.draw(3); pass.end();
device.queue.submit([enc.finish()]);               // execute + implicit present

Count the objects you own at steady state: adapter, device, context, shader module, pipeline, buffer, bind group — seven, all created once. Per frame you touch exactly four things: writeBuffer, encoder, pass, submit. That ratio (everything at boot, nothing but recording per frame) is the WebGPU performance model.

Self-check before lesson 0001

  1. Which two D3D12 objects does requestDevice() merge into one, and what does that eliminate from every frame?
  2. Where do barriers go if there's no barrier API — and what did we trade away to get that?
  3. A bind group is created against pipeline.getBindGroupLayout(0). What exactly is being reflected, and what would you hand-write instead for two pipelines sharing bindings?
  4. Why is an invalid shader module created "successfully" — what does WebGPU gain by deferring the failure to an async channel?
  5. loadOp: 'clear' vs 'load' — which one did D3D12 make you think about more explicitly, and why does WebGPU put it in the pass declaration?

Reply with answers (or "ready") in chat, then open Lesson 0001.