#07 ビジュアルコーディング パーティクル

軌跡とトレイル——動きの痕跡を描く

パーティクルはその瞬間の「点」ですが、軌跡(トレイル)を残すことで「動き」そのものを視覚化できます。流れ星・レーザービーム・光る蛇——どれも軌跡の表現です。

今回は残像・履歴配列・グラデーションという3つのアプローチを実装し、それぞれの特徴を理解します。


アプローチ1: 単純残像

最もシンプルな方法は、背景を半透明で上書きし続けることです。前回までのデモでも使っていた手法です。

// 完全消去(残像なし)
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

// 残像あり(約10フレームで消える)
ctx.fillStyle = 'rgba(13, 17, 23, 0.1)';
ctx.fillRect(0, 0, W, H);

アルファ値の意味:

  • 0.05 → 約20フレーム残る(長い尾)
  • 0.1 → 約10フレーム残る(中程度の尾)
  • 0.3 → 約3フレーム残る(短い尾)
  • 1.0 → 残像なし
残像つきの軌道アニメーション——背景を薄く上書き
var t = 0;
var particles = [];

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.06)';
ctx.fillRect(0, 0, W, H);

t += 0.025;

var cx = W / 2 + Math.cos(t) * W * 0.28;
var cy = H / 2 + Math.sin(t * 1.7) * H * 0.3;

for (var i = 0; i < 3; i++) {
  var angle = Math.random() * Math.PI * 2;
  particles.push({
    x: cx, y: cy,
    vx: Math.cos(angle) * (Math.random() * 1.5 + 0.3),
    vy: Math.sin(angle) * (Math.random() * 1.5 + 0.3),
    life: 40, maxLife: 40,
    hue: (t * 40) % 360
  });
}

particles.forEach(function(p) {
  p.x += p.vx; p.y += p.vy; p.life--;
  var ratio = p.life / p.maxLife;
  ctx.fillStyle = 'hsl(' + p.hue + ', 90%, 70%)';
  ctx.globalAlpha = ratio;
  ctx.beginPath();
  ctx.arc(p.x, p.y, 2.5 * ratio, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

アプローチ2: 位置履歴配列

残像は「過去の描画内容が薄く残る」という副作用を利用しています。より明示的に軌跡を制御したい場合は、位置の履歴を配列に保存する方法を使います。

var history = [];

// 毎フレーム、現在位置を追加
history.push({ x: p.x, y: p.y });

// 一定数を超えたら古いものを削除
if (history.length > 20) {
  history.shift();  // 先頭の要素を取り除く
}

保存した履歴を順番に描画します。古い点ほど小さく・透明にすることで、尾のような表現になります。

history.forEach(function(pos, i) {
  var ratio = i / history.length;  // 古いほど0、新しいほど1
  ctx.globalAlpha = ratio;
  ctx.fillStyle = 'cyan';
  ctx.beginPath();
  ctx.arc(pos.x, pos.y, 3 * ratio, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});
20フレームの位置を繋いで軌跡を描く蛇
var snakes = [];

for (var s = 0; s < 3; s++) {
snakes.push({
  x: Math.random() * W,
  y: Math.random() * H,
  vx: (Math.random() - 0.5) * 4,
  vy: (Math.random() - 0.5) * 4,
  history: [],
  hue: s * 120
});
}

function loop() {
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

snakes.forEach(function(snake) {
  snake.vx += (Math.random() - 0.5) * 0.4;
  snake.vy += (Math.random() - 0.5) * 0.4;
  var spd = Math.sqrt(snake.vx * snake.vx + snake.vy * snake.vy);
  if (spd > 4) { snake.vx = snake.vx / spd * 4; snake.vy = snake.vy / spd * 4; }

  snake.x += snake.vx; snake.y += snake.vy;
  if (snake.x < 0 || snake.x > W) snake.vx *= -1;
  if (snake.y < 0 || snake.y > H) snake.vy *= -1;
  snake.x = Math.max(0, Math.min(W, snake.x));
  snake.y = Math.max(0, Math.min(H, snake.y));

  snake.history.push({ x: snake.x, y: snake.y });
  if (snake.history.length > 30) snake.history.shift();

  snake.history.forEach(function(pos, i) {
    var ratio = (i + 1) / snake.history.length;
    ctx.globalAlpha = ratio;
    ctx.fillStyle = 'hsl(' + snake.hue + ', 90%, ' + (50 + ratio * 30) + '%)';
    ctx.beginPath();
    ctx.arc(pos.x, pos.y, 5 * ratio, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
  });
});

requestAnimationFrame(loop);
}
loop();

アプローチ3: グラデーショントレイル

履歴配列の点を ctx.lineTo で繋げてラインとして描くと、より滑らかな軌跡になります。さらに lineWidth を変化させることでグラデーショントレイルになります。

for (var i = 1; i < history.length; i++) {
  var ratio = i / history.length;
  ctx.globalAlpha = ratio;
  ctx.strokeStyle = 'hsl(180, 80%, 70%)';
  ctx.lineWidth = 4 * ratio;
  ctx.beginPath();
  ctx.moveTo(history[i - 1].x, history[i - 1].y);
  ctx.lineTo(history[i].x, history[i].y);
  ctx.stroke();
  ctx.globalAlpha = 1;
}

1区間ずつ描画して globalAlphalineWidth を変化させています。

宇宙船のような光るグラデーショントレイル
var ships = [];

for (var i = 0; i < 5; i++) {
var angle = Math.random() * Math.PI * 2;
ships.push({
  x: W / 2, y: H / 2,
  vx: Math.cos(angle) * 3,
  vy: Math.sin(angle) * 3,
  history: [],
  hue: i * 72
});
}

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.4)';
ctx.fillRect(0, 0, W, H);

ships.forEach(function(ship) {
  ship.vx += (Math.random() - 0.5) * 0.5;
  ship.vy += (Math.random() - 0.5) * 0.5;
  var spd = Math.sqrt(ship.vx * ship.vx + ship.vy * ship.vy);
  if (spd > 4) { ship.vx = ship.vx / spd * 4; ship.vy = ship.vy / spd * 4; }

  ship.x += ship.vx; ship.y += ship.vy;
  if (ship.x < 0) ship.x = W;
  if (ship.x > W) ship.x = 0;
  if (ship.y < 0) ship.y = H;
  if (ship.y > H) ship.y = 0;

  ship.history.push({ x: ship.x, y: ship.y });
  if (ship.history.length > 40) ship.history.shift();

  for (var i = 1; i < ship.history.length; i++) {
    var ratio = i / ship.history.length;
    ctx.globalAlpha = ratio * 0.9;
    ctx.strokeStyle = 'hsl(' + ship.hue + ', 90%, ' + (60 + ratio * 20) + '%)';
    ctx.lineWidth = 3 * ratio + 0.5;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(ship.history[i - 1].x, ship.history[i - 1].y);
    ctx.lineTo(ship.history[i].x, ship.history[i].y);
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  ctx.fillStyle = 'white';
  ctx.beginPath();
  ctx.arc(ship.x, ship.y, 3, 0, Math.PI * 2);
  ctx.fill();
});

requestAnimationFrame(loop);
}
loop();

マウスに追従する光のトレイル

マウスの動きを追って輝く軌跡を作ります。マウスの動きを履歴として記録し、それをグラデーションラインで描画します。

マウスに追従する光のトレイル
var history = [];
var t = 0;

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.25)';
ctx.fillRect(0, 0, W, H);

t += 0.03;
if (history.length === 0 || Math.hypot(mx - history[history.length-1].x, my - history[history.length-1].y) > 2) {
  history.push({ x: mx, y: my, hue: (t * 60) % 360 });
}
if (history.length > 60) history.shift();

for (var i = 1; i < history.length; i++) {
  var ratio = i / history.length;
  ctx.globalAlpha = ratio * 0.85;
  ctx.strokeStyle = 'hsl(' + history[i].hue + ', 90%, ' + (55 + ratio * 25) + '%)';
  ctx.lineWidth = 6 * ratio;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.beginPath();
  ctx.moveTo(history[i - 1].x, history[i - 1].y);
  ctx.lineTo(history[i].x, history[i].y);
  ctx.stroke();
  ctx.globalAlpha = 1;
}

if (history.length > 0) {
  var last = history[history.length - 1];
  ctx.globalAlpha = 0.9;
  ctx.fillStyle = 'white';
  ctx.beginPath();
  ctx.arc(last.x, last.y, 4, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
}

requestAnimationFrame(loop);
}
loop();

マウスをキャンバス上で動かすと、虹色の光の軌跡が描かれます。


3つのアプローチの比較

手法メリットデメリット
残像(背景半透明上書き)実装が最も簡単・軽量全パーティクルの残像が一律に消える
位置履歴配列軌跡の長さを個別に制御できるメモリ使用量が増える
ラインで繋ぐ最も滑らかな軌跡実装が少し複雑

単純な炎やパーティクルには残像が向いており、「特定のオブジェクトに長い尾を付けたい」場合は履歴配列を使います。


まとめ

この回でやったこと:

  • 背景を rgba で薄く上書きする残像エフェクトを使った
  • history.push/shift で位置履歴を一定数に保ちながら管理した
  • 古い点ほど globalAlpharadius を小さくして蛇状の尾を表現した
  • ctx.lineTo で連続した線を描き、グラデーショントレイルを実装した

次回は煙エフェクトを実装します。大きく広がる半透明の円とグラデーションを使って、柔らかい煙の表現を作ります。