前回の記事
では WGSL compute シェーダで sin() を評価し、結果をラインストリップ
として描画する方法を示した——CPU 往復なし。この記事ではその技術を
2つの追加で拡張する:ドラッグで振幅を制御するインタラクティブな
高調波オーバーレイと、ピークをビューポートの95%以内に保つ
GPU 計算オートスケール——こちらも CPU へのデータ読み戻しなし。
下のライブデモは /scripts/sine-harmonic-webgpu.js を読み込む。
上ドラッグで高調波振幅を増やし、下ドラッグで減らす。
上下ドラッグで5倍高調波の振幅を調整。 青 = 基本正弦波、オレンジ = 高調波、シアン = 和。 ピークがビューポートの95%を超えると Y がオートスケール。 WebGPU 対応ブラウザ(Chrome / Edge 113+)が必要。 現在の高調波振幅:0.000
動作の仕組み
プロットは前回の記事の compute→ストレージバッファ→レンダーパス パターンを基にしている。新しい部分は高調波オーバーレイ、atomic-max オートスケール、ポインタインタラクション。
1バッファから3曲線
compute シェーダはストレージバッファをポイントごとに vec4<f32> で
埋める:(x, base, harmonic, sum)。ここで:
base = sin(x * frequency)
harmonic = harmAmp * sin(x * frequency * 5)
sum = base + harmonic3つのレンダーパイプライン——vs_base/fs_base、vs_harm/fs_harm、
vs_sum/fs_sum——それぞれ同じバッファから異なる成分(.y、.z、
.w)を読む。3つすべてが単一のバインドグループを共有し、1つの
レンダーパスで3回の draw 呼び出しで描画される。基本波と高調波
カーブはアルファブレンディングで半透明(アルファ0.35);和は不透明
で最後に描画されるので最前面に来る。
atomicMax による GPU 側オートスケール
オートスケールはすべてのポイントの最大 |sum| を必要とする——
レンダーパスがそれを除数として使う前に。CPU でこれを行うには
読み戻しが必要で、GPU パイプラインをストールさせる。代わりに
compute シェーダがアトミック操作で GPU 上に max を書き込む:
// compute シェーダ内、sum 計算後:
let absSum = abs(sum);
atomicMax(&maxAbs, bitcast<u32>(absSum));これが機能するのは、正の IEEE 754 フロートが u32 に bitcast される
とき全順序を保持するから——大きいフロートは大きいビットパターンを
持つ。abs() が値を非負に保証するので、bitcast された u32 への
atomicMax は正しくフロート最大値を見つける。CPU 読み戻しなし、
リダクションカーネルなし、追加 compute パスなし。
maxAbs バッファ(4バイト、1つの u32)は compute パスの前に
encoder.clearBuffer() で毎フレームゼロにリセットされる。レンダー
シェーダはそれを通常の u32 として読み、スケール係数を計算する:
fn computeYScale() -> f32 {
let maxVal = bitcast<f32>(rmaxAbs);
return min(1.0, 0.95 / max(maxVal, 0.001));
}min(1.0, ...) により、オートスケールはピークがビューポート半高さの
95%を超えるときのみ発動する。信号が小さいとき(例:高調波振幅が
ゼロ付近)、yScale = 1.0 でカーブは自然なサイズで描画される。和が
0.95 を超えて大きくなると、yScale が比例して縮小し、ピークは常に
ちょうど95%に収まる。
ポインタインタラクション → compute uniform
高調波振幅は compute uniform バッファ内の単一の f32。ポインタ
イベントが JavaScript 変数を更新し、レンダーループが毎フレーム
device.queue.writeBuffer で uniform バッファに書き込む。GPU
リソースは再作成されない——シェーダ、パイプライン、バッファ、
バインドグループはすべて初期化時に1回作成。毎フレーム変わるのは
16バイトの uniform バッファのみ。
キャンバスは data-amp-display="elementId" 属性で現在の振幅を持つ
HTML 要素をオプションで更新できる。direction-cubes の記事
と同じコールバックパターン。
自分のページで使う
スクリプトを静的フォルダに置き、sine-harmonic-canvas クラスの
キャンバスを追加:
<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>data-amp-display 属性はオプション——あれば、ドラッグごとに要素が
現在の高調波振幅で更新される。
基本周波数、高調波倍率、振幅感度を変更するにはスクリプト先頭の
FREQUENCY、HARMONIC_MULT、AMP_SENSITIVITY 定数を編集。
完全なソース
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// GPU 側関数評価、インタラクティブ高調波オーバーレイ、GPU 計算
// オートスケールを備えた最小限 WebGPU ラインプロット。
//
// 3つの重ね合わせカーブを描画:
// 1. 基本正弦波:sin(x * frequency)
// 2. 高調波: harmAmp * sin(x * frequency * 5)
// 3. 和: base + harmonic
//
// ユーザーは上下ドラッグで harmAmp を制御。Y 軸はオートスケール
// され、和のピークがビューポート半高さの95%を超えないようにする。
// オートスケール max は atomicMax で GPU 上で計算——CPU 読み戻しなし。
//
// ページ上のすべての <canvas class="sine-harmonic-canvas"> を自動初期化。
// オプション:data-amp-display="elementId" で HTML 要素を現在の高調波
// 振幅で更新。
//
// なぜ IIFE か?すべてを即時呼び出し関数式でラップして、ヘルパーや
// 定数、WGSL ソースがグローバルスコープに漏れないようにする。
// スクリプトは <script src=... defer> でそのままインクルードされる
// 想定で、同じページ上の他のスクリプトと共存しうるため、分離が重要。
(function () {
'use strict';
// ─────────────────────────────────────────────────────────────────────
// 設定
// ─────────────────────────────────────────────────────────────────────
const FREQUENCY = 8.0 * Math.PI; // 基本正弦波:4フルサイクル
const HARMONIC_MULT = 5.0; // 高調波は基本周波数の5倍
const MAX_POINTS = 4096;
const AMP_SENSITIVITY = 0.005; // 垂直ドラッグのピクセルあたり
const AMP_MAX = 1.5; // 高調波振幅をクランプ
// ─────────────────────────────────────────────────────────────────────
// WGSL シェーダ
// ─────────────────────────────────────────────────────────────────────
// 1つのシェーダモジュール、4つのエントリポイント:
// cs_main —— compute:points[] を埋める + |sum| の atomic max
// vs_base —— render:基本正弦波を描画(p.y)
// vs_harm —— render:高調波を描画(p.z)
// vs_sum —— render:和を描画(p.w)
//
// 各ポイントは vec4<f32>:(x, base, harmonic, sum)。
// compute シェーダは3つの y 値すべてと atomic max を書き込む。
// render シェーダは同じバッファを読み、GPU が書いた maxAbs 値から
// yScale を計算——CPU 読み戻しなし。
//
// Atomic max のトリック:正の IEEE 754 フロートは u32 に bitcast
// されるとき順序を保持するため、bitcast<u32>(abs(y)) への atomicMax
// は正しいフロート最大値を与える。まず abs() を取り、すべての値が
// 正であることを保証する。
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);
// |sum| の atomic max。正の IEEE 754 フロートは u32 に bitcast
// されるとき全順序を保持するため、bitcast 値への atomicMax は
// 正しくフロート最大値を見つける。まず abs() を取り値が非負
// であることを保証する(有限入力に対して sin() が NaN を返す
// ことはないので NaN は問題にならない)。
let absSum = abs(sum);
atomicMax(&maxAbs, bitcast<u32>(absSum));
}
// ── Render ───────────────────────────────────────────────────────────
// 3つの頂点エントリポイント、カーブごとに1つ。すべて同じバインド
// グループ(points + maxAbs)を共有。yScale は頂点ごとに GPU が
// 書いた maxAbs から計算——uniform 不要。
//
// オートスケールポリシー:yScale = min(1.0, 0.95 / maxVal)。
// maxVal <= 0.95 のとき yScale = 1.0(スケーリングなし——信号が小さい)。
// maxVal > 0.95 のとき yScale = 0.95 / maxVal(収まるよう縮小)。
// つまりオートスケールはピークがビューポート半高さの95%を超える
// ときのみ発動。拡大はしない。
@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> {
// 薄い青、半透明——背景カーブ。
return vec4<f32>(0.3, 0.5, 0.8, 0.35);
}
@fragment
fn fs_harm(in : VSOut) -> @location(0) vec4<f32> {
// 薄いオレンジ、半透明——背景カーブ。
return vec4<f32>(0.9, 0.55, 0.25, 0.35);
}
@fragment
fn fs_sum(in : VSOut) -> @location(0) vec4<f32> {
// 明るいシアン、繊細な y グラデーション——前景カーブ。
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);
}
`;
// ─────────────────────────────────────────────────────────────────────
// キャンバスごとの初期化
// ─────────────────────────────────────────────────────────────────────
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 });
// --- バインドグループレイアウト ---
// 明示的レイアウト('auto' ではない)を使い、3つのレンダー
// パイプラインが単一バインドグループを共有できるようにする。
// compute レイアウトは3バインディング(uniform + 2ストレージ);
// render レイアウトは2つ(両方とも読み取り専用ストレージ)。
// 両方とも同じ pointBuffer と maxAbsBuffer を参照するが、
// レイアウトが異なるため別のバインドグループ経由。
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' } },
],
});
// --- パイプライン ---
// 1つの compute パイプライン + 3つのレンダーパイプライン(基本波、
// 高調波、和)。3つのレンダーパイプラインすべてが同じバインド
// グループレイアウトを共有するため、単一の renderBindGroup が
// すべてで動作。
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
compute: { module, entryPoint: 'cs_main' },
});
const renderPipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [renderLayout] });
// 薄い基本波と高調波カーブが互い、および上に描かれる明るい和
// カーブを隠さないようにアルファブレンディング。
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');
// --- バッファ ---
// computeUniforms: 16バイト(u32 + 3 × f32)
// pointBuffer: MAX_POINTS × 16バイト(ポイントごとに vec4)
// maxAbsBuffer: 4バイト(単一 u32、毎フレーム0にリセット)
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 } },
],
});
// --- インタラクション:ドラッグで高調波振幅を制御 ---
// 上ドラッグ(deltaY < 0)→ 振幅増加
// 下ドラッグ(deltaY > 0)→ 振幅減少
// 振幅は [-AMP_MAX, +AMP_MAX] にクランプ。
//
// setPointerCapture でドラッグ中にポインタがキャンバスを離れても
// pointermove イベントを受け続ける。
let harmAmp = 0.0;
let dragging = false;
let startY = 0;
let startAmp = 0.0;
// オプション:現在の振幅で HTML 要素を更新。
// キャンバスは data-amp-display="elementId" 属性を持てる;
// あればスクリプトがその要素の 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();
});
// --- レンダーループ ---
// 毎フレーム:
// 1. 必要ならキャンバスをリサイズ。
// 2. compute uniform を書き込み(pointCount、frequency、harmAmp、mult)。
// 3. clearBuffer で maxAbs → 0(このフレームの atomic max をリセット)。
// 4. compute パス:ポイントを埋める + |sum| の atomic max。
// 5. render パス:基本波、高調波、和を描画(3回の draw、1バインドグループ)。
// 6. 送信して次フレームをスケジュール。
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);
// compute uniform を書き込み。pointCount は u32、残りは f32——
// 1バッファで型を混在させるため DataView を使う(正弦波記事と同じ)。
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();
// compute パスの前に maxAbs を0にリセット。clearBuffer はゼロ
// バイトで埋める——u32 の0は max の単位元。
encoder.clearBuffer(maxAbsBuffer, 0, 4);
// Compute パス——3曲線すべてを評価 + GPU 上で atomic max。
const cp = encoder.beginComputePass();
cp.setPipeline(computePipeline);
cp.setBindGroup(0, computeBindGroup);
cp.dispatchWorkgroups(Math.ceil(pointCount / 64));
cp.end();
// Render パス——3曲線すべてを単一パスで描画。
// 順序が重要:基本波と高調波は半透明(最初に描画)、和は不透明
// (最後に描画、最前面)。
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);
}
// ─────────────────────────────────────────────────────────────────────
// 自動初期化
// ─────────────────────────────────────────────────────────────────────
function initAll() {
document.querySelectorAll('canvas.sine-harmonic-canvas').forEach(initCanvas);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
})();スクリプトと上記のインラインリスティングは同一で、両方とも CC0-1.0
で公開。この実装は正弦波プロット
を2つの GPU 側技術で拡張している:ゼロ読み戻しオートスケールのための
atomicMax と、重ね合わせカーブ用に単一ストレージバッファとバインド
グループを共有する複数レンダーパイプライン。同じ
atomicMax-on-bitcast-float のトリックは WebGCodeViewer
プロジェクトの WssMiniplotRenderer でも使われ、GPU パイプラインを
ストールさせずに速度、加速度、ジャークプロットの Y 軸範囲を計算して
いる。