← 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.
The single most disorienting thing coming from D3D12: WebGPU splits its API into two phases.
| Phase | When | Calls | D3D12 analogue |
|---|---|---|---|
| Boot (async) | once, at startup | requestAdapter → requestDevice → configure → create* | factory → device → PSO/descriptor setup at init |
| Frame (sync) | every frame | writeBuffer, createCommandEncoder, beginRenderPass, draw, submit | your 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.
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');
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' | ... */ });
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:
device.lost is the removed-device callback — but the browser can
recreate internally in some cases; you handle it by rebuilding everything and
re-running your init path.device.destroy() exists for explicit teardown (tab usually does it for you).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' });
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().
getPreferredCanvasFormat(): bgra8unorm on most platforms, rgba8unorm on
some — always use it instead of hardcoding, and reuse the same value in the
pipeline's fragment target (§5).alphaMode: 'opaque' | 'premultiplied'. How the canvas composites with
the page. Your clear alpha of 1.0 + 'opaque' = normal behavior.ResizeBuffers. The canvas's drawing buffer size is
canvas.width/height; change those attributes and the next
getCurrentTexture() is the new size. (CSS size is display-only — the classic
"blurry canvas" bug is CSS size ≠ attribute size.)getContext('webgpu') twice returns the same context.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 { ... }
`});
What's happening under the hood, and why you care:
'vs'/'fs' in createRenderPipeline. Same module can
serve both stages (we do exactly that above).pipeline.getBindGroupLayout() reads later (§6).@builtin(position) = SV_Position. @location(n) = semantic index.
vec4f = float4. But: WGSL attaches semantics to struct fields, not to
parameters, and address spaces (var<uniform>, var<storage>) replace
cbuffer/register bookkeeping entirely.device.createRenderPipeline() — the PSOconst 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:
pipeline.getBindGroupLayout(n) hands you
back the derived layout so you can build matching bind groups.device.createPipelineLayout({ bindGroupLayouts: [...] })), which is your
explicit root signature. Lesson 0002+ territory.format ≠ canvas format
throws a synchronous exception naming both formats — the most common day-one
error.SetGraphicsRoot32BitConstant improvisation).
Everything is in the pipeline object or in bind groups. Period.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)
}],
});
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:
| WGSL | JS resource | GPUBufferUsage | D3D12 analogue |
|---|---|---|---|
var<uniform> | buffer | UNIFORM (+COPY_DST) | CBV, 256B-aligned offsets |
var<storage> (ro) | buffer | STORAGE | SRV structured buffer |
var<storage, read_write> | buffer | STORAGE | UAV |
texture_2d<f32> | texture | TEXTURE_BINDING | SRV tex2d |
@builtin(position) output | — | RENDER_ATTACHMENT | RTV/DSV |
Writing data: no Map/Unmap in the frame loop. Two mechanisms:
device.queue.writeBuffer(buf, offset, arrayBuffer) — small, per-frame,
staged internally. Our time uniform uses this every frame.MapAsync + device.queue.copyBufferToBuffer — for big uploads, ring
buffers, the whole staging pattern. Lesson 0002 material; it's the one place
where D3D12's upload-heap instincts still apply directly.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()]);
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:
pass.setViewport(...) if you need otherwise.device.queue.onSubmittedWorkDone() is the "wait for GPU idle" primitive when
you actually need one (e.g. readback timing).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.
device.queue.submit([enc.finish()]); // ordered, async
device.queue.writeBuffer(uniformBuf, 0, data); // also ordered, before submits
device.queue.onSubmittedWorkDone(); // Promise — GPU idle wait
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.
WebGPU is aggressively validated, in two distinct channels you must keep straight:
| Channel | Timing | Surfaces as | Examples |
|---|---|---|---|
| Synchronous | at the create call | JS exception (try/catch works) | fragment format ≠ canvas format; bad usage-flag combos |
| Asynchronous | at validation time, later | error-scope / uncaptured error; object becomes an invalid object | shader 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.
// ---------- 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.
requestDevice() merge into one, and what
does that eliminate from every frame?pipeline.getBindGroupLayout(0). What
exactly is being reflected, and what would you hand-write instead for two
pipelines sharing bindings?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.