pointer-trail-emitter について
ポインターの移動距離に応じて間隔を決める、ポインター連動パーティクルトレイルのデザインスキルです。配置、漂い、描画上の制約、入力アクセシビリティ、モーション軽減を扱います。
主な機能
- 01移動距離に基づく粒子放出
- 02軌跡に沿った均等配置
- 03エミッターの遅れと粒子の漂い
- 04大きな移動時の生成数制限
- 05キーボードとタッチ入力への配慮
- 06モーション軽減用の静止画
活用シーン
- 01ポインター速度を問わず連続した軌跡を作成
- 02急な移動による途切れや集中を防止
- 03シーン内の3D効果に漂う粒子を追加
- 04アクセシビリティとモーション軽減に対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mengto/ skills --skill pointer-trail-emitterインストール後、Claude Code で/pointer-trail-emitterを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamldemo/index.htmlPROMPT.md詳細
Pointer Trail Emitter
手の動きの速さに応じてトレイルの密度を変える必要がある場合は、エミッターを自分で構築します。
Shaders ライブラリのパッケージ化された WebGPU の見た目を使いたい場合は、add-shader-cursor-trail または shaders-cursor-ripples を選びます。カーソルでマスク越しに 2 枚目の画像を表示したい場合は、reveal-hover-effect を選びます。セクションを粒子で満たし、ポインターはそれを乱すだけにしたい場合は、ambient-section-particles を選びます。ポインター自体に粒子を描かせる場合は、こちらを選びます。
同梱のデモでは、ステージを意図的にニュートラルにしています。無地の暗い領域なら、背景画像がトレイルと競合することなく、間隔、散らばり、漂いを簡単に見極められます。ウィスプは依存関係のない Vanilla JavaScript で、Canvas 2D API を通じて描画されます。CSS はインターフェースのスタイル設定にのみ使います。シェーダー、WebGL、Three.js は使いません。エミッターを構成に埋め込まず、テスト可能な状態に保つため、ライブキャンバスはインターフェースと分離してください。
時間ではなく距離に応じて生成する
仕組みはこれだけです。エミッターが移動した距離を蓄積し、一定の間隔で生成します。
E.acc += moved;
let guard = 0;
while (E.acc >= STEP && guard++ < 14) {
E.acc -= STEP;
spawn(/* … */);
}
こうすると、手の動きにかかわらず、経路に沿った間隔は STEP になります。そのため、ゆっくり動かしても素早く動かしても、トレイルは連続したリボンのように見えます。
代わりに生成をタイマーに連動させると、間隔は速度に比例します。ポインターが生成の間に移動する距離は speed × interval です。素早く動かすと線が途切れて点々になり、手を止めると粒子がすべて同じ場所に積み重なります。 これを防ぐための仕組みです。一度切り替えを作って、この違いを確かめる価値があります。
同じ一定の経路で計測すると、距離に応じた生成では、ゆっくり動かしたときに 1885 個、速く動かしたときに 1738 個の粒子が配置され、差は 1.08 倍でした。数は経路の長さに応じます。同じ 2 回の動きをタイマー方式で行うと、2537 個と 1545 個が配置され、差は 1.64 倍でした。数は時計に応じて変わります。
ループには上限を設けます。ウィンドウのフォーカスが外れたり、タブが復帰したり、ポインターが瞬間移動したりすると、非常に大きな moved が一度に発生することがあります。上限がないと、その 1 フレームで何千もの粒子が生成され、処理が停止します。
粒子を本来あるべき位置に配置する
フレーム内の粒子をすべてポインターの現在位置に生成すると、線分の片端に固まります。素早く動かすと、後方に隙間のある塊のように見えます。各粒子を線分上のそれぞれの距離に配置します。
const t = moved > 1e-6 ? Math.min(1, guard * STEP / moved) : 0;
spawn(E.lx + dx * t, E.ly + dy * t, ang);
進める前にリングバッファのスロットを取得する
const i = E.i; E.i = (i + 1) % N; // correct
先にインデックスを進めると、位置は次のスロットに、寿命は現在のスロットに書き込まれます。そのため、すべての粒子が直前の粒子の開始位置に現れます。 密度の高いトレイルでは目立ちませんが、密度が低いと、生成のたびに現れます。見分けるポイントは、粒子がカーソルの一歩後ろに見え、フェードインではなく突然現れることです。
エミッターをポインターの後ろで遅らせる
エミッターをポインターに固定するのではなく、滑らかに追従させます。
E.x = damp(E.x, px, 16, dt);
エミッターを固定すると、素早く動かしたときに、トレイルがカーソルに溶接されたように見えます。遅れがあることで、漂いにゆとりが生まれます。
トレイルをワールドではなく画面に固定する
シーン内の 3D トレイルでは、点をカメラの子にして、カメラ空間で処理します。ポインターを、フラスタム自身の半分の高さを使って変換します。
const hh = Math.tan(camera.fov * Math.PI / 360) * D;
const x = nx * hh * camera.aspect, y = ny * hh;
代わりにワールド平面へ逆投影すると、トレイルはセットに固定されます。リグが動いたり視差が生じたりすると、トレイルは手の下に留まらず、画面上を泳ぐように動きます。
深度を無視するクワッドまたはポイント(depthTest:false、depthWrite:false)を使い、独自の描画順序を設定します。シーンにミラーや反射プローブなどの追加パスがある場合は、トレイルを独自のレイヤーに配置して、そうしたパスには決して表示されないようにします。
トレイルが置かれる平面に合わせて散らばりをスケーリングする
散らばりは絶対値だけでは意味がありません。36° のカメラから 3.4 単位離れた位置では、トレイルが置かれる平面の高さはわずか約 2.2 単位です。そのため、±0.03 単位の揺らぎは漂いではなく、カーソルに縫い付けられた糸のようになります。
平面の範囲を計算し、散らばりをその割合で表します。あるカメラでは柔らかな雲のように見える同じ値が、別のカメラでは硬い線に見えることがあります。
流れに任せる
初期速度よりも減衰のほうが重要です。1 - 1.1 * dtでは、すべての粒子が生成位置から0.1ユニット以内で停止し、軌跡が広がりません。これを半分にすると、散らばりが維持されます。
ゆっくりとしたカールを加えて、ドリフトが一直線に進むのではなく、ほつれるようにします。また、投げられたものではなく浮力のあるもののように振る舞うよう、わずかな一定の上昇を加えます。
丸い粒子に不要なものは削除する
丸いスプライトに向きはありません。パーティクルごとの角度属性と、回転したgl_PointCoordの参照処理を、ゼロのまま残すのではなく、完全に削除します。見えない回転のために、属性1つ、アップロード1回、フラグメントごとの命令をいくつも使う必要はありません。
粒子は小さく保ちます。かすかなハローの内側に、数ピクセルのコアを置きます。スプライトが小さいからこそ、画面いっぱいに大きなスプライトを描く場合のような加算描画コストをかけずに、数を増やせます。
手が止まっていても、呼吸を保つ
距離に基づく発生では、ポインターが静止していると移動距離がないため、何も発生せず、静止した手の下で軌跡が消えてしまいます。そのため、このケースだけを対象に、タイマーでゆっくりとしたアイドル発生を追加します。
重要なのは「まれに」です。約0.4秒に1個です。静止したポインターから頻繁に発生させると、フレーム中央に煙の柱が恒久的に伸びていきます。これは、この仕組みで防ごうとしているタイマーの問題を、手作業で再導入することになります。
数値
高さ約2.2ユニットの平面上で、36°カメラから3.4ユニットぶら下がる軌跡に合わせて調整しています。空間値は、ご使用の平面の大きさに応じてスケールしてください。
| パラメーター | 値 | 備考 |
|---|---|---|
| 発生間隔 | 0.030ユニット | 発生位置間の距離 |
| フレームあたりの最大発生数 | 14 | テレポート防止 |
| エミッターの減衰 | damp(…, 16, dt) | ポインターに遅れて追従 |
| 散乱 | ±0.30ユニット | 平面の高さの約13% |
| 奥行きのジッター | ±0.45ユニット | 平らな層状表示を崩す |
| 寿命 | 1.45~2.75 s | アイドル粒子は2.1~3.4 s |
| 初速 | 移動方向に沿って−0.09、横方向に±0.19 | 移動方向とは逆向き |
| 流れの減衰 | 1 − 0.5 * dt | 1.1から半減。上記を参照 |
| 浮力 | +0.022 · dt | |
| カール | sin(t·1.3 + φ)·0.17、cos(t·1.1 + 1.7φ)·0.14 | 粒子ごとの位相φ |
| サイズ | 0.018~0.050、×(1 + 0.55u) | 粒子は柔らかくなりますが、膨らみません |
| 不透明度 | u 0~0.12でフェードイン、0.22~1でフェードアウト、×0.9 | |
| 数 | デスクトップでは190、低ティアでは90 | |
| アイドル発生 | 0.42秒ごと |
z-indexを上げるためにDOMオーバーレイへ移さない
WebGLキャンバス内の要素がページより前面に出ることはありません。キャンバスは独自のスタッキング階層にある1つの要素です。そのため、レイヤーを実現するには2Dオーバーレイキャンバスしかないように思えます。確かにそうですが、それでも移行する価値はありません。
移行すると、ポスト処理チェーンが失われます。粒子はブルームのない硬い点になり、より大きく淡いコピーを重ねても同じ効果にはなりません。また、定数はすべて再選定ではなく換算する必要があります。px_per_unit = (innerHeight / 2) / (tan(fov / 2) * D)、スプライトの直径はinnerHeight * size / Dです。換算する代わりに目視で見た目を作り直すと、別のエフェクトになり、再承認が必要になります。
このレイヤーが本当に必要なら、純粋な移植として実装し、誰かに見せる前に旧ビルドとフレームを比較してください。
プロファイルからコストを把握する
粒子ごとの更新コストは無視できるほど小さいものです。190粒子の軌跡をCPUプロファイルで確認したところ、更新処理は**サンプルの0.00%**でした。プロファイラーのサンプリング下限を下回っています。コストはすべて加算描画にあるため、調整するのはスプライトのサイズと数で、優先順位もこの順です。
回帰を報告する前に測定してください。この軌跡でフレーム時間を比較した際、かつてp90が20~30%上昇したことがありましたが、実際にはノイズでした。同一コードを3回実行した結果は226 / 374 / 243 msでした。確信する前に、複数回実行してください。
ライフサイクルと動きを減らす設定
matchMedia('(hover: none)')で判定します。タッチ操作では追従するホバー位置がないため、発生器を停止位置に置くか、ドラッグ中のみpointermoveから動かします。停止した発生器は、永久にプルームを成長させてしまいます。prefers-reduced-motion: reduceの場合は、非表示にするのではなく、フレーム全体にあらかじめ軌跡を配置したデザイン済みの静止画を描画します。コントロールは操作可能なままにし、その静止画を再描画できるようにします。document.hiddenの間は一時停止し、再開時に時間の基準をリセットして、dtを約 1/30 秒に制限し、DPR の上限を 2 にします。- ポインター操作だけに依存してはいけません。エフェクトを完全に操作できるよう、マウスを使わずに操作できるキーボード入力経路を用意します。
確認
- ポインターを動かす前から、中立的な背景上で軌跡を判別できる
- エフェクトを完成させて見せるために、背景画像に依存していない
- 経路上の間隔が一定で、素早く動かしてもゆっくり動かしても、同じリボンが描かれる
- 推測ではなく実測で確認する:一定の経路上でのモート数が速度によってほとんど変わらない
- 素早く動かしても、モートが一端に固まらず、セグメント全体に配置される
- インデックスを進める前に、リングバッファのスロットを取得する
- 3D:カメラに親子付けされ、リグが移動しても軌跡が手元に留まる
- 散乱量を絶対値ではなく、平面の範囲に対する値で指定する
- モートが発生位置のごく近くで止まらず、惰性で進む
- ポインターが停止してもオーラを保ち、柱状に成長しない
- 円形スプライトにパーティクルごとの角度属性を使わない
- ポインターが瞬間移動した場合に備え、生成ループに上限を設ける
- キーボードで操作でき、タッチ操作でプルームが発生しない
- 動きを減らす設定では、非表示のレイヤーではなく、デザイン済みの静止画を描画する
- コストに関する主張はプロファイル計測に基づいており、報告前に回帰を再実行している