Subterranean Flower

JavaScriptとフォースマップでパーティクルアニメーションを作る

Author
古都こと
ふよんとえんよぷよぐやま!!!

パーティクル!それはプログラムの砂場であり、最高のおもちゃです。パーティクルに対して様々な処理を行うことで、今まで多くのアニメーションが生まれています。

今回はフォースマップという考えを取り入れて、パーティクルを動かしてみましょう。

フォースマップとパーティクル

フォースマップという言葉を聞いたことがあるでしょうか?フォースマップはその名の通り「力場」を作り、その力場に沿ってパーティクルを動かすときに使われるテクニックです。

なお、ググってもあまり出てこないので、もっとメジャーな呼び方があるかもですね。少なくともFlash時代は「フォースマップ」で通じたんですが……今だとなんと呼ぶのでしょうね?

フォースマップではキャンバスをマス目状に区切り、それぞれのマスにベクトルが働いています。

パーティクルをこれに沿って動かすことで面白い挙動を実現できます。

頑張って作ってみましょう!

動作デモ

実際の動作デモは以下のリンクからどうぞ:

https://sbfl.net/blog/static/demo/forcemap-animation/

ソースコード

ソースコードはgithubにもおいてあります。ご自由にどうぞ。

https://github.com/subterraneanflowerblog/forcemap-animation

作ってみる

まず、パーティクルから作っていきましょう。適当なHTMLファイルと、適当なjsファイルを用意して始めましょう。

// パーティクルクラス
// 位置(x, y)と加速度(vx, vy)を持つ
class Particle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = 0;
    this.vy = 0;
  }

  // 毎フレームパーティクルを動かす
  // フォースマップにしたがった加速度を得る
  update(forcemap) {
    const force = forcemap.getForce(this.x, this.y);
    this.vx += force.x;
    this.vy += force.y;
    this.x += this.vx;
    this.y += this.vy;
  }

  // 点を描画
  render(context) {
    context.strokeStyle = 'white';
    context.strokeRect(this.x, this.y, 1, 1);
  }
}

パーティクルクラスは位置(x, y)と加速度(vx, vy)を持ちます。位置は加速度に応じて変わり、加速度はフォースマップに応じて変わります。計算式は自由ですが、ここでは単純な足し算をしています。

フォースマップはまだ作成していませんが、とりあえず受け取って、そこから今のマス目のベクトルを取得し、加速度に加算します。

また、描画機能もつけておきます。パーティクルの描画は1ピクセルの点を描くだけです。

次にフォースマップを作ります。同じjsファイル内に書いて大丈夫です。フォースマップは幅・高さと、各マス目の大きさを受け取り、初期化します。

// フォースマップ
class Forcemap {
  constructor(width, height, meshWidth = 10, meshHeight = 10) {
    this._width = width;
    this._height = height;
    this._meshWidth = meshWidth;
    this._meshHeight = meshHeight;

    // x,yともに-0.5から0.5までの範囲で力場を作る
    this._map = (new Array(width * height))
                    .fill(null)
                    .map((f) => ({ x: -Math.random() + 0.5, y: -Math.random() + 0.5}));
  }

  // x,yで指定された場所の力を取得する
  getForce(x, y) {
    const force = this._map[this._width * Math.floor(y/this._meshHeight) + Math.floor(x/this._meshWidth)];
    return force || { x: 0, y: 0 };
  }

  // 描画する
  render(context) {
    const width = this._width * this._meshWidth;
    const height = this._height * this._meshHeight;
    
    context.strokeStyle = 'rgb(60, 60, 60)';

    // 各マス目について
    for(let x = 0; x < width; x += this._meshWidth) {
      for(let y = 0; y < height; y+= this._meshHeight) {
        const center = { x: x + this._meshWidth/2, y: y + this._meshHeight/2 }; // マス目の中心
        const meshForce = this.getForce(Math.floor(x), Math.floor(y)); // マス目のベクトル
        const forceSize = Math.sqrt((meshForce.x ** 2) + (meshForce.y ** 2)); // ベクトルの大きさ
        const drawRadius = forceSize * Math.min(this._meshWidth, this._meshHeight); // 描画サイズ
        const radian = Math.atan2(meshForce.y, meshForce.x); // 力の角度

        // 矢印を描く
        context.beginPath();
        context.moveTo(center.x - drawRadius * Math.cos(radian), center.y - drawRadius * Math.sin(radian));
        context.lineTo(center.x + drawRadius * Math.cos(radian), center.y + drawRadius * Math.sin(radian));
        context.lineTo(center.x + drawRadius * Math.cos(radian + Math.PI/2), center.y + drawRadius * Math.sin(radian + Math.PI/2));
        context.lineTo(center.x + drawRadius * Math.cos(radian - Math.PI/2), center.y + drawRadius * Math.sin(radian - Math.PI/2));
        context.lineTo(center.x + drawRadius * Math.cos(radian), center.y + drawRadius * Math.sin(radian));
        context.stroke();

        context.strokeRect(x, y, this._meshWidth, this._meshHeight);
      }
    }
  }
}

フォースマップは各マス目ごとにベクトルを持ちます。ここではランダムなベクトルにしています。力場のデータの持ち方は、ここでは一次元配列にしていますが、二次元配列でも大丈夫です。

座標(x,y)のベクトルを取得するgetForceメソッドを実装しています。これにより内部表現に関わらずベクトルを取得することができます。

また、描画機能もつけています。これはグリッドとベクトルの矢印を描画するようになっています。フォースマップに描画機能は本来不要なのですが、見えた方が面白いので作りました。矢印の描画周りがちょっと苦しいのでもっと綺麗に書けるかもですね。

さて、ここまでできたらあとは必要なものを準備して描画するだけです。jsファイルに下記を追加します:

const canvasSize = 800;
const canvas = document.createElement('canvas');
canvas.width = canvasSize;
canvas.height = canvasSize;
const context = canvas.getContext('2d');
document.body.appendChild(canvas);

const meshNum = 10;
const meshSize = Math.floor(canvasSize / meshNum);

const forcemap = new Forcemap(meshNum, meshNum, meshSize, meshSize);

const particles = (new Array(200))
    .fill(null)
    .map((n) => new Particle(Math.random()*canvasSize, Math.random()*canvasSize));

canvasの準備と、フォースマップの作成、パーティクルの生成です。canvasのサイズは適当にしておいてください。

フォースマップは10x10のマス目で作成します。各マス目の大きさはcanvasのサイズを10で割るだけです(10x10なので)。

パーティクルはランダムな位置に生成します。今回は200個生成しました。パーティクルの個数は性能の許す限り増やしていってもいいでしょう。

これらのコードの下に、次はループと描画の処理を追加します。ループにはrequestAnimationFrameを使用します。

JavaScriptを用いたアニメーションについて詳しくは、「JavaScriptとcanvasでアニメーションを作る」もご覧ください。

// 毎フレームこの関数を実行する
function loop(timestamp) {
  // 描画をクリア
  context.fillStyle = 'black';
  context.fillRect(0, 0, canvasSize, canvasSize);

  // フォースマップの描画
  forcemap.render(context);

  // パーティクルの更新と描画
  for(const p of particles) {
    // フォースマップを与えてアップデート
    p.update(forcemap);

    // 外にはみ出したら適当に中に戻してやる
    if(p.x < 0 || p.x > canvasSize) {
      p.x = Math.random() * canvasSize;
      p.vx = 0;
    }

    if(p.y < 0 || p.y > canvasSize) {
      p.y = Math.random() * canvasSize;
      p.vy = 0;
    }

    // パーティクルを描画
    p.render(context);
  }

  // 次のフレームを要求
  requestAnimationFrame((ts) => loop(ts));
}

// ループ開始!
requestAnimationFrame((ts) =>loop(ts));

ループ処理は簡単で、パーティクルのアップデート、描画、を繰り返すだけです。

ここまでかけたら実行してみましょう。

動きましたか?おめでとうございます!

より発展的なフォースマップ

フォースマップは今回はランダムにベクトルを生成しましたが、きちんと計算された力場にしても面白いはずです。

例えばすべてのベクトルが外を向いているとか、その逆とか、一方向にひたすら流れるとか。

発想次第で色々と面白いことができるので、思いついたら試してみてください。