Dublin Library

The Publishing project

WebGPU Animation Architecture

In this post

Structuring a WebGPU application requires matching your render loop architecture to your application’s interactivity. Using a continuous animation loop everywhere is a performance anti-pattern that drains battery life and wastes hardware resources.

This post started as part of the WebGPU 2026 update but it grew into a separate article because the topic of animation architecture is not specific to the 2026 update. The principles here apply to any WebGPU application, regardless of the year.

Different strategies

Single-Pass (Static Rendering)

Execute your WebGPU pipeline exactly once, without a loop, when dealing with:

  • Data Visualization: Static charts, graphs, or mathematical plots that do not animate.
  • Image Processing: Compute shaders running a filter over a static photograph.
  • Asset Generation: Using the GPU to calculate procedural textures or geometry that you will later export or use elsewhere in the DOM.

Continuous Loop (requestAnimationFrame)

Implement a continuous loop exclusively when the visual state changes frame-over-frame:

  • Animations: Time-based movement, such as particle systems or spinning 3D models.
  • Real-Time Interactivity: Scenes with constant camera controls (like orbit controls) where the user drags the viewport.
  • Video Processing: Applying shaders to a <video> element or webcam stream where every frame contains new pixel data.

Refactoring to requestAnimationFrame

When refactoring a static script to support requestAnimationFrame, you must divide your code into a one-time initialization phase and a per-frame update loop. Note the critical requirement to request a new textureView inside the loop.

async function startRenderLoop(canvas: HTMLCanvasElement): Promise<void> {
  // --- Phase 1: One-time Initialization ---
  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter!.requestDevice();
  const context = canvas.getContext("webgpu")!;

  context.configure({
    device,
    format: navigator.gpu.getPreferredCanvasFormat(),
    alphaMode: "premultiplied",
  });

  // Fetch shaders and bake the immutable pipeline once
  const shaderModule = await loadShader("/shaders/triangle.wgsl", device);
  const pipeline = device.createRenderPipeline({ /* ... */ });

  // --- Phase 2: The Render Loop ---
  function frame(timestamp: number) {
    const commandEncoder = device.createCommandEncoder();

    // CRITICAL: We must request a new texture view every single frame
    const textureView = context.getCurrentTexture().createView();

    const renderPass = commandEncoder.beginRenderPass({
      colorAttachments: [{
        view: textureView,
        clearValue: { r: 0.1, g: 0.2, b: 0.3, a: 1.0 },
        loadOp: "clear",
        storeOp: "store",
      }],
    });

    renderPass.setPipeline(pipeline);
    renderPass.draw(3);
    renderPass.end();

    device.queue.submit([commandEncoder.finish()]);
    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

Render-on-Demand

For applications that are mostly static but occasionally interactive (like a 3D product viewer where the user occasionally rotates the camera), the optimal architecture is render-on-demand.

Instead of a continuous loop, wrap your render logic in a function and only trigger it when a specific event dirties the state. This provides the responsiveness of a loop when needed while automatically returning the GPU to an idle state the moment the user stops interacting.

let isDirty = true;

function render() {
  if (!isDirty) return;

  const commandEncoder = device.createCommandEncoder();
  const textureView = context.getCurrentTexture().createView();

  // ... pass setup and draw calls ...

  device.queue.submit([commandEncoder.finish()]);

  // Mark as clean until new input arrives
  isDirty = false;
}

// Only trigger a new frame when the user interacts or the window resizes
canvas.addEventListener("mousemove", (event) => {
  updateCameraPosition(event);
  isDirty = true;
  requestAnimationFrame(render);
});

window.addEventListener("resize", () => {
  resizeCanvas();
  isDirty = true;
  requestAnimationFrame(render);
});

// Initial render
requestAnimationFrame(render);

Conclusion

WebGPU applications should avoid a one-size-fits-all approach to rendering. By carefully choosing between single-pass, continuous loops, and render-on-demand rendering strategies, developers can optimize performance, reduce power consumption, and provide a better user experience.