WebGPU Line Plots with GPU-Side Function Evaluation

A typical line plot pipeline goes: evaluate the function on the CPU, upload the points to the GPU, draw them. This works fine for a few hundred points, but when you need one point per pixel column (or analytical evaluation of a piecewise function at arbitrary zoom levels), the CPU round-trip becomes the bottleneck.

This post shows a minimal, dependency-free WebGPU implementation that evaluates sin() on the GPU in a compute shader and draws the resulting points as a line strip — all without reading the data back to the CPU. The full source is at the bottom; the live demo below loads the exact same script from /scripts/sine-plot-webgpu.js.

4 full sine cycles, one point per pixel column. The function is evaluated in a WGSL compute shader — no CPU round-trip. Requires a WebGPU-capable browser (Chrome / Edge 113+).

How it works

The plot uses two passes in a single command encoder submission:

  1. Compute pass — a compute shader fills a storage buffer with vec2<f32> points. Each workgroup thread computes one point: x = i / (pointCount - 1) (normalized 0..1 across the width), y = amplitude * sin(x * frequency). The buffer is sized to MAX_POINTS (4096) and the actual point count is passed as a uniform so it can adapt to the canvas width without reallocating the buffer.
  2. Render pass — a render pipeline draws the same storage buffer as a line-strip. The vertex shader maps the normalized x from [0,1] to clip space [-1,+1] and passes y through (already in [-amplitude, +amplitude]). The fragment shader outputs a blue gradient based on the y coordinate.

The key techniques are:

  1. GPU-side function evaluation. The sin() call lives in the WGSL compute shader, not in JavaScript. The CPU only writes uniforms (point count, frequency, amplitude) and submits the command buffer. This is the same pattern used by the WssMiniplotRenderer in the WebGCodeViewer project, where a compute shader evaluates velocity, acceleration, and jerk analytically from WSS (Weighted Switching Structure) arcs — one point per pixel column, at any zoom level, without re-evaluating on the CPU.
  2. Storage buffer as compute-to-render bridge. The point buffer is declared read_write in the compute shader and read in the render shader. Both passes are recorded in the same command encoder, so WebGPU handles the synchronization — no explicit barriers or readback needed. The buffer is allocated once at MAX_POINTS × 8 bytes and reused every frame.
  3. Mixed-type uniforms via DataView. The compute uniform struct has one u32 (point count) followed by three f32 values. A Float32Array would store the integer as a float, and the GPU would reinterpret the float bits as a u32 — producing a garbage value (~1 billion) and breaking the shader. The fix is to use an ArrayBuffer + DataView with setUint32 / setFloat32 so each field is written with the correct binary representation.
  4. Workgroup sizing. The compute shader uses @workgroup_size(64), so the dispatch count is ceil(pointCount / 64). Each thread checks if (i >= cu.pointCount) { return; } to avoid writing past the active range when pointCount is not a multiple of 64.
  5. No depth buffer, no blending. Unlike the floor grid or navigation cube posts, this pipeline is pure 2D — no depth attachment, no alpha blending, no perspective matrix. The render pass has a single color attachment cleared to a dark background.

Using it on your own page

Drop the script into your static folder and add a canvas with the sine-plot-canvas class — the script auto-initializes every matching canvas on the page:

example.html
<canvas class="sine-plot-canvas"
        style="width:640px;height:240px;"></canvas>
<script src="/scripts/sine-plot-webgpu.js" defer></script>

Size the canvas with CSS (width/height); the script handles devicePixelRatio scaling internally. To change the frequency or amplitude, edit the FREQUENCY and AMPLITUDE constants at the top of the script.

To plot a different function, replace the sin() call in the cs_main entry point with your own WGSL expression. The rest of the pipeline (buffer management, dispatch, render pass) stays the same.

Full source

sine-plot-webgpu.js
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Minimal WebGPU line plot with GPU-side function evaluation.
// Renders a sine wave by evaluating sin() in a compute shader, then
// drawing the resulting points as a line strip. The JavaScript side
// only writes uniforms and submits command buffers — no function
// evaluation happens on the CPU.
//
// Auto-initializes every <canvas class="sine-plot-canvas"> on the page.
//
// Why an IIFE?  We wrap everything in an immediately-invoked function
// expression so that none of the helpers, constants, or the WGSL source
// leak into the global scope.  The script is intended to be included
// verbatim with <script src=... defer>, possibly alongside other scripts
// on the same page, so isolation matters.
(function () {
  'use strict';

  // ─────────────────────────────────────────────────────────────────────
  // Configuration
  // ─────────────────────────────────────────────────────────────────────
  // The plot shows `amplitude * sin(x * frequency)` where x ranges from
  // 0 to 1 across the canvas width. With frequency = 8π, that gives 4
  // full cycles. These are baked into the compute uniform at runtime —
  // not into the shader source — so they can be changed without
  // recompiling the shader.

  const FREQUENCY = 8.0 * Math.PI;  // 4 full cycles across the width
  const AMPLITUDE = 0.8;            // 80% of half-height
  const MAX_POINTS = 4096;          // cap for the storage buffer

  // ─────────────────────────────────────────────────────────────────────
  // WGSL shader
  // ─────────────────────────────────────────────────────────────────────
  // One shader module, two pipelines (compute + render).
  //
  // The compute shader fills a storage buffer with vec2<f32> points:
  //   x = i / (pointCount - 1)   →  0..1 across the width
  //   y = amplitude * sin(x * frequency)
  //
  // The render shader reads the same buffer and draws it as a line
  // strip in clip space. The x coordinate is mapped from [0,1] to
  // [-1,+1] (clip space X). The y coordinate is already in [-amplitude,
  // +amplitude], which is close enough to [-1,+1] for a full-screen
  // plot — the yScale uniform can be used to adjust if needed.
  //
  // The key point: sin() is called once per point on the GPU. The CPU
  // never evaluates the function. This is the same pattern used by the
  // WssMiniplotRenderer in WebGCodeViewer, where the compute shader
  // evaluates velocity/acceleration/jerk analytically from WSS arcs.

  const SHADER = /* wgsl */ `
  // ── Compute: fill points[] with sin(x) ──────────────────────────────
  struct ComputeUniforms {
    pointCount : u32,
    frequency  : f32,
    amplitude  : f32,
    _pad       : f32,
  };

  @group(0) @binding(0) var<uniform>  cu : ComputeUniforms;
  @group(0) @binding(1) var<storage, read_write> points : array<vec2<f32>>;

  @compute @workgroup_size(64)
  fn cs_main(@builtin(global_invocation_id) gid : vec3<u32>) {
    let i = gid.x;
    if (i >= cu.pointCount) { return; }
    let n = f32(cu.pointCount);
    let x = f32(i) / max(n - 1.0, 1.0);
    let y = cu.amplitude * sin(x * cu.frequency);
    points[i] = vec2<f32>(x, y);
  }

  // ── Render: line-strip in clip space ────────────────────────────────
  struct RenderUniforms {
    yScale : f32,
    _pad0  : f32,
    _pad1  : f32,
    _pad2  : f32,
  };

  @group(0) @binding(0) var<uniform>  ru : RenderUniforms;
  @group(0) @binding(1) var<storage, read> rpoints : array<vec2<f32>>;

  struct VSOut {
    @builtin(position) pos : vec4<f32>,
    @location(0)       uv  : vec2<f32>,
  };

  @vertex
  fn vs_main(@builtin(vertex_index) vi : u32) -> VSOut {
    let p = rpoints[vi];
    var out : VSOut;
    out.pos = vec4<f32>(2.0 * p.x - 1.0, p.y * ru.yScale, 0.0, 1.0);
    out.uv  = p;
    return out;
  }

  @fragment
  fn fs_main(in : VSOut) -> @location(0) vec4<f32> {
    let t = in.uv.y * 0.5 + 0.5;
    return vec4<f32>(0.2 + 0.1 * t, 0.5 + 0.2 * t, 1.0, 1.0);
  }
`;

  // ─────────────────────────────────────────────────────────────────────
  // Per-canvas initialization
  // ─────────────────────────────────────────────────────────────────────
  // Called once for each <canvas class="sine-plot-canvas"> on the page.
  // Sets up the WebGPU device, compute + render pipelines, buffers, and
  // starts the render loop. All GPU resources are captured in the
  // closure so the render loop can access them without global state.

  async function initCanvas(canvas) {
    // --- WebGPU bootstrap ---
    if (!navigator.gpu) {
      canvas.replaceWith(document.createTextNode('WebGPU is not supported in this browser.'));
      return;
    }
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
      canvas.replaceWith(document.createTextNode('No WebGPU adapter available.'));
      return;
    }
    const device = await adapter.requestDevice();
    const ctx = canvas.getContext('webgpu');
    const format = navigator.gpu.getPreferredCanvasFormat();
    ctx.configure({ device, format, alphaMode: 'premultiplied' });

    // Compile the WGSL shader into a shader module.
    const module = device.createShaderModule({ code: SHADER });

    // --- Pipelines ---
    // Two pipelines sharing the same shader module:
    //   computePipeline — fills the point buffer (compute pass)
    //   renderPipeline  — draws the point buffer as a line strip
    //
    // Both use layout: 'auto', which makes WebGPU derive the bind group
    // layout from the shader.
    const computePipeline = device.createComputePipeline({
      layout: 'auto',
      compute: { module, entryPoint: 'cs_main' },
    });

    const renderPipeline = device.createRenderPipeline({
      layout: 'auto',
      vertex:   { module, entryPoint: 'vs_main' },
      fragment: { module, entryPoint: 'fs_main', targets: [{ format }] },
      primitive: { topology: 'line-strip' },
    });

    // --- Buffers ---
    // computeUniforms: 16 bytes (u32 pointCount + 3 × f32)
    // renderUniforms:  16 bytes (f32 yScale + 3 × f32 padding)
    // pointBuffer:     MAX_POINTS × 8 bytes (vec2<f32> per point)
    //
    // pointBuffer is a storage buffer that is written by the compute
    // pass and read by the render pass — both in the same command
    // encoder, so no readback to the CPU is needed.

    const computeUniforms = device.createBuffer({
      size: 16,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });

    const renderUniforms = device.createBuffer({
      size: 16,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });

    const pointBuffer = device.createBuffer({
      size: MAX_POINTS * 8,
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    });

    const computeBindGroup = device.createBindGroup({
      layout: computePipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: computeUniforms } },
        { binding: 1, resource: { buffer: pointBuffer } },
      ],
    });

    const renderBindGroup = device.createBindGroup({
      layout: renderPipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: renderUniforms } },
        { binding: 1, resource: { buffer: pointBuffer } },
      ],
    });

    // --- Render loop ---
    // Each frame:
    //   1. Resize canvas if the CSS size changed.
    //   2. Write compute uniforms (pointCount, frequency, amplitude).
    //      pointCount is u32 — must use a DataView, not Float32Array,
    //      or the GPU will reinterpret the float bits as an integer
    //      and get a garbage value.
    //   3. Write render uniforms (yScale = 1.0).
    //   4. Record a compute pass: dispatch enough workgroups to fill
    //      pointCount points (64 threads per workgroup).
    //   5. Record a render pass: draw pointCount vertices as a line
    //      strip.
    //   6. Submit and schedule the next frame.
    function frame() {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const cw = Math.max(1, Math.floor(canvas.clientWidth * dpr));
      const ch = Math.max(1, Math.floor(canvas.clientHeight * dpr));
      if (canvas.width !== cw || canvas.height !== ch) {
        canvas.width = cw; canvas.height = ch;
      }

      const pointCount = Math.min(MAX_POINTS, cw);

      // Write compute uniforms. pointCount is u32, the rest are f32,
      // so we use a DataView to mix types in one buffer.
      const cuData = new ArrayBuffer(16);
      const cuView = new DataView(cuData);
      cuView.setUint32(0, pointCount, true);
      cuView.setFloat32(4, FREQUENCY, true);
      cuView.setFloat32(8, AMPLITUDE, true);
      cuView.setFloat32(12, 0, true);
      device.queue.writeBuffer(computeUniforms, 0, cuData);

      device.queue.writeBuffer(renderUniforms, 0, new Float32Array([
        1.0, 0, 0, 0,
      ]));

      const encoder = device.createCommandEncoder();

      // Compute pass — generate sine wave points on the GPU.
      const cp = encoder.beginComputePass();
      cp.setPipeline(computePipeline);
      cp.setBindGroup(0, computeBindGroup);
      cp.dispatchWorkgroups(Math.ceil(pointCount / 64));
      cp.end();

      // Render pass — draw the points as a line strip.
      const rp = encoder.beginRenderPass({
        colorAttachments: [{
          view: ctx.getCurrentTexture().createView(),
          clearValue: { r: 0.055, g: 0.055, b: 0.07, a: 1 },
          loadOp: 'clear',
          storeOp: 'store',
        }],
      });
      rp.setPipeline(renderPipeline);
      rp.setBindGroup(0, renderBindGroup);
      rp.draw(pointCount);
      rp.end();

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

  // ─────────────────────────────────────────────────────────────────────
  // Auto-initialization
  // ─────────────────────────────────────────────────────────────────────
  function initAll() {
    document.querySelectorAll('canvas.sine-plot-canvas').forEach(initCanvas);
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll);
  } else {
    initAll();
  }
})();

The script and the inline listing above are identical and both released under CC0-1.0. The implementation is a stripped-down, dependency-free version of the WssMiniplotRenderer from the WebGCodeViewer project. The original evaluates velocity, acceleration, and jerk analytically from WSS arcs in a compute shader — one point per pixel column, at any zoom level — using the same compute-to-storage-buffer-to-render-pass pattern shown here.


Check out similar posts by category: Javascript WebGPU Web