渦と引力場——パーティクルが力に引き寄せられる
重力は「下向きに一定の力」でした。今回はより高度な「力の場(ベクトル場)」を実装します。引力場はパーティクルを特定の点へ引き寄せ、渦場は螺旋状に巻き込みます。さらにフローフィールドを使えば、複雑な流れのアニメーションが生まれます。
ベクトル場とは
ベクトル場(vector field)とは、空間の各点に方向と大きさを持つベクトルが定義された場のことです。パーティクルシステムでは「各パーティクルの位置で力の方向と大きさを計算し、速度に加算する」という形で利用します。
重力は「全ての点で下向き(0, +g)」という均一なベクトル場でした。今回はパーティクルの位置によって向きが変わる、より複雑な場を作ります。
引力場——中心に向かって引き寄せる
引力場の計算は次のとおりです。
- パーティクルから引力中心へのベクトル
(dx, dy)を求める - 距離
distを計算する - 単位ベクトル
(nx, ny) = (dx/dist, dy/dist)に変換する - 距離に応じた力の大きさ
Fを計算する - 速度に加算する
var dx = target.x - p.x;
var dy = target.y - p.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var F = G / (dist * dist); // 距離の2乗に反比例(万有引力の法則)
p.vx += (dx / dist) * F;
p.vy += (dy / dist) * F;
距離が近すぎると力が無限大になるので、最小距離を設けておくと安全です。
var dist = Math.max(Math.sqrt(dx * dx + dy * dy), 10);
var particles = [];
for (var i = 0; i < 200; i++) {
particles.push({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
life: 999, maxLife: 999,
hue: Math.random() * 360
});
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.15)';
ctx.fillRect(0, 0, W, H);
var tx = W / 2, ty = H / 2;
particles.forEach(function(p) {
var dx = tx - p.x;
var dy = ty - p.y;
var dist = Math.max(Math.sqrt(dx * dx + dy * dy), 15);
var F = 300 / (dist * dist);
p.vx += (dx / dist) * F;
p.vy += (dy / dist) * F;
p.vx *= 0.97;
p.vy *= 0.97;
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > W || p.y < 0 || p.y > H) {
p.x = Math.random() * W;
p.y = Math.random() * H;
p.vx = (Math.random() - 0.5) * 2;
p.vy = (Math.random() - 0.5) * 2;
}
ctx.fillStyle = 'hsl(' + p.hue + ', 80%, 65%)';
ctx.beginPath();
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
ctx.fill();
});
requestAnimationFrame(loop);
}
loop(); 反発場——マウス位置から逃げる
引力の符号を反転するだけで、反発場になります。マウス位置を反発中心にすると、マウスから逃げるパーティクルが作れます。
var F = 800 / (dist * dist);
p.vx -= (dx / dist) * F; // 符号を反転 → 引力→反発力
p.vy -= (dy / dist) * F;
var particles = [];
for (var i = 0; i < 250; i++) {
particles.push({
x: Math.random() * W,
y: Math.random() * H,
vx: 0, vy: 0,
hue: 200 + Math.random() * 60
});
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.2)';
ctx.fillRect(0, 0, W, H);
particles.forEach(function(p) {
var dx = mx - p.x;
var dy = my - p.y;
var dist = Math.max(Math.sqrt(dx * dx + dy * dy), 10);
if (dist < 120) {
var F = 600 / (dist * dist);
p.vx -= (dx / dist) * F;
p.vy -= (dy / dist) * F;
}
p.vx *= 0.92;
p.vy *= 0.92;
p.x += p.vx;
p.y += p.vy;
p.x = Math.max(0, Math.min(W, p.x));
p.y = Math.max(0, Math.min(H, p.y));
var speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
var lightness = 40 + speed * 8;
ctx.fillStyle = 'hsl(' + p.hue + ', 80%, ' + Math.min(lightness, 85) + '%)';
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fill();
});
requestAnimationFrame(loop);
}
loop(); 速度が速いほど明るくなるよう lightness を速度から計算しています。
渦場——接線方向の力で螺旋を作る
渦場は「パーティクルを中心の周りに回す」力の場です。引力方向(中心向き)に対して垂直な方向(接線方向)に力を加えます。
2Dの接線ベクトルは、方向ベクトル (nx, ny) を90°回転させた (-ny, nx) です。
// 通常の引力方向(中心向き)
// vx += nx * F;
// vy += ny * F;
// 渦場(接線方向)
p.vx += -ny * strength; // 法線ベクトルを90°回転
p.vy += nx * strength;
var particles = [];
for (var i = 0; i < 300; i++) {
var angle = Math.random() * Math.PI * 2;
var r = Math.random() * Math.min(W, H) * 0.45;
particles.push({
x: W / 2 + Math.cos(angle) * r,
y: H / 2 + Math.sin(angle) * r,
vx: 0, vy: 0,
hue: (angle * 180 / Math.PI + 200) % 360
});
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.12)';
ctx.fillRect(0, 0, W, H);
var cx = W / 2, cy = H / 2;
particles.forEach(function(p) {
var dx = cx - p.x;
var dy = cy - p.y;
var dist = Math.max(Math.sqrt(dx * dx + dy * dy), 5);
var nx = dx / dist, ny = dy / dist;
var tangentStrength = 1.5;
var attractStrength = 0.3;
p.vx += -ny * tangentStrength + nx * attractStrength;
p.vy += nx * tangentStrength + ny * attractStrength;
p.vx *= 0.97;
p.vy *= 0.97;
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > W || p.y < 0 || p.y > H) {
var a = Math.random() * Math.PI * 2;
var rv = Math.random() * Math.min(W, H) * 0.45;
p.x = cx + Math.cos(a) * rv;
p.y = cy + Math.sin(a) * rv;
p.vx = 0; p.vy = 0;
}
ctx.fillStyle = 'hsl(' + p.hue + ', 85%, 65%)';
ctx.beginPath();
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
ctx.fill();
});
requestAnimationFrame(loop);
}
loop(); フローフィールド——ノイズライクな流れの場
フローフィールドは「空間の各点に方向が定義された場」です。ノイズ関数を使うと自然な流れが生まれますが、sin/cos の組み合わせでも十分効果的です。
// 座標と時間から方向を計算するフィールド関数
function getAngle(x, y, t) {
return Math.sin(x * 0.02 + t) * Math.cos(y * 0.02 + t * 0.7) * Math.PI * 2;
}
各パーティクルに対して、現在位置での方向を計算し、その方向に少しだけ速度を加算します。
var angle = getAngle(p.x, p.y, t);
p.vx += Math.cos(angle) * 0.3;
p.vy += Math.sin(angle) * 0.3;
var particles = [];
var t = 0;
for (var i = 0; i < 400; i++) {
particles.push({
x: Math.random() * W,
y: Math.random() * H,
vx: 0, vy: 0,
life: Math.random() * 200 + 100,
maxLife: 300,
hue: Math.random() * 360
});
}
function getAngle(x, y, time) {
return Math.sin(x * 0.015 + time * 0.5) * Math.cos(y * 0.015 + time * 0.3) * Math.PI * 3;
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.08)';
ctx.fillRect(0, 0, W, H);
t += 0.01;
particles.forEach(function(p) {
var angle = getAngle(p.x, p.y, t);
p.vx += Math.cos(angle) * 0.4;
p.vy += Math.sin(angle) * 0.4;
p.vx *= 0.95;
p.vy *= 0.95;
p.x += p.vx;
p.y += p.vy;
p.life--;
var ratio = p.life / p.maxLife;
ctx.globalAlpha = ratio * 0.7;
ctx.fillStyle = 'hsl(' + p.hue + ', 80%, 65%)';
ctx.beginPath();
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
if (p.life <= 0 || p.x < 0 || p.x > W || p.y < 0 || p.y > H) {
p.x = Math.random() * W;
p.y = Math.random() * H;
p.vx = 0; p.vy = 0;
p.life = Math.random() * 200 + 100;
p.hue = Math.random() * 360;
}
});
requestAnimationFrame(loop);
}
loop(); フィールドの式を変えるだけでまったく異なる流れが生まれます。t を変化させているため、流れ自体も時間とともにゆっくり変化します。
複数の力を組み合わせる
実際のエフェクトでは、複数の力を同時に使います。引力 + 渦力の組み合わせが特に使いやすく、引力係数を増やすと中心に収束し、渦力係数を増やすと大きく回転します。
// 引力(中心向き)+ 渦力(接線向き)の組み合わせ
p.vx += nx * attract + (-ny) * vortex;
p.vy += ny * attract + nx * vortex;
まとめ
この回でやったこと:
- 引力場を「距離の2乗に反比例した力を中心方向に加算」で実装した
- 反発場は引力の符号を反転するだけで実現した
- 渦場は方向ベクトルを90°回転させた接線方向に力を加えた
- フローフィールドは
sin/cosの組み合わせで空間全体に方向を定義した
次回はパーティクルの分裂を実装します。1粒が複数の子パーティクルに分かれる連鎖反応で、花火・爆発・細胞分裂のような表現が可能になります。