文字にカーソルを近づけると粒子がほどけ、離すと元に戻る。ボタンを押すと、同じ6,000個の粒子が銀河やハートに集まります。

WebGPUを使って、この小さなインタラクションを作りました。面白かったのは、形を変えるたびに粒子を作り直さなくても、行き先を差し替えるだけで変形がつながることです。

動画はブラウザ上で動くデモを20秒録画したものです。文字をカーソルで崩す → 銀河 → ハート → 文字、の順に切り替わります。録画中のカーソル移動と切り替えは自動操作です。生成映像ではありません。音声はありません。

→ 粒子デモを開いてさわる

対応環境では、マウスや指を動かして粒子を押しのけられます。横のボタンで形を変更でき、「一時停止」で動きを止められます。非対応環境でも上の動画で結果を見られます。

WebGPUを試すきっかけ

海外のコミュニティでは、WebGPUで大量の粒子を動かす制作例が共有されています。2026年4月のRedditの投稿もその一つです。そこで示された粒子数や速度は今回のデモの検証結果ではないので、比較には使っていません。

公式のweb.devによる対応状況の紹介は2025年11月25日公開です。「今日対応した」というニュースではありませんが、ブラウザで描画だけでなく計算にもGPUを使う、という入口として参考になります。2026年9月18日にGPU for the Webの実装状況も再確認しました。

今回はAIモデルの実行ではなく、フロントエンドの表現として試しています。WebGPUを使えば必ず速くなる、と言うためのベンチマークでもありません。

粒子に「現在地」と「行き先」を持たせる

各粒子が持つのは位置・速度・色のための値・サイズ。別の配列に目標座標を用意します。

文字の目標座標は、Canvas 2Dに「YABI LABS」と描き、透明でないピクセルを拾って作りました。銀河とハートは数式から座標を作っています。この初期生成と形の変更はJavaScript側の担当です。

形を切り替える処理の中心はこれだけです。

function shape(next) {
  mode = next;
  device.queue.writeBuffer(targets, 0, points(mode));
  // ボタンのaria-pressedも更新する
}

points()は6,000組の座標を返します。今の位置や速度はリセットせず、目標だけをGPUのバッファに渡します。そのため「別の絵に切り替わる」というより、粒子が次の居場所へ移動する見え方になります。

ばねで戻り、カーソルから逃げる

毎フレームの更新はWGSLのcompute shaderで行います。目標までの距離に比例して引き戻し、カーソルが近いときだけ外向きの力を足します。以下は実装の中心部分です。

let delta = goal[i] + wave - p.pos;
var force = delta * 0.018;
let away = p.pos - u.mouse;
let d = length(away);
if (u.engaged > 0.5 && d < 110.) {
  force += normalize(away + vec2f(0.01)) * (1. - d / 110.) * 5.5;
}
let dt = clamp(u.dt, 0.1, 2.);
p.vel = (p.vel + force * dt) * pow(0.84, dt);
p.pos += p.vel * dt;
ps[i] = p;

0.018は戻る力、0.84は速度の減衰です。ここを変えると、落ち着くまでの揺れ方も変わります。今回は小さな波のずれも加えています。

dtは約16.667ミリ秒を基準にした経過時間です。大きく時間が飛んだときに一気に移動しないよう上限を設けています。ただし固定時間刻みの物理シミュレーションではなく、端末ごとに完全に同じ軌跡になる設計ではありません。

ワークグループは64個単位で実行し、ceil(6000 / 64)組を呼び出します。余った呼び出しが配列の外を読まないよう、先頭でi >= 6000uなら終了しています。

計算結果をJavaScriptに戻さず描画する

位置と速度を保存するstorage bufferをcompute shaderが更新し、同じバッファをvertex shaderが読みます。毎フレーム6,000個の位置をJavaScriptへ読み戻す処理は入れていません。

const cp = encoder.beginComputePass();
cp.setPipeline(compute);
cp.setBindGroup(0, computeGroup);
cp.dispatchWorkgroups(Math.ceil(N / 64));
cp.end();

// 続くrender passで同じ粒子バッファを読む
rp.setPipeline(render);
rp.setBindGroup(0, renderGroup);
rp.draw(6, N);

描画は粒子ごとに四角形を1枚、合計6頂点ずつ。fragment shaderで中心から離れるほど透明にして、丸い光の粒に見せています。

なお、動画に説明テキストを重ねるため、WebGPUの描画結果をCanvas 2Dに合成しています。GPUだけで最終画面まで完結する構成ではありません。パフォーマンスを詰めるなら、この合成も計測対象にする必要があります。

実際につまずいたWGSLの名前とデータ配置

最初はカーソルが有効かどうかを表す名前にactiveを使い、WGSLのコンパイルで止まりました。予約語との衝突を避けてengagedに変更しています。

画面が出ないときに位置計算を疑い続けるより、getCompilationInfo()でshaderのエラーを表示できるようにしておく方が原因を追いやすくなりました。

もう一つ気をつけたのが、JavaScriptとWGSLでのデータ配置です。今回の粒子構造体は1個32バイト。uniformはvec3fのアラインメントと構造体末尾の余白を含めて48バイト確保しています。単純に「書いた数値の個数×4バイト」とすると、shaderが読む位置とずれることがあります。

詳しいルールはWGSL仕様のメモリレイアウトを参照してください。今回のデモのJavaScriptソースにも構造体定義とバッファ作成処理をまとめています。

対応環境と、今回確認した範囲

ブラウザ名だけで動作を保証することはできません。OS・GPU・ドライバなどにも依存し、WebGPUにはHTTPSなどの安全なコンテキストが必要です。ローカル開発ではlocalhostを使えます。

デモはnavigator.gpuの有無に加え、requestAdapter()でGPUが取得できるか確認します。起動できないときは案内を表示します。OSで動きを抑える設定が有効な場合は、起動後に一時停止するようにしました。

制作時の実ブラウザで、文字・銀河・ハートの切り替えと20秒の録画を確認しています。FPS表示はその時点の描画ループの目安であり、GPU単体の処理速度や全端末の性能を示すものではありません。

今回の学びは、派手な絵を作ること以上に、同じデータを計算と描画で使い、行き先だけを変えると表現を増やしやすいという点でした。まず少ない要素で仕組みを作り、操作したときの動きを調整する順番がよさそうです。

小さなUIの操作感に関心があれば、ゲーム風の選択部品「Pocket Choice」もどうぞ。