The chirp plot post
showed a static ggplot-style plot with GPU-side evaluation. This post
extends it to real-time streaming data: a 1 kHz oscilloscope
displaying 5 sine waves simultaneously, with data generated in
JavaScript and uploaded to the GPU in chunks of 20 samples (50 Hz)
via asynchronous queue.writeBuffer.
The GPU stores the data in a ring buffer and automatically discards anything older than 5 seconds — the plot auto-scrolls as new data arrives. The 5 sine waves have phase angles spread equally between 0° and 360° (0°, 72°, 144°, 216°, 288°), each rendered in a different color with MSAA anti-aliasing.
5 sine waves at 1 kHz, 2 Hz, phases 0°–288°. Data generated in JS,
uploaded in chunks of 20 samples (50 Hz) via async
queue.writeBuffer. GPU ring buffer auto-scrolls,
discarding data older than 5 seconds. MSAA anti-aliased line
strips. Requires a WebGPU-capable browser (Chrome / Edge 113+).
How it works
Architecture overview
The plot has three concurrent activities:
- Data generation (JavaScript, 50 Hz): generates 20 samples per
chunk, 5 channels per sample, and uploads them to the GPU ring
buffer via
queue.writeBuffer. - Rendering (WebGPU, 60 Hz): renders the grid + 5 line strips from the ring buffer, auto-scrolling to show the last 5 seconds.
- Axis labels (Canvas 2D, 60 Hz): draws tick labels, axis titles, and a 5-color legend on a 2D overlay canvas.
Ring buffer with modulo addressing
The GPU stores data in a circular buffer of 5020 sample slots (5000
visible + 20 extra for chunk alignment). Each slot stores (t, v)
for all 5 channels interleaved: 10 floats per sample, 50,200 floats
total (~200 KB).
The CPU writes chunks sequentially via queue.writeBuffer, wrapping
around when the end of the buffer is reached:
function writeChunk() {
if (writeIndex + CHUNK_SIZE <= BUFFER_SAMPLES) {
const offset = writeIndex * sampleStride;
device.queue.writeBuffer(ringBuffer, offset, chunkData);
} else {
// Split: write what fits, then wrap.
const fits = BUFFER_SAMPLES - writeIndex;
device.queue.writeBuffer(ringBuffer, writeIndex * sampleStride,
chunkData.subarray(0, fits * NUM_CHANNELS * 2));
device.queue.writeBuffer(ringBuffer, 0,
chunkData.subarray(fits * NUM_CHANNELS * 2));
}
writeIndex = (writeIndex + CHUNK_SIZE) % BUFFER_SAMPLES;
}The vertex shader reads the ring buffer using modulo addressing:
vertex vi maps to buffer slot (writeIndex - WINDOW_SAMPLES + vi) % bufferSamples. This ensures the line strip always reads samples in
chronological order, regardless of where the write head is:
let sampleIdx = (ru.writeIndex + ru.bufferSamples - WINDOW_SAMPLES + vi) % ru.bufferSamples;
let base = (sampleIdx * NUM_CHANNELS + ch) * 2;
let t = ringBuffer[base];
let v = ringBuffer[base + 1];Auto-scroll via timestamp culling
The X viewport is always [currentTime - 5, currentTime], where
currentTime is the timestamp of the most recent sample. Samples
older than currentTime - 5 map to X < -1 (off-screen left) and are
clipped by the rasterizer. No explicit deletion is needed — old data
remains in the ring buffer but is simply not visible.
Uninitialized buffer slots are set to t = -1e30 at init, so they
map far off-screen and never appear in the visible window.
Async data upload via queue.writeBuffer
device.queue.writeBuffer is asynchronous and non-blocking — it
copies the data to a staging area and uploads it to the GPU on the
next queue submission. The JavaScript data generator never blocks on
GPU operations:
chunkTimer = setInterval(() => {
generateChunk(); // fill chunkData with 20 samples × 5 channels
writeChunk(); // async upload to GPU ring buffer
}, CHUNK_INTERVAL_MS); // 20 ms = 50 Hz
The chunk data is a pre-allocated Float32Array that is reused every
chunk — no garbage collection pressure.
5 channels via instanced rendering
All 5 channels are drawn in a single instanced draw call:
rp.draw(WINDOW_SAMPLES, NUM_CHANNELS). Each instance is one channel.
The vertex shader uses @builtin(instance_index) as the channel
index to:
- Read the correct
(t, v)pair from the interleaved ring buffer - Read the per-channel color from a storage buffer
@vertex
fn vs_line(@builtin(vertex_index) vi : u32,
@builtin(instance_index) ch : u32) -> LineVSOut {
let sampleIdx = (ru.writeIndex + ru.bufferSamples - WINDOW_SAMPLES + vi) % ru.bufferSamples;
let base = (sampleIdx * NUM_CHANNELS + ch) * 2;
// ...
out.color = channelColors[ch].rgb;
return out;
}The 5 colors are stored in a storage buffer (80 bytes, written once at init):
const CHANNEL_COLORS = [
[0.00, 0.45, 0.75], // blue
[0.85, 0.33, 0.10], // orange
[0.00, 0.62, 0.45], // green
[0.80, 0.10, 0.20], // red
[0.58, 0.40, 0.74], // purple
];MSAA anti-aliasing
The line strips are rendered with hardware MSAA, using the same
sample-count probing technique from the
MSAA post.
Five pipelines are pre-created for sample counts [1, 2, 4, 8, 16],
probed with pushErrorFilter/popErrorFilter. The highest supported
sample count is used by default.
Grid and axis labels
The grid uses the same fwidth()-based anti-aliased line technique
as the
chirp plot post.
Axis tick labels and the 5-color legend are drawn on a 2D canvas
overlay. The X axis shows relative time (e.g., “-5.0s” to “0s”)
since the absolute timestamps are meaningless to the user.
Using it on your own page
Drop the script into your static folder and add a canvas with the
realtime-plot-canvas class, wrapped in a position:relative
container:
<div style="position:relative;width:100%;height:450px;">
<canvas class="realtime-plot-canvas"
style="width:100%;height:100%;display:block;"></canvas>
</div>
<script src="/scripts/realtime-plot-webgpu.js" defer></script>The container must be position:relative so the 2D overlay canvas
can be anchored on top of the WebGPU canvas.
To change the sample rate, window length, chunk size, sine frequency,
or channel count, edit the constants at the top of the script. To
feed real data (e.g., from a WebSocket), replace the generateChunk()
function with your data source.
Related posts
- Interactive Trace Highlighting in WebGPU Oscilloscope with Legend Hover — extends this oscilloscope with legend hover highlighting
- Interactive MSAA Antialiasing for WebGPU Line Plots — MSAA sample-count probing
- Interactive WebGPU Line Plots with GPU-Side Autoscale — mouse zoom/pan for static plots
- ggplot-Style WebGPU Chirp Plot with GPU Autoscale and Infinite Zoom — the grid shader technique used here
- WebGPU Line Plots with GPU-Side Function Evaluation — the original sine-wave line plot
Full source
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Real-time WebGPU oscilloscope-style plot of 5 sine waves at 1 kHz.
//
// Data is generated in JavaScript and passed to the GPU in chunks of
// 20 samples (50 Hz update rate) via queue.writeBuffer — which is
// asynchronous and non-blocking. The GPU stores the data in a ring
// buffer and automatically discards anything older than 5 seconds
// (auto-scrolling behavior).
//
// The 5 sine waves have phase angles spread equally between 0° and 360°
// (0°, 72°, 144°, 216°, 288°), each rendered in a different color with
// MSAA anti-aliasing.
//
// Auto-initializes every <canvas class="realtime-plot-canvas"> on the page.
(function () {
'use strict';
// ─────────────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────────────
const SAMPLE_RATE = 1000; // 1 kHz
const WINDOW_SEC = 5.0; // 5-second visible window
const WINDOW_SAMPLES = SAMPLE_RATE * WINDOW_SEC; // 5000
const NUM_CHANNELS = 5;
const CHUNK_SIZE = 20; // 20 samples per chunk → 50 Hz update
const CHUNK_INTERVAL_MS = CHUNK_SIZE / SAMPLE_RATE * 1000; // 20 ms
// Total ring buffer size: one slot per sample per channel.
// We store (timestamp, value) per sample, interleaved by channel.
// Layout: [ch0_t, ch0_v, ch1_t, ch1_v, ..., ch4_t, ch4_v] per sample index.
const BUFFER_SAMPLES = WINDOW_SAMPLES + CHUNK_SIZE; // slight overalloc
const BUFFER_FLOATS = BUFFER_SAMPLES * NUM_CHANNELS * 2;
const BUFFER_BYTES = BUFFER_FLOATS * 4;
// Sine wave parameters.
const SINE_FREQ = 2.0; // 2 Hz — visible cycles in 5s window
const SINE_AMPLITUDE = 1.0;
// 5 phase angles spread equally between 0° and 360°.
const PHASES = [0, 72, 144, 216, 288].map(deg => deg * Math.PI / 180);
// 5 distinct colors (ggplot-like palette).
const CHANNEL_COLORS = [
[0.00, 0.45, 0.75], // blue
[0.85, 0.33, 0.10], // orange
[0.00, 0.62, 0.45], // green
[0.80, 0.10, 0.20], // red
[0.58, 0.40, 0.74], // purple
];
// MSAA sample counts to probe.
const MSAA_SAMPLE_COUNTS = [1, 2, 4, 8, 16];
// Plot margins in CSS pixels.
const MARGIN_LEFT = 64;
const MARGIN_RIGHT = 80; // extra for legend
const MARGIN_TOP = 16;
const MARGIN_BOTTOM = 48;
// ─────────────────────────────────────────────────────────────────────
// WGSL shader
// ─────────────────────────────────────────────────────────────────────
// Two pipelines:
// 1. line — renders 5 line strips (one per channel) with MSAA
// 2. grid — renders the plot background with fwidth grid lines
//
// The ring buffer stores (timestamp, value) pairs for all 5 channels,
// interleaved: sample i has channels at indices [i*10 .. i*10+9].
//
// The vertex shader receives a sample index and channel index, reads
// the (t, v) pair from the ring buffer, maps to clip space, and
// passes the channel color to the fragment shader.
//
// Auto-scroll: the uniform `currentTime` is the timestamp of the most
// recent sample. The X viewport is [currentTime - WINDOW_SEC, currentTime].
// Samples older than currentTime - WINDOW_SEC map to X < -1 (off-screen)
// and are clipped by the rasterizer.
const SHADER = /* wgsl */ `
struct RenderUniforms {
currentTime : f32,
windowSec : f32,
yMin : f32,
yMax : f32,
plotX : f32,
plotY : f32,
plotW : f32,
plotH : f32,
canvasW : f32,
canvasH : f32,
xMajorStep : f32,
yMajorStep : f32,
writeIndex : u32,
bufferSamples : u32,
};
@group(0) @binding(0) var<uniform> ru : RenderUniforms;
@group(0) @binding(1) var<storage, read> ringBuffer : array<f32>;
@group(0) @binding(2) var<storage, read> channelColors : array<vec4<f32>>;
// World → clip space.
fn worldToClip(t : f32, v : f32) -> vec4<f32> {
let xMin = ru.currentTime - ru.windowSec;
let xMax = ru.currentTime;
let plotPx = (t - xMin) / (xMax - xMin) * ru.plotW;
let plotPy = (ru.yMax - v) / (ru.yMax - ru.yMin) * ru.plotH;
let canvasPx = ru.plotX + plotPx;
let canvasPy = ru.plotY + plotPy;
return vec4<f32>(
(canvasPx / ru.canvasW) * 2.0 - 1.0,
1.0 - (canvasPy / ru.canvasH) * 2.0,
0.0, 1.0
);
}
// ── Line strip pipeline ─────────────────────────────────────────────
// 5 channels drawn in a single instanced draw call. Each instance is
// one channel (line strip). The vertex shader reads (t, v) from the
// ring buffer and the color from the channelColors storage buffer,
// both indexed by @builtin(instance_index).
struct LineVSOut {
@builtin(position) clipPos : vec4<f32>,
@location(0) color : vec3<f32>,
};
@vertex
fn vs_line(@builtin(vertex_index) vi : u32,
@builtin(instance_index) ch : u32) -> LineVSOut {
// Ring buffer with modulo addressing.
// writeIndex points to the next slot to write. The last
// WINDOW_SAMPLES samples are at slots (writeIndex - WINDOW_SAMPLES)
// through (writeIndex - 1), modulo bufferSamples.
// Vertex vi (0..WINDOW_SAMPLES-1) reads the oldest-to-newest
// visible sample.
let sampleIdx = (ru.writeIndex + ru.bufferSamples - ${WINDOW_SAMPLES}u + vi) % ru.bufferSamples;
let base = (sampleIdx * ${NUM_CHANNELS}u + ch) * 2u;
let t = ringBuffer[base];
let v = ringBuffer[base + 1u];
var out : LineVSOut;
out.clipPos = worldToClip(t, v);
out.color = channelColors[ch].rgb;
return out;
}
@fragment
fn fs_line(in : LineVSOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
// ── Grid pipeline ───────────────────────────────────────────────────
struct GridVSOut {
@builtin(position) clipPos : vec4<f32>,
@location(0) canvasPx : vec2<f32>,
};
@vertex
fn vs_grid(@builtin(vertex_index) vi : u32) -> GridVSOut {
var pos = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, 1.0),
);
var out : GridVSOut;
out.clipPos = vec4<f32>(pos[vi], 0.0, 1.0);
out.canvasPx = vec2<f32>(
(pos[vi].x * 0.5 + 0.5) * ru.canvasW,
(1.0 - pos[vi].y * 0.5 - 0.5) * ru.canvasH,
);
return out;
}
// Distance (in pixels) from coord to the nearest grid line at
// multiples of step. pxPerWorld converts world units to pixels.
fn gridLineDistPx(coord : f32, step : f32, pxPerWorld : f32) -> f32 {
let p = coord / step;
let frac_p = fract(p);
let dWorld = min(frac_p, 1.0 - frac_p);
return dWorld * pxPerWorld;
}
// Grid line intensity from a pixel distance: 1.0 on the line,
// anti-aliased to 0 over ~1px. width is the line half-width in px.
fn gridLineIntensity(distPx : f32, width : f32) -> f32 {
return 1.0 - smoothstep(0.0, max(width, 0.0001), distPx);
}
@fragment
fn fs_grid(in : GridVSOut) -> @location(0) vec4<f32> {
let marginColor = vec3<f32>(0.94, 0.94, 0.94);
let plotBg = vec3<f32>(0.97, 0.97, 0.97);
let px = in.canvasPx.x;
let py = in.canvasPx.y;
let inside = px >= ru.plotX && px < ru.plotX + ru.plotW
&& py >= ru.plotY && py < ru.plotY + ru.plotH;
if (!inside) {
return vec4<f32>(marginColor, 1.0);
}
let xMin = ru.currentTime - ru.windowSec;
let worldX = xMin + (px - ru.plotX) / ru.plotW * ru.windowSec;
let worldY = ru.yMax - (py - ru.plotY) / ru.plotH * (ru.yMax - ru.yMin);
// World → pixel scale for each axis.
let pxPerWorldX = ru.plotW / ru.windowSec;
let pxPerWorldY = ru.plotH / (ru.yMax - ru.yMin);
// Distance to the nearest minor/major grid line (horizontal or
// vertical) in pixels. min() across axes gives the closest line
// of either orientation.
let minorD = min(gridLineDistPx(worldX, ru.xMajorStep * 0.5, pxPerWorldX),
gridLineDistPx(worldY, ru.yMajorStep * 0.5, pxPerWorldY));
let majorD = min(gridLineDistPx(worldX, ru.xMajorStep, pxPerWorldX),
gridLineDistPx(worldY, ru.yMajorStep, pxPerWorldY));
// ~1px wide lines, anti-aliased.
let xMinor = gridLineIntensity(minorD, 1.0);
let xMajor = gridLineIntensity(majorD, 1.0);
let minorColor = vec3<f32>(0.88, 0.88, 0.88);
let majorColor = vec3<f32>(0.75, 0.75, 0.78);
var color = plotBg;
color = mix(color, minorColor, xMinor * 0.5);
color = mix(color, majorColor, xMajor * 0.7);
return vec4<f32>(color, 1.0);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Nice tick step algorithm
// ─────────────────────────────────────────────────────────────────────
function niceStep(range, targetCount) {
const raw = range / targetCount;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const norm = raw / mag;
let step;
if (norm <= 1.5) step = 1;
else if (norm <= 3) step = 2;
else if (norm <= 7) step = 5;
else step = 10;
return step * mag;
}
function formatTick(v) {
const abs = Math.abs(v);
if (abs === 0) return '0';
if (abs >= 1000 || abs < 1e-3) return v.toExponential(1);
// Fixed notation with up to 3 decimals, trailing zeros stripped.
let s = v.toFixed(3);
// Strip trailing zeros and a trailing decimal point.
s = s.replace(/0+$/, '').replace(/\.$/, '');
return s;
}
// ─────────────────────────────────────────────────────────────────────
// 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 ---
const renderLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
{ binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
],
});
// --- MSAA probe + render pipelines ---
// Probe each sample count via createRenderPipelineAsync (rejects on
// unsupported sample count). The WebGPU spec only guarantees 1× and 4×.
const supportedSC = [];
const linePipelines = [];
const gridPipelines = [];
const lineLayout = device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
const gridLayout = device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
for (const sc of MSAA_SAMPLE_COUNTS) {
try {
const [linePipe, gridPipe] = await Promise.all([
device.createRenderPipelineAsync({
layout: lineLayout,
vertex: { module, entryPoint: 'vs_line' },
fragment: { module, entryPoint: 'fs_line', targets: [{ format }] },
primitive: { topology: 'line-strip' },
multisample: { count: sc },
}),
device.createRenderPipelineAsync({
layout: gridLayout,
vertex: { module, entryPoint: 'vs_grid' },
fragment: { module, entryPoint: 'fs_grid', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
multisample: { count: sc },
}),
]);
supportedSC.push(sc);
linePipelines.push(linePipe);
gridPipelines.push(gridPipe);
} catch {
// Unsupported sample count — skip.
}
}
// Use 4× MSAA if available, otherwise the highest supported count
// below 4 (avoids the steeper cost of 8×/16× on high-DPI displays).
const TARGET_SC = 4;
let msaaIndex = supportedSC.length - 1;
const targetIdx = supportedSC.indexOf(TARGET_SC);
if (targetIdx !== -1) msaaIndex = targetIdx;
// --- Buffers ---
// Ring buffer: stores (t, v) for all 5 channels, interleaved.
// Updated via queue.writeBuffer (async, non-blocking).
const ringBuffer = device.createBuffer({
size: BUFFER_BYTES,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// Render uniforms (56 bytes: 12 × f32 + 2 × u32).
const renderUniforms = device.createBuffer({
size: 56,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// Channel colors storage buffer: 5 × vec4<f32> (80 bytes).
// Written once at init, read by the vertex shader per instance.
const colorBuffer = device.createBuffer({
size: NUM_CHANNELS * 16,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// Write channel colors once.
const colorData = new Float32Array(NUM_CHANNELS * 4);
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
colorData[ch * 4] = CHANNEL_COLORS[ch][0];
colorData[ch * 4 + 1] = CHANNEL_COLORS[ch][1];
colorData[ch * 4 + 2] = CHANNEL_COLORS[ch][2];
colorData[ch * 4 + 3] = 1.0;
}
device.queue.writeBuffer(colorBuffer, 0, colorData);
const gridBindGroup = device.createBindGroup({
layout: renderLayout,
entries: [
{ binding: 0, resource: { buffer: renderUniforms } },
{ binding: 1, resource: { buffer: ringBuffer } },
{ binding: 2, resource: { buffer: colorBuffer } },
],
});
const lineBindGroup = device.createBindGroup({
layout: renderLayout,
entries: [
{ binding: 0, resource: { buffer: renderUniforms } },
{ binding: 1, resource: { buffer: ringBuffer } },
{ binding: 2, resource: { buffer: colorBuffer } },
],
});
// --- MSAA texture ---
let msaaTexture = null;
let msaaSC = 0, msaaW = 0, msaaH = 0;
function ensureMsaa(w, h, sc) {
if (sc === 1) {
if (msaaTexture) { msaaTexture.destroy(); msaaTexture = null; }
msaaSC = 1; msaaW = w; msaaH = h;
return;
}
if (msaaTexture && msaaSC === sc && msaaW === w && msaaH === h) return;
if (msaaTexture) msaaTexture.destroy();
msaaTexture = device.createTexture({
size: [w, h, 1], format,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
sampleCount: sc,
});
msaaSC = sc; msaaW = w; msaaH = h;
}
// --- 2D canvas overlay for axis labels + legend ---
const overlay = document.createElement('canvas');
overlay.style.position = 'absolute';
overlay.style.left = '0';
overlay.style.top = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.pointerEvents = 'none';
canvas.style.position = canvas.style.position || 'relative';
canvas.parentElement.appendChild(overlay);
const octx = overlay.getContext('2d');
// --- Ring buffer state ---
// The ring buffer is written in chunks of CHUNK_SIZE samples.
// Each sample has NUM_CHANNELS channels, each storing (t, v) = 2 floats.
// So each chunk is CHUNK_SIZE * NUM_CHANNELS * 2 floats.
//
// writeIndex: the next sample slot to write (0..BUFFER_SAMPLES-1).
// When writeIndex + chunkSize > BUFFER_SAMPLES, we wrap around.
//
// Initialization: all timestamps are set to -1e30 (far off-screen
// left) so uninitialized slots don't appear in the visible window.
// The one spurious line segment at the wrap boundary is acceptable
// — by the time the buffer wraps (after 5s of data), the old data
// at the beginning is already outside the visible window.
let writeIndex = 0;
let currentTime = 0.0; // timestamp of most recent sample
let sampleCounter = 0; // global sample counter (for timestamp)
// Initialize ring buffer with far-off timestamps.
const initBuf = new Float32Array(BUFFER_FLOATS);
for (let i = 0; i < BUFFER_SAMPLES; i++) {
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const idx = (i * NUM_CHANNELS + ch) * 2;
initBuf[idx] = -1e30; // t = far off-screen
initBuf[idx + 1] = 0.0; // v = 0
}
}
device.queue.writeBuffer(ringBuffer, 0, initBuf);
// Pre-allocate chunk upload buffer (reused each chunk).
const chunkFloats = CHUNK_SIZE * NUM_CHANNELS * 2;
const chunkData = new Float32Array(chunkFloats);
// --- Data generation ---
// Generate one chunk of CHUNK_SIZE samples. Each sample has 5 channels
// with sine waves at different phases. Timestamps are in seconds.
function generateChunk() {
for (let i = 0; i < CHUNK_SIZE; i++) {
const t = sampleCounter / SAMPLE_RATE;
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const phase = 2 * Math.PI * SINE_FREQ * t + PHASES[ch];
const v = SINE_AMPLITUDE * Math.sin(phase);
const idx = (i * NUM_CHANNELS + ch) * 2;
chunkData[idx] = t;
chunkData[idx + 1] = v;
}
sampleCounter++;
currentTime = t;
}
}
// Write a chunk to the ring buffer at the current write index.
// Handles wraparound: if the chunk would overflow the buffer, it
// splits into two writes.
function writeChunk() {
const chunkBytes = chunkFloats * 4;
const sampleStride = NUM_CHANNELS * 2 * 4; // bytes per sample
if (writeIndex + CHUNK_SIZE <= BUFFER_SAMPLES) {
// Single write — no wraparound.
const offset = writeIndex * sampleStride;
device.queue.writeBuffer(ringBuffer, offset, chunkData);
} else {
// Split: write what fits, then wrap.
const fits = BUFFER_SAMPLES - writeIndex;
const fitsFloats = fits * NUM_CHANNELS * 2;
const offset = writeIndex * sampleStride;
device.queue.writeBuffer(ringBuffer, offset, chunkData.subarray(0, fitsFloats));
// Wrap: write the rest at the beginning.
const restFloats = (CHUNK_SIZE - fits) * NUM_CHANNELS * 2;
device.queue.writeBuffer(ringBuffer, 0, chunkData.subarray(fitsFloats));
}
writeIndex = (writeIndex + CHUNK_SIZE) % BUFFER_SAMPLES;
}
// --- Chunk generation timer ---
// Generate and upload a chunk every CHUNK_INTERVAL_MS (20 ms = 50 Hz).
let chunkTimer = null;
function startStreaming() {
if (chunkTimer) return;
chunkTimer = setInterval(() => {
generateChunk();
writeChunk();
}, CHUNK_INTERVAL_MS);
}
function stopStreaming() {
if (chunkTimer) { clearInterval(chunkTimer); chunkTimer = null; }
}
startStreaming();
// --- Y viewport (fixed for sine waves) ---
const yMin = -SINE_AMPLITUDE * 1.2;
const yMax = SINE_AMPLITUDE * 1.2;
// --- Axis label drawing ---
function drawAxes(cssW, cssH, dpr) {
const cw = canvas.width, ch = canvas.height;
if (overlay.width !== cw || overlay.height !== ch) {
overlay.width = cw; overlay.height = ch;
}
octx.clearRect(0, 0, cw, ch);
octx.save();
octx.scale(dpr, dpr);
const ml = MARGIN_LEFT, mr = MARGIN_RIGHT, mt = MARGIN_TOP, mb = MARGIN_BOTTOM;
const px = ml, py = mt;
const pw = cssW - ml - mr, ph = cssH - mt - mb;
// Plot axes (ggplot style: only bottom and left axis lines,
// no top/right border, with small outward-pointing tick marks).
octx.strokeStyle = '#333';
octx.lineWidth = 1;
octx.beginPath();
// Left axis.
octx.moveTo(px, py);
octx.lineTo(px, py + ph);
// Bottom axis.
octx.lineTo(px + pw, py + ph);
octx.stroke();
// Tick marks.
octx.font = '11px sans-serif';
octx.fillStyle = '#333';
// X axis: time relative to current time (e.g., "-5s" to "0s").
const xStep = niceStep(WINDOW_SEC, 8);
const xStart = Math.ceil((currentTime - WINDOW_SEC) / xStep) * xStep;
octx.textAlign = 'center';
octx.textBaseline = 'top';
for (let x = xStart; x <= currentTime + xStep * 0.001; x += xStep) {
const sx = px + (x - (currentTime - WINDOW_SEC)) / WINDOW_SEC * pw;
if (sx < px - 1 || sx > px + pw + 1) continue;
// Outward tick mark.
octx.beginPath();
octx.moveTo(sx, py + ph);
octx.lineTo(sx, py + ph + 4);
octx.stroke();
const label = x === currentTime ? '0s' : (x - currentTime).toFixed(1) + 's';
octx.fillText(label, sx, py + ph + 8);
}
// Y axis.
const yStep = niceStep(yMax - yMin, 6);
const yStart = Math.ceil(yMin / yStep) * yStep;
octx.textAlign = 'right';
octx.textBaseline = 'middle';
for (let y = yStart; y <= yMax + yStep * 0.001; y += yStep) {
const sy = py + (yMax - y) / (yMax - yMin) * ph;
if (sy < py - 1 || sy > py + ph + 1) continue;
// Outward tick mark.
octx.beginPath();
octx.moveTo(px, sy);
octx.lineTo(px - 4, sy);
octx.stroke();
octx.fillText(formatTick(y), px - 8, sy);
}
// Axis titles.
octx.font = '13px sans-serif';
octx.fillStyle = '#222';
octx.textAlign = 'center';
octx.textBaseline = 'bottom';
octx.fillText('t (s, relative)', ml + pw / 2, cssH - 4);
octx.save();
octx.translate(12, mt + ph / 2);
octx.rotate(-Math.PI / 2);
octx.textAlign = 'center';
octx.textBaseline = 'top';
octx.fillText('amplitude', 0, 0);
octx.restore();
// Legend (right margin).
const legendX = ml + pw + 8;
const legendY = mt + 4;
octx.font = '11px sans-serif';
octx.textAlign = 'left';
octx.textBaseline = 'middle';
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
const [r, g, b] = CHANNEL_COLORS[ch];
const ly = legendY + ch * 18;
// Color swatch.
octx.fillStyle = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`;
octx.fillRect(legendX, ly - 5, 12, 10);
// Label.
octx.fillStyle = '#333';
const phaseDeg = Math.round(PHASES[ch] * 180 / Math.PI);
octx.fillText(`φ=${phaseDeg}°`, legendX + 16, ly);
}
octx.restore();
}
// --- Render loop ---
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 ml = MARGIN_LEFT * dpr;
const mr = MARGIN_RIGHT * dpr;
const mt = MARGIN_TOP * dpr;
const mb = MARGIN_BOTTOM * dpr;
const plotX = ml;
const plotY = mt;
const plotW = Math.max(1, cw - ml - mr);
const plotH = Math.max(1, ch - mt - mb);
const sc = supportedSC[msaaIndex];
const xMajorStep = niceStep(WINDOW_SEC, 8);
const yMajorStep = niceStep(yMax - yMin, 6);
// Write render uniforms.
const ruData = new Float32Array(12);
ruData[0] = currentTime;
ruData[1] = WINDOW_SEC;
ruData[2] = yMin;
ruData[3] = yMax;
ruData[4] = plotX;
ruData[5] = plotY;
ruData[6] = plotW;
ruData[7] = plotH;
ruData[8] = cw;
ruData[9] = ch;
ruData[10] = xMajorStep;
ruData[11] = yMajorStep;
device.queue.writeBuffer(renderUniforms, 0, ruData);
// Also write writeIndex and bufferSamples as u32 at offset 48 (bytes).
const bufInfo = new Uint32Array([writeIndex, BUFFER_SAMPLES]);
device.queue.writeBuffer(renderUniforms, 48, bufInfo);
ensureMsaa(cw, ch, sc);
const encoder = device.createCommandEncoder();
// Render pass — grid + 5 line strips.
const canvasView = ctx.getCurrentTexture().createView();
const colorAttachment = {
clearValue: { r: 0.94, g: 0.94, b: 0.94, a: 1 },
loadOp: 'clear',
};
if (sc > 1) {
colorAttachment.view = msaaTexture.createView();
colorAttachment.resolveTarget = canvasView;
colorAttachment.storeOp = 'discard';
} else {
colorAttachment.view = canvasView;
colorAttachment.storeOp = 'store';
}
const rp = encoder.beginRenderPass({ colorAttachments: [colorAttachment] });
// 1) Grid (background).
rp.setPipeline(gridPipelines[msaaIndex]);
rp.setBindGroup(0, gridBindGroup);
rp.draw(6);
// 2) 5 line strips — single instanced draw, 5 instances (one per channel).
rp.setPipeline(linePipelines[msaaIndex]);
rp.setBindGroup(0, lineBindGroup);
// Draw all 5 channels in a single instanced draw call.
// Each instance is one channel; the vertex shader reads the
// per-channel color from the colorBuffer storage buffer.
//
// Only draw the valid (non-sentinel) samples. Before the buffer
// is full, the leading slots are sentinels (t = -1e30) and the
// valid samples sit at the tail of the vertex range
// (vi = WINDOW_SAMPLES - validSamples .. WINDOW_SAMPLES - 1).
// firstVertex skips the sentinel vertices so no spurious line
// segment is drawn from the sentinel region into the plot.
const validSamples = Math.min(sampleCounter, WINDOW_SAMPLES);
const firstVertex = WINDOW_SAMPLES - validSamples;
rp.draw(validSamples, NUM_CHANNELS, firstVertex, 0);
rp.end();
device.queue.submit([encoder.finish()]);
// Draw axis labels + legend on the 2D overlay.
drawAxes(canvas.clientWidth, canvas.clientHeight, dpr);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Clean up on page unload.
window.addEventListener('beforeunload', stopStreaming);
}
// ─────────────────────────────────────────────────────────────────────
// Auto-initialization
// ─────────────────────────────────────────────────────────────────────
function initAll() {
document.querySelectorAll('canvas.realtime-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 combines the ring
buffer pattern from the
WebGCodeViewer project’s
ToolpathRenderer (which uses a circular buffer for streaming
toolpath data) with the MSAA and grid techniques from previous posts.