【ecosim】
眺めるための生態系モデルシミュレーター

  • 眺めるためだけの生態系シミュレーターをmacOS向けに作っています。ライオンやシマウマといった「種」は作らず、遺伝形質の組み合わせだけで生き物を出しています。
  • 相手を見つけないと繁殖できない仕様に変えたとき、3つのシードすべてで捕食者の出生数が0になりました。条件が二重になっていたのが原因です。
  • 乱獲を抑えたのは、抑制のコードではありません。食べたら5秒から9秒は休む、という当たり前の挙動を足しただけでした。

ゲームのような見た目の生態系シミュレーターを作っています。
ただ、点数も勝ち負けも、操作するプレイヤーもいません。
起動して、放っておいて、思い出したころに画面を見る。

16×16ピクセルの8ビット風の生き物が、草原と森と川のある世界を歩き回ります。
腹が減れば食べて、暗くなれば休んで、近くに相手が居れば求愛して、エネルギーが尽きれば死ぬ。
ここまでは、たぶん誰が作っても似た形になります。

その先で決めたことより、決めそこねて作り直したことのほうが多くなりました。

関連記事

1. 「種」を決めず、遺伝形質の組み合わせで生き物を作る

最初に決めたのは、生き物の「種」を作らないことでした。
ライオンというクラス、シマウマというクラス、という作り方をしない。

食性ごとの色。草食は白、雑食はオレンジ、腐肉食は灰色

かわりに、一匹ずつが遺伝形質の束を持ちます。
食性が6段階、体格、速度、知覚の広さ、好む地形、群れやすさ、体型が3種類、本体の色、差し色。
この値の組み合わせが、そのまま「その生き物」です。

サバンナとジャングルの野生動物32種をどう表すかは、別に調べて資料に残してあります。
ただしそれはクラスの一覧ではなく、どの形質をどのあたりに置けばその動物らしくなるか、という対応表です1。
コードのほうに32種は出てきません。

見た目も同じ作り方です。
16×16のドット絵は、その個体の形質から計算で描かれます。
体の色は食べ方ごとに決まっていて、模様や差し色には、食べ方とは無関係に遺伝する別の色を使います。

左から草食、雑食、腐肉食。
本体の色が食べ方を表していて、そこに乗っている桃色や水色のほうが血筋です。

1.1. 見た目はファミコンの実機のパレットに丸めた

色相と彩度は、ドット絵風のパレットから選択するようにしました。
明るさ4段と色相16列の表を持たせて、遺伝形質が示す色を必ずどれかの列へ丸めます2。
中間色が出なくなって、それらしくなりました。

2. 画面を知らない本体を、4つのターゲットで使い回す

プログラムは、Swift Package Manager(Swift Package Manager:スイフト・パッケージ・マネージャー)のひとつのパッケージに、4つのターゲットを並べた形です3。

Sources/
├── EcoSimCore/      画面を持たないシミュレーション本体
├── EcoSimApp/       SwiftUIとSpriteKitで描く画面
├── EcoSimAnalyzer/  画面なしで何本も走らせて集計する
└── EcoSimCLI/       起動中のアプリをターミナルから操作する ecosimctl

EcoSimCoreが読み込んでいるのはFoundationだけです。
SwiftUIもSpriteKitもimportしていない。
残りの3つはどれもEcoSimCoreに依存していて、互いには依存していません。

描画の都合が本体に入り込まないので、同じ世界を画面なしで180秒ぶん回して数えられます。
あとで出てくる「分析にかけたら」は、このEcoSimAnalyzerのことです。
テストも同じ本体を相手にしていて、日常的に回すものが51件、生態系を長く走らせて確かめるものが9件あります。

2.1. 世界まるごとが、値型の構造体ひとつに収まっている

世界を表す EcosystemWorld は、クラスではなく構造体です。
生き物の Creature、植物の Plant、死骸の Corpse、遺伝子の Genome も、全部構造体。
Swiftの構造体は値型(value type:バリュータイプ)で、代入するとまるごと複製されます4。

参照を共有しないので、どこかで書き換えた世界が別の場所の世界まで変えてしまう、ということが起きません。
Swift 6の並行処理のチェックにも、Sendable を付けるだけで素直に通りました。
クラスを使っているのは画面側だけで、SpriteKitのシーンを継承する EcosystemScene と、画面の状態をまとめる SimulationController です。

世界を進めるのは、step という関数ひとつです。

public mutating func step(deltaTime rawDeltaTime: Double) {
    let deltaTime = min(max(rawDeltaTime, 0), parameters.movement.maximumDeltaTime)
    guard deltaTime > 0 else { return }
    time += deltaTime

    updatePlants(deltaTime: deltaTime)
    updateCreatures(deltaTime: deltaTime)
    if parameters.stabilizer.enabled { applyStabilizers() }

    if time - lastSampleTime >= 1 {
        recordHistory()
    }
}Code language: JavaScript (javascript)

植物を更新して、動物を更新して、スタビライザーを当てて、1秒ごとに記録を取る。
処理の順番は、ここを読めば全部わかります。

このファイルは一時期1,448行まで膨らんでいました。
いまは同じ型の extension を役割ごとのファイルに分けていて、本体が222行、残りの6本が77行から496行です。
いちばん長い動物の更新だけは、乱数を引く順番が変わらないように、1つの処理のまま残してあります。

2.2. 固定刻みで、画面の速さと世界の進み方を切り離した

SpriteKitは、画面を描き直すたびに update を呼んできます。
ただ、その間隔は負荷によって揺れる。
経過時間をそのまま step に渡すと、同じシードでも毎回少しずつ違う世界になります。

そこで SimulationController が経過時間をためておき、30分の1秒たまるごとに step を1回呼ぶようにしました。
固定刻み(fixed timestep:フィックスト・タイムステップ)と呼ばれるやり方です5。
計測の部分を省くと、こうなっています。

accumulator += min(frameTime - lastFrameTime, 0.1) * speedMultiplier
while accumulator >= fixedStep {
    world.step(deltaTime: fixedStep)
    accumulator -= fixedStep
}Code language: JavaScript (javascript)

速度の2倍や4倍は、ためる量に掛け算しているだけです。
0.1秒で頭打ちにしているのは、スリープから戻ったときに何千回もまとめて進めないため。

乱数も自前で持っています。
SeededRandom はシードから数列を作る小さな構造体で、地形の生成も遺伝子の変異も、全部これを通します。
Swift標準の乱数生成器はシードを指定できないので、これがないと同じ世界を二度作れません6。
既定のシードは8016で、この記事の画面もその世界です。

2.3. 遺伝子はジェネリクスで1種類だけ書いた

遺伝子を表す型は、2つしかありません。
初期化の部分を省くと、これで全部です。

public struct Allele<Value: Equatable & Sendable>: Equatable, Sendable {
    public var value: Value
    public var dominance: UInt64
}

public struct DiploidGene<Value: Equatable & Sendable>: Equatable, Sendable {
    public var first: Allele<Value>
    public var second: Allele<Value>

    public var expressed: Value {
        first.dominance >= second.dominance ? first.value : second.value
    }
}Code language: PHP (php)

Allele は値と優性の強さの組で、DiploidGene はそれを2本持ち、強いほうを表に出します。
中身の型をあとから決められるジェネリクス(generics:総称型)で書いたので、速度のような小数も、食性のような整数も、同じ型で持てる。
Genome は、これを22本並べただけの構造体です。

交叉のコードも、型ごとに書き分けずに済みました。
両親の遺伝子から1本ずつ選ぶ関数を1つ書けば、22本すべてに使えます。
変異だけは、小数と整数で振れ方が違うので2つに分けています。

見た目のドット絵も、この境界の内側で作っています。
CreatureSpriteGenerator がゲノムから返すのは、16×16の256マスにパレットの番号を入れた配列だけ。
本体が知っているのは「2番は体、6番は輪郭」という番号までで、それを何色で塗るかは画面側の PixelTextureFactory が決めます。

3. 単独繁殖をやめたら、捕食者が1匹も生まれなくなった

異性の成体との求愛を経ないと繁殖できないようにしたところ、3つのシードすべてで、180秒のあいだに捕食者の出生数が07。
草食のほうも、90匹前後から4匹か5匹まで落ちました。

原因は、条件が二重になったことです。
単独で増えていたころは、歩いていて、子供ではなく、繁殖に要るエネルギーがある、という条件を自分だけが満たせばよかった。
交配を必須にした瞬間、それを相手も同時に満たしていて、しかも知覚の範囲内に居ることまで必要になりました。

初期10匹の捕食者では、まず起きません。

直したのは8か所です。
効いたのは、相手側の条件からエネルギーの要件を外したことと、求愛の相手を探す半径を知覚の1.4倍から5倍へ広げたこと。
それと、求愛が始まるかどうかの確率のゲートを、完全になくしたことでした。

相手を探すこと自体が、すでに十分な制限になっていました。
そこへさらに確率を掛けていたので、二重に絞っていたわけです。

産子数も、はじめの案は多すぎました。
体が小さいほどたくさん産むという式の係数が大きく、生まれたそばから餓死が増えた。
上限を6匹から4匹へ下げ、産んだ数に応じて親の消耗を重くしています。

捕食者の初期数を10から13へ増やしたこともありましたが、これは逆効果でした。
草食への圧が強まって、かえって共倒れになる。
10に戻しました。

捕食者を足すより、草食の繁殖力を上げるほうが効きました。

3.1. 最初に置く個体が赤ちゃんだったせいで、開始直後に全滅していた

子供時代を実装したときにも、似たことが起きています。
世界に最初から置かれる個体が年齢0で生成されていて、全員が子供として始まっていました。

子供は繁殖できません。
開始からしばらく、誰ひとり増えられない空白ができる。
そのあいだに食い荒らされて、複数のシードで草食がほぼ全滅していました。

最初に置く個体だけ成体として生成するようにして、解消しています。
子供時代を通るのは、シミュレーションが始まってから生まれた子だけです。

3.2. 捕食を即死から2段階にしたら、乱獲が勝手に収まった

捕食が一瞬で終わるのが不自然だったので、2段階に分けました。
0.35秒ほど襲いかかる時間を置き、成功したら5秒から9秒のあいだ食べて休む。
そのあいだは次の狩りに行きません。

狙っていたのは見た目の自然さだけです。
ところが分析にかけたら、3つのシードすべてで非常段階の介入が一度も起きない、それまででいちばん健全なバランスになっていました8。

捕食を抑えるコードは1行も足していません。
食べたら休む、という当たり前の挙動が、そのまま乱獲の抑制になっていた。

4. 絶滅を避けるスタビライザーは4段。下の段ほど手を出さない

それでも崩れることはあるので、絶滅を避ける仕掛けを入れてあります。
下から順に4段で、上へ行くほど強く介入します。

  • 1段目は何もしません。
    数が足りているあいだ、仕掛けは黙っています。
  • 2段目が動くのは、ある食べ方の生き物が1匹から3匹まで減ったときです。
    することは、残っている個体のエネルギーを足すだけ。
    数は増やしません。

ここに条件がもう一つあって、残っているのが片方の性別だけになっていたら、その個体の近くに異性を1匹だけ置きます。
雌雄があって単独では繁殖できない以上、最後の1匹が生き延びても相手が居なければそこで打ち止めだからです9。

  • 3段目は、その食べ方の生き物が0匹になったときです。
    直前まで居た場合にかぎって、休眠していた個体が戻ってきたという扱いで3匹を出します。
  • 4段目は、0匹の状態が長く続いたときです。
    5匹を新しく作ります10。

先日の夜中に画面を開いたら、記録がこう並んでいました。

スタビライザーログ。7行すべてがT2で、食性ごとに低個体数の支援が並んでいる

Tのあとの数字が段です。
7行ぜんぶが2段目で、しかも食べ方がばらばらに並んでいます。
雑食、肉食、頂点捕食、腐肉食、木の葉食。

文字の色が変わるのは3段目からなので、全部が灰色のこの画面は、崩れかけてはいるがまだ誰も絶滅していない、という意味です。

数そのものは、画面から触れるようにしてあります。

パラメータ画面のPLANTS。生産量1.5、初期数84、上限132、生え直し0.6

植物の生産量、初期数、上限、食べ尽くされたあとの生え直しやすさ。
下へスクロールすると、草食と捕食それぞれの数の上限も出てきます11。

この記録を普段の画面に出していないのは、DEBUGを押さないと開かないようにしてあるからです。
眺めている最中に「いま助けました」と表示されたら、見えているものを素直に見られなくなります。

5. 書いていない連鎖が出てくる。それを創発と呼ぶ

同じ夜、その前に見たときの草食のバイオマスは22.3でした。

崩れる前のバイオマス。草食22.3、捕食5.7、植物3.6K

それが5.9まで落ちていて、植物のほうは3.6Kから6.2Kへ増えています。

崩れたあとのバイオマス。草食5.9、捕食5.3、植物6.2K

120ステップのあいだに、草食が4分の1になって、植物が7割ほど増えた。

「草食が減ったら植物を増やす」という処理は、コードのどこにもありません。
植物のほうにも、動物の数をかぞえる仕組みは入っていない。
決まっているのは、生えて、育って、食べられたぶん減って、条件が合えば生え直す、というところまでです12。

それでも草食が減れば、食べられる量が減ります。
食べられなければ、植物は育つ。

部品それぞれの規則には無いのに、集まったときだけ現れるものを創発(emergence:エマージェンス)と呼びます13。
食べる側が減って食べられる側が増えるこの連鎖のほうは、生態学だと栄養カスケードと呼ぶそうです14。

  1. 中身を手で並べるかわりに、計算とランダムさで作り出すやり方を手続き型生成と呼びます。地形ならパーリンノイズ、洞窟なら細胞オートマトンというように、作りたいものごとに定番の手法があります。 – Procedural generation – Wikipedia
  2. ファミコンの映像チップは、4bitで色相を、そこへ重ねる2bitで明るさ4段を選ぶ作りになっていて、出せるのは64通りです。16列のうち実際に色がつくのは12で、残りは灰色と黒に固定されています。 – PPU palettes – NESdev Wiki
  3. Swiftのパッケージは、Package.swift にパッケージ名、ターゲット、外に出す製品、依存関係を書いて組み立てます。ライブラリの製品はターゲットの公開APIを使えるようにし、実行ファイルの製品はそのまま起動できるプログラムになります。 – Package — Swift Package Manager
  4. Appleは、Swiftで新しい型を作るときは既定で構造体を使い、参照の共有や継承が必要なときにだけクラスを選ぶよう勧めています。 – Choosing Between Structures and Classes
  5. ゲームの物理計算で広く知られた手法で、描画のたびに経過時間をためておき、決まった刻み幅ぶんずつ取り崩して計算を進めます。刻み幅がフレームごとに変わると、計算の結果まで変わってしまうためです。 – Fix Your Timestep!
  6. Swiftの既定の乱数生成器 SystemRandomNumberGenerator はシードを受け取らず、再現性より予測されにくさを優先しています。再現したい場合は RandomNumberGenerator プロトコルに合わせた型を自分で用意します。 – SystemRandomNumberGenerator | Apple Developer Documentation
  7. 計算で作る擬似乱数は、シードと手順が同じなら必ず同じ数列を返します。だから世界を作り直しても条件をそろえられて、直す前と後を同じ土俵で比べられます。 – 擬似乱数 – Wikipedia
  8. この判定は180秒ぶんを自動で走らせた集計で、実際に画面を眺めたときの体感とは別ものです。数字の上では安定していても、見ていて増減が激しく感じられることはあります。そのときは産子数のばらつきを縮めるか、食料の上限を上げる方向で調整する予定です。
  9. 密度が下がるほど1個体あたりの増え方が落ちる現象をアリー効果と呼びます。主な原因のひとつが、相手を見つけられないまま寿命が来ることです。 – Allee Effects | Learn Science at Scitable
  10. 保全生態学には、個体数が変動しても絶滅せずに長く存続できる最小の個体数という考え方があります。陸上の脊椎動物では平均500から1000個体とされていて、5匹はその発想をごく小さく借りたものです。 – 最小存続可能個体数 – Wikipedia
  11. ある環境で存続できる生物の最大量を環境収容力と呼びます。個体数が増えるほど増え方が鈍り、飽和密度でとまるS字の曲線になります。 – 環境収容力 – Wikipedia
  12. 鳥の群れを再現したボイドも、近くの仲間と離れる・向きをそろえる・近づくという3つの規則だけで動きます。群れ全体の形はどこにも書かれていません。 – Boids (Flocks, Herds, and Schools: a Distributed Behavioral Model)
  13. 下位の要素どうしのやりとりから、個々の要素は持っていない性質が上位に現れることを指します。全体は部分の総和以上である、という言い方をされます。 – 創発 – Wikipedia
  14. よく引かれるのはイエローストーン国立公園にオオカミが戻った例ですが、木本の回復が捕食者による上からの効果なのか、気候や水の条件など下からの要因なのかで見解が割れています。上からの効果そのものを否定する研究者は居らず、争点はどの程度きいたかという範囲です。 – The Big Scientific Debate: Trophic Cascades