p5.jsでFalling Sandシミュレーション

に公開

Falling Sandシミュレーションは格子上に区切った2D空間で砂のような粒子の落下の再現するシミュレーションです。Falling Sandという名前ですが、発展させると砂だけでなく、液体や気体といった物質、火などの現象も再現することができます。有名なところだとNoitaというゲームではFalling Sandシミュレーションをベースにして作られています(Noitaすごい面白いのでおすすめです、難しいけど)。

https://www.youtube.com/watch?v=L4u7Zy_b868
https://store.steampowered.com/app/881100/Noita/?l=japanese

この記事ではp5.jsを使ってFalling Sandシミュレーションで粒子の落下を実装してみます。

https://github.com/aadebdeb/p5-falling-sand-simulation

仕組み

Falling Sandシミュレーションは各格子に砂が存在する場合に以下の規則に沿って更新していきます。

  1. 真下に砂または地面がない場合、真下に移動する
  2. 左下(または右下)に砂または地面がない場合は、左下(または右下)に移動する
  3. 移動できない場合、その場にとどまる

この操作をシミュレーション空間の下から上に向かっておこなっていきます。

実装

Falling Sandシミュレーションの実装です。FallingSandSimulationインスタンスのupdateメソッドを呼び出すと1ステップだけシミュレーションが進行します。

class Sand {
  constructor(seed) {
    this._seed = seed; // 色付けのためのパラメータ
  }

  get seed() {
    return this._seed;
  }
}

class FallingSandSimulation {
  constructor(width, height) {
    this._width = width;
    this._height = height;
    this._cells = new Array(height);
    for (let yi = 0; yi < height; yi++) {
      this._cells[yi] = new Array(width).fill(null); // nullはセルに何もないことを表す
    }
  }

  get width() {
    return this._width;
  }

  get height() {
    return this._height;
  }

  getAt(x, y) {
    return this._cells[y][x];
  }

  setAt(x, y, sand) {
    this._cells[y][x] = sand;
  }

  update() {
    for (let y = 0; y < this._height; y++) {
      for (let x = 0; x < this._width; x++) {
        this._updateCell(x, y);
      }
    }
  }

  _updateCell(x, y) {
    if (y === 0) return; // もし砂が地面に到達している場合は何もしない
    if (!this._cells[y][x]) return; // もし砂がない場合は何もしない

    const bottomCell = this._cells[y - 1][x];
    if (!bottomCell) { // もし真下が空いている場合は真下に移動する
      this._swap(x, y, x, y - 1);
      return;
    }

    const isBottomLeftEmpty = x > 0 && !this._cells[y - 1][x - 1];
    const isBottomRightEmpty = x < this._width - 1 && !this._cells[y - 1][x + 1];
    if (isBottomLeftEmpty && isBottomRightEmpty) { // もし左下と右下の両方が空いている場合はランダムに左下または右下に移動する
      if (Math.random() < 0.5) {
        this._swap(x, y, x - 1, y - 1);
      } else {
        this._swap(x, y, x + 1, y - 1);
      }
    } else if (isBottomLeftEmpty) { // もし左下だけが空いている場合は左下に移動する
      this._swap(x, y, x - 1, y - 1);
    }
    else if (isBottomRightEmpty) { // もし右下だけが空いている場合は右下に移動する
      this._swap(x, y, x + 1, y - 1);
    }
  }

  _swap(x1, y1, x2, y2) {
    const temp = this._cells[y1][x1];
    this._cells[y1][x1] = this._cells[y2][x2];
    this._cells[y2][x2] = temp;
  }
}

実装したシミュレーションをp5.jsを使って描画します。

const CellSize = 5;
const sim = new FallingSandSimulation(100, 100);

function setup() {
  createCanvas(sim.width * CellSize, sim.height * CellSize);
  frameRate(30);
  colorMode(HSB, 360, 100, 100);
}

function draw() {
  sim.update();
  addSands(3);
  render();
}

// マウスが押されている箇所に砂を生成する
function addSands(extent = 1) {
  if (!mouseIsPressed) return;
  const mx = Math.floor(mouseX / CellSize);
  const my = Math.floor((height - mouseY) / CellSize);
  const e = extent - 1;
  for (let ex = -e; ex <= e; ex++) {
    for (let ey = -e; ey <= e; ey++) {
      const x = mx + ex;
      const y = my + ey;
      if (abs(ex) + abs(ey) <= e && x >= 0 && x < sim.width && y >= 0 && y < sim.height) {
        sim.setAt(x, y, new Sand(random(Number.MAX_SAFE_INTEGER)));
      }
    }
  }
}

function render() {
  background(0, 0, 0);
  noStroke();
  for (let yi = 0; yi < sim.height; yi++) {
    for (let xi = 0; xi < sim.width; xi++) {
      const cell = sim.getAt(xi, yi);
      if (cell) {
        fill(30, 50, 30 + cell.seed % 20);
        rect(CellSize * xi, height - CellSize * (yi + 1), CellSize, CellSize);
      }
    }
  }
}

左右の偏りの解消

ここまでの実装でピラミッド上に砂粒が積まれていきますが、左右で積まれ方に偏りが生じています。

この原因は水平方向には常に左から右に走査しているためです。これを解消するために左右はランダムに走査するようにします。

class FallingSandSimulation {
  ...
  update() {
    for (let y = 0; y < this._height; y++) {
      // 左右どちらから走査するかをランダムに決定する
      let isLeftToRight = Math.random() < 0.5;
      for (let x = 0; x < this._width; x++) {
        this._updateCell(isLeftToRight ? x : this._width - 1 - x, y);
      }
    }
  }
  ...
}

これにより左右の偏りがなくなりました。

ちなみにNoitaではランダムに左右の走査方向を変えるのではなく、行ごとに交互に走査方向を変えつつ、各行の走査方向がフレームごとに切り替わるようになっているようです。

class FallingSandSimulation {
  ...
  update() {
    let isLeftToRight = frameCount % 2 === 0;
    for (let y = 0; y < this._height; y++) {
      for (let x = 0; x < this._width; x++) {
        this._updateCell(isLeftToRight ? x : this._width - 1 - x, y);
      }
      isLeftToRight = !isLeftToRight;
    }
  }
  ...
}

速度の導入

ここまででFalling Sandシミュレーションの基本はできましたが、今の実装では1フレームに1ステップ分しか砂が移動しないので時間がかかります。ここでは高速化のために重力と速度の概念を導入して、1フレームに1格子分以上移動できるようにします。

class Sand {
  constructor(seed)  {
    this.velocity = 0; // 速度のパラメータ
    this._seed = seed;
  }
  ...
}

class FallingSandSimulation {
  ...
  update() {
    for (let y = 0; y < this._height; y++) {
      for (let x = 0; x < this._width; x++) {
        const cell = this.getAt(x, y);
        if (cell) {
          cell.velocity += 0.5; // 重力による加速
        }
      }
    }
    ...
  }

  _updateCell(x, y) {
    const cell = this.getAt(x, y);
    if (!cell) return; // 砂がない場合は何もしない

    let movable = false;
    let target = [x, y];
    // 速度の分だけ移動を繰り返す
    for (let i = 0; i < Math.ceil(cell.velocity); i++) {
      let nextStep = this._getNextStep(target[0], target[1]);
      if (!nextStep) break;
      target = nextStep;
      movable = true;
    }

    if (movable) {
      this._swap(x, y, target[0], target[1]);
    } else {
      cell.velocity = 0;
    }
  }

  _getNextStep(x, y) {
    if (y === 0) return null;

    const bottomCell = this._cells[y - 1][x];
    if (!bottomCell) {
      return [x, y - 1];
    }

    const isBottomLeftEmpty = x > 0 && !this._cells[y - 1][x - 1];
    const isBottomRightEmpty = x < this._width - 1 && !this._cells[y - 1][x + 1];
    if (isBottomLeftEmpty && isBottomRightEmpty) {
      if (Math.random() < 0.5) {
        return [x - 1, y - 1];
      } else {
        return [x + 1, y - 1];
      }
    } else if (isBottomLeftEmpty) {
      return [x - 1, y - 1];
    }
    else if (isBottomRightEmpty) {
      return [x + 1, y - 1];
    }
  }
  ...
}

終わりに

p5.jsでFalling Sandシミュレーションを実装しました。ルールはシンプルですが、納得性のある粒子の挙動をシミュレーションできるのが面白いです。この記事では解説していませんが、これを発展させて液体や気体を導入したり、よりリアルな挙動への対応などをしようとすると意外と考えることが多くて難易度が高く苦労しています...

参考

https://www.youtube.com/watch?v=prXuyMCgbTc
https://www.youtube.com/watch?app=desktop&v=VLZjd_Y1gJ8
https://www.youtube.com/watch?v=5Ka3tbbT-9E
https://jason.today/falling-sand
https://winter.dev/articles/falling-sand
https://w-shadow.com/blog/2009/09/29/falling-sand-style-water-simulation/

Discussion