正弦波の記事 では単純なラインプロットの 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 位相分解)を組み合わせている:
- GPU 側関数評価 — チャープは CPU ではなくコンピュートシェーダで評価 (正弦波の記事)
- MSAA アンチエイリアシング — ラインストリップはハードウェア マルチサンプリングで描画、初期化時にサンプル数をプローブ (MSAA の記事)
- fwidth ベース動的グリッド — グリッドラインはフラグメント シェーダでスクリーン空間微分を使いアンチエイリアスされ、 ズームしても量子化しない (メキシカンハットの記事)
- 2D キャンバスオーバーレイ — 軸ティックラベルとプロットボーダーは WebGPU キャンバスの上に配置された兄弟 2D キャンバスに描画
- GPU オートスケール — 並列 min/max リデュースでチャープポイントから Y 範囲を計算し、非同期で CPU に読み戻す
- 無限ズーム — サンプル数はキャンバス幅に比例(ビューポート範囲では なく)、ズームインで常により詳細が見える
- f32 位相分解 — チャープ位相を大きな定数部と小さなサンプルごとの 増分に分割し、正弦加算公式で評価。f32 量子化なしに任意のズーム レベルで正弦が滑らかに保たれる
GPU 側チャープ評価
コンピュートシェーダがストレージバッファを vec2<f32> ポイントで満たす。
ポイント数はビューポート範囲ではなく、キャンバス幅 × オーバーサンプリング
係数(デフォルト2倍)に比例。つまりズームレベルに関係なくサンプル密度は
常に約2サンプル/ピクセル——サンプル数を増やさずにズームインで
チャープのより多くのサイクルが見える:
@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 を加算せずに
頂点シェーダでクリップ空間にマップすること:
$\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 ラインが得られる:
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 で鮮明でテキストをネイティブに扱う:
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 s | s | 0 s, 2 s, 5 s |
| 1 ms – 1 s | ms | 0 ms, 500 ms |
| 1 µs – 1 ms | µs | 0 µs, 200 µs |
| < 1 µs | ns | 0 ns, 100 ns |
これにより任意のズームレベルでラベルが短く保たれる——0.001 s ではなく
1 ms、0.0005 s ではなく 500 µs——単位接頭辞でスケールが
すぐ読み取れる。
並列 min/max リデュースによる GPU オートスケール
Y ビューポートは可視チャープデータに合わせて自動調整される。 これは GPU 上の2パス並列リデュースで計算:
- パス1:各ワークグループ(64スレッド)がポイントバッファの 自分のスライスでローカル min/max Y を計算し、ワークグループ バッファに格納。
- パス2:ワークグループ0がすべてのワークグループ結果を読み、
グローバル min/max を計算して単一の
vec2<f32>に格納。
結果は MAP_READ バッファにコピーされ、非同期で CPU に読み戻される。
読み戻しが完了すると、yMin/yMax が10% マージンで更新される:
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 を調整して実装:
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 クラスのキャンバスを追加:
<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 定数を編集。
完全なソース
// 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.