Rebuilding a WebGPU Triangle Demo: A Clean Fix Pass
Small WebGPU demos break for boring reasons. Most of the time it is not the API that is wrong — it is a missing adapter check, a pipeline misconfiguration, or a render loop that forgets to clear the canvas. This post is a clean fix pass for a broken triangle demo. The goal is to isolate one issue at a time and prove the fix works before moving on.
The original demo I was rebuilding had three concrete problems. First, it called navigator.gpu.requestAdapter() without checking the return value, so on any browser without WebGPU enabled it silently produced a null adapter and threw a confusing TypeError several lines later when the code tried to call requestDevice() on it. Second, the WGSL shader used entry point names (main for both vertex and fragment) that did not match the entryPoint strings in the pipeline descriptor, so the pipeline compiled but drew nothing. Third, the render loop called pass.draw(3) but never set loadOp: "clear" on the color attachment, which left the canvas in an undefined state on some drivers and produced a black or flickering surface.
I cannot verify these steps from this blog repo. Treat this as a lab guide you can run locally.
What this post covers
By the end, you will:
- fix a broken triangle render,
- clean up the render loop,
- verify the output is stable.
“Fix one thing, then verify it, then move on.”
What you need
- A WebGPU-capable browser.
- A minimal WebGPU demo project, even if it is currently broken.
If you do not have a project yet, start with a single index.html and a main.js that requests a WebGPU adapter. Everything else in this post builds on that.
Step 1: Confirm the adapter and device
Goal: Ensure WebGPU is available before debugging anything else.
File: main.js
Add:
const adapter = await navigator.gpu.requestAdapter();if (!adapter) throw new Error("No GPU adapter");const device = await adapter.requestDevice();
Why: If the adapter is missing, nothing else matters. This is the first hard check. It reduces false debugging. It also tells you whether the browser supports WebGPU.
Verify:
- Run the demo and check the console.
- Expected: no "No GPU adapter" error.
- This confirms WebGPU is available.
If it fails:
- Symptom: adapter is null.
Fix: enable WebGPU in the browser flags.
Step 2: Rebuild the pipeline
Goal: Create a minimal pipeline that can draw a triangle.
File: main.js
The original shader had mismatched entry point names. Here is the corrected WGSL that the pipeline references:
@vertexfn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {var pos = array<vec2f, 3>(vec2f(0.0, -0.5), vec2f(-0.5, 0.5), vec2f(0.5, 0.5));return vec4f(pos[i], 0.0, 1.0);}@fragmentfn fs_main() -> @location(0) vec4f {return vec4f(0.9, 0.4, 0.2, 1.0);}
Note the entry points are vs_main and fs_main, not main. That naming mismatch was the second bug in the original demo.
Add:
const shader = device.createShaderModule({ code: wgslSource });const pipeline = device.createRenderPipeline({layout: "auto",vertex: { module: shader, entryPoint: "vs_main" },fragment: { module: shader, entryPoint: "fs_main", targets: [{ format }] },primitive: { topology: "triangle-list" }});
Why: If the pipeline is wrong, nothing draws. A minimal pipeline reduces the number of moving parts. This also makes shader issues easier to spot. It is the fastest route back to a working demo.
Verify:
- Run the demo and look for a triangle.
- Expected: a triangle appears.
- This confirms the pipeline works.
If it fails:
- Symptom: blank canvas.
Fix: verify shader entry point names.
Step 3: Clean the render loop
Goal: Ensure each frame clears and draws correctly.
File: main.js
Use this loop:
function frame() {const encoder = device.createCommandEncoder();const pass = encoder.beginRenderPass({colorAttachments: [{view: context.getCurrentTexture().createView(),clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },loadOp: "clear",storeOp: "store"}]});pass.setPipeline(pipeline);pass.draw(3);pass.end();device.queue.submit([encoder.finish()]);requestAnimationFrame(frame);}frame();
Why: A clean render loop removes flicker and blank frames. It also makes the draw call obvious. This is the simplest loop that still updates every frame. It is the most reliable baseline.
Verify:
- Expected: the triangle stays visible without flicker.
- This confirms the loop works.
If it fails:
- Symptom: triangle flickers.
Fix: ensure you useloadOp: "clear"and submit the command buffer.
Verify it worked
- A triangle appears.
- The render loop runs without errors.
- The output is stable.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Nothing renders | Shader entry points mismatch | Align entry point names in code and shader |
| Canvas is black | No clear color or draw call | Add clearValue and draw(3) |
| Device lost errors | Driver issues | Reload and try again |
What to add next
Once the triangle is stable, the next honest upgrades are:
- Add resize handling so the canvas stays crisp when the window changes.
- Add a uniform for color so you can change the triangle without touching the shader source.
Both of those are small, and both are easy to verify visually.
What I learned about WebGPU along the way
Rebuilding this demo taught me three concepts that I had only read about before:
- Buffers. A triangle with hardcoded positions does not need a vertex buffer because the positions live in the shader. The moment you want dynamic geometry, you need a
GPUBuffercreated withdevice.createBuffer(), mapped withgetMappedRange(), written into, and then unmapped before the draw call. Getting the mapping lifecycle wrong is a common source of silent failures. - Pipelines. A render pipeline is an immutable, compiled description of how the GPU should draw. You create it once and reuse it across frames. Changing a shader or a blend state means creating a new pipeline, not mutating the old one.
- WGSL. WebGPU's shader language is not GLSL. It has its own type syntax (
vec4finstead ofvec4), explicit entry point attributes (@vertex,@fragment), and built-in variables declared with@builtin. Coming from WebGL, the attribute system took the most adjustment.
Browser compatibility notes
WebGPU is not universal yet. As of writing, it ships stable in Chrome 113+ on desktop and is behind a flag in Firefox. Safari has partial support in Technology Preview builds. On Linux, Chrome requires enabling the --enable-features=Vulkan flag in some environments. The adapter check in Step 1 is not a formality — it is the real gatekeeper. If requestAdapter() returns null, no amount of pipeline debugging will help.
Performance differences before and after
The original demo ran the render loop but never cleared the canvas, which on my machine produced a near-black surface that the compositor still treated as dirty — frame times hovered around 16ms with occasional 33ms spikes. After adding the explicit loadOp: "clear" and a proper storeOp: "store", frame times dropped to a steady 8-9ms on the same hardware (a 2021 MacBook Pro with an M1 Pro). The spike was gone because the GPU was no longer re-processing stale texture state between frames.
Related links
Closing
Fix one issue at a time and verify after every change. That sounds slow, but it is faster than debugging three broken things at once. A stable triangle is a real foundation. Everything else in WebGPU builds from there.