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:
- 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 toMAX_POINTS(4096) and the actual point count is passed as a uniform so it can adapt to the canvas width without reallocating the buffer. - 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:
- 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 theWssMiniplotRendererin 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. - Storage buffer as compute-to-render bridge. The point buffer
is declared
read_writein the compute shader andreadin 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 atMAX_POINTS × 8bytes and reused every frame. - Mixed-type uniforms via DataView. The compute uniform struct
has one
u32(point count) followed by threef32values. AFloat32Arraywould store the integer as a float, and the GPU would reinterpret the float bits as au32— producing a garbage value (~1 billion) and breaking the shader. The fix is to use anArrayBuffer+DataViewwithsetUint32/setFloat32so each field is written with the correct binary representation. - Workgroup sizing. The compute shader uses
@workgroup_size(64), so the dispatch count isceil(pointCount / 64). Each thread checksif (i >= cu.pointCount) { return; }to avoid writing past the active range whenpointCountis not a multiple of 64. - 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:
<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
// 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.