GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

フォーリングサンド(falling sand simulator) 9種の作り方

フォーリングサンド(falling sand simulator)とは、画面上部から落ちる粒を演出ではなく計算で積もらせる仕組みです。ライブラリ·WebGL なしでセル1つの規則4つ(下・左下・右下・横)で砂が積み、水が流れ、火が広がります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

落下という一つの規則が九つの素材に分かれる理由

9つの順番は派手さではなく、格子に持たせる規則の数が少しずつ増えていく順に並んでいます。01の下が空けば落ちるという一段規則から、02は斜めの安息角、03は密度差のある液体、04は消耗しながら燃え広がる火、05は水を消費して育つ成長、06は入力で素材を注ぐスポナー、07は隣接を溶かす反応、08は壁が誘導する循環、09はそれら全部を一つの格子に集めた生態系へと積み上がっていきます。ブラウザ上でfalling sand simulation onlineとして体験できるプレビューは、どの段階もzipの中身とまったく同じ計算です。

falling sand game というジャンル名がそのまま検索語になる理由

この分野は自国語の言い方ではなく、falling sand gameという英語の固有名詞のまま探されることがほとんどです。名前を持つジャンルとして最初に広まったのが英語圏だったためで、素材ごとの規則を積み上げていくこの作り方自体が、そのジャンル名の由来でもあります。

砂はなぜセル・オートマトンで落ちるか — 4方向規則と確率的広がりの計算

9つの盤を貫く数は多くありません。下・左下・右下・横という4方向のどこへ移動できるかを1マスごとに判定し、動けなければ何もしないというシンプルな規則を、走査の順序と乱数だけで積み重ねています。

下から上へ・左右交互に走査する順序が対称性を守る

毎フレーム最下段の1つ上の行から最上段へ向けて走査し、さらに偶数フレームは左から右、奇数フレームは右から左と横方向の走査順も入れ替えます。一方向だけで走査すると、先に処理されたマスの空きを後から処理するマスが横取りしてしまい、山が片側に寄って崩れていきます。

ImageDataへの一括書き込みが格子の大きさを支える

03・04・07・09のように格子が大きい、あるいは色の計算が重い盤は、1マスごとにfillRectを呼ぶ代わりにピクセルバッファへ書き込んでからputImageDataで一度だけ画面へ反映します。01・02・05・06・08はマスの数が少ないブロック型なので、fillRectをマスの単位で呼んでも重くなりません。

mulberry32のシード固定が再現性を作る

9つの盤はすべてMath.random()を直接呼ばず、固定したシードから疑似乱数列を作るmulberry32を使っています。同じシードなら砂の降り方も酸の溶け方も毎回同じ順序で再現されるため、記事の実測値と手元で開いたzipの動きが食い違うことはありません。

項目別コード

以下の9つは、実際に動いているデモのソースファイルからそのまま書き写した関数です。要約した断片ではなく実コードなので、そのままページの外へ持ち出して自分の画面でも確認できます。

01基本の落下 — 下のマスが空なら落ちる、規則ひとつ

格子の各マスに素材番号を持たせ、毎フレーム下のマスが空なら下へ移すという規則ひとつだけを動かすと、上の3つのスポナーから注がれた粒が床の上に山として積もります。ローディング画面の静かな落下や、控えめなヒーロー背景に向く一枚です。実測では累積面積33.333%・強度平均171.2で、24フレーム中23フレームに動きがありました。

基本の落下 — 下のマスが空なら落ちる、規則ひとつ
新しいタブで開く

01 · 基本の落下 — 下のマスが空なら落ちる、規則ひとつ — 計測 累積面積 33.333% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrame
  function update() {
    for (var y = rows - 2; y >= 0; y--) {
      for (var x = 0; x < cols; x++) {
        var i = idx(x, y);
        if (grid[i] !== 1) continue;
        var destY = y;
        for (var s = 1; s <= FALL; s++) {
          var yy = y + s;
          if (yy >= rows) break;
          if (grid[idx(x, yy)] === 0) destY = yy; else break;
        }
        if (destY !== y) {
          var di = idx(x, destY);
          grid[di] = 1; shade[di] = shade[i];
          grid[i] = 0;
        }
      }
    }
  }

下→上へ走査し空きマスの分だけ落とす一段規則

update()は行を下から上へ走査し、各マスの下にFALLマス分だけ空きが続いているかを1マスずつ確認してから、最も遠い空きマスへまとめて移動させます。FALLを1から2〜3へ増やすだけで、同じ規則のまま落下速度を上げられます。

02斜面スライド — 左右斜め規則で自然な安息角

下のマスが塞がっていれば左下・右下の順で空きを試し、確率SLIDE_Pで斜めへ滑らせる規則を01に足すと、粒が一直線に積もらず実際の砂山のように広がって安息角を作ります。砂時計の読み込みインジケーターや、絵具を垂らすアート表現に向きます。実測では累積面積33.878%・強度平均95.9で、24フレーム中23フレームに動きがありました。

斜面スライド — 左右斜め規則で自然な安息角
新しいタブで開く

02 · 斜面スライド — 左右斜め規則で自然な安息角 — 計測 累積面積 33.878% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrame
        if (rand() < SLIDE_P) {
          var dir = rand() < 0.5 ? -1 : 1;
          var try1 = (x + dir >= 0 && x + dir < cols && grid[idx(x + dir, y + 1)] === 0 && grid[idx(x + dir, y)] === 0);
          var try2 = false;
          if (!try1) {
            var d2 = -dir;
            try2 = (x + d2 >= 0 && x + d2 < cols && grid[idx(x + d2, y + 1)] === 0 && grid[idx(x + d2, y)] === 0);
          }
          if (try1 || try2) {
            var dx = try1 ? dir : -dir;
            var di = idx(x + dx, y + 1);
            grid[di] = 1; shade[di] = shade[i]; grid[i] = 0;
          }
        }

左右どちらを先に試すかを毎回ランダムに選ぶ理由

dirをrand() < 0.5で毎回左右どちらかに決めてから左下→右下の順に空きを探すため、常に同じ方向を優先すると山が片側に寄る非対称を防げます。try1が塞がっていればtry2側を試す二段構えが、斜面の対称性を保つ核心です。

03水の流れ — 下が塞がると左右へ広がる液体規則

水のマスは下→対角→左右の順で空きを探し、砂のマスは下→対角のみで止まる密度差の規則を組み合わせると、砂は積もり続けながら水だけが低い場所を探して流れていきます。絵具のにじみ表現や、地形が浸水していくゲーム演出に向きます。実測では累積面積33.589%・強度平均172.0で、24フレーム中23フレームに動きがありました。

水の流れ — 下が塞がると左右へ広がる液体規則
新しいタブで開く

03 · 水の流れ — 下が塞がると左右へ広がる液体規則 — 計測 累積面積 33.589% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDataputImageDatarequestAnimationFrame
          var moved = false;
          for (var s = 1; s <= SPREAD; s++) {
            nx = x + flowDir * s;
            if (nx < 0 || nx >= cols) break;
            var ti = idx(nx, y);
            if (grid[ti] === EMPTY) {
              // 通路チェック
              var clear = true;
              for (var q = 1; q <= s; q++) {
                if (grid[idx(x + flowDir * q, y)] !== EMPTY) { clear = false; break; }
              }
              if (clear) { swap(i, ti); moved = true; break; }
            } else break;
          }

左右へ最大SPREADマス伸ばして通路を確認する水平拡散

水のマスは左右へ1マスずつ試すのではなく、SPREADマス先まで一度に通路が空いているかを確認してから、届く最も遠いマスへ一気に移動します。この通路チェックがなければ壁の向こうへワープしてしまうため、拡散の速さと壁の再現性を同時に保っています。

04炎の延焼 — 隣接燃料を燃やし減っていく消耗規則

炎のマスは毎フレーム隣接4方向の木・油マスを確率IGNITE_Pで炎に変えながら、自分の寿命をLIFE分だけ減らして灰になる消耗規則で動きます。ゲームの破壊エフェクトや、ダークテーマのヒーロー背景の火の粉に向きます。実測では累積面積49.402%・強度平均39.2で、24フレーム中23フレームに動きがありました。

炎の延焼 — 隣接燃料を燃やし減っていく消耗規則
新しいタブで開く

04 · 炎の延焼 — 隣接燃料を燃やし減っていく消耗規則 — 計測 累積面積 49.402% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDataputImageDatarequestAnimationFrame
          if (life[i] <= 1) {
            grid[i] = ASH;
            if (y > 0 && grid[idx(x, y - 1)] === EMPTY && rand() < 0.5) {
              grid[idx(x, y - 1)] = SMOKE;
              if (y > 1 && grid[idx(x, y - 2)] === EMPTY && rand() < 0.5) grid[idx(x, y - 2)] = SMOKE;
            }
          } else {
            life[i]--;
          }

寿命が尽きた炎だけが灰と煙を残す

life[i]が1以下になった瞬間だけ灰(ASH)へ変わり、確率50%で真上のマスに煙(SMOKE)を、さらに確率50%でもう1マス上にも煙を送ります。寿命が残っている間はlife[i]--で数を減らすだけなので、燃え広がる判定と燃え尽きる判定が同じ1マスの中で独立して進みます。

05植物の成長 — 水に出会うと上へ育つ生成規則

種・植物のマスは下・右・左のいずれかに水が触れているかを調べ、確率GROW_Pで真上の空きマスを新しい茎に変えながらその水を消費する規則ひとつで、水が流れた通り道に沿って草が伸びていきます。インタラクティブアートの庭や、瞑想的な背景演出に向きます。実測では累積面積30.37%・強度平均194.2で、24フレーム中23フレームに動きがありました。

植物の成長 — 水に出会うと上へ育つ生成規則
新しいタブで開く

05 · 植物の成長 — 水に出会うと上へ育つ生成規則 — 計測 累積面積 30.37% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrame
          var touch = [
            [0, 1, y < rows - 1 ? grid[idx(x, y + 1)] : STONE],
            [1, 0, x < cols - 1 ? grid[idx(x + 1, y)] : STONE],
            [-1, 0, x > 0 ? grid[idx(x - 1, y)] : STONE]
          ];
          for (var t2 = 0; t2 < 3; t2++) {
            if (touch[t2][2] !== WATER) continue;
            var wx = x + touch[t2][0], wy = y + touch[t2][1];
            if (y > 0 && grid[idx(x, y - 1)] === EMPTY && rand() < GROW_P) {
              grid[idx(x, y - 1)] = PLANT;      // 新しい茎 — plant/leafの交代色は描画側で行番号から決める
              grid[idx(wx, wy)] = EMPTY;         // 水を消費
              moved[idx(x, y - 1)] = 1;
            }
            break; // 水一滴は一度だけ使う
          }

水1滴につき成長は1回だけに絞るbreak

touch配列で下・右・左の3方向を順に調べ、水が見つかった時点でbreakするため、同じ1滴の水が複数の方向へ二重に消費されることはありません。この一行がなければ水1滴が茎を何本も同時に押し上げてしまい、成長速度の実測ができなくなります。

06ポインターブラシ — カーソルから素材を筆のように注ぐスポナー

ポインターを動かすとその座標周辺の半径BRUSH_Rマス以内の空きマスへ、確率POUR_Pで選択中の素材を注ぐ規則が動きます。カーソルが触れていない自動デモの間はリサージュ曲線の8の字経路を筆が自分でたどります。ヒーローのお絵描き背景や、製品ページの素材デモに向きます。実測では累積面積35.529%・強度平均120.7で、24フレーム中23フレームに動きがありました。

ポインターブラシ — カーソルから素材を筆のように注ぐスポナー
新しいタブで開く

06 · ポインターブラシ — カーソルから素材を筆のように注ぐスポナー — 計測 累積面積 35.529% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovefillRectrequestAnimationFrame
  function pour(gx, gy, mat) {
    var r2 = BRUSH_R * BRUSH_R;
    for (var dy = -BRUSH_R; dy <= BRUSH_R; dy++) {
      for (var dx = -BRUSH_R; dx <= BRUSH_R; dx++) {
        if (dx * dx + dy * dy > r2) continue;
        var x = gx + dx, y = gy + dy;
        if (x < 0 || x >= cols || y < 0 || y >= rows) continue;
        var i = idx(x, y);
        if (grid[i] === EMPTY && rand() < POUR_P) grid[i] = mat;
      }
    }
  }

pointermoveが座標を渡しリサージュ曲線が経路を継ぐ二段構え

brush.hasPointerがfalseの間だけlissajous(t)が呼ばれてリサージュ曲線上の座標が筆の位置に入り、pointermoveが一度でも届くとその実座標へ切り替わります。pointerdownは使わず、押す動作なしに触れているだけで注げるのがこの盤の性質です。

07酸の腐食 — 出会った素材を消す反応規則

酸のマスは下・左・右の隣接が土・石・砂であればそのマスを空にしてから、確率EAT_Pで酸自身も消えるという反応規則ひとつで、地面を突き抜けて通路を掘っていきます。ゲームパズルの地形破壊演出や、鋭さを見せるインタラクティブデモに向きます。実測では累積面積30.269%・強度平均139.8で、24フレーム中23フレームに動きがありました。

酸の腐食 — 出会った素材を消す反応規則
新しいタブで開く

07 · 酸の腐食 — 出会った素材を消す反応規則 — 計測 累積面積 30.269% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDataputImageDatarequestAnimationFrame
  function updateAcid(x, y, dir) {
    var m = get(x, y);
    if (m !== ACID) return;
    var spots = [[x, y + 1], [x - 1, y], [x + 1, y]];
    var melted = false;
    for (var i = 0; i < spots.length; i++) {
      var nx = spots[i][0], ny = spots[i][1];
      if (isErodible(get(nx, ny))) { set(nx, ny, EMPTY); melted = true; }
    }
    if (melted && rand() < EAT_P) { set(x, y, EMPTY); return; }
    if (moveInto(x, y, x, y + 1, ACID)) return;
    var first = dir > 0 ? -1 : 1;
    if (moveInto(x, y, x + first, y + 1, ACID)) return;
    if (moveInto(x, y, x - first, y + 1, ACID)) return;
    if (moveInto(x, y, x + first, y, ACID)) return;
    moveInto(x, y, x - first, y, ACID);
  }

隣接3方向を溶かしてから移動を5段階で試す順序

spots配列で下・左・右の3マスをまず溶かし切ってからmeltedがtrueならEAT_Pの判定に進み、それでも消えなければ下→斜め2方向→横2方向の順にmoveIntoを5回まで試します。溶かす判定と移動する判定を分けているため、酸は一歩ごとに周囲を食べながら進むことができます。

08砂時計タイマー — 壁が落下を誘導する時間表示

首の細い上下二つの円錐を壁マスで固定してから上側を砂で満たすと、01と同じ「下が空けば落ちる」規則と斜め滑りだけで上が空き下が満ちる本物の砂時計のリズムが生まれます。カウントダウンタイマーや、イベントページの待機演出に向きます。実測では累積面積50.213%・強度平均187.5で、24フレーム中21フレームに動きがありました。

砂時計タイマー — 壁が落下を誘導する時間表示
新しいタブで開く

08 · 砂時計タイマー — 壁が落下を誘導する時間表示 — 計測 累積面積 50.213% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

fillRectrequestAnimationFrame
  function updateSand(x, y, dir) {
    if (get(x, y) !== SAND) return false;
    if (moveInto(x, y, x, y + 1)) return true;
    if (rand() > SLIDE_P) return false;
    var first = dir > 0 ? -1 : 1;
    if (moveInto(x, y, x + first, y + 1)) return true;
    return moveInto(x, y, x - first, y + 1);
  }

落下が止まったことを検知して自動で反転する

毎フレームtick - lastMoveを数え、12フレーム以上どの砂も動かず全体の周期も40フレームを超えたら、gridをUint8Arrayで作り直してbuildHourglass()をもう一度呼び出します。反転をアニメーションで作るのではなく規則が自然に止まった瞬間を検知しているため、同じ形をもう一度積み直すだけで循環が続きます。

09生態系シーン — 砂・水・炎・植物が同じ格子で回る統合

地形と湖の上で雨のスポナーが水を落とし、STRIKE_T秒ごとに乱数で選んだ草マスへ自動で炎を1点灯す点火装置が、砂・水・炎・植物の4つの規則を同じ格子の上で同時に回します。インタラクティブヒーローの生きた背景や、ゲームワールドのデモに向きます。実測では累積面積32.884%・強度平均165.1で、24フレーム中23フレームに動きがありました。

生態系シーン — 砂・水・炎・植物が同じ格子で回る統合
新しいタブで開く

09 · 生態系シーン — 砂・水・炎・植物が同じ格子で回る統合 — 計測 累積面積 32.884% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ImageDataputImageDatarequestAnimationFrame
  function updateFire(x, y) {
    if (get(x, y) !== FIRE) return;
    life[idx(x, y)]++;
    if (life[idx(x, y)] > 46) { set(x, y, rand() < 0.5 ? SMOKE : ASH); return; }
    var around = [[0, 1], [0, -1], [-1, 0], [1, 0], [-1, -1], [1, 1], [-1, 1], [1, -1]];
    var burned = false;
    for (var i = 0; i < around.length; i++) {
      var nx = x + around[i][0], ny = y + around[i][1];
      var m = get(nx, ny);
      if (isFlammable(m) && rand() < 0.34) { set(nx, ny, FIRE); burned = true; }
      else if (m === WATER) { set(x, y, ASH); return; } // 水に触れた炎は灰になって消える
    }
    if (burned) set(x, y, ASH); // 燃やし切ったマスには灰が残る
  }

クリックではなく周期タイマーが火種を配る

updateFire()自体は毎フレーム呼ばれますが、火種が新しく生まれるのはtickがnextStrikeを超えた瞬間だけで、そこで乱数を使い最大60回まで座標を試し、燃えるマス(isFlammable)が見つかった1点だけをFIREに変えます。この盤に人の入力は要らず、雨・成長・延焼のすべてが時間の経過だけで自己完結して循環します。

砂シミュレーションを実際に使う画面

ローディング画面や404ページのように一瞬だけ滞在する場所に09の生態系シーンを一つ置くと、訪問者がクリックするたびに06のポインターブラシが素材を注ぎ、画面がそのたびに違う形で埋まります。ゲームや開発ツールのヒーロー領域には、壁が流れを区切る08の砂時計タイマーの方が合います。決まったリズムで満ちては空になるので、待機の文言とかみ合います。

02の斜面スライドや03の水の流れのように規則一つだけで回る項目は、サイドバーの飾りやカード背景のように視線を大きく奪わなくてよい場所に置きます — 09のように複数の素材が重なるシーンは、画面全体を占める場所にだけ使います。

確率の境界を超えると砂がここで壊れる落とし穴

走査方向を毎フレーム反転しないと山が片側へ崩れる

02の斜め滑りを確認する途中、横方向の走査を左から右だけに固定してみたところ、山の右側だけが先に埋まって非対称な形になりました。tick & 1で走査方向を切り替える1行を戻すと、左右対称な安息角に直りました。

fillRectをマスごとに呼ぶと大きい格子で重くなる

09のように格子数の多い生態系の盤で色の計算をfillRectのまま残すと、毎フレームの描画だけでコマ落ちが出ます。ImageDataのバッファへ色を積んでからputImageDataで一度に反映する形に直すと、同じ規則のまま滑らかに戻ります。

壁を先に固定してから砂を注がないと08の首が崩れる

08の二つの円錐は壁マス(WALL)として先に固定してから上側を砂で満たす順序が前提で、砂を先に置いてから壁を後から重ねると首のマスが砂に置き換わって形が崩れます。9つのソース一式を手元に置いておけばfree falling sand simulatorとして好きな順序を自分で試しながら確認できるので、この崩れ方も自分の画面で再現できます。

9つとも同じ書式で書かれた完成ソース一式はパスワード付きzipにまとめてあり、解凍のパスワードはjetqny5vです。

セルサイズ一つで変わる3つのバリエーションとアクセシビリティ対応

定数ひとつで変わる3つのバリエーション

名前 変える値 手触り
落下速度 01 基本の落下 FALL 1 → 3 砂が滝のように一気に落ちて画面が忙しくなる
安息角 02 斜面スライド 対角試行確率 1.0 → 0.4 低い確率にすると砂が湿ったように急な角度で固まる
水の広がり幅 03 水の流れ 左右同時試行 1 → 3 水が薄く広く伸びて洪水のように広がる

どの変形もソース冒頭の定数ブロックにある値を1行だけ書き換える軸で、残りの走査や乱数の計算はそのまま動いています。

動きを減らす設定で残る静止フレーム

prefers-reduced-motion: reduceが検知されると、9つの盤はどれもrequestAnimationFrameのループを開始せず、stillFrame()が規則をまとめて数十〜数百回だけ進めてから1枚だけを描きます。01・03は40回分を進めた粒の山を、06は自動筆を50歩動かした跡を、08は220回分進めて反転済みの形を、09は地形を完成させてから110回分の生態を進めた1枚を、それぞれ見せます。

全9盤がrole="application"で読み上げに応答する仕組み

9つのcanvasはいずれもrole="application"とaria-label、tabindex="0"を持つ実験盤として作られており、キーボードのタブ移動で盤の領域へ焦点を移せます。ポインターの入力を受け取るのは06だけで、それ以外の8つは自動で進む場面なのでポインターがなくても完結します。

ポインターブラシを触る前によくある質問

この落下砂シミュレーションはCSSアニメーションで作れませんか?

CSSの@keyframesは決めた区間の間をブラウザが補間するだけなので、1マスごとに素材番号を持たせて毎フレーム規則を計算し直すこのようなセル・オートマトンは作れません。ここで紹介した9つはすべてrequestAnimationFrameのループの中で格子そのものを書き換えており、CSSトゥイーンでは砂も水も火も動かせません。

falling sands game のようなブラウザゲームとzipのコードは何が違いますか?

配布されている多くのfalling sands gameは遊びとして素材の切り替えやスコアの仕組みを持っていますが、このzipは9つの盤それぞれが1つの規則だけを見せる教材として作られています。動くコードの中身は同じセル・オートマトンなので、ゲームの素材選択UIなどを足せばそのまま遊べる形に広げられます。

08の砂時計だけなぜ21/24フレームなのですか?

08は落下が止まって反転するまでの間、短い静止区間をはさむ規則になっているため、その区間に重なった数フレームは動きの量が計測のしきい値を下回りました。動きが完全に止まっているのではなく、上下が入れ替わる直前の一瞬が数フレームに収まっているだけです。

もっと読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。