GPU オートスケールと無限ズーム付き ggplot スタイル WebGPU チャーププロット

正弦波の記事 では単純なラインプロットの GPU 側関数評価を示した。この記事はその技術を 中心に本格的な ggplot スタイルプロットを構築する:アンチエイリアス MSAA ラインレンダリング、ズームしても量子化しない動的グリッド、 GPU 計算のオートスケール、軸ラベル、無限ズーム/パン操作を持つ チャープ信号。

チャープは $y = A\sin(2\pi(f_0 t + \frac{1}{2}k t^2))$ —— 周波数が $t \in [0, 10]$ で $f_0 = 1,\text{Hz}$ から $f_0 + kT = 101,\text{Hz}$ へ線形に増加する正弦波。ズームの優れた テストケース:低ズームでは全周波数スイープが見え、高ズームでは 個々の正弦波サイクルが見える。

スクロールでズーム(カーソル方向)、 ドラッグでパン、 ダブルクリックでリセット。 チャープは GPU 上で1ピクセルあたり2サンプルで評価される—— サンプル数を増やさずにズームインでより詳細が見える。 Y 軸オートスケールは並列 min/max リデュースで GPU 上で計算。 グリッドラインは fwidth() ベースのアンチエイリアスを使用。 WebGPU 対応ブラウザ(Chrome / Edge 113+)が必要。

動作の仕組み

プロットは以前の記事の5つの技術に、2つの新しい技術(GPU オートスケールと f32 位相分解)を組み合わせている:

  1. GPU 側関数評価 — チャープは CPU ではなくコンピュートシェーダで評価 (正弦波の記事)
  2. MSAA アンチエイリアシング — ラインストリップはハードウェア マルチサンプリングで描画、初期化時にサンプル数をプローブ (MSAA の記事)
  3. fwidth ベース動的グリッド — グリッドラインはフラグメント シェーダでスクリーン空間微分を使いアンチエイリアスされ、 ズームしても量子化しない (メキシカンハットの記事)
  4. 2D キャンバスオーバーレイ — 軸ティックラベルとプロットボーダーは WebGPU キャンバスの上に配置された兄弟 2D キャンバスに描画
  5. GPU オートスケール — 並列 min/max リデュースでチャープポイントから Y 範囲を計算し、非同期で CPU に読み戻す
  6. 無限ズーム — サンプル数はキャンバス幅に比例(ビューポート範囲では なく)、ズームインで常により詳細が見える
  7. f32 位相分解 — チャープ位相を大きな定数部と小さなサンプルごとの 増分に分割し、正弦加算公式で評価。f32 量子化なしに任意のズーム レベルで正弦が滑らかに保たれる

GPU 側チャープ評価

コンピュートシェーダがストレージバッファを vec2<f32> ポイントで満たす。 ポイント数はビューポート範囲ではなく、キャンバス幅 × オーバーサンプリング 係数(デフォルト2倍)に比例。つまりズームレベルに関係なくサンプル密度は 常に約2サンプル/ピクセル——サンプル数を増やさずにズームインで チャープのより多くのサイクルが見える:

chirp.wgsl
@compute @workgroup_size(64)
fn cs_points(@builtin(global_invocation_id) gid : vec3<u32>) {
  let i = gid.x;
  if (i >= cu.pointCount) { return; }
  let n = f32(cu.pointCount);

  // delta(xMin からの相対時間)— 小さく、f32 全精度。
  let delta = f32(i) / max(n - 1.0, 1.0) * (cu.xMax - cu.xMin);

  // 深いズームでの f32 量子化を避けるため phase = phase0 + dPhase に分解
  // (後述の「位相分解による f32 精度」参照)。
  let phase0 = 6.28318530718 * (cu.chirpF0 * cu.xMin + 0.5 * cu.chirpK * cu.xMin * cu.xMin);
  let fInst  = cu.chirpF0 + cu.chirpK * cu.xMin;
  let dPhase = 6.28318530718 * (fInst * delta + 0.5 * cu.chirpK * delta * delta);

  // sin(a+b) = sin(a)cos(b) + cos(a)sin(b) — dPhase を全精度に保ちながら、
  // phase0 の f32 誤差を一定オフセットとして吸収。
  let y = cu.amplitude * (sin(dPhase) * cos(phase0) + cos(dPhase) * sin(phase0));

  // delta(絶対 t ではない)を格納 — 頂点シェーダが delta を直接
  // クリップ空間にマップし、深いズームで xMin + delta を計算する
  // f32 精度損失を回避。
  points[i] = vec2<f32>(delta, y);
}

CPU はチャープを一切評価しない。uniform(xMin、xMax、チャープ パラメータ)をアップロードしてコンピュートパスをディスパッチするだけ。

位相分解による f32 精度

深いズーム——例えば $t = 10$ 付近で5 ms 表示——では、サンプルあたりの 時間ステップは $\sim 1.5,\text{µs}$ だが、$t \approx 10$ での f32 ulp (unit in the last place)は $\sim 9.5 \times 10^{-7}$ なので、連続する サンプルが同じ t 値に丸まることがある。$t = 10$ での位相は $2\pi \cdot 510 \approx 3204$ で、そこでの f32 ulp は $\sim 2.4 \times 10^{-4}$ —— サンプルあたりの位相ステップの約4倍にすぎないので、 約4サンプルに1つが同一の位相を得て、正弦波が目に見える階段状に 崩壊する。

解決策は位相を大きな定数と小さなサンプルごとの増分に 分解すること:

$$\varphi(t) = \underbrace{2\pi\bigl(f_0\,t_0 + \tfrac{1}{2}k\,t_0^2\bigr)}_{\varphi_0} \;+\; \underbrace{2\pi\bigl((f_0 + k\,t_0)\,\delta + \tfrac{1}{2}k\,\delta^2\bigr)}_{\Delta\varphi}$$

ここで $t_0 = \texttt{xMin}$、$\delta = i/(n{-}1) \cdot (\texttt{xMax} - \texttt{xMin})$。$\Delta\varphi$ は小さな数から f32 全精度で計算される。正弦は加算公式で評価:

$$\sin(\varphi_0 + \Delta\varphi) = \sin\Delta\varphi \cdot \cos\varphi_0 + \cos\Delta\varphi \cdot \sin\varphi_0$$

$\sin\varphi_0$ と $\cos\varphi_0$ は f32 精度を失うが、誤差は すべてのサンプルで同一——量子化ではなく一定の位相オフセット。 $\Delta\varphi$ は小さいので $\sin\Delta\varphi$ と $\cos\Delta\varphi$ は正確。結果、任意のズームレベルで滑らかな量子化されない正弦波。

X 座標にも同じ処理が必要。f32 で t = xMin + delta を計算すると 深いズームで精度を失う:$t = 10$ 付近で約1 ms 以下の表示では、 サンプルあたりのステップ($\sim 3 \times 10^{-7}$)は $t \approx 10$ での f32 ulp($\sim 9.5 \times 10^{-7}$)より小さく、連続サンプルが同じ t に 潰れてラインストリップが階段状になる。解決策は t の代わりに delta をポイントバッファに格納し、xMin を加算せずに 頂点シェーダでクリップ空間にマップすること:

$$\text{plotPx} = \frac{\delta}{\texttt{xMax} - \texttt{xMin}} \cdot \texttt{plotW}$$

$\delta$ と $\texttt{xMax} - \texttt{xMin}$ はどちらも全精度を持つ 小さな f32 値なので、任意のズームレベルでマッピングは正確。 絶対 xMin はグリッドシェーダだけが使う(グリッドライン配置のため ピクセル位置からワールド座標を計算する——そこでは量子化は無害。 グリッドラインは f32 ulp よりはるかに大きい「きれいな」ティック ステップにスナップするため)。

MSAA アンチエイリアシング

ラインストリップはハードウェア MSAA で描画。初期化時に5つの レンダーパイプラインを事前作成(サンプル数 [1, 2, 4, 8, 16] ごと1つ)、 各サンプル数は pushErrorFilter/popErrorFilter でプローブして アダプタサポートを検出。詳細は MSAA の記事 参照。

fwidth による動的グリッド

グリッドはフラグメントシェーダで全画面クワッドとして描画される。 各ピクセルの delta(ビューポート原点からのオフセット)を キャンバスピクセル位置から計算し、fract() で各ティックステップに アンチエイリアスされたグリッドラインを描画。グリッドライン距離は 軸ごとの pxPerWorld スケール係数でピクセル空間で計算され、 ズームレベルに関係なく均一な約1px ラインが得られる:

grid.wgsl
fn gridLineDistPx(base : f32, delta : f32, step : f32, pxPerWorld : f32) -> f32 {
  let baseFrac = fract(base / step);
  let p = baseFrac + delta / step;
  let frac_p = fract(p);
  let dWorld = min(frac_p, 1.0 - frac_p);
  return dWorld * pxPerWorld;
}

base + delta 分解はラインシェーダで使われるのと同じ技術: fract(base / step) はすべてのピクセルで一定(その f32 精度損失は 量子化ではなく固定グリッドシフト)、delta / step は小さな数から f32 全精度で計算される。これがないと、深いズームで f32 の worldX = xMin + delta を計算すると複数ピクセルが同じ worldX を 共有し、太い不均一なグリッドラインが生じる。

これはメキシカンハットのフロアグリッド と 同じ技術の 2D 版。グリッドラインはズームレベルに関係なく常に 約1px 幅で、サブピクセルになると滑らかにフェード。量子化も LOD ポッピングもない。

2D キャンバスオーバーレイによる軸ラベル

WebGPU でのテキスト描画は複雑(SDF アトラス、グリフキャッシュなど)。 ズーム/パン中に毎フレーム変わる軸ラベルには、より単純なアプローチが 2D キャンバスオーバーレイ:WebGPU キャンバスの上に配置された 兄弟 <canvas> 要素に Canvas 2D API で描画。2D コンテキストは 任意の DPR で鮮明でテキストをネイティブに扱う:

overlay.js
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.parentElement.appendChild(overlay);
const octx = overlay.getContext('2d');

毎フレーム、WebGPU レンダー後に drawAxes() がオーバーレイをクリアし octx.fillText() でティックラベルを描画。ティック位置は「きれいな」 ティックステップアルゴリズム(1、2、5 × 10ⁿ)で現在のビューポートから 計算される。

X 軸は xMin からの相対時刻を表示し、ティックステップサイズに 基づき適切な SI 単位に自動スケール:

ステップサイズ単位ラベル例
≥ 1 ss0 s, 2 s, 5 s
1 ms – 1 sms0 ms, 500 ms
1 µs – 1 msµs0 µs, 200 µs
< 1 µsns0 ns, 100 ns

これにより任意のズームレベルでラベルが短く保たれる——0.001 s ではなく 1 ms、0.0005 s ではなく 500 µs——単位接頭辞でスケールが すぐ読み取れる。

並列 min/max リデュースによる GPU オートスケール

Y ビューポートは可視チャープデータに合わせて自動調整される。 これは GPU 上の2パス並列リデュースで計算:

  1. パス1:各ワークグループ(64スレッド)がポイントバッファの 自分のスライスでローカル min/max Y を計算し、ワークグループ バッファに格納。
  2. パス2:ワークグループ0がすべてのワークグループ結果を読み、 グローバル min/max を計算して単一の vec2<f32> に格納。

結果は MAP_READ バッファにコピーされ、非同期で CPU に読み戻される。 読み戻しが完了すると、yMin/yMax が10% マージンで更新される:

autoscale.js
readbackBuffer.mapAsync(GPUMapMode.READ).then(() => {
  const arr = new Float32Array(readbackBuffer.getMappedRange(0, 8));
  const min = arr[0], max = arr[1];
  readbackBuffer.unmap();
  autoscalePending = false;
  if (min <= max && isFinite(min) && isFinite(max)) {
    const range = max - min;
    const margin = range > 0 ? range * 0.1 : Math.abs(max) * 0.1 + 0.1;
    yMin = min - margin;
    yMax = max + margin;
  }
});

リデュースはメインレンダー後に別のコマンドエンコーダで送信され、 前の読み戻しが完了したときのみ新しいリデュースが送信される (キューイングなし)。これによりバッファマッピングの競合を防ぐ。

ユーザーが垂直にドラッグ(手動 Y パン)するとオートスケールは無効化。 スクロールズームでオートスケールが再有効化され、Y 範囲が新しい X 範囲に適応。ダブルクリックで初期ビューにリセット。

インタラクション:カーソル方向ズーム、ドラッグパン

  • スクロール:カーソル位置方向にズーム——カーソル下のワールド X 座標が固定される。X のみズーム;Y はオートスケールが制御。
  • ドラッグ:X・Y 両方向にパン。垂直ドラッグはオートスケールを無効化。
  • ダブルクリック:初期ビューにリセットしてオートスケールを再有効化。

カーソル方向ズームは、ズーム前にカーソル位置のワールド X を計算し、 ズーム後にそのワールド X が同じスクリーン位置にマップされるよう xMin/xMax を調整して実装:

zoom.js
const worldX = cssXToWorldX(cssX);
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
xMin = worldX - (worldX - xMin) * factor;
xMax = worldX + (xMax - worldX) * factor;

自分のページで使う

スクリプトを静的フォルダに置き、position:relative コンテナで 囲んだ chirp-plot-canvas クラスのキャンバスを追加:

example.html
<div style="position:relative;width:100%;height:450px;">
  <canvas class="chirp-plot-canvas"
          style="width:100%;height:100%;display:block;"></canvas>
</div>
<script src="/scripts/chirp-plot-webgpu.js" defer></script>

2D オーバーレイキャンバスを WebGPU キャンバスの上に重ねるため、 コンテナは position:relative でなければならない。

チャープパラメータ、初期ビューポート、オーバーサンプリング係数を 変更するには、スクリプト先頭の CHIRP_F0、CHIRP_K、AMPLITUDE、 X_MIN_INIT、X_MAX_INIT、OVERSAMPLE 定数を編集。

完全なソース

chirp-plot-webgpu.js
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// GPU 側関数評価、MSAA アンチエイリアシング、動的グリッド、
// オートスケール、無限ズーム/パンを備えた ggplot スタイル
// WebGPU チャープ信号ラインプロット。
//
// チャープは y = A·sin(2π·(f₀·t + ½·k·t²)) —— 周波数が t とともに
// 線形に増加する正弦波。関数は GPU 上で1ピクセルあたり1サンプル
// (× オーバーサンプリング)で評価されるので、ズームインでサンプル数を
// 増やさずにより詳細が見える。
//
// ページ上のすべての <canvas class="chirp-plot-canvas"> を自動初期化。
(function () {
  'use strict';

  // ─────────────────────────────────────────────────────────────────────
  // 設定
  // ─────────────────────────────────────────────────────────────────────

  // チャープパラメータ:y = A·sin(2π·(f₀·t + ½·k·t²))
  // 時刻 t での周波数:f(t) = f₀ + k·t
  // f₀=1, k=10 → 周波数は t∈[0,10] で 1 Hz から 101 Hz へ。
  const CHIRP_F0 = 1.0;
  const CHIRP_K = 10.0;
  const AMPLITUDE = 1.0;

  // 初期 X ビューポート
  const X_MIN_INIT = 0.0;
  const X_MAX_INIT = 10.0;

  // サンプリング:1ピクセルあたり1ポイント × オーバーサンプリング係数。
  // これが無限ズームを可能に——サンプル密度はビューポート範囲ではなく
  // 常にキャンバス幅に比例。
  const OVERSAMPLE = 2;
  const MAX_POINTS = 16384;

  // プローブする MSAA サンプル数(WebGPU が保証するのは 1× と 4× のみ)。
  const MSAA_SAMPLE_COUNTS = [1, 2, 4, 8, 16];

  // CSS ピクセルでのプロットマージン(軸ラベル用)。
  const MARGIN_LEFT = 64;
  const MARGIN_RIGHT = 16;
  const MARGIN_TOP = 16;
  const MARGIN_BOTTOM = 48;

  // ─────────────────────────────────────────────────────────────────────
  // WGSL シェーダ
  // ─────────────────────────────────────────────────────────────────────
  // 3つのパイプラインがこのモジュールを共有:
  //   1. compute  — ポイントバッファをチャープ値で満たす
  //   2. line     — チャープを MSAA ラインストリップとして描画
  //   3. grid     — グリッドラインでプロット領域の背景を描画
  //
  // コンピュートシェーダは現在のビューポート内の等間隔 X 位置で
  // チャープを評価。ポイント数はビューポート範囲ではなくキャンバス幅に
  // 比例するので、ズームインでサンプル数を増やさずにより詳細が見える。

  const SHADER = /* wgsl */ `
  // ── コンピュート:points[] をチャープ値で満たす ───────────────────────
  struct ComputeUniforms {
    pointCount : u32,
    _pad0      : u32,
    xMin       : f32,
    xMax       : f32,
    chirpF0    : f32,
    chirpK     : f32,
    amplitude  : f32,
    _pad1      : 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_points(@builtin(global_invocation_id) gid : vec3<u32>) {
    let i = gid.x;
    if (i >= cu.pointCount) { return; }
    let n = f32(cu.pointCount);

    // delta(xMin からの相対時間)— 小さく、f32 全精度。
    let delta = f32(i) / max(n - 1.0, 1.0) * (cu.xMax - cu.xMin);

    // 深いズームでの f32 量子化を避けるため phase = phase0 + dPhase に
    // 分解。|xMin| が大きく |xMax - xMin| が小さいとき、f32 で
    // t = mix(xMin, xMax, ...) を計算すると精度を失う:連続サンプル間の
    // ステップが xMin での f32 ulp より小さくなり得るので、複数サンプルが
    // 同じ t、同じ位相を得て、正弦が目に見える量子化を生じる。
    //
    // phase0 は xMin での一定位相(大きく、全サンプルで同じ)。
    // dPhase はサンプルごとの増分で、小さな数(delta、xMin での
    // 瞬時周波数)から f32 全精度で計算。
    //
    // 加算公式による sin(phase0 + dPhase):
    //   sin(dPhase)*cos(phase0) + cos(dPhase)*sin(phase0)
    // sin/cos(phase0) は精度を失うが、誤差は全サンプルで同一——
    // 量子化ではなく一定の位相オフセット。
    // sin/cos(dPhase) は正確(小さな引数)。
    let phase0 = 6.28318530718 * (cu.chirpF0 * cu.xMin + 0.5 * cu.chirpK * cu.xMin * cu.xMin);
    let fInst  = cu.chirpF0 + cu.chirpK * cu.xMin;
    let dPhase = 6.28318530718 * (fInst * delta + 0.5 * cu.chirpK * delta * delta);

    let y = cu.amplitude * (sin(dPhase) * cos(phase0) + cos(dPhase) * sin(phase0));

    // ポイントバッファには delta(絶対 t ではない)を格納。ライン頂点
    // シェーダは xMin + delta を計算せずに delta を直接クリップ空間に
    // マップ(深いズームではサンプルごとの delta が xMin での f32 ulp
    // より小さくなり、f32 精度を失うため)。
    points[i] = vec2<f32>(delta, y);
  }

  // ── コンピュート:オートスケール用の min/max Y リデュース ─────────────
  // 各ワークグループが64ポイントでローカル min/max を計算し、その後
  // ワークグループ0がすべてのワークグループ結果を単一の vec2<f32>
  // (minY, maxY) にリデュースしてリデュースバッファに格納。
  //
  // 簡単のため2パスリデュース:第1パスはワークグループごとの min/max を
  // ワークグループバッファに書く;第2パス(ワークグループ0のみ)は
  // すべてのワークグループ結果を読んで最終 min/max を書く。
  // MAX_POINTS=16384 では最大256ワークグループなので、第2パスは
  // 256値を読む64スレッドの単一ワークグループ——256要素の
  // ワークグループ uniform 制限内に十分収まる。

  struct ReduceUniforms {
    pointCount : u32,
    workgroupCount : u32,
    _pad0 : u32,
    _pad1 : u32,
  };

  @group(0) @binding(0) var<uniform>  rdu : ReduceUniforms;
  @group(0) @binding(1) var<storage, read> rdpoints : array<vec2<f32>>;
  @group(0) @binding(2) var<storage, read_write> wgMinMax : array<vec2<f32>>;
  @group(0) @binding(3) var<storage, read_write> finalMinMax : vec2<f32>;

  var<workgroup> wgMin : f32;
  var<workgroup> wgMax : f32;

  @compute @workgroup_size(64)
  fn cs_reduce(@builtin(workgroup_id) wid : vec3<u32>,
               @builtin(local_invocation_id) lid : vec3<u32>,
               @builtin(local_invocation_index) lidx : u32) {
    // パス1:各ワークグループがローカル min/max を計算。
    if (lidx == 0) { wgMin = 1e30; wgMax = -1e30; }
    workgroupBarrier();

    let i = wid.x * 64 + lidx;
    if (i < rdu.pointCount) {
      let y = rdpoints[i].y;
      // アトミック不要のリデュース:ワークグループ共有変数を使い、
      // ワークグループ内で単純なシリアルリデュース。
      // 64スレッドなら十分速い。
    }

    // 各スレッドが自分の Y 値を(有効なら)ローカル変数に読む。
    let myY = select(rdpoints[i].y, 0.0, i >= rdu.pointCount);
    let valid = i < rdu.pointCount;

    // ワークグループ内シリアルリデュース(64スレッド → 単純ループ)。
    // workgroupBarrier + 共有変数による単一スレッド累積を使う。
    if (lidx == 0) {
      for (var j = 0u; j < 64u; j++) {
        let idx = wid.x * 64 + j;
        if (idx < rdu.pointCount) {
          let yv = rdpoints[idx].y;
          if (yv < wgMin) { wgMin = yv; }
          if (yv > wgMax) { wgMax = yv; }
        }
      }
      wgMinMax[wid.x] = vec2<f32>(wgMin, wgMax);
    }

    workgroupBarrier();

    // パス2:ワークグループ0がすべてのワークグループ結果をリデュース。
    if (wid.x == 0 && lidx == 0) {
      var gMin = 1e30;
      var gMax = -1e30;
      for (var w = 0u; w < rdu.workgroupCount; w++) {
        let mm = wgMinMax[w];
        if (mm.x < gMin) { gMin = mm.x; }
        if (mm.y > gMax) { gMax = mm.y; }
      }
      finalMinMax = vec2<f32>(gMin, gMax);
    }
  }

  // ── 共有レンダー uniform(ライン + グリッドパイプライン) ─────────────
  // ワールド座標 → キャンバスピクセル → クリップ空間をマップ。
  struct RenderUniforms {
    xMin       : f32,
    xMax       : f32,
    yMin       : f32,
    yMax       : f32,
    plotX      : f32,  // plot area origin X in canvas pixels
    plotY      : f32,  // plot area origin Y in canvas pixels
    plotW      : f32,  // plot area width in canvas pixels
    plotH      : f32,  // plot area height in canvas pixels
    canvasW    : f32,
    canvasH    : f32,
    xMajorStep : f32,
    yMajorStep : f32,
  };

  @group(0) @binding(0) var<uniform> ru : RenderUniforms;

  // ワールド → クリップ空間ヘルパー(グリッド頂点シェーダのみ使用)。
  fn worldToClip(worldX : f32, worldY : f32) -> vec4<f32> {
    let plotPx = (worldX - ru.xMin) / (ru.xMax - ru.xMin) * ru.plotW;
    let plotPy = (ru.yMax - worldY) / (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
    );
  }

  // delta → クリップ空間(ライン頂点シェーダで使用)。
  // delta は xMin からの相対時間。深いズームでの f32 精度損失を避ける
  // ため絶対 t ではなくポイントバッファに格納される。
  // xMin + delta を計算せずに delta を直接プロットピクセルにマップ。
  fn deltaToClip(delta : f32, worldY : f32) -> vec4<f32> {
    let plotPx = delta / (ru.xMax - ru.xMin) * ru.plotW;
    let plotPy = (ru.yMax - worldY) / (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
    );
  }

  // ── ラインストリップパイプライン ──────────────────────────────────────
  @group(0) @binding(1) var<storage, read> rpoints : array<vec2<f32>>;

  @vertex
  fn vs_line(@builtin(vertex_index) vi : u32) -> @builtin(position) vec4<f32> {
    let p = rpoints[vi];
    // p.x は delta(xMin からの相対時間)で、絶対 t ではない。
    return deltaToClip(p.x, p.y);
  }

  @fragment
  fn fs_line() -> @location(0) vec4<f32> {
    return vec4<f32>(0.0, 0.45, 0.75, 1.0);  // ggplot-like blue
  }

  // ── グリッドパイプライン ──────────────────────────────────────────────
  // 全画面クワッドを描画。フラグメントシェーダは以下を計算:
  //   1. ピクセルがプロット領域内かマージン内か。
  //   2. 領域内なら、fwidth() を使ったアンチエイリアス major/minor
  //      グリッドライン。
  //
  // グリッドラインはワールド座標で計算——フラグメントシェーダがピクセル
  // 位置をワールド X/Y に戻し、fract() と fwidth() で各ティックステップに
  // 1px 幅のアンチエイリアスラインを描画。mexhat フロアグリッドと
  // 同じ技術の 2D 版。

  struct GridVSOut {
    @builtin(position) clipPos : vec4<f32>,
    @location(0)       canvasPx : vec2<f32>,  // pixel coords [0,cw]×[0,ch]
  };

  @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;
  }

  // base+delta から step の倍数にある最も近いグリッドラインまでの
  // 距離(ピクセル)。base はビューポート原点(xMin または yMin)、
  // delta はその原点からのピクセルごとのオフセット(小さく、f32 全精度)。
  // fract((base+delta)/step) を fract(fract(base/step) + delta/step) に
  // 分解し、大きな base 値がピクセルごとの delta を量子化しないようにする。
  fn gridLineDistPx(base : f32, delta : f32, step : f32, pxPerWorld : f32) -> f32 {
    let baseFrac = fract(base / step);
    let p = baseFrac + delta / step;
    let frac_p = fract(p);
    let dWorld = min(frac_p, 1.0 - frac_p);
    return dWorld * pxPerWorld;
  }

  // ピクセル距離からのグリッドライン強度:ライン上で 1.0、約1px で
  // 0 にアンチエイリアス。width はラインの半幅(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);
    // プロット背景(ggplot 風オフホワイト)。
    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);
    }

    // ビューポート原点からの delta(小さく、f32 全精度)。
    // 深いズームで f32 精度を失う xMin + delta の計算を回避。
    let deltaX = (px - ru.plotX) / ru.plotW * (ru.xMax - ru.xMin);
    let deltaY = (py - ru.plotY) / ru.plotH * (ru.yMax - ru.yMin);

    // 軸ごとのワールド → ピクセルスケール。
    let pxPerWorldX = ru.plotW / (ru.xMax - ru.xMin);
    let pxPerWorldY = ru.plotH / (ru.yMax - ru.yMin);

    // 最も近いマイナー/メジャーグリッドライン(水平または垂直)までの
    // ピクセル距離。軸をまたいだ min() でいずれかの向きの
    // 最も近いラインが得られる。
    let minorD = min(gridLineDistPx(ru.xMin, deltaX, ru.xMajorStep * 0.5, pxPerWorldX),
                     gridLineDistPx(ru.yMin, deltaY, ru.yMajorStep * 0.5, pxPerWorldY));
    let majorD = min(gridLineDistPx(ru.xMin, deltaX, ru.xMajorStep, pxPerWorldX),
                     gridLineDistPx(ru.yMin, deltaY, ru.yMajorStep, pxPerWorldY));

    // 約1px 幅のライン、アンチエイリアス付き。
    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);
  }
`;

  // ─────────────────────────────────────────────────────────────────────
  // きれいなティックステップアルゴリズム(ggplot スタイル)
  // ─────────────────────────────────────────────────────────────────────
  // データ範囲 [min, max] と目標ティック数を与えて、
  // 「きれいな」ステップサイズ(1、2、5 × 10^n)を返す。

  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;
  }

  // ─────────────────────────────────────────────────────────────────────
  // キャンバスごとの初期化
  // ─────────────────────────────────────────────────────────────────────

  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 });

    // --- バインドグループレイアウト ---
    const computeLayout = device.createBindGroupLayout({
      entries: [
        { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
        { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
      ],
    });

    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' } },
      ],
    });

    // --- コンピュートパイプライン(チャープポイント生成) ---
    const computePipeline = device.createComputePipeline({
      layout: device.createPipelineLayout({ bindGroupLayouts: [computeLayout] }),
      compute: { module, entryPoint: 'cs_points' },
    });

    // --- リデュースパイプライン(オートスケール min/max) ---
    // GPU 上でチャープポイントの min/max Y を計算。結果は非同期で
    // CPU に読み戻され、Y ビューポートを調整する。
    const reduceLayout = device.createBindGroupLayout({
      entries: [
        { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
        { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
        { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
        { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
      ],
    });
    const reducePipeline = device.createComputePipeline({
      layout: device.createPipelineLayout({ bindGroupLayouts: [reduceLayout] }),
      compute: { module, entryPoint: 'cs_reduce' },
    });

    // リデュースバッファ。
    // wgMinMax:ワークグループごとに1つの vec2(16384ポイントで
    // 最大256ワークグループ)。
    // finalMinMax:単一 vec2 (minY, maxY) — リデュース結果。
    // readbackBuffer:finalMinMax のコピー先、CPU 読み取り用にマップ。
    const maxWorkgroups = Math.ceil(MAX_POINTS / 64);
    const reduceUniforms = device.createBuffer({
      size: 16,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
    const wgMinMaxBuffer = device.createBuffer({
      size: maxWorkgroups * 8,
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    });
    const finalMinMaxBuffer = device.createBuffer({
      size: 8,
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
    });
    const readbackBuffer = device.createBuffer({
      size: 8,
      usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
    });
    // reduceBindGroup は pointBuffer の後に作成(後述)。

    // --- MSAA プローブ + レンダーパイプライン ---
    // すべてのサンプル数がすべてのアダプタでサポートされるわけではない。
    // WebGPU 仕様が保証するのは 1× と 4× のみ;2×、8×、16× は
    // アダプタ依存。createRenderPipelineAsync でパイプラインを作成して
    // プローブする——サンプル数が未サポートなら Promise が reject
    // するのでそのサンプル数をスキップ。
    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 {
        // サポートされないサンプル数 — スキップ。
      }
    }
    // 4× MSAA が使えればそれを使い、なければ4未満の最も高いサポート数を
    // 使う(高 DPI ディスプレイでの 8×/16× の大きなコストを回避)。
    const TARGET_SC = 4;
    let msaaIndex = supportedSC.length - 1;
    const targetIdx = supportedSC.indexOf(TARGET_SC);
    if (targetIdx !== -1) msaaIndex = targetIdx;

    // --- バッファ ---
    const computeUniforms = device.createBuffer({
      size: 32,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });

    const renderUniforms = device.createBuffer({
      size: 48,
      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: computeLayout,
      entries: [
        { binding: 0, resource: { buffer: computeUniforms } },
        { binding: 1, resource: { buffer: pointBuffer } },
      ],
    });

    const lineBindGroup = device.createBindGroup({
      layout: renderLayout,
      entries: [
        { binding: 0, resource: { buffer: renderUniforms } },
        { binding: 1, resource: { buffer: pointBuffer } },
      ],
    });

    const gridBindGroup = device.createBindGroup({
      layout: renderLayout,
      entries: [
        { binding: 0, resource: { buffer: renderUniforms } },
        { binding: 1, resource: { buffer: pointBuffer } },
      ],
    });

    // リデュースバインドグループ(pointBuffer が必要なのでここで作成)。
    const reduceBindGroup = device.createBindGroup({
      layout: reduceLayout,
      entries: [
        { binding: 0, resource: { buffer: reduceUniforms } },
        { binding: 1, resource: { buffer: pointBuffer } },
        { binding: 2, resource: { buffer: wgMinMaxBuffer } },
        { binding: 3, resource: { buffer: finalMinMaxBuffer } },
      ],
    });

    // --- MSAA テクスチャ ---
    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 キャンバスオーバーレイ ---
    // WebGPU キャンバスの上に配置する兄弟 <canvas>。
    // 軸ティックラベルとプロットボーダーを Canvas 2D API で描画——
    // WebGPU でテキストを描くより単純で、任意の DPR で鮮明。
    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';
    // オーバーレイを重ねるには WebGPU キャンバスが position:relative 必須。
    canvas.style.position = canvas.style.position || 'relative';
    canvas.parentElement.appendChild(overlay);
    const octx = overlay.getContext('2d');

    // --- ビューポート状態 ---
    let xMin = X_MIN_INIT, xMax = X_MAX_INIT;
    let yMin = -AMPLITUDE * 1.1, yMax = AMPLITUDE * 1.1;

    // --- インタラクション:スクロールズーム、ドラッグパン ---
    // スクロール:カーソル位置方向にズーム(カーソル下のワールド
    //   ポイントは固定)。ズームは X のみに影響;Y はオートスケール
    //   (または垂直ドラッグしたユーザー)が制御。
    // ドラッグ:X・Y 両方向にパン。垂直ドラッグはオートスケールを
    //   無効化(ユーザーが手動で Y を制御)。ダブルクリックで
    //   オートスケールを再有効化。
    let dragging = false, lastX = 0, lastY = 0;

    // キャンバス CSS ピクセル X をワールド X に変換。
    function cssXToWorldX(cssX) {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const ml = MARGIN_LEFT * dpr;
      const mr = MARGIN_RIGHT * dpr;
      const pw = canvas.width - ml - mr;
      const px = cssX * dpr - ml;
      return xMin + px / pw * (xMax - xMin);
    }

    canvas.style.cursor = 'crosshair';
    canvas.style.touchAction = 'none';

    canvas.addEventListener('pointerdown', (e) => {
      dragging = true; lastX = e.clientX; lastY = e.clientY;
      canvas.setPointerCapture(e.pointerId);
      canvas.style.cursor = 'grabbing';
    });
    canvas.addEventListener('pointerup', () => {
      dragging = false; canvas.style.cursor = 'crosshair';
    });
    canvas.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const ml = MARGIN_LEFT * dpr, mr = MARGIN_RIGHT * dpr;
      const mt = MARGIN_TOP * dpr, mb = MARGIN_BOTTOM * dpr;
      const pw = canvas.width - ml - mr;
      const ph = canvas.height - mt - mb;
      const dx = (e.clientX - lastX) * dpr;
      const dy = (e.clientY - lastY) * dpr;
      lastX = e.clientX; lastY = e.clientY;

      // X パン:ピクセルデルタをワールドデルタに変換。
      const xRange = xMax - xMin;
      const xDelta = -dx / pw * xRange;
      xMin += xDelta; xMax += xDelta;

      // Y パン:ピクセルデルタをワールドデルタに変換(反転 — 下ドラッグ=上)。
      const yRange = yMax - yMin;
      const yDelta = dy / ph * yRange;
      yMin += yDelta; yMax += yDelta;

      // ユーザーが手動で Y を制御 → オートスケールを無効化。
      autoscaleEnabled = false;
    });

    canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const ml = MARGIN_LEFT * dpr, mr = MARGIN_RIGHT * dpr;
      const pw = canvas.width - ml - mr;
      const rect = canvas.getBoundingClientRect();
      const cssX = e.clientX - rect.left;
      const worldX = cssXToWorldX(cssX);
      const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
      // カーソル方向ズーム:worldX を固定に保つ。
      xMin = worldX - (worldX - xMin) * factor;
      xMax = worldX + (xMax - worldX) * factor;
      // ズーム時にオートスケールを再有効化(Y が新しい X 範囲に適応)。
      autoscaleEnabled = true;
    }, { passive: false });

    // ダブルクリック:初期ビューにリセット + オートスケールを再有効化。
    canvas.addEventListener('dblclick', () => {
      xMin = X_MIN_INIT; xMax = X_MAX_INIT;
      yMin = -AMPLITUDE * 1.1; yMax = AMPLITUDE * 1.1;
      autoscaleEnabled = true;
    });

    // --- オートスケール状態 ---
    // GPU リデュースは非同期で実行。リデュースコンピュート + コピーを
    // 送信し、読み戻しバッファをマップ。マップが完了すると
    // yMin/yMax を10% マージンで更新。前の読み戻しが完了したときのみ
    // 新しいリデュースを送信(キューイングなし)。
    let autoscalePending = false;
    let autoscaleEnabled = true;

    function submitAutoscale(pointCount) {
      if (autoscalePending || !autoscaleEnabled) return;
      autoscalePending = true;

      const wgCount = Math.ceil(pointCount / 64);
      const rduData = new ArrayBuffer(16);
      const rduView = new DataView(rduData);
      rduView.setUint32(0, pointCount, true);
      rduView.setUint32(4, wgCount, true);
      rduView.setUint32(8, 0, true);
      rduView.setUint32(12, 0, true);
      device.queue.writeBuffer(reduceUniforms, 0, rduData);

      const enc = device.createCommandEncoder();
      const rp = enc.beginComputePass();
      rp.setPipeline(reducePipeline);
      rp.setBindGroup(0, reduceBindGroup);
      rp.dispatchWorkgroups(wgCount);
      rp.end();
      enc.copyBufferToBuffer(finalMinMaxBuffer, 0, readbackBuffer, 0, 8);
      device.queue.submit([enc.finish()]);

      // 読み取り用にマップ。完了時に yMin/yMax を更新。
      readbackBuffer.mapAsync(GPUMapMode.READ).then(() => {
        const arr = new Float32Array(readbackBuffer.getMappedRange(0, 8));
        const min = arr[0], max = arr[1];
        readbackBuffer.unmap();
        autoscalePending = false;
        if (min <= max && isFinite(min) && isFinite(max)) {
          const range = max - min;
          const margin = range > 0 ? range * 0.1 : Math.abs(max) * 0.1 + 0.1;
          yMin = min - margin;
          yMax = max + margin;
        }
      }).catch(() => { autoscalePending = false; });
    }

    // --- 軸ラベル描画(Canvas 2D オーバーレイ) ---
    // 2D オーバーレイキャンバスにティックラベルとプロットボーダーを描画。
    // WebGPU レンダー後の毎フレーム呼ばれる。
    function drawAxes(cssW, cssH, dpr) {
      const cw = Math.round(cssW * dpr);
      const ch = Math.round(cssH * dpr);
      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;

      // プロット軸(ggplot スタイル:下と左の軸線のみ、
      // 上/右のボーダーなし、外向きの小さなティックマーク)。
      octx.strokeStyle = '#333';
      octx.lineWidth = 1;
      octx.beginPath();
      // 左軸。
      octx.moveTo(px, py);
      octx.lineTo(px, py + ph);
      // 下軸。
      octx.lineTo(px + pw, py + ph);
      octx.stroke();

      // ティックマーク。
      octx.font = '11px sans-serif';
      octx.fillStyle = '#333';

      // X 軸ティックラベル(下)— xMin からの相対時刻、
      // ステップサイズに基づき s / ms / µs / ns に自動スケール。
      const xStep = niceStep(xMax - xMin, 8);
      const xStart = Math.ceil(xMin / xStep) * xStep;
      octx.textAlign = 'center';
      octx.textBaseline = 'top';
      for (let x = xStart; x <= xMax + xStep * 0.001; x += xStep) {
        const sx = px + (x - xMin) / (xMax - xMin) * pw;
        // ラベルが外に溢れないようプロット領域にクリップ。
        if (sx < px - 1 || sx > px + pw + 1) continue;
        // 外向きティックマーク。
        octx.beginPath();
        octx.moveTo(sx, py + ph);
        octx.lineTo(sx, py + ph + 4);
        octx.stroke();
        octx.fillText(formatTimeTick(x - xMin, xStep), sx, py + ph + 8);
      }

      // Y 軸ティックラベル(左)。
      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;
        // 外向きティックマーク。
        octx.beginPath();
        octx.moveTo(px, sy);
        octx.lineTo(px - 4, sy);
        octx.stroke();
        octx.fillText(formatTick(y), px - 8, sy);
      }

      // 軸タイトル。
      octx.font = '13px sans-serif';
      octx.fillStyle = '#222';
      // X タイトル(ティックの下に中央寄せ)。
      octx.textAlign = 'center';
      octx.textBaseline = 'bottom';
      octx.fillText('t (relative)', ml + pw / 2, cssH - 4);
      // Y タイトル(回転、ティックの左)。
      octx.save();
      octx.translate(12, mt + ph / 2);
      octx.rotate(-Math.PI / 2);
      octx.textAlign = 'center';
      octx.textBaseline = 'top';
      octx.fillText('y', 0, 0);
      octx.restore();

      octx.restore();
    }

    // ティック値のフォーマット:ステップサイズに十分な小数桁を表示。
    function formatTick(v) {
      const abs = Math.abs(v);
      if (abs === 0) return '0';
      if (abs >= 1000 || abs < 1e-3) return v.toExponential(1);
      // 最大3桁の小数を持つ固定表記、末尾のゼロを除去。
      let s = v.toFixed(3);
      // 末尾のゼロと末尾の小数点を除去。
      s = s.replace(/0+$/, '').replace(/\.$/, '');
      return s;
    }

    // 時間ティック(秒)の自動単位選択フォーマット。
    // その単位で step >= 1 となる最大の単位を選び、ラベルが
    // 短く保たれるようにする(例:"0.001 s" ではなく "1 ms")。
    function formatTimeTick(delta, step) {
      let unit, scale;
      if (step >= 1)        { unit = 's';  scale = 1; }
      else if (step >= 1e-3) { unit = 'ms'; scale = 1e3; }
      else if (step >= 1e-6) { unit = 'µs'; scale = 1e6; }
      else                   { unit = 'ns'; scale = 1e9; }

      const stepInUnit = step * scale;
      let decimals = 0;
      if (stepInUnit < 1)   decimals = 1;
      if (stepInUnit < 0.1) decimals = 2;
      if (stepInUnit < 0.01) decimals = 3;

      let s = (delta * scale).toFixed(decimals);
      if (decimals > 0) s = s.replace(/0+$/, '').replace(/\.$/, '');
      return s + ' ' + unit;
    }

    // --- レンダーループ ---
    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;
      }

      // キャンバスピクセルでのプロット領域(DPR スケールのマージンを考慮)。
      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 pointCount = Math.min(MAX_POINTS, Math.max(2, Math.floor(plotW * OVERSAMPLE)));
      const sc = supportedSC[msaaIndex];

      // きれいなティックステップを計算。
      const xRange = xMax - xMin;
      const yRange = yMax - yMin;
      const xMajorStep = niceStep(xRange, 8);
      const yMajorStep = niceStep(yRange, 6);

      // コンピュート uniform を書き込み。
      const cuData = new ArrayBuffer(32);
      const cuView = new DataView(cuData);
      cuView.setUint32(0, pointCount, true);
      cuView.setUint32(4, 0, true);
      cuView.setFloat32(8, xMin, true);
      cuView.setFloat32(12, xMax, true);
      cuView.setFloat32(16, CHIRP_F0, true);
      cuView.setFloat32(20, CHIRP_K, true);
      cuView.setFloat32(24, AMPLITUDE, true);
      cuView.setFloat32(28, 0, true);
      device.queue.writeBuffer(computeUniforms, 0, cuData);

      // レンダー uniform を書き込み。
      const ruData = new Float32Array(12);
      ruData[0] = xMin; ruData[1] = xMax;
      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);

      ensureMsaa(cw, ch, sc);

      const encoder = device.createCommandEncoder();

      // コンピュートパス — GPU 上でチャープポイントを生成。
      const cp = encoder.beginComputePass();
      cp.setPipeline(computePipeline);
      cp.setBindGroup(0, computeBindGroup);
      cp.dispatchWorkgroups(Math.ceil(pointCount / 64));
      cp.end();

      // レンダーパス — グリッド(背景)+ ラインストリップ(チャープ)。
      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) グリッド(背景)
      rp.setPipeline(gridPipelines[msaaIndex]);
      rp.setBindGroup(0, gridBindGroup);
      rp.draw(6);

      // 2) ラインストリップ(チャープ)
      rp.setPipeline(linePipelines[msaaIndex]);
      rp.setBindGroup(0, lineBindGroup);
      rp.draw(pointCount);

      rp.end();
      device.queue.submit([encoder.finish()]);

      // オートスケールリデュースを送信(非同期 — 読み戻し完了時に yMin/yMax を更新)。
      submitAutoscale(pointCount);

      // 2D オーバーレイに軸ラベルを描画。
      drawAxes(canvas.clientWidth, canvas.clientHeight, dpr);

      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
  }

  // ─────────────────────────────────────────────────────────────────────
  // 自動初期化
  // ─────────────────────────────────────────────────────────────────────
  function initAll() {
    document.querySelectorAll('canvas.chirp-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 techniques from the sine-wave, MSAA, and Mexican hat posts. The GPU autoscale reduce is a standard parallel reduction pattern — the same approach is used by the ToolpathRenderer in the WebGCodeViewer project for computing bounding boxes of toolpath segments.


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