DEV Community

no momo
no momo

Posted on

When I Used AI to Write an HTML Game but Was Forced to Learn WebGPU Compute Shaders

Here’s the thing.

Last week I wanted to build a simple particle firework effect – the kind where you click the screen and hundreds of colorful particles explode, fall, and fade away. I’d written something similar with Canvas 2D before, so I knew the drill. But this time I decided to be lazy: I dumped the requirements into Claude and asked it to generate a complete HTML file on the spot.

The AI gave me working code in maybe fifteen seconds. Clean, well‑commented, even with gradient‑colored particles. I opened it in the browser, clicked a couple of times – 200 particles, smooth as butter. Then I bumped the count to 2,000 – the frame rate tanked to ~35 fps. At 5,000 particles, it became a slideshow at 12 fps.

That’s when it hit me: AI can write code that runs, but it won’t help you when it runs like crap.

So I went down a frustrating but incredibly rewarding debugging rabbit hole. And the thing that finally saved me wasn’t a smarter AI – it was WebGPU’s compute shaders.


Step 1: Finding the Real Bottleneck

The AI‑generated code looked like this (simplified):

const particles = [];

function init(count) {
  for (let i = 0; i < count; i++) {
    particles.push({
      x: canvas.width/2,
      y: canvas.height/2,
      vx: (Math.random() - 0.5) * 10,
      vy: (Math.random() - 0.5) * 10 - 5,
      life: 1.0,
      color: `hsl(${Math.random() * 60 + 20}, 100%, 60%)`
    });
  }
}

function update() {
  for (let p of particles) {
    p.x += p.vx;
    p.y += p.vy;
    p.vy += 0.08; // gravity
    p.life -= 0.01;
  }
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (let p of particles) {
    ctx.globalAlpha = p.life;
    ctx.fillStyle = p.color;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 4 * p.life, 0, Math.PI * 2);
    ctx.fill();
  }
}
Enter fullscreen mode Exit fullscreen mode

The logic is straightforward: update loops over 5,000 particles on the CPU, recalculating positions and velocities; draw loops over them again, calling Canvas 2D APIs to draw circles.

So where’s the problem? 5,000 calls to beginPath + arc + fill is simply too heavy for Canvas 2D. Chrome’s Performance panel showed that the draw function consumed 78% of the frame time, most of it inside fill itself.

I tried all the usual optimizations:

  • Batch drawing? Not possible – every particle has its own color and alpha.
  • Use ImageData and manipulate pixels directly? Too much hassle, and I’d lose anti‑aliasing.
  • Reduce particle count? Then the firework wouldn’t look impressive enough. I needed a way to offload both the computation and the heavy drawing from the CPU to the GPU.

Step 2: What the Heck Is a WebGPU Compute Shader? (In Human Language)

WebGPU has two main use cases:

  1. Rendering (Render Pass) – drawing triangles, textures, etc. – the WebGL replacement.
  2. Computation (Compute Pass) – running massive parallel math on the GPU and reading the results back. A compute shader is basically a “mini‑program that runs on your graphics card.” It doesn’t care about drawing – it only crunches numbers. And its superpower is that it can perform the exact same operation on thousands of data elements simultaneously, with each element completely independent.

My 5,000 particles all follow the exact same update logic: position += velocity, velocity += gravity, life -= 0.01. That’s a perfect fit for parallelism.

So I rewrote the update function as a WebGPU compute shader, submitting it once per frame to let the GPU calculate all 5,000 new particle states in one go, then either reading the results back to the CPU (or directly piping them into the render pipeline).

Here’s the core part.


Step 3: A Minimal Working WebGPU Compute Shader

First, I defined a particle data structure (in WGSL, WebGPU’s shading language):

1.// Each particle occupies 16 bytes (16‑byte aligned)
2.struct Particle {
3.    pos: vec2<f32>,
4.    vel: vec2<f32>,
5.    life: f32,
6.    pad: f32,  // alignment padding
7.};
Enter fullscreen mode Exit fullscreen mode

Then the compute shader entry point:

1.@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
2.
3.@compute @workgroup_size(64)
4.fn main(@builtin(global_invocation_id) id: vec3<u32>) {
5.    let index = id.x;
6.    if (index >= arrayLength(&particles)) { return; }
7.
8.    var p = particles[index];
9.
10.    // Physics update (identical to the JS version)
11.    p.pos = p.pos + p.vel;
12.    p.vel = p.vel + vec2<f32>(0.0, 0.08);
13.    p.life = p.life - 0.01;
14.
15.    // Bounce off walls (simple)
16.    if (p.pos.x < 0.0 || p.pos.x > 1.0) { p.vel.x = -p.vel.x; }
17.    if (p.pos.y < 0.0 || p.pos.y > 1.0) { p.vel.y = -p.vel.y; }
18.
19.    particles[index] = p;
20.}
Enter fullscreen mode Exit fullscreen mode

This shader gets compiled, and each frame I submit it to the GPU – one submission updates all particles. @workgroup_size(64) means each workgroup processes 64 particles; the GPU automatically schedules a huge number of threads in parallel.

In JavaScript, the invocation looks like this:

const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, bindGroup);
computePass.dispatchWorkgroups(Math.ceil(particleCount / 64));
computePass.end();
Enter fullscreen mode Exit fullscreen mode

And that’s it. The GPU does the heavy lifting in the background, and the CPU no longer has to loop over 5,000 items.


Step 4: The Real Pain Wasn’t Writing the Code

At this point you might think everything went smoothly – but the hardest part of the whole process wasn’t writing the WGSL. It was debugging.

WebGPU compute shaders have almost no debugging tools. You can’t console.log a particle’s position, and you can’t set breakpoints inside the shader. You’re left with:

Reading the result buffer back to the CPU and logging it with JavaScript – which kills performance.
Guessing by visual output – for example, if particles fly off‑screen, you suspect coordinate normalization issues or buffer alignment mistakes.
I spent a solid two hours stuck on one stupid problem: WGSL’s vec2 has 8‑byte alignment, and JavaScript’s Float32Array stores data sequentially – but I forgot to set the correct stride when creating the buffer. The particles’ positions were completely scrambled, as if they’d been randomly thrown outside the canvas.

The fix came from a brutally primitive approach: I wrote a small utility to copy the GPU buffer data back to the CPU, printed the first 10 particle positions, compared them with the JavaScript version, and that’s when I spotted the misalignment.

This experience taught me a hard lesson: WebGPU is powerful, but it’s incredibly unforgiving for beginners. AI won’t help you debug, and search engines won’t find your exact error – you just have to grit your teeth and read the spec.


Step 5: The Final Result (No Made‑Up Numbers, Just My Honest Impression)

On my MacBook Pro (M1 Pro):

  • 5,000 particles with Canvas 2D – about 12–15 fps, and the page felt sluggish.
  • After switching to WebGPU compute shaders + only passing the final positions back to Canvas 2D for drawing – rock‑solid 60 fps, and CPU usage dropped from ~70% to ~8%. Note that I didn’t move the drawing to the GPU (that would require a full render pipeline) – I only moved the computation to the GPU and kept using Canvas 2D’s fillRect or arc for rendering. Even so, the improvement was night and day.

If I had also moved the rendering to WebGPU (using drawIndexed to draw thousands of triangles), it could theoretically reach thousands of frames per second – but the monitor only refreshes at 60 Hz, so there’s no point.


So How Much Did AI Actually Help?

  • Generating the initial code – yes, it was fast, but that was the “just works” version.
  • Writing the WGSL syntax – I asked it to produce a compute shader template, and it did – but once something broke, the AI had zero understanding of WebGPU’s binding‑group layout errors, because it lacks runtime context.
  • Solving the actual problem – I had to read the WebGPU spec three times and dig through Chrome’s about://gpu error logs myself. AI is a great junior dev – but when you hit low‑level API pitfalls, it’s as blind as you are.

What This Story Taught Me

  1. What WebGPU truly changes isn’t “graphics quality” – it’s “computational capacity.” Even for 2D games, moving physics/particle logic to the GPU can boost performance several‑fold.
  2. In 2026, HTML game development is no longer about “knowing Canvas” – it’s about “knowing how to program the GPU.” Game engines will eventually abstract this, but if you want to squeeze every last drop of performance, you have to understand compute shaders.
  3. AI writes code impressively, but it won’t step into the pit for you. The real value of a developer lies precisely in being able to crawl out after falling in.

Finally, I Won’t Talk About WebXR

Because I know if I started rambling about how “WebXR brings the metaverse into the browser,” I’d be back in empty‑rhetoric territory.

So I’ll just leave you with one question:

Have you ever run into a “CPU can’t keep up” wall while building an HTML game? If so, what did you do about it?
If you’re also considering WebGPU, I can put together a small repository with the complete runnable code (WGSL + JS bindings) from this article and share it with you. Now that would be real meat. 😊

Top comments (0)