The previous post
showed how to evaluate sin() in a WGSL compute shader and draw the
result as a line strip — no CPU round-trip. This post extends that
technique with two additions: an interactive harmonic overlay whose
amplitude is controlled by dragging, and a GPU-computed autoscale
that keeps the peak within 95% of the viewport — also without reading
any data back to the CPU.
The live demo below loads /scripts/sine-harmonic-webgpu.js. Drag up
to increase the harmonic amplitude, drag down to decrease it.
Drag up/down to adjust the 5× harmonic amplitude. Blue = base sine, orange = harmonic, cyan = sum. Y autoscales when the peak exceeds 95% of the viewport. Requires a WebGPU-capable browser (Chrome / Edge 113+). Current harmonic amplitude: 0.000
How it works
The plot builds on the compute-to-storage-buffer-to-render-pass pattern from the previous post. The new parts are the harmonic overlay, the atomic-max autoscale, and the pointer interaction.
Three curves from one buffer
The compute shader fills a storage buffer with vec4<f32> per point:
(x, base, harmonic, sum) where:
base = sin(x * frequency)
harmonic = harmAmp * sin(x * frequency * 5)
sum = base + harmonicThree render pipelines — vs_base/fs_base, vs_harm/fs_harm,
vs_sum/fs_sum — each read a different component (.y, .z, .w)
from the same buffer. All three share a single bind group and are drawn
in a single render pass with three draw calls. The base and harmonic
curves are semi-transparent (alpha 0.35) with alpha blending; the sum
is opaque and drawn last so it sits on top.
GPU-side autoscale via atomicMax
The autoscale needs the maximum |sum| across all points — before
the render pass uses it as a divisor. Doing this on the CPU would
require a readback, which stalls the GPU pipeline. Instead, the compute
shader writes the max on the GPU using an atomic operation:
// In the compute shader, after computing sum:
let absSum = abs(sum);
atomicMax(&maxAbs, bitcast<u32>(absSum));This works because positive IEEE 754 floats preserve total ordering
when bitcast to u32 — a larger float has a larger bit pattern. Since
abs() guarantees the value is non-negative, atomicMax on the
bitcast u32 correctly finds the float maximum. No CPU readback, no
reduction kernel, no extra compute pass.
The maxAbs buffer (4 bytes, one u32) is reset to zero each frame
with encoder.clearBuffer() before the compute pass. The render
shaders read it as a plain u32 and compute the scale factor:
fn computeYScale() -> f32 {
let maxVal = bitcast<f32>(rmaxAbs);
return min(1.0, 0.95 / max(maxVal, 0.001));
}The min(1.0, ...) ensures the autoscale only kicks in when the peak
would exceed 95% of the viewport half-height. When the signal is
small (e.g., harmonic amplitude near zero), yScale = 1.0 and the
curve is drawn at its natural size. When the sum grows beyond 0.95,
yScale shrinks proportionally so the peak always sits at exactly 95%.
Pointer interaction → compute uniform
The harmonic amplitude is a single f32 in the compute uniform
buffer. Pointer events update a JavaScript variable; the render loop
writes it to the uniform buffer each frame via device.queue.writeBuffer.
No GPU resource is recreated — the shader, pipelines, buffers, and
bind groups are all created once at init. Only the 16-byte uniform
buffer changes per frame.
The canvas can optionally update an HTML element with the current
amplitude via a data-amp-display="elementId" attribute, following
the same callback pattern used in the
direction-cubes post.
Using it on your own page
Drop the script into your static folder and add a canvas with the
sine-harmonic-canvas class:
<canvas class="sine-harmonic-canvas"
data-amp-display="amp-readout"
style="width:640px;height:280px;"></canvas>
<span id="amp-readout">0.000</span>
<script src="/scripts/sine-harmonic-webgpu.js" defer></script>The data-amp-display attribute is optional — if present, the element
is updated with the current harmonic amplitude on every drag.
To change the base frequency, harmonic multiplier, or amplitude
sensitivity, edit the FREQUENCY, HARMONIC_MULT, and
AMP_SENSITIVITY constants at the top of the script.
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,
// interactive harmonic overlay, and GPU-computed autoscale.
//
// Renders three overlaid curves:
// 1. Base sine: sin(x * frequency)
// 2. Harmonic: harmAmp * sin(x * frequency * 5)
// 3. Sum: base + harmonic
//
// The user drags up/down to control harmAmp. The Y axis autoscales so
// the peak of the sum never exceeds 95% of the viewport half-height.
// The autoscale max is computed on the GPU via atomicMax — no CPU
// readback.
//
// Auto-initializes every <canvas class="sine-harmonic-canvas"> on the
// page. Optional: data-amp-display="elementId" to update an HTML
// element with the current harmonic amplitude.
//
// 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
// ─────────────────────────────────────────────────────────────────────
const FREQUENCY = 8.0 * Math.PI; // base sine: 4 full cycles
const HARMONIC_MULT = 5.0; // harmonic is 5× the base frequency
const MAX_POINTS = 4096;
const AMP_SENSITIVITY = 0.005; // per pixel of vertical drag
const AMP_MAX = 1.5; // clamp harmonic amplitude
// ─────────────────────────────────────────────────────────────────────
// WGSL shader
// ─────────────────────────────────────────────────────────────────────
// One shader module, four entry points:
// cs_main — compute: fill points[] + atomic max of |sum|
// vs_base — render: draw base sine (p.y)
// vs_harm — render: draw harmonic (p.z)
// vs_sum — render: draw sum (p.w)
//
// Each point is a vec4<f32>: (x, base, harmonic, sum).
// The compute shader writes all three y values plus the atomic max.
// The render shaders read the same buffer and compute yScale from
// the GPU-written maxAbs value — no CPU readback.
//
// Atomic max trick: positive IEEE 754 floats preserve ordering when
// bitcast to u32, so atomicMax on bitcast<u32>(abs(y)) gives the
// correct float maximum. We take abs() first to ensure all values
// are positive.
const SHADER = /* wgsl */ `
// ── Compute ──────────────────────────────────────────────────────────
struct ComputeUniforms {
pointCount : u32,
frequency : f32,
harmAmp : f32,
harmMult : f32,
};
@group(0) @binding(0) var<uniform> cu : ComputeUniforms;
@group(0) @binding(1) var<storage, read_write> points : array<vec4<f32>>;
@group(0) @binding(2) var<storage, read_write> maxAbs : atomic<u32>;
@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 base = sin(x * cu.frequency);
let harm = cu.harmAmp * sin(x * cu.frequency * cu.harmMult);
let sum = base + harm;
points[i] = vec4<f32>(x, base, harm, sum);
// Atomic max of |sum|. Positive IEEE 754 floats preserve total
// order when bitcast to u32, so atomicMax on the bitcast value
// correctly finds the float maximum. We take abs() first to
// ensure the value is non-negative (NaN is not a concern here
// because sin() never returns NaN for finite inputs).
let absSum = abs(sum);
atomicMax(&maxAbs, bitcast<u32>(absSum));
}
// ── Render ───────────────────────────────────────────────────────────
// Three vertex entry points, one per curve. All share the same
// bind group (points + maxAbs). yScale is computed per-vertex from
// the GPU-written maxAbs — no uniform needed.
//
// Autoscale policy: yScale = min(1.0, 0.95 / maxVal).
// When maxVal <= 0.95, yScale = 1.0 (no scaling — signal is small).
// When maxVal > 0.95, yScale = 0.95 / maxVal (scale down to fit).
// This means the autoscale only kicks in when the peak would exceed
// 95% of the viewport half-height. It never scales up.
@group(0) @binding(0) var<storage, read> rpoints : array<vec4<f32>>;
@group(0) @binding(1) var<storage, read> rmaxAbs : u32;
struct VSOut {
@builtin(position) pos : vec4<f32>,
@location(0) y : f32,
};
fn computeYScale() -> f32 {
let maxVal = bitcast<f32>(rmaxAbs);
return min(1.0, 0.95 / max(maxVal, 0.001));
}
@vertex
fn vs_base(@builtin(vertex_index) vi : u32) -> VSOut {
let p = rpoints[vi];
let s = computeYScale();
var out : VSOut;
out.pos = vec4<f32>(2.0 * p.x - 1.0, p.y * s, 0.0, 1.0);
out.y = p.y;
return out;
}
@vertex
fn vs_harm(@builtin(vertex_index) vi : u32) -> VSOut {
let p = rpoints[vi];
let s = computeYScale();
var out : VSOut;
out.pos = vec4<f32>(2.0 * p.x - 1.0, p.z * s, 0.0, 1.0);
out.y = p.z;
return out;
}
@vertex
fn vs_sum(@builtin(vertex_index) vi : u32) -> VSOut {
let p = rpoints[vi];
let s = computeYScale();
var out : VSOut;
out.pos = vec4<f32>(2.0 * p.x - 1.0, p.w * s, 0.0, 1.0);
out.y = p.w;
return out;
}
@fragment
fn fs_base(in : VSOut) -> @location(0) vec4<f32> {
// Dim blue, semi-transparent — background curve.
return vec4<f32>(0.3, 0.5, 0.8, 0.35);
}
@fragment
fn fs_harm(in : VSOut) -> @location(0) vec4<f32> {
// Dim orange, semi-transparent — background curve.
return vec4<f32>(0.9, 0.55, 0.25, 0.35);
}
@fragment
fn fs_sum(in : VSOut) -> @location(0) vec4<f32> {
// Bright cyan with subtle y-gradient — the foreground curve.
let t = in.y * 0.5 + 0.5;
return vec4<f32>(0.2 + 0.1 * t, 0.8 + 0.1 * t, 1.0, 1.0);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Per-canvas initialization
// ─────────────────────────────────────────────────────────────────────
async function initCanvas(canvas) {
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' });
const module = device.createShaderModule({ code: SHADER });
// --- Bind group layouts ---
// Explicit layouts (not 'auto') so the three render pipelines can
// share a single bind group. The compute layout has three bindings
// (uniform + two storage); the render layout has two (both storage,
// read-only). Both reference the same pointBuffer and maxAbsBuffer,
// but through separate bind groups because the layouts differ.
const computeLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
],
});
const renderLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
],
});
// --- Pipelines ---
// One compute pipeline + three render pipelines (base, harmonic,
// sum). All three render pipelines share the same bind group
// layout, so a single renderBindGroup works for all of them.
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
compute: { module, entryPoint: 'cs_main' },
});
const renderPipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
// Alpha blending so the dim base and harmonic curves don't obscure
// each other or the bright sum curve drawn on top.
const blend = {
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
};
function makePipeline(vs, fs) {
return device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: { module, entryPoint: vs },
fragment: { module, entryPoint: fs, targets: [{ format, blend }] },
primitive: { topology: 'line-strip' },
});
}
const basePipeline = makePipeline('vs_base', 'fs_base');
const harmPipeline = makePipeline('vs_harm', 'fs_harm');
const sumPipeline = makePipeline('vs_sum', 'fs_sum');
// --- Buffers ---
// computeUniforms: 16 bytes (u32 + 3 × f32)
// pointBuffer: MAX_POINTS × 16 bytes (vec4 per point)
// maxAbsBuffer: 4 bytes (single u32, reset to 0 each frame)
const computeUniforms = device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const pointBuffer = device.createBuffer({
size: MAX_POINTS * 16,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const maxAbsBuffer = device.createBuffer({
size: 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const computeBindGroup = device.createBindGroup({
layout: computeLayout,
entries: [
{ binding: 0, resource: { buffer: computeUniforms } },
{ binding: 1, resource: { buffer: pointBuffer } },
{ binding: 2, resource: { buffer: maxAbsBuffer } },
],
});
const renderBindGroup = device.createBindGroup({
layout: renderLayout,
entries: [
{ binding: 0, resource: { buffer: pointBuffer } },
{ binding: 1, resource: { buffer: maxAbsBuffer } },
],
});
// --- Interaction: drag to control harmonic amplitude ---
// Drag up (deltaY < 0) → increase amplitude
// Drag down (deltaY > 0) → decrease amplitude
// The amplitude is clamped to [-AMP_MAX, +AMP_MAX].
//
// setPointerCapture ensures we keep receiving pointermove events
// even if the pointer leaves the canvas while dragging.
let harmAmp = 0.0;
let dragging = false;
let startY = 0;
let startAmp = 0.0;
// Optional: update an HTML element with the current amplitude.
// The canvas can have a data-amp-display="elementId" attribute;
// if present, the script updates that element's textContent.
const displayId = canvas.getAttribute('data-amp-display');
const display = displayId ? document.getElementById(displayId) : null;
function updateDisplay() {
if (display) display.textContent = harmAmp.toFixed(3);
}
updateDisplay();
canvas.style.cursor = 'ns-resize';
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', (e) => {
dragging = true;
startY = e.clientY;
startAmp = harmAmp;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointerup', () => { dragging = false; });
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dy = e.clientY - startY;
harmAmp = Math.max(-AMP_MAX, Math.min(AMP_MAX, startAmp - dy * AMP_SENSITIVITY));
updateDisplay();
});
// --- Render loop ---
// Each frame:
// 1. Resize canvas if needed.
// 2. Write compute uniforms (pointCount, frequency, harmAmp, mult).
// 3. clearBuffer maxAbs → 0 (reset for this frame's atomic max).
// 4. Compute pass: fill points + atomic max of |sum|.
// 5. Render pass: draw base, harmonic, sum (3 draw calls, 1 bind group).
// 6. Submit and schedule 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, rest are f32 — use
// DataView to mix types in one buffer (same as the sine-wave post).
const cuData = new ArrayBuffer(16);
const cuView = new DataView(cuData);
cuView.setUint32(0, pointCount, true);
cuView.setFloat32(4, FREQUENCY, true);
cuView.setFloat32(8, harmAmp, true);
cuView.setFloat32(12, HARMONIC_MULT, true);
device.queue.writeBuffer(computeUniforms, 0, cuData);
const encoder = device.createCommandEncoder();
// Reset maxAbs to 0 before the compute pass. clearBuffer fills
// with zero bytes, which is u32 0 — the identity element for max.
encoder.clearBuffer(maxAbsBuffer, 0, 4);
// Compute pass — evaluate all three curves + atomic max on GPU.
const cp = encoder.beginComputePass();
cp.setPipeline(computePipeline);
cp.setBindGroup(0, computeBindGroup);
cp.dispatchWorkgroups(Math.ceil(pointCount / 64));
cp.end();
// Render pass — draw all three curves in a single pass.
// Order matters: base and harmonic are semi-transparent (drawn
// first), sum is opaque (drawn last, on top).
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.setBindGroup(0, renderBindGroup);
rp.setPipeline(basePipeline);
rp.draw(pointCount);
rp.setPipeline(harmPipeline);
rp.draw(pointCount);
rp.setPipeline(sumPipeline);
rp.draw(pointCount);
rp.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// ─────────────────────────────────────────────────────────────────────
// Auto-initialization
// ─────────────────────────────────────────────────────────────────────
function initAll() {
document.querySelectorAll('canvas.sine-harmonic-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 extends the
sine-wave plot
with two GPU-side techniques: atomicMax for zero-readback autoscale,
and multiple render pipelines sharing a single storage buffer and bind
group for overlaid curves. The same atomicMax-on-bitcast-float trick
is used in the WssMiniplotRenderer from the
WebGCodeViewer project
to compute the Y-axis bounds for velocity, acceleration, and jerk plots
without stalling the GPU pipeline.