Canvasを用いたマウスストーカーの技術解説
本稿では、JavaScriptとHTML5のCanvas APIを用いて実装された、マウスカーソルの動きに追従する動的なパーティクルエフェクト(通称:マウスストーカー)のコードを解剖し、その構造と設計思想について詳細に解説する。
1. 全体像と処理の流れ
このプログラムは、画面全体を覆う透明な描画領域(Canvas)を動的に生成し、マウスの移動を検知して光る粒子(パーティクル)を発生させ、それらを物理演算風に動かしながら徐々に消滅させるという一連の処理をアニメーションループによって実現している。
大まかな処理の流れは以下の通りである。
- 二重起動の防止と初期化タイミングの制御
- Canvas要素の動的生成とCSSによる画面全体への配置
- ウィンドウのリサイズに応じた描画領域の最適化
- マウス移動時のパーティクルデータの生成と蓄積
- 描画ループ(アニメーションフレーム)による座標更新と描画・削除
2. 各コードブロックの詳細解説と「なぜそう記述するのか」
コードを機能単位に分割し、具体的な記述内容とその背後にある技術的理由(なぜそのように書くのか)を紹介する。
2.1. 即時実行関数(IIFE)と二重起動防止
(function () {
if (document.getElementById('cyber-cursor-canvas')) return;
window.addEventListener('DOMContentLoaded', () => {
// ...
});
})();
【技術解説】
(function () { ... })(): IIFE(Immediately Invoked Function Expression:即時実行関数)と呼ばれる。関数を定義すると同時に実行する構文である。- なぜか:グローバル変数(プログラム全体からアクセスできる変数)の汚染を防ぐためである。この中に閉じ込めた変数(
canvasやparticlesなど)は外部からアクセスできず、他のスクリプトとの衝突を回避できる。
- なぜか:グローバル変数(プログラム全体からアクセスできる変数)の汚染を防ぐためである。この中に閉じ込めた変数(
if (document.getElementById(...)) return;: 既に特定のIDを持つCanvasが存在する場合は、処理を中断するガード句である。- なぜか:このスクリプトが誤って複数回読み込まれたり、実行されたりした場合に、不要なCanvasが何重にも生成されて描画パフォーマンスが著しく低下するのを防ぐためである。
DOMContentLoaded: HTML文書の読み込みが完了し、DOM(Document Object Model:HTMLを解析したプログラム用データ構造)ツリーが構築された時点で発生するイベントである。- なぜか:HTMLが完全に読み込まれる前に
document.body.appendChildを実行しようとすると、body要素が存在しないためエラーが発生する。それを防止するための確実な初期化タイミングの制御である。
- なぜか:HTMLが完全に読み込まれる前に
2.2. Canvas要素の動的生成とスタイル定義
const canvas = document.createElement('canvas');
canvas.id = 'cyber-cursor-canvas';
const ctx = canvas.getContext('2d');
canvas.style.position = 'fixed';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100vw';
canvas.style.height = '100vh';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '9999';
document.body.appendChild(canvas);
【技術解説】
document.createElement('canvas'): メモリ上にHTMLの描画領域である<canvas>要素を作成する。canvas.getContext('2d'): Canvasに描画するための「2Dグラフィックスコンテキスト」を取得する。これにより、線や四角形を描画する関数が利用可能になる。- CSSスタイルの設定:
position = 'fixed'/top = '0'/left = '0': スクロールしても画面の左上に常に固定されるようにする。width = '100vw'/height = '100vh': ビューポート(画面の表示領域)の幅・高さ100%に広げる。pointer-events = 'none': この実装において極めて重要な1行である。 マウスのクリックやドラッグなどのイベントを、このCanvasをすり抜けて背後のコンテンツ(ボタンやリンクなど)に透過させる。- なぜか:これが設定されていないと、最前面(
zIndex = '9999')にある透明なCanvasが画面全体をブロックしてしまい、ユーザーはページ内の他の要素を一切クリックできなくなる。
- なぜか:これが設定されていないと、最前面(
zIndex = '9999': ページ上の他のどの要素よりも手前にCanvasを配置する。
2.3. レスポンシブ対応(画面リサイズ処理)
let particles = [];
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
【技術解説】
canvas.width = window.innerWidth;: Canvas要素自体の解像度(ピクセル数)をブラウザの表示幅に一致させる。- なぜか:CSSの
100vwなどの指定は「表示上の引き伸ばしサイズ」を決めるだけであり、Canvas内部の解像度はデフォルト(300x150ピクセルなど)のままになる。これをブラウザサイズと同期させないと、描画されるパーティクルがぼやけたり歪んだりする。
- なぜか:CSSの
resizeイベントの購読: ユーザーがブラウザのウィンドウサイズを変更するたびにresize関数を実行し、表示崩れを防ぐ。
2.4. マウス移動によるパーティクルの生成
window.addEventListener('mousemove', (e) => {
for (let i = 0; i < 2; i++) {
particles.push({
x: e.clientX,
y: e.clientY,
vx: (Math.random() - 0.5) * 2.5,
vy: (Math.random() - 0.5) * 2.5,
alpha: 1.0,
size: Math.random() * 3 + 1,
color: Math.random() > 0.4 ? '#00f2fe' : '#ff007f'
});
}
});
【技術解説】
マウスが動くたびに、パーティクルを定義するオブジェクト(連想配列)を生成し、配列 particles に追加(push)する。
e.clientX,e.clientY: ブラウザの表示領域内における、現在のマウスカーソルの座標(X, Y)。これをパーティクルの初期位置とする。vx,vy: 速度ベクトル(Velocity)。毎フレームごとに座標をどれだけ移動させるかを表す。(Math.random() - 0.5) * 2.5:Math.random()は0以上1未満の小数を返す。そこから0.5を引くことで、-0.5から0.5の範囲にする。これに2.5を掛けることで、最終的に-1.25から1.25のランダムな速度を得る。- なぜか:正の値だけだと右下にしか飛び散らない。正負の値を均等に発生させることで、マウス位置を中心に360度ランダムな方向へ拡散するエフェクトを実現している。
alpha: パーティクルの不透明度(アルファ値)。初期値は1.0(完全不透明)。size:1.0〜4.0ピクセルの間でランダムに決定される粒子の大きさ。color: サイバーパンク感を強調するネオンブルー(#00f2fe)とネオンピンク(#ff007f)を40%対60%の確率でランダムに選択。
2.5. アニメーションループと描画処理
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.alpha -= 0.015;
if (p.alpha <= 0) {
particles.splice(i, 1);
continue;
}
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
ctx.shadowBlur = 8;
ctx.shadowColor = p.color;
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
ctx.restore();
}
requestAnimationFrame(animate);
}
animate();
【技術解説】
この関数の内部が、毎秒約60回(ディスプレイの更新レートに依存)呼び出され、描画を更新する。
ctx.clearRect(0, 0, ...): Canvas全体をいったん透明な状態にクリアする。- なぜか:これをしないと、前のフレームで描いたパーティクルがそのまま画面に残り、すべて線のように繋がって塗りつぶされてしまう。
- 配列を後ろからループする
for (let i = particles.length - 1; i >= 0; i--):- なぜか:この実装における最も重要なアルゴリズムの一つである。 通常の順方向ループ(
i = 0から開始)の中でsplice(i, 1)を使って配列から要素を削除すると、削除されたインデックス以降の要素が手前に1つずつ詰められる。その結果、削除した要素の「直後の要素」がループ処理をスキップされてしまう。このバグ(処理漏れ)を防ぐため、配列は必ず末尾(後ろ)から逆順にループ処理を行う。
- なぜか:この実装における最も重要なアルゴリズムの一つである。 通常の順方向ループ(
- 物理演算とフェードアウト:
p.x += p.vx/p.y += p.vy: 速度を位置に加算し、粒子を移動させる。p.alpha -= 0.015: 毎フレーム不透明度を約1.5%ずつ減少させる。if (p.alpha <= 0): 不透明度が0以下、つまり完全に見えなくなった段階でparticles.splice(i, 1)を実行し、メモリから削除する。- なぜか:不要になったオブジェクトを配列に残し続けると、メモリを消費し続け、最終的にブラウザの動作が重くなる(メモリリークの防止)。
- サイバー調のグラフィック表現:
ctx.save()/ctx.restore(): Canvasの描画設定(状態)を一時的に保存し、描画後に元に戻す。- なぜか:グローバルなアルファ値(透明度)や影(グロー効果)の設定は、個々のパーティクルごとに変更したい。
saveとrestoreで囲まないと、後続の他の描画処理にまでこの影や透明度の設定が影響を及ぼしてしまう。
- なぜか:グローバルなアルファ値(透明度)や影(グロー効果)の設定は、個々のパーティクルごとに変更したい。
ctx.shadowBlur = 8/ctx.shadowColor = p.color: パーティクルの周囲にぼかし(光彩)を加えることで、ネオンのように自発光しているような視覚効果(グロー効果)を生み出している。ctx.fillRect(...): 四角形を描画する。中心座標がマウス位置に合うよう、p.x - p.size / 2で補正している。
requestAnimationFrame(animate): ブラウザに対して、次の画面更新(描画フレーム)タイミングで再びanimate関数を呼び出すようスケジュールする。- なぜか:かつては
setIntervalやsetTimeoutが使われていたが、requestAnimationFrameを使用することで、ブラウザの描画周期に完全に同期した滑らかなアニメーションが実現できる。また、ページが裏のタブに回って非表示になった際には自動的に実行が一時停止されるため、CPUやバッテリーの消費を大幅に抑えられるというメリットがある。
- なぜか:かつては
3. まとめ:本実装の優れている点と注意点
優れている点
- パフォーマンスへの配慮:
requestAnimationFrameの利用や、不要になったパーティクルの厳密なメモリ解放(splice)など、フロントエンドパフォーマンスを考慮した堅牢な設計となっている。 - インタラクションの非妨害性:
pointer-events: noneの設定により、リッチなビジュアル表現を行いながらもウェブサイトとしてのユーザビリティを一切損なっていない。
パフォーマンス向上のためのアドバイス
本実装では、パーティクルに shadowBlur(影のぼかし) を使用してサイバー感のある発光(グロー)を表現している。 Canvasにおけるシャドウ処理はグラフィックスプロセッサ(GPU)に比較的高い負荷をかける。もし、さらに多くのパーティクルを一度に表示させたい場合や、低スペックのモバイル端末での動作をより滑らかにしたい場合は、以下の代替案を検討すると良い。
- 代替案:影(
shadowBlur)を使わず、小さな正方形の代わりに「中心が明るく、外側に向かって透明になる円形のグラデーション(createRadialGradient)」をパーティクルとして描画する。これにより、描画負荷を低減しながら同様の発光エフェクトを得ることができる。
- コメントを投稿するにはログインしてください