← Blog
SimulationAlgorithmCanvas APITypeScript

セルオートマトンの世界 — ライフゲームから波動関数崩壊まで

8 分で読める

このトピックを深掘りする

クリエイティブコーディング

ブラウザでの数学的シミュレーション・自然現象の可視化・生成アートの実装技法

全12本中 1 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

セルオートマトンの共通構造

GameOfLife、SandFall、WaveFunctionCollapseは見た目がまったく違うが、Canvas API上で実装した共通の設計パターンがある:

  1. グリッド: 2D配列に各セルの状態を格納
  2. 近傍ルール: 隣接セルの状態を参照して次の状態を決定
  3. 一括更新: 全セルの次状態を計算してから、一括で書き換える

GameOfLife — 生命のルール

コンウェイのライフゲームはたった4つのルールで驚くほど複雑な振る舞いを見せる:

const W = 200
const H = 150
let grid = new Uint8Array(W * H)
let nextGrid = new Uint8Array(W * H)

function stepLife() {
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      let neighbors = 0

      for (let dy = -1; dy <= 1; dy++) {
        for (let dx = -1; dx <= 1; dx++) {
          if (dx === 0 && dy === 0) continue
          const nx = (x + dx + W) % W
          const ny = (y + dy + H) % H
          neighbors += grid[ny * W + nx]
        }
      }

      const idx = y * W + x
      const alive = grid[idx]

      if (alive) {
        nextGrid[idx] = neighbors === 2 || neighbors === 3 ? 1 : 0
      } else {
        nextGrid[idx] = neighbors === 3 ? 1 : 0
      }
    }
  }

  // バッファスワップ(コピーなし)
  const temp = grid
  grid = nextGrid
  nextGrid = temp
}

Uint8Arrayを使う理由は2つ: メモリ効率(1セルあたり1バイト)と、JITコンパイラが整数演算に最適化しやすいこと。(x + dx + W) % W はトーラス境界(右端が左端に繋がる)。

バッファスワップの重要性

grid を直接上書きすると、更新済みのセルが未更新セルの近傍計算に影響する。ダブルバッファリングで解決する。上のコードではコピーではなく参照を入れ替えているので、コスト0。

SandFall — 落下シミュレーション

SandFallは重力に従って粒子が落下するオートマトン。ルールはシンプル:

type Material = 0 | 1 | 2 // 0: 空気, 1: 砂, 2: 水

function stepSand(grid: Uint8Array, w: number, h: number) {
  // 下から上にスキャン(重力方向の処理順が重要)
  for (let y = h - 2; y >= 0; y--) {
    for (let x = 0; x < w; x++) {
      const idx = y * w + x
      if (grid[idx] !== 1) continue

      const below = (y + 1) * w + x
      const belowLeft = (y + 1) * w + (x - 1)
      const belowRight = (y + 1) * w + (x + 1)

      if (grid[below] === 0) {
        // 真下が空なら落下
        grid[below] = 1
        grid[idx] = 0
      } else if (x > 0 && grid[belowLeft] === 0) {
        // 左下が空なら斜め落下
        grid[belowLeft] = 1
        grid[idx] = 0
      } else if (x < w - 1 && grid[belowRight] === 0) {
        // 右下が空なら斜め落下
        grid[belowRight] = 1
        grid[idx] = 0
      }
    }
  }
}

下から上にスキャンするのが重要。上からスキャンすると、1回のステップで粒子が複数セル落下してしまう。

水の場合は左右にも流れるルールを追加する。たった数行の追加で「液体」と「固体」の挙動の違いが表現できる。

WaveFunctionCollapse — 制約伝播

WFC(波動関数崩壊)はプロシージャル生成アルゴリズム。各セルが「取りうる状態の集合」を持ち、観測と制約伝播を繰り返す:

type Cell = {
  collapsed: boolean
  options: Set<number>
}

function findLowestEntropy(cells: Cell[][]): [number, number] | null {
  let minEntropy = Infinity
  let candidates: [number, number][] = []

  for (let y = 0; y < cells.length; y++) {
    for (let x = 0; x < cells[y].length; x++) {
      if (cells[y][x].collapsed) continue
      const entropy = cells[y][x].options.size
      if (entropy < minEntropy) {
        minEntropy = entropy
        candidates = [[x, y]]
      } else if (entropy === minEntropy) {
        candidates.push([x, y])
      }
    }
  }

  if (candidates.length === 0) return null
  return candidates[Math.floor(Math.random() * candidates.length)]
}

function collapse(cells: Cell[][], x: number, y: number) {
  const options = Array.from(cells[y][x].options)
  const chosen = options[Math.floor(Math.random() * options.length)]
  cells[y][x].options = new Set([chosen])
  cells[y][x].collapsed = true
}

function propagate(cells: Cell[][], rules: Map<number, Set<number>[]>) {
  const stack: [number, number][] = []
  // ... 制約を近傍に伝播
  // 隣接セルの選択肢を互換性ルールで削減
}

WFCのポイントは最小エントロピーセルを先に確定すること。選択肢が少ないセルを先に決めることで、矛盾の発生を抑える。

ImageDataによる高速描画

30,000セル(200x150)を毎フレーム描画するには、fillRect ではなく ImageDataを直接操作する:

function renderGrid(ctx: CanvasRenderingContext2D, grid: Uint8Array, w: number, h: number) {
  const imageData = ctx.createImageData(w, h)
  const data = imageData.data

  for (let i = 0; i < w * h; i++) {
    const px = i * 4
    const cell = grid[i]

    if (cell === 1) {       // 砂
      data[px] = 194
      data[px + 1] = 178
      data[px + 2] = 128
    } else if (cell === 2) { // 水
      data[px] = 64
      data[px + 1] = 164
      data[px + 2] = 223
    }
    data[px + 3] = 255
  }

  ctx.putImageData(imageData, 0, 0)
}

putImageData は1回のGPU転送で全ピクセルを更新する。セルごとに fillRect を呼ぶのと比べて桁違いに速い。

まとめ:セルオートマトン実装の道具たち

セルオートマトンと複雑系の関係を深く理解するなら、道具棚の書籍が最適。単純なルールから複雑な振る舞いが生まれるメカニズムは、プログラミング全般の設計思想に通じる。