GPU 側オートスケールによるインタラクティブ WebGPU ラインプロット

前回の記事 では 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)。ここで:

example.txt
base     = sin(x * frequency)
harmonic = harmAmp * sin(x * frequency * 5)
sum      = base + harmonic

3つのレンダーパイプライン——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 を書き込む:

autoscale.wgsl
// 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 として読み、スケール係数を計算する:

autoscale.wgsl
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 クラスの キャンバスを追加:

example.html
<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 定数を編集。

完全なソース

sine-harmonic-webgpu.js
// 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 軸範囲を計算して いる。


同じカテゴリの類似記事をチェック: Javascript WebGPU Web