脳も設計図もないのに、粘菌の網や動物の模様が形になるわけ

迷路を満たした粘菌は、入口と出口の餌をいちばん短い道の管で結ぶ。栄養の少ない寒天の細菌は枝分かれし、魚の縞は体が育っても同じ間隔のまま本数を増やす。どれも、まわりだけを見る決まりの積み重ねでできる。

記事

脳も設計図もないのに、粘菌の網や動物の模様が形になるわけ

迷路を満たした粘菌は、入口と出口の餌をいちばん短い道の管で結ぶ。栄養の少ない寒天の細菌は枝分かれし、魚の縞は体が育っても同じ間隔のまま本数を増やす。どれも、まわりだけを見る決まりの積み重ねでできる。

作成日 2026年11月3日・更新日 2026年11月3日・執筆・監修 佐々木 航

現象

迷路の入口と出口を、1本の管が結ぶ

迷路の入口と出口に餌を置くと、粘菌は2つの餌を、いちばん短い道を通る1本の管で結ぶ。理化学研究所の中垣俊之と北海道大学の山田裕康らが、2000年に報告した実験である1。モジホコリという粘菌の変形体(アメーバのように這い回る時期の体)を小さく切って迷路のあちこちに置くと、かけらはくっつき合って迷路全体を満たす。そこへ餌を置くと、変形体はまず行き止まりの道から体を引き揚げ、入口と出口をつなぐ道のすべてに管を残した。最後に残ったのは、いちばん短い道を通る太い管1本だった。

変形体には脳も神経もない。体は原形質と呼ばれる物質の塊で、全体で1つの細胞である1。それでも、迷路の答えを出したように見える。

全体を見渡す者がいないのに形ができる例は、ほかにもある。寒天の上で育つ細菌の集まりは、栄養が少ないと木の枝のように分かれる。ヒョウの斑点やシマウマのしまも、皮膚の細胞が設計図を見て塗り分けているわけではない。どれも、一つひとつの部分が自分のまわりだけを見て動き、その積み重ねで形ができると考えられている。

画面の皿では、この3つがそれぞれの決まりで動く。粘菌では、画質に応じて4万〜22万ほどの粒が匂いをたどって網を張る。皿をたたいて餌を置くと、網は餌どうしを結び直す。

仕組み

粒は、前の3か所の匂いだけを比べる

画面の粘菌は、Jeff Jonesが2010年に示した粒のモデルで動く2。一つひとつの粒は向きを持ち、少し先の3か所、つまり前と左前と右前の匂いを嗅ぐ。いちばん濃いほうへ向きを変えて1マス進み、着いた所に匂いを残す。残った匂いはまわりへ広がりながら、毎分一定の割合で薄れる。

匂いの濃い所(ほかの粒が通った跡) F L R 前 左前(いちばん濃い) 右前 22.5° 3.2 mm 濃いほうへ 45°回る 1. 3か所の匂いを比べる 2. 濃いほうへ向きを変える 3. 1マス進む(混んだマスには 入らない) 4. 着いた所に匂いを残す 5. 匂いは広がり、薄れる
粒は前・左前・右前の匂いを比べ、濃いほうへ回って1マス進み、匂いを残す

決まりはこれだけだが、粒が多いと網ができる。たまたま匂いの濃い筋ができると、そこへ粒が集まり、筋はさらに濃くなる。粒の来ない所の匂いは薄れて消える。濃い所がますます濃くなるこの仕組み(正のフィードバック)と、薄れる働きの釣り合いで、網の目の大きさが決まる。

本物の変形体の網は、管の中を流れる原形質で形が決まると考えられている。管が太いほど原形質は流れやすく、流れの多い管は太り、流れの少ない管はやせる。手老篤史らは2007年に、これを管の太さ(流れやすさ)と、管を流れる量の式で表した3。

流れが太さを上回る管は太り、下回る管はやせる。2つの餌の間に道が2本あれば、短い道のほうが流れやすいので流れが集まり、遠回りの管はやせて消える。

画面の粒のモデルには管も流れも無いので、流れの代わりに決まりを1つ足してある。粒は最後に触れた餌を覚えていて、別の餌に着いたときにだけ「餌から離れて歩いた道のり」を0に戻す。別の餌に着かないまま10.5 cm歩いた粒は、行き止まりの枝や網の外れにいるとみなし、粒の届いている餌の1つへ移す。原形質が流れの少ない枝から引き揚げる代わりである。餌と餌の間を行き来する粒の通る管だけが匂いを保ち、ほかの枝は薄れて消えていく。

仕組み

栄養と仲間がそろった縁でだけ、菌は動く

細菌の集まりと動物の模様は、どちらも反応拡散方程式で説明される。物質や生き物が増えたり減ったりする「反応」と、濃い所から薄い所へ散らばる「拡散」を組み合わせた式である。

寒天の上の枯草菌(納豆菌の仲間)は、栄養を取り込んで増え、栄養は寒天の中を広がる。川崎らは1997年に、栄養の濃さと菌の密度の変わり方を、次のような式で表した4。

は、菌が栄養を取り込んで増える速さである。は栄養が濃い所から薄い所へ広がることを、は菌が動き回って広がることを表す。菌が動ける速さは、栄養と菌の両方が多い所でだけ大きい。集まりの内側は栄養を食べ尽くしているので、菌はほとんど動かない。動くのは、まだ栄養の残る縁の菌だけになる。は菌の動きやすさを決める数で、画面では場所ごとに少しばらつかせてある。

栄養が少ないと、縁はなめらかに進めない。少しでも先へ出た所は、まだ手つかずの栄養に先に届くので、もっと速く伸びる。へこんだ所は、両側の菌に栄養を取られて伸びが止まる。こうして縁は分かれ、枝になる。栄養が多いと、へこんだ所にも栄養が十分に残り、縁は埋まってまるく広がる。

菌の量の増え方も、栄養で頭打ちになる。よく混ぜた液の中なら、菌の数の増え方は、菌1個あたりの増える速さと、栄養で決まる上限を使ったロジスティック方程式で表せる。

はじめはが小さいので、ほぼの速さで増える。一定の時間ごとに2倍、4倍、8倍と増えていく、指数関数的な増え方である。上限に近づくと増え方が鈍り、グラフはS字の曲線になる。ただし寒天の上では、栄養を食べ尽くした所から外へは、縁が進む速さでしか増えられない。そのため寒天の上の菌の量は、液の中よりずっと遅れて上限に近づく。

仕組み

近くで増え、少し離れた所で抑える

イギリスの数学者アラン・チューリングは1952年に、2種類の物質が反応しながら広がると、はじめは一様だった所に、ひとりでに濃淡の波ができることを示した5。物質が広がれば濃さはならされそうに思える。ところが、広がる速さが物質ごとに違うと、逆に一様な状態が崩れる。こうしてできる模様を、チューリング模様と呼ぶ。

画面の模様は、GrayとScottの式で動く。は材料、は材料を使って自分を増やす物質である。

は、がを使って増える反応である。は材料を外から補う速さ、はが消える速さを表す。とはそれぞれの広がる速さで、はの2倍の速さで広がる()。

場所 u(材料。vの2倍の速さで広がる) vの山 山と山の間隔(広がる速さと反応の速さで決まる) 山のすぐそばでは vがvを増やす 少し離れた所ではuが足りず 新しい山ができにくい
vの山のそばではvが増え、少し離れた所では材料uが足りず、新しい山ができにくい

が少し多い所では、がさらに増える。そのとき材料のを使うので、まわりのが減る。は速く広がるので、減った所は山より少し広い範囲に及ぶ。すると山のすぐ外ではが増えにくい。近くで増え、少し離れた所で抑える。この組み合わせが、山を一定の間隔で並べる。動物の模様を調べてきた大阪大学の近藤滋らも、模様ができる条件を「近距離の活性化」と「長距離の抑制」の組み合わせとしてまとめている6。

とを少し変えるだけで、模様は斑点にも、しまにも、迷路にもなる。Pearsonは1993年に、この式の計算で、育って2つに分かれる斑点などのさまざまな模様を報告した7。

試す

餌を置き、菌を植え、種をまく

  1. 皿に散らばった粒のそばに、3 cmほど離して餌を2つ置く。1〜2時間で皿全体に細かい網が張り、2時間を過ぎたころから、餌から離れた枝がやせはじめる。半日ほどで、網は2つの餌を結ぶ太い管と、そこから延びる数本の枝だけになる。餌どうしを行き来する粒の通る管だけが残るからだ。
  2. 関東の17の都市の位置に餌を置く。海には光を当て、粘菌が入れないようにしてある。はじめは陸の全体に細かい網が張り、半日から1日ほどで、隣り合う都市どうしを結ぶ太い管の網に絞られる。餌を足したり、ドラッグで動かしたりすると、網はつなぎ直される。
  3. 栄養の多い寒天(栄養の濃さ)の真ん中に菌を植える。へこんだ所にも栄養が残るので、集まりはまるく広がり、約10時間で皿の縁に届く。年輪のような線は、一定の時間ごとの縁の位置である。グラフの菌の量は、はじめに急に増えたあと増え方が鈍り、点線(よく混ぜた液の場合)よりずっと遅れて上限に届く。
  4. 栄養の少ない寒天()に植える。縁が分かれ、放射状の枝が伸びる。先に出た枝ほど、手つかずの栄養を先に取れるからだ。枝の先の外側には、栄養が減った所がうすい点で見える。育つのも遅く、皿の縁に届くまで3日ほど、栄養を使い切るまで4日近くかかる。栄養の多い皿の7倍ほどの時間だ。
  5. 7つの模様の種から始める。斑点は大きくなると2つに分かれ、皿を埋めるまで数を増やす。はじめは7個が数十個へと勢いよく増えるが、空いた所が減るにつれて増え方は鈍り、150個ほどで止まる。グラフの模様の面積もS字を描く。
  6. 皿の真ん中に1本の線を引く。線は同じ幅のしまになり、ところどころで枝分かれしながら皿に広がる。そのあと「消える速さ k」だけを0.064に上げると、しまは細ってちぎれ、残った切れ端から斑点が分かれて増え、斑点と短いしまが混じった模様に変わる。との組み合わせごとに、保てる模様の形が違うからだ。
現実では

鉄道網に似た網と、皮膚の上で動く縞

粘菌が作る網は、人が設計した交通網と比べられてきた。2010年、北海道大学の手老篤史らは、関東の主な都市の位置に餌を置き、東京にあたる位置から粘菌を広げた8。粘菌は明るい光を避けるので、山や湖など線路を通しにくい所には光を当てた9。粘菌ははじめ一面に細かい網を広げ、やがて餌どうしを結ぶ管だけを残した。研究に加わった中垣俊之によると、できた網は経済性と効率性、一部が切れてもつながったままでいる耐故障性のどれもうまく満たし、「現実の鉄道網に匹敵する(あるいは若干優れた)」働きを示した10。

細菌の集まりの形は、寒天の硬さと栄養の濃さで決まる。松下貢らは枯草菌を使い、どんな形の集まりができるかを、寒天と栄養の濃さを縦横にとった図(形態図)にまとめた11。寒天が約0.8%より濃いと菌は寒天の上を動けず、栄養が少ないと細かく枝分かれした形になる。寒天が0.5%より薄く栄養が少ないと枝が密に並ぶ形に、栄養が多いと枝が埋まった詰まった形になる。育つまでの時間は、細かく枝分かれした形が1か月ほど、枝が密に並ぶ形が数日、柔らかい寒天の詰まった形が半日ほどだ。画面の「栄養が少ない寒天」と「多い寒天」は、柔らかい寒天の上のこの2つにあたる。

動物の模様が皮膚の上で動いている例もある。タテジマキンチャクダイの縞は、体が育つと縞の分かれ目がファスナーのように横へずれ、縞の本数が増える。それでも縞の間隔は保たれる6。チューリングの理論が予測するとおりの動きで、近藤滋らが1995年に報告した。プレコ(ナマズの仲間)の斑点は、分かれたり、間に新しい斑点が加わったりして、斑点の大きさと密度を保つ6。

ゼブラフィッシュでは、仕組みの中身も分かってきた。実験によく使われる小さな魚で、しまは主に、黒い色素の細胞(黒色素胞)と黄色い色素の細胞(黄色素胞)の並びでできている。色素の細胞をレーザーで消した所に細胞が戻ってくる様子を調べると、黄色素胞が黒色素胞に与える働きは、細胞どうしの距離で変わっていた。まとめると「近くで活性化し、遠くで抑える」というチューリングの条件に合う6。ただし遠くへの働きかけは、物質の拡散ではなく、細胞から伸びる突起で伝わる12。式の形は同じでも、中身は物質の広がりとは限らない。

省いていること

画面の皿が省いていること

  • 粘菌の粒は本物の細胞の一部ではなく、網の形を作る決まりの模型である。本物の網は管の中の原形質の流れで決まるが、画面では流れの代わりに「別の餌に着くと道のりを数え直し、10.5 cm迷った粒は餌へ移す」決まりを使う。この決まりと10.5 cmという長さは、網の様子を画面で見て決めたもので、実験から求めた値ではない。そのため餌のまわりの短い枝は、消え切らずに残ることがある。画面の皿には迷路が無く、2本の道から短いほうを選ぶ実験の様子は再現していない
  • 粘菌の時間は、粒が1時間に約2 cm進むとして直した目安である。本物の変形体も1時間に数 cm這い進む9が、網が絞られるまでの時間は、実験の時間と比べられるものではない。画質(格子の細かさ)によって、網の太さも少し変わる
  • 関東の場面は、都市の緯度と経度から餌を置き、海岸線は大まかになぞった。17の都市は画面のために選んだもので、実験の餌の配置とは同じではない。山には光を当てていない。実験と違い、粘菌は東京から広げるのではなく、陸の全体に広がった状態から始まる
  • 細菌の式は、栄養が少ないときの取り込み方()で、菌の死や、栄養に向かって泳ぐ動き(化学走性)は入れていない。栄養の濃さは、実験のペプトンの濃さとは対応しない。時間は、栄養の多い皿が半日ほど、少ない皿が数日で育つという実験のおよその値に近づくように、画面を見て決めた目安である。この対応では、よく混ぜた液の中で菌が2倍に増える時間が1〜3分になり、ふつう数十分かかる本物の枯草菌より短い
  • 模様の式は化学反応の模型で、とに生き物の意味は無い。体の形や成長、ヒョウの花のような形の斑点は入れていない
参考文献

参考文献

  1. 理化学研究所・北海道大学「粘菌が迷路を最短ルートで解く能力があることを世界で初めて発見」報道発表資料, 2000. https://www.riken.jp/medialibrary/riken/pr/press/2000/20000928_1/20000928_1.pdf
  2. Jones, J.「Characteristics of Pattern Formation and Evolution in Approximations of Physarum Transport Networks」『Artificial Life』16(2), 127–153, MIT Press, 2010. https://doi.org/10.1162/artl.2010.16.2.16202
  3. Tero, A., Kobayashi, R., Nakagaki, T.「A mathematical model for adaptive transport network in path finding by true slime mold」『Journal of Theoretical Biology』244(4), 553–564, Elsevier, 2007. https://doi.org/10.1016/j.jtbi.2006.07.015
  4. Kawasaki, K., Mochizuki, A., Matsushita, M., Umeda, T., Shigesada, N.「Modeling Spatio-Temporal Patterns Generated by Bacillus subtilis」『Journal of Theoretical Biology』188(2), 177–185, Academic Press, 1997. https://doi.org/10.1006/jtbi.1997.0462
  5. Turing, A. M.「The chemical basis of morphogenesis」『Philosophical Transactions of the Royal Society of London. Series B』237(641), 37–72, The Royal Society, 1952. https://doi.org/10.1098/rstb.1952.0012
  6. Kondo, S., Iwashita, M., Yamaguchi, M.「How animals get their skin patterns: fish pigment pattern as a live Turing wave」『The International Journal of Developmental Biology』53(5–6), 851–856, 2009. https://doi.org/10.1387/ijdb.072502sk
  7. Tero, A., Takagi, S., Saigusa, T., Ito, K., Bebber, D. P., Fricker, M. D., Yumiki, K., Kobayashi, R., Nakagaki, T.「Rules for Biologically Inspired Adaptive Network Design」『Science』327(5964), 439–442, 2010. https://doi.org/10.1126/science.1177894
  8. Golding, I., Kozlovsky, Y., Cohen, I., Ben-Jacob, E.「Studies of bacterial branching growth using reaction–diffusion models for colonial development」『Physica A』260(3–4), 510–554, Elsevier, 1998. https://arxiv.org/abs/cond-mat/9807088

注

  1. 参考文献1。論文はNakagaki, T., Yamada, H., Tóth, Á.「Maze-solving by an amoeboid organism」『Nature』407(6803), 470, 2000。迷路の満たし方は、北海道大学CoSTEPの記事「生き物の「賢さ」に迫る」(2014年)での中垣俊之の説明による。https://costep.open-ed.hokudai.ac.jp/like_hokudai/article/2751 ↩ ↩2

  2. 参考文献2。 ↩

  3. 参考文献3。式の形は、この模型を数学的に調べたBonifaci, V., Mehlhorn, K., Varma, G.「Physarum Can Compute Shortest Paths」(arXiv:1106.0423、2011年)の式(1)による。 ↩

  4. 参考文献4。式の形と、栄養が少ないときの取り込みは、参考文献8の4章D節の紹介による。 ↩

  5. 参考文献5。 ↩

  6. 参考文献6。タテジマキンチャクダイの縞はKondo, S., Asai, R.「A reaction–diffusion wave on the skin of the marine angelfish Pomacanthus」『Nature』376, 765–768, 1995、プレコの斑点はAsai, R.ほか『Mechanisms of Development』89, 87–92, 1999の結果として紹介されている。 ↩ ↩2 ↩3 ↩4

  7. Pearson, J. E.「Complex Patterns in a Simple System」『Science』261(5118), 189–192, 1993。 ↩

  8. 参考文献7。 ↩

  9. Ed Yong「Slime mould attacks simulates Tokyo rail network」(National Geographicのサイトの連載「Not Exactly Rocket Science」、2010年1月21日)による。https://www.nationalgeographic.com/science/article/slime-mould-attacks-simulates-tokyo-rail-network ↩ ↩2

  10. 中垣俊之「単細胞生物粘菌の「賢さ」を探る」『科研費NEWS』2010年度VOL.4, 13, 日本学術振興会。https://www.jsps.go.jp/file/storage/grants/j-grantsinaid/22_letter/data/news_2010_vol4/p-13.pdf ↩

  11. 参考文献8の2章C節。松下らの一連の実験(枯草菌OG-01株)をまとめたもの。 ↩

  12. 近藤滋「生物の模様と形」講演の要旨(明治大学 先端数理科学インスティテュート、2017年)による。https://cmma.mims.meiji.ac.jp/pdf/2017091113.pdf ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、1コマごとに「GPUの格子と粒を、決まった刻みで何回か進める → 皿の中を白と黒の網点で描く → 0.3秒ごとに場を小さくまとめて読み出し、グラフと数字にする」をくり返す。3つのモデルは別々の式だが、どれも「マスや粒が、まわりだけを見て次の値を決める」形なので、画面の画素を塗るのと同じ仕組みで、すべてのマスを一斉に計算できる。

皿の大きさと、画質ごとの細かさ

皿は直径90 mmで、皿を囲む正方形を格子に分けて計算する。格子の一辺のマスの数GRIDと、粒の面の一辺AG(粒の数はこの2乗まで)は、画質(低・中・高)で変わる。粘菌の長さはすべてmmで決めてあり、計算のときにマスの数へ直す。たとえばセンサーまでの距離SOは3.2 mmで、格子768なら約27マスになる。

定数と単位(src/sims/life-growth.ts の 33〜58 行)
type Model = "slime" | "colony" | "turing";const DISH = 90; // 皿の直径 mmconst GRID: Record<Model, number[]> = { slime: [320, 512, 768], colony: [320, 448, 576], turing: [192, 256, 384] }; // 格子の一辺のマスの数(画質 0・1・2)const BUDGET = [1.4e6, 3.2e6, 6.4e6]; // 1 コマで計算するマスと粒の数の上限(画質 0・1・2)const INSIDE = 0.985; // 皿の内側(半径に対する割合)// 粘菌の粒。長さは mm、時間は分const AG = [200, 320, 480]; // 粒の面の一辺(粒の数はこの 2 乗。皿の中のマスの約半分)const SLIME = {  SA: (22.5 * Math.PI) / 180, RA: (45 * Math.PI) / 180, // センサーの角度・1 回に回る角度  SO: 3.2, // センサーまでの距離 mm  V: 0.35, // 粒の速さ mm/分(1 時間に 2.1 cm)  LOST: 105, // 別の餌に着かないままこの道のり(mm)を進んだ粒は、つながった餌へ移す  FOOD: 1.9, // 餌(オートミールのかけら)の半径 mm  DEP: 5, // 粒が 1 歩ごとに残す匂い  FE: 10, // 餌が 1 刻みに出す匂い(餌の上のマスに足す)  CAP: 1, // 1 マスに入れる粒の数(混んだマスへは進まず、向きを変える)  RATE: 60, // 速さ 1 倍で 1 秒に進める時間(分)};const MAXFOOD = 20;// 細菌の集まり(無次元)const COL = { L: 500, S0: 2, BM: 0.71, SIG: 2.5, MIN: 2.4, RATE: 12.5, THR: 0.08, RING: 3 };//   L 皿の直径・S0 菌の動きやすさを決める数 σ0・BM と SIG 植える菌の濃さと広がり(b = BM·exp(−r²/SIG²))・MIN 時間 1 を何分とみなすか・//   RATE 速さ 1 倍で 1 秒に進める時間(栄養 n0 = 1.3 のとき。栄養が少ないと育つのが遅いので、(1.3/n0)^1.6 倍に速める)・//   THR 菌がいるとみなす b ÷ n0・RING 菌が届いた時刻の線を何秒(再生の時間)ごとに引くか// 動物の模様(無次元・格子 1 マス = 長さ 1 は格子 256 のとき)const TUR = { DU: 0.3, RATE: 500, THR: 0.2 }; // Du(格子 256 のマスで)・1 秒に進める時間・模様とみなす v

粒は1刻みに1マス進む。そこで「粒の速さV = 0.35 mm/分」から、1刻みが何分にあたるかを決めている(格子768なら約0.33分)。細菌と模様の式は単位を持たない無次元の式で、細菌だけは皿の直径を500、時間1を2.4分として、画面の時間と面積に直している。

粒は、面の1画素

粘菌の粒は、1つが面(テクスチャ)の1画素にあたる。画素の4つの数に、位置x・y、向き(と最後に触れた餌の番号)、餌を離れてから進んだ歩数を入れてある。FS_AGは、すべての粒について同時に「3か所を嗅ぐ → 向きを変える → 1マス進む」を計算し、新しい面に書く。

粘菌の粒(src/sims/life-growth.ts の 101〜197 行)
// 粒の面: 1 つの画素 = 1 つの粒。x, y(マス)・z = 向き + 8 × 最後に触れた餌の番号(31 はまだ無し)・w = 餌から離れて進んだ歩数(前の刻みで進めなかった粒は負)const FS_AG = `uniform sampler2D uAg, uTr, uMask, uDep;uniform float uSA, uRA, uSO, uLost, uSeed, uNF, uLive, uCap;uniform vec4 uFood[${MAXFOOD}];bool open(vec2 p) { if (p.x < 0.0 || p.y < 0.0 || p.x >= uN || p.y >= uN) return false; return texelFetch(uMask, ivec2(p), 0).r > 0.5; }float smell(vec2 p) { return open(p) ? texelFetch(uTr, ivec2(p), 0).r : -1.0; } // 壁や光の所は −1(嗅ぎ分けで必ず負ける)vec2 dir(float a) { return vec2(cos(a), sin(a)); }void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  vec4 a = texelFetch(uAg, c, 0);  if (a.x < 0.0) { o = a; return; } // 使わない粒(海のように通れない所が多い皿では、粒を減らす)  float id = floor(a.z / 8.0), h = a.z - id * 8.0, lost = a.w < 0.0 ? -a.w - 1.0 : a.w;  vec2 p = a.xy;  // 嗅ぐ: 前(F)・左前(L)・右前(R)  float F = smell(p + uSO * dir(h)), L = smell(p + uSO * dir(h + uSA)), R = smell(p + uSO * dir(h - uSA));  if (F > L && F > R) {}  else if (F < L && F < R) h += rnd(c, uSeed, 0) < 0.5 ? -uRA : uRA;  else if (L > R) h += uRA;  else if (R > L) h -= uRA;  // 1 マス進む。壁や光の所、ほかの粒がいるマス(前の刻みの数で見る)なら進まず、向きを変える。  // ただし今のマスのほうが混んでいれば、空いているほうへは進める(餌の上に集まった粒が散れるように)  vec2 q = p + dir(h);  bool moved = false;  if (open(q)) {    float there = texelFetch(uDep, ivec2(q), 0).g, here = texelFetch(uDep, ivec2(p), 0).g;    if (ivec2(q) == ivec2(p) || there < uCap || there < here - 0.5) { p = q; moved = true; }  }  if (!moved) h = rnd(c, uSeed, 1) * 6.2831853;  // 餌の上: 前と別の餌なら、離れて進んだ歩数を 0 に戻す(餌と餌の間を通った粒だけが網を保つ)  float fed = -1.0;  for (int k = 0; k < ${MAXFOOD}; k++) { if (float(k) >= uNF) break; vec2 d = p - uFood[k].xy; if (dot(d, d) < uFood[k].z * uFood[k].z) fed = float(k); }  if (fed >= 0.0 && fed != id) lost = 0.0; else lost = min(lost + 1.0, uLost + 1.0);  if (fed >= 0.0) id = fed;  // 別の餌に着かないまま進みすぎた粒(行き止まりの枝や、餌と餌の間を通らない所にいる粒)は、網がつながった餌の 1 つへ移す  // (流れの少ない管から、原形質が餌のある所へ引き揚げることの代わり。粒の数は変わらない)  if (lost > uLost && uLive > 0.5) {    float m = floor(rnd(c, uSeed, 2) * uLive), j = 0.0;    vec4 f = uFood[0]; float fk = 0.0;    for (int k = 0; k < ${MAXFOOD}; k++) { if (float(k) >= uNF) break; if (uFood[k].w > 0.5) { if (j == m) { f = uFood[k]; fk = float(k); } j += 1.0; } }    float an = rnd(c, uSeed, 3) * 6.2831853, rr = sqrt(rnd(c, uSeed, 4)) * f.z * 0.8;    p = f.xy + rr * dir(an); h = rnd(c, uSeed, 5) * 6.2831853; id = fk; lost = 0.0; moved = true;  }  o = vec4(p, mod(h, 6.2831853) + 8.0 * id, moved ? lost : -lost - 1.0);}`;// 粒を置く: 通れる所に一様に。uFrac の割合の粒だけを使い、残りは皿の外にしまうconst FS_AGINIT = `uniform sampler2D uMask;uniform float uSeed, uLost, uFrac;bool open(vec2 p) { if (p.x < 0.0 || p.y < 0.0 || p.x >= uN || p.y >= uN) return false; return texelFetch(uMask, ivec2(p), 0).r > 0.5; }void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  vec2 p = vec2(uN * 0.5);  if (rnd(c, uSeed, 60) > uFrac) { o = vec4(-10.0, -10.0, 8.0 * 31.0, 0.0); return; }  for (int t = 0; t < 24; t++) { vec2 q = vec2(rnd(c, uSeed, 2 * t), rnd(c, uSeed + 1.0, 2 * t + 1)) * uN; if (open(q)) { p = q; break; } }  o = vec4(p, rnd(c, uSeed, 6) * 6.2831853 + 8.0 * 31.0, floor(rnd(c, uSeed, 7) * uLost));}`;// 粒が匂いを残す: 粒 1 つを 1 画素の点として、足し算で重ねて描く。r = 残した匂い(進めた粒だけ)・g = マスにいる粒の数const VS_DEP = `#version 300 esprecision highp float;precision highp int;precision highp sampler2D;in float aPos;uniform sampler2D uAg;uniform float uA, uN;out float vM;void main() {  int i = int(aPos), A = int(uA);  vec4 a = texelFetch(uAg, ivec2(i % A, i / A), 0);  vM = a.w >= 0.0 ? 1.0 : 0.0;  gl_Position = vec4(a.xy / uN * 2.0 - 1.0, 0.0, 1.0);  gl_PointSize = 1.0;}`;const FS_DEP = `#version 300 esprecision highp float;in float vM;uniform float uDepA;out vec4 o;void main() { o = vec4(vM * uDepA, 1.0, 0.0, 1.0); }`;// 匂い: 残った匂いを足し、3×3 のマス(通れるマスだけ)で平均して広げ、餌の匂いを足し、uKeep 倍に薄めるconst FS_TR = `uniform sampler2D uTr, uDep, uMask;uniform float uKeep, uFE, uNF;uniform vec4 uFood[${MAXFOOD}];void main() {  ivec2 c = ivec2(gl_FragCoord.xy), n = textureSize(uTr, 0);  if (texelFetch(uMask, c, 0).r < 0.5) { o = vec4(0.0); return; }  float me = texelFetch(uTr, c, 0).r + texelFetch(uDep, c, 0).r, s = me, w = 1.0;  for (int k = 0; k < 8; k++) {    ivec2 q = clamp(c + OFF[k], ivec2(0), n - 1);    if (texelFetch(uMask, q, 0).r > 0.5) { s += texelFetch(uTr, q, 0).r + texelFetch(uDep, q, 0).r; w += 1.0; }  }  float v = s / w;  vec2 p = vec2(c) + 0.5;  for (int k = 0; k < ${MAXFOOD}; k++) { if (float(k) >= uNF) break; vec2 d = p - uFood[k].xy; if (dot(d, d) < uFood[k].z * uFood[k].z) v += uFE; }  o = vec4(v * uKeep, 0.0, 0.0, 1.0);}`;

匂いを残すところでは、粒を1画素の点として描き、重なった点の値を足し算で重ねる(色を混ぜる設定を「足す」にする)。点は赤に残した匂いを、緑に1を書くので、緑はそのマスにいる粒の数になる。次の刻みで粒が進むとき、行き先のマスの緑が1以上なら、そこには入らずに向きを変える。ただし今いるマスのほうが混んでいれば、空いたほうへは進める。Jonesのモデルの「1マスに粒1つ」の決まりを、1刻み前の数で近似している。

FS_TRは、匂いを3×3のマスで平均して広げ、餌の匂いを足して、uKeep倍に薄める。uKeepは「1分に何%薄れるか」のつまみの値を、1刻みの分数に直した値である。粒は最後に触れた餌の番号を覚えていて、別の餌に着いたときだけ歩数を0に戻す。歩数が105 mmぶんを超えた粒は、粒の届いている餌の1つへ移す。餌に粒が届いているかは、読み出した粒の数からCPUが決めて、uFoodの4つ目の数で渡す。

菌の流れを、マスの境目ごとに足す

細菌の式の広がりの項は、隣のマスとの境目を通る流れの合計として計算している。境目の流れやすさは両側のの平均で、流れはの差に比例する。片側から出た分は、そのまま隣に入るので、菌の量は計算の途中で増えも減りもしない。

細菌と栄養(src/sims/life-growth.ts の 199〜225 行)
// 場: r = 菌の密度 b・g = 栄養 n・b = 菌が届いた時刻(まだなら 0)// ∇·(D∇b) は、隣のマスとの間の D(両側の平均)で流れを足し合わせる形(菌の量が保たれる)。9 点の重み: 辺 2/3・角 1/6(角の距離 √2 の分を含む)const FS_COL = `uniform sampler2D uS;uniform float uDt, uIdx2, uS0, uSeed, uT, uThr, uPlate;// σ = σ0(1 + Δ)。Δ は、場所ごとに決まった寒天のむら(皿ごとの種 uPlate)と、刻みごとの揺らぎを半分ずつ(どちらも −1〜1 の三角分布)float sig(ivec2 q) { return uS0 * (1.0 + 0.5 * (rnd(q, uPlate, 2) - rnd(q, uPlate, 3)) + 0.5 * (rnd(q, uSeed, 0) - rnd(q, uSeed, 1))); }void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  vec4 s = texelFetch(uS, c, 0);  if (!inDish(c)) { o = vec4(0.0); return; }  float Dc = sig(c) * s.g * s.r, fb = 0.0, fn = 0.0;  for (int k = 0; k < 8; k++) {    ivec2 q = c + OFF[k];    if (!inDish(q)) continue; // 皿の縁: 出入りなし    vec4 t = texelFetch(uS, q, 0);    float Dq = sig(q) * t.g * t.r;    fb += W9[k] * 0.5 * (Dc + Dq) * (t.r - s.r);    fn += W9[k] * (t.g - s.g);  }  float g = s.g * s.r; // 菌が栄養を取り込んで増える速さ nb  float b = max(0.0, s.r + uDt * (fb * uIdx2 + g));  float n = max(0.0, s.g + uDt * (fn * uIdx2 - g));  float ta = s.b;  if (ta <= 0.0 && b > uThr) ta = uT;  o = vec4(b, n, ta, 1.0);}`;

隣のマスは、辺で接する4つに重み2/3、角で接する4つに重み1/6を掛けて足す。この9点の重みは、4つだけを使う場合より向きによる偏りが小さく、集まりが格子にそって菱形にゆがみにくい。のばらつき(三角分布)は、寒天の場所ごとに決まった分と刻みごとの揺らぎを半分ずつにしてある。

計算は陽的オイラー法で進める。今の値と変わる速さから、次の刻みの値を求めるいちばん簡単な方法である。刻みは、この方法で値が発散しない条件の約半分にした。の最大はほどなので、栄養が多いほど刻みは細かくなる。菌の密度が初めてしきい値を超えた時刻を3つ目の数に書いておき、描くときに「同じ時刻に届いた所」を結ぶ線にしている。

模様の式

GrayとScottの式も、同じ9点の重みでラプラシアンを計算する。皿の外のマスは、自分と同じ値とみなして使う。これで縁では物質が出入りしない。

模様の式(src/sims/life-growth.ts の 227〜241 行)
// 場: r = u・g = v。ラプラシアンは 9 点(マスの幅 = 1)const FS_TUR = `uniform sampler2D uS;uniform float uDt, uDu, uDv, uF, uK;void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  if (!inDish(c)) { o = vec4(1.0, 0.0, 0.0, 1.0); return; }  vec4 s = texelFetch(uS, c, 0);  vec2 lap = vec2(0.0);  for (int k = 0; k < 8; k++) { ivec2 q = c + OFF[k]; vec2 t = inDish(q) ? texelFetch(uS, q, 0).rg : s.rg; lap += W9[k] * (t - s.rg); }  float u = s.r, v = s.g, uvv = u * v * v;  u += uDt * (uDu * lap.x - uvv + uF * (1.0 - u));  v += uDt * (uDv * lap.y + uvv - (uF + uK) * v);  o = vec4(clamp(u, 0.0, 1.0), clamp(v, 0.0, 1.0), s.b, 1.0);}`;

広がる速さは、格子256のときの値にを掛ける。格子を細かくしても、模様の大きさが皿に対して変わらないようにするためだ。刻みはになるように決めている。

1コマに進める回数と、読み出し

planは、「時間の速さ × 1秒に進める時間」を刻みの数に直し、端数は次のコマへ持ち越す。刻みの長さは画面のコマ数によらず一定なので、速い端末でも遅い端末でも、同じ式で同じだけ時間が進む。1コマの計算量(マスと粒の数 × 刻み)が上限BUDGETを超えるときは、そのコマでは上限までにとどめ、残りは捨てる。遅い端末では、そのぶん皿の時間の進みが遅くなる。

進め方と測り方(src/sims/life-growth.ts の 409〜624 行)
// 測る: uB × uB マスを 1 つにまとめる。r = 平均・g = しきい値を超えたマスの割合・b = 粒の数(粘菌)・a = 皿の中のマスの割合const FS_RED = `uniform sampler2D uS, uD;uniform float uB, uMode, uThr;void main() {  ivec2 c = ivec2(gl_FragCoord.xy);  int B = int(uB);  float s1 = 0.0, s2 = 0.0, s3 = 0.0, s4 = 0.0;  for (int j = 0; j < 8; j++) {    if (j >= B) break;    for (int i = 0; i < 8; i++) {      if (i >= B) break;      ivec2 q = c * B + ivec2(i, j);      vec4 f = texelFetch(uS, q, 0);      float x = uMode > 1.5 ? f.g : f.r, ins = inDish(q) ? 1.0 : 0.0;      s1 += x; s2 += x > uThr ? ins : 0.0; s4 += ins;      if (uMode < 0.5) s3 += texelFetch(uD, q, 0).g;    }  }  float k = 1.0 / float(B * B);  o = vec4(s1 * k, s2 * k, s3, s4 * k);}`;/** 粘菌の 1 刻み: 粒が嗅いで進む → 粒が匂いを残す → 匂いが広がって薄れる */function slimeStep(api: Api, s: S) {  const g = s.g!, gl = g.gl, P = g.P, N = g.N, Ag = g.Ag!, c = cellMM(s), mps = minPerStep(s);  const food = s.foodBuf; food.fill(0); let live = 0;  s.foods.forEach((f, k) => { food[k * 4] = (f.x * 0.5 + 0.5) * N; food[k * 4 + 1] = (f.y * 0.5 + 0.5) * N; food[k * 4 + 2] = SLIME.FOOD / c; food[k * 4 + 3] = f.reached ? 1 : 0; if (f.reached) live++; });  // 1. 粒  gl.bindFramebuffer(gl.FRAMEBUFFER, Ag.writeFb); gl.viewport(0, 0, g.A, g.A);  const pa = P.pAg; pa.use(); common(pa, g);  pa.tex("uAg", 0, Ag.read); pa.tex("uTr", 1, g.S.read); pa.tex("uMask", 2, g.mask); pa.tex("uDep", 3, g.dep!);  pa.set("uCap", SLIME.CAP); pa.set("uSA", SLIME.SA); pa.set("uRA", SLIME.RA); pa.set("uSO", SLIME.SO / c); pa.set("uLost", SLIME.LOST / c);  pa.set("uSeed", (s.seed = (s.seed + 1) % 99991)); pa.set("uNF", s.foods.length); pa.set("uLive", live);  if (P.locAg) gl.uniform4fv(P.locAg, food);  g.quad.draw(); Ag.swap();  // 2. 匂いを残す(点を足し算で重ねる)  gl.bindFramebuffer(gl.FRAMEBUFFER, g.depFb); gl.viewport(0, 0, N, N); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);  const pd = P.pDep; pd.use(); pd.tex("uAg", 0, Ag.read); pd.set("uA", g.A); pd.set("uN", N); pd.set("uDepA", SLIME.DEP);  gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);  gl.bindVertexArray(g.vao); gl.drawArrays(gl.POINTS, 0, g.A * g.A); gl.bindVertexArray(null);  gl.disable(gl.BLEND);  // 3. 広がって薄れる。薄れる速さは「1 分に何 %」から 1 刻みの割合に直す  g.S.bindWrite();  const pt = P.pTr; pt.use(); common(pt, g);  pt.tex("uTr", 0, g.S.read); pt.tex("uDep", 1, g.dep!); pt.tex("uMask", 2, g.mask);  pt.set("uKeep", Math.pow(1 - api.num("decay") / 100, mps));  pt.set("uFE", SLIME.FE); pt.set("uNF", s.foods.length);  if (P.locTr) gl.uniform4fv(P.locTr, food);  g.quad.draw(); g.S.swap();}function colonyStep(s: S) {  const g = s.g!, p = g.P.pCol;  g.S.bindWrite(); p.use(); common(p, g); p.tex("uS", 0, g.S.read);  const dx = colDx(s);  p.set("uDt", colDt(s)); p.set("uIdx2", 1 / (dx * dx)); p.set("uS0", COL.S0); p.set("uSeed", (s.seed = (s.seed + 1) % 99991));  p.set("uT", s.time + colDt(s)); p.set("uThr", COL.THR * s.n0); p.set("uPlate", 50000 + s.plate);  g.quad.draw(); g.S.swap();}function turingStep(api: Api, s: S) {  const g = s.g!, p = g.P.pTur;  g.S.bindWrite(); p.use(); common(p, g); p.tex("uS", 0, g.S.read);  const Du = turDu(s);  p.set("uDt", turDt(s)); p.set("uDu", Du); p.set("uDv", Du / 2); p.set("uF", api.num("F")); p.set("uK", api.num("k"));  g.quad.draw(); g.S.swap();}/** 1 コマに進める刻みの数。時間の速さ × 1 秒あたりの時間を刻みに直し、1 コマの計算量の上限で切る */function plan(api: Api, s: S) {  const g = s.g!, N = g.N;  const cost = s.model === "slime" ? 2 * g.A * g.A + N * N : N * N;  const budget = Math.max(1, Math.floor(BUDGET[api.quality] / cost));  const rate = s.model === "slime" ? SLIME.RATE : s.model === "colony" ? colRate(s) : TUR.RATE;  s.acc += (api.dt * api.num("speed") * rate) / stepTime(s);  const n = Math.min(budget, Math.floor(s.acc));  s.acc -= n; if (s.acc > budget) s.acc = 0;  return n;}function advance(api: Api, s: S, n: number) {  for (let i = 0; i < n; i++) {    if (s.model === "slime") slimeStep(api, s); else if (s.model === "colony") colonyStep(s); else turingStep(api, s);    s.time += stepTime(s); s.steps++;  }}/** 測る: 場を小さくまとめた面を描き、読み出しは待たずに次のコマ以降で受け取る */function drawReduce(api: Api, s: S) {  const g = s.g!, gl = g.gl, p = g.P.pRed;  gl.bindFramebuffer(gl.FRAMEBUFFER, g.redFb); gl.viewport(0, 0, g.M, g.M);  p.use(); common(p, g); p.tex("uS", 0, g.S.read); p.tex("uD", 1, g.dep ?? g.S.read);  p.set("uB", g.B); p.set("uMode", s.model === "slime" ? 0 : s.model === "colony" ? 1 : 2);  p.set("uThr", s.model === "slime" ? tubeThr(api, s) : s.model === "colony" ? COL.THR * s.n0 : TUR.THR);  g.quad.draw();}function requestRead(api: Api, s: S) {  const g = s.g!, gl = g.gl;  if (g.sync) return;  drawReduce(api, s);  gl.bindBuffer(gl.PIXEL_PACK_BUFFER, g.pbo); gl.readPixels(0, 0, g.M, g.M, gl.RGBA, gl.FLOAT, 0); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);  g.sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); g.syncT = s.time; s.lastReq = api.t;  gl.flush();}function pollRead(api: Api, s: S) {  const g = s.g!, gl = g.gl;  if (!g.sync) return;  if (gl.getSyncParameter(g.sync, gl.SYNC_STATUS) !== gl.SIGNALED) return;  gl.deleteSync(g.sync); g.sync = null;  gl.bindBuffer(gl.PIXEL_PACK_BUFFER, g.pbo); gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, g.buf); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);  analyze(api, s, g.syncT);}function readNow(api: Api, s: S) {  const g = s.g!, gl = g.gl;  if (g.sync) return;  drawReduce(api, s);  gl.readPixels(0, 0, g.M, g.M, gl.RGBA, gl.FLOAT, g.buf);  analyze(api, s, s.time);}/** * 粘菌の匂いの平均: 粒が残す量(粒の数 × DEP)と、薄れる量(全体の匂い × (1 − uKeep))が釣り合うときの、通れるマス 1 つあたりの値。 * 管とみなすしきい値と、網点の濃さの基準に使う(薄れる速さのつまみを動かしても、網の見え方がそろう) */function meanTrail(api: Api, s: S) {  const keep = Math.pow(1 - api.num("decay") / 100, minPerStep(s));  return (s.active0 * SLIME.DEP) / (Math.max(1e-3, 1 - keep) * Math.max(1, s.free));}const tubeThr = (api: Api, s: S) => meanTrail(api, s) * 1.2; /** 小さくまとめた面(M × M)から、面積・量・斑点の数・餌のつながりを数える */function analyze(api: Api, s: S, tAt: number) {  const g = s.g!, M = g.M, b = g.buf, B = g.B, st = s.st;  if (!Number.isFinite(b[0])) return;  const cellCM = cellMM(s) / 10, blockCM2 = (B * cellCM) ** 2;  let area = 0, sum = 0, ins = 0;  for (let k = 0; k < M * M; k++) { area += b[k * 4 + 1]; sum += b[k * 4]; ins += b[k * 4 + 3]; }  st.area = area * blockCM2; st.frac = ins > 0 ? area / ins : 0;  let v = 0;  if (s.model === "slime") {    let agents = 0; for (let k = 0; k < M * M; k++) agents += b[k * 4 + 2];    st.agents = agents;    // 管: まとめたマスの匂いの平均がしきい値を超える所    const bin = new Uint8Array(M * M); for (let k = 0; k < M * M; k++) bin[k] = b[k * 4] > tubeThr(api, s) * 0.5 ? 1 : 0;    const { lab } = label(bin, M);    const blk = (f: Food) => [clamp(Math.floor((f.x * 0.5 + 0.5) * M), 0, M - 1), clamp(Math.floor((f.y * 0.5 + 0.5) * M), 0, M - 1)];    const sets: Set<number>[] = [];    for (const f of s.foods) {      const [bx, by] = blk(f); let near = 0; const ls = new Set<number>();      for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {        const x = bx + dx, y = by + dy; if (x < 0 || y < 0 || x >= M || y >= M) continue;        const k = y * M + x; near += b[k * 4 + 2]; if (lab[k] > 0) ls.add(lab[k]);      }      // 粒が来ていれば「網がつながった餌」。来なくなっても、しばらくはつながったままとみなす      if (near >= 4) { f.reached = true; f.miss = 0; } else if (f.reached && ++f.miss > 6) f.reached = false;      sets.push(ls);    }    let links = 0;    s.foods.forEach((f, i) => {      const was = f.link;      f.link = sets.some((o, j) => j !== i && [...o].some((l) => sets[i].has(l)));      if (f.link) links++;      if (f.link && !was && s.flags.ready) api.note(`${f.name || "餌"}が、ほかの餌と網でつながりました`, 2200);    });    st.links = links;    // 網の面積が、いちばん広かったときの半分を切ったら知らせる(余分な枝が消え、餌を結ぶ管に絞られた)    s.top = Math.max(s.top, st.area);    if (s.foods.length >= 2 && !s.flags.thin && s.top > 0 && st.area < s.top * 0.5 && s.time > 600 && s.flags.ready) { s.flags.thin = true; api.note("餌から離れた枝がやせて消え、餌どうしを結ぶ管が残りました", 3200); }    v = st.area;  } else if (s.model === "colony") {    const dx = colDx(s), bc = (B * dx) ** 2;    st.total = sum * bc; s.K = s.n0 * ins * bc;    v = clamp((st.total / Math.max(1e-9, s.K)) * 100, 1e-4, 100);    // 皿の縁に届いたか    let edge = false;    for (let y = 0; y < M && !edge; y++) for (let x = 0; x < M; x++) { const X = ((x + 0.5) / M) * 2 - 1, Y = ((y + 0.5) / M) * 2 - 1; if (X * X + Y * Y > 0.9 * 0.9 && b[(y * M + x) * 4 + 1] > 0.3) { edge = true; break; } }    if (edge && !st.edge && s.flags.ready) api.note("菌の集まりが皿の縁に届きました", 2600);    st.edge = edge;    if (v > 97 && !s.flags.full && s.flags.ready) { s.flags.full = true; api.note("栄養をほぼ使い切り、菌の量が頭打ちになりました", 3000); }  } else {    const bin = new Uint8Array(M * M); for (let k = 0; k < M * M; k++) bin[k] = b[k * 4 + 1] > 0.5 ? 1 : 0;    const { n, ext } = label(bin, M);    const lim = 8 * (M / 128); // 斑点の直径は皿の約 3 %(まとめたマスで 4 つほど)。その 2 倍を超える塊は細長い模様とみなす    let spots = 0, long = 0;    for (let i = 1; i <= n; i++) { if (ext[i] <= lim) spots++; else long++; }    st.spots = spots; st.long = long;    v = st.frac * 100;    if (v > 1 && s.hist.v.length > 8 && !s.flags.full) {      const L = s.hist.v.length, old = s.hist.v[Math.max(0, L - 8)];      if (v - old < 0.3 && v > 20) { s.flags.full = true; if (s.flags.ready) api.note("模様が皿いっぱいに広がり、もう増えなくなりました", 2800); }    }  }  s.have = true;  record(s, tAt, v);}/** 8 方向につながった塊に番号を付ける。ext は塊の外接の四角の長い辺(まとめたマスの数) */function label(bin: Uint8Array, M: number) {  const lab = new Int32Array(M * M), stack = new Int32Array(M * M), ext: number[] = [0];  let n = 0;  for (let k = 0; k < M * M; k++) {    if (!bin[k] || lab[k]) continue;    n++; let sp = 0, x0 = M, x1 = 0, y0 = M, y1 = 0;    stack[sp++] = k; lab[k] = n;    while (sp) {      const q = stack[--sp], x = q % M, y = (q / M) | 0;      x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y);      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {        const X = x + dx, Y = y + dy; if (X < 0 || Y < 0 || X >= M || Y >= M) continue;        const r = Y * M + X; if (bin[r] && !lab[r]) { lab[r] = n; stack[sp++] = r; }      }    }    ext.push(Math.max(x1 - x0, y1 - y0) + 1);  }  return { lab, n, ext };}/** グラフの点を足す(300 点を超えたら 1 つおきに間引く) */function record(s: S, t: number, v: number) {  const H = s.hist;  if (H.t.length && t <= H.t[H.t.length - 1]) { H.v[H.v.length - 1] = v; return; }  H.t.push(t); H.v.push(v);  if (H.t.length > 300) { H.t = H.t.filter((_, i) => i % 2 === 0); H.v = H.v.filter((_, i) => i % 2 === 0); }}

グラフと数字は、場をFS_REDで128×128ほどにまとめた面から求める。まとめた面は、GPUに「読み出して」と頼んだあと待たずに次のコマへ進み、届いたことを確かめてから受け取る(fenceSync)。描くたびにGPUの計算が終わるのを待つと、コマが止まってしまうからだ。受け取った面からは、網や菌の集まりの面積、菌の量、斑点の数を数える。斑点と網のつながりは、しきい値を超えたマスを8方向につながった塊に分けて(label)調べている。

指の操作と配置

皿の位置と大きさは、描画面の縦と横から毎コマ決める。横長なら左に皿、右にグラフを、縦長なら上に皿、下にグラフを置く。右下の角には「記事ビュー」のボタンが重なるので、グラフはそこを避ける。

操作(src/sims/life-growth.ts の 681〜741 行)
interface Lay { cx: number; cy: number; r: number; g: { x: number; y: number; w: number; h: number } | null; k: number }/** * 配置は描画面の縦横から決める。横長なら左に皿・右にグラフ、縦長なら上に皿・下にグラフ。 * 右下の角(右から 170px・下から 50px)には「記事ビュー」のボタンが重なるので、グラフと文字はそこを避ける */const SAFE_W = 170, SAFE_H = 56;function layout(api: Api): Lay {  const W = api.w, H = api.h, k = clamp(api.u(1), 0.74, 1.35), pad = 14 * k, ax = 40 * k, top = 22 * k, bot = 26 * k;  if (W >= H * 1.15) {    const gw = clamp(W * 0.27, 190 * k, 340 * k);    const r = Math.min((H - 2 * pad) / 2, (W - gw - 3 * pad - ax) / 2);    if (r >= 90 * k) {      const x0 = Math.max(pad, (W - (2 * r + 2 * pad + ax + gw)) / 2);      const gh = clamp(Math.min(r * 1.0, H - top - bot - SAFE_H - 2 * pad), 70 * k, 250 * k);      const gy = Math.min(H / 2 - gh / 2, H - SAFE_H - bot - gh);      return { cx: x0 + r, cy: H / 2, r, k, g: { x: x0 + 2 * r + 2 * pad + ax, y: Math.max(top + pad, gy), w: gw - 22 * k, h: gh } };    }  } else {    const gh = clamp(H * 0.2, 80 * k, 180 * k);    const r = Math.min((W - 2 * pad) / 2, (H - gh - top - bot - 2 * pad - SAFE_H) / 2);    if (r >= 90 * k) {      const y0 = Math.max(pad, (H - SAFE_H - (2 * r + gh + top + bot + pad)) / 2);      return { cx: W / 2, cy: y0 + r, r, k, g: { x: pad + ax, y: y0 + 2 * r + pad + top, w: W - 2 * pad - ax - 6 * k, h: gh } };    }  }  return { cx: W / 2, cy: H / 2, r: Math.max(40, Math.min(W, H) / 2 - pad), k, g: null };}/** 指の位置を皿の座標(中心 0・半径 1・上が +)に */function dishAt(api: Api): [number, number] { const L = layout(api); return [(api.pointer.x - L.cx) / L.r, (L.cy - api.pointer.y) / L.r]; }function touch(type: string, api: Api, s: S) {  if (!s.g) return;  const [x, y] = dishAt(api), inside = x * x + y * y < 0.96 * 0.96, L = layout(api);  if (s.model === "slime") {    if (type === "down") {      let best = -1, bd = 18 * L.k;      s.foods.forEach((f, i) => { const d = Math.hypot((f.x - x) * L.r, (f.y - y) * L.r); if (d < bd) { bd = d; best = i; } });      if (best >= 0) { s.drag = { i: best, moved: false, fresh: false }; return; }      if (!inside) return;      if (!openAt(s, x, y)) { api.note("光を当てた所(海)には、餌を置けません", 2000); return; }      if (s.foods.length >= MAXFOOD) { api.note(`餌は ${MAXFOOD} 個まで置けます。餌をたたくと取り除けます`, 2400); return; }      s.foods.push({ x, y, name: "", side: "r", reached: false, miss: 0, link: false });      s.drag = { i: s.foods.length - 1, moved: false, fresh: true };      s.flags.ready = true;      api.emit("food", { n: s.foods.length });    } else if (type === "move" && s.drag && api.pointer.down) {      const f = s.foods[s.drag.i]; if (!f) return;      if (Math.hypot(api.pointer.x - api.pointer.x0, api.pointer.y - api.pointer.y0) > 6) s.drag.moved = true;      if (!s.drag.moved) return;      const r = Math.hypot(x, y), lim = 0.92, nx = r > lim ? (x / r) * lim : x, ny = r > lim ? (y / r) * lim : y;      if (openAt(s, nx, ny)) { f.x = nx; f.y = ny; }    } else if (type === "up" || type === "cancel") {      if (s.drag && !s.drag.moved && !s.drag.fresh && type === "up") s.foods.splice(s.drag.i, 1);      s.drag = null;    }    return;  }  // 細菌と模様: なぞった所に植える・種をまく  if (type === "down") { if (!inside) return; s.brush = [x, y]; brush(s, x, y, x, y); s.flags.ready = true; api.emit("paint", { model: s.model }); }  else if (type === "move" && s.brush && api.pointer.down) { if (!inside) return; brush(s, s.brush[0], s.brush[1], x, y); s.brush = [x, y]; }  else if (type === "up" || type === "cancel") s.brush = null;}

粘菌では、たたいた所に餌を置き、餌の上から押してドラッグすると動かせる。動かさずに離すと取り除く。細菌と模様では、なぞった線分の近くのマスに、菌を植えるか、模様の種(u = 0.5、v = 0.25)をまく。

白と黒の網点で描く

FS_SHOWは、画面の画素ごとに皿のどこにあたるかを求め、4つのマスの値を直線で補ってから、濃さを4×4の網点で白か黒に落とす。粘菌は匂いの濃さを皿全体の平均との比で、細菌は菌の密度を、模様はを、それぞれ濃さに直している。

描画(src/sims/life-growth.ts の 743〜878 行)
// 皿の中を、場の値から白と黒の網点にする。場の値は 4 つのマスから直線で補うconst FS_SHOW = `uniform sampler2D uS, uMask, uDep;uniform float uModel, uVis, uN0, uRing, uPx;uniform vec3 uC;${DITHER}vec4 at(ivec2 q) { return texelFetch(uS, clamp(q, ivec2(0), ivec2(int(uN) - 1)), 0); }void main() {  vec2 fc = gl_FragCoord.xy, q = (fc - uC.xy) / uC.z;  if (dot(q, q) > 1.0) { o = vec4(uPaper, 1.0); return; }  vec2 g = (q * 0.5 + 0.5) * uN - 0.5;  ivec2 i0 = ivec2(floor(g)); vec2 f = g - floor(g);  vec4 a = at(i0), b = at(i0 + ivec2(1, 0)), c = at(i0 + ivec2(0, 1)), d = at(i0 + ivec2(1, 1));  vec4 s = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);  float tone;  if (uModel < 0.5) {    // 粘菌: 匂いの濃さ(皿全体の平均 uVis との比)。平均の半分までは黒、管は白。粒がいるマスは淡い点で重ねる。光を当てた所は斜線    tone = 1.0 - exp(-max(0.0, s.r / uVis - 0.5) / 1.6);    ivec2 mi = clamp(ivec2(floor(g + 0.5)), ivec2(0), ivec2(int(uN) - 1));    tone = max(tone, 0.28 * min(1.0, texelFetch(uDep, mi, 0).g));    if (texelFetch(uMask, mi, 0).r < 0.5) tone = dot(q, q) < ${INSIDE * INSIDE} && mod(fc.x - fc.y, 7.0 * uPx) < 1.3 * uPx ? 1.0 : 0.0;  } else if (uModel < 1.5) {    // 細菌: 菌の密度を濃い網点で。外側は栄養が減った所をうすい点で。菌が届いた時刻の等しい線(年輪のような線)を地の色の細い線で    float dn = clamp(s.r / uN0, 0.0, 1.0);    tone = 0.86 * pow(dn, 0.6);    float gmin = min(min(a.g, b.g), min(c.g, d.g)); // 皿の外のマス(栄養 0)と補ったところでは、減った所とみなさない    if (dn < 0.06 && gmin > 0.0) tone = max(tone, 0.2 * clamp(1.0 - s.g / uN0, 0.0, 1.0));    float tmin = min(min(a.b, b.b), min(c.b, d.b));    if (tmin > 0.0 && dn > 0.2) {      float x = s.b / uRing, w = fwidth(x);      if (abs(fract(x - 0.5) - 0.5) < 0.45 * w) tone = 0.0;    }  } else {    // 模様: v の濃さ。縁だけ網点になるように急に切り替える    tone = smoothstep(0.1, 0.3, s.g);  }  o = vec4(mono(clamp(tone, 0.0, 1.0), fc), 1.0);}`;function show(api: Api, s: S, L: Lay) {  const g = s.g!, gl = g.gl, p = g.P.pShow;  toScreen(gl, api.glw, api.glh);  const sx = api.glw / api.w;  p.use(); common(p, g);  p.tex("uS", 0, g.S.read); p.tex("uMask", 1, g.mask); p.tex("uDep", 2, g.dep ?? g.S.read);  p.set("uInk", ...api.inkRGB); p.set("uPaper", ...api.paperRGB);  p.set("uModel", s.model === "slime" ? 0 : s.model === "colony" ? 1 : 2);  p.set("uVis", s.model === "slime" ? meanTrail(api, s) : 1); p.set("uN0", Math.max(0.05, s.n0)); p.set("uRing", colRate(s) * COL.RING); p.set("uPx", Math.max(1, sx));  p.set("uC", L.cx * sx, (api.h - L.cy) * sx, L.r * sx);  g.quad.draw();}const niceUp = (v: number) => { const e = Math.pow(10, Math.floor(Math.log10(Math.max(v, 1e-9)))), m = v / e; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * e; };/** 時間の軸の単位: 粘菌と細菌は時間、模様は無次元の時間(千) */function tUnit(s: S, t: number) { return s.model === "slime" ? t / 60 : s.model === "colony" ? (t * COL.MIN) / 60 : t / 1000; }function graph(api: Api, s: S, L: Lay) {  const G = L.g; if (!G) return;  const k = L.k, fs = 10.5 * k, H = s.hist, n = H.t.length;  const title = s.model === "slime" ? "網の面積(cm²)" : s.model === "colony" ? "菌の量(上限に対する %・対数の目盛り)" : "模様の面積(%)";  api.text(title, G.x - 34 * k, G.y - 9 * k, { size: 11.5 * k });  const tmax = niceUp(Math.max(s.model === "turing" ? 5 : 4, n ? tUnit(s, H.t[n - 1]) * 1.05 : 0));  const X = (t: number) => G.x + (tUnit(s, t) / tmax) * G.w;  const log = s.model === "colony";  let ymax = 1;  if (!log) { for (const v of H.v) ymax = Math.max(ymax, v); ymax = s.model === "turing" ? Math.min(100, niceUp(ymax * 1.1)) : niceUp(ymax * 1.1); }  const Y = (v: number) => (log ? G.y + G.h - ((Math.log10(clamp(v, 1e-3, 100)) + 3) / 5) * G.h : G.y + G.h - (v / ymax) * G.h);  // 目盛り  api.line(G.x, G.y + G.h, G.x + G.w, G.y + G.h, { w: 1 * k }); api.line(G.x, G.y, G.x, G.y + G.h, { w: 1 * k });  const yt = log ? [0.001, 0.1, 10, 100] : [0, ymax / 2, ymax];  for (const v of yt) {    const y = Y(v);    api.line(G.x - 4 * k, y, G.x, y, { w: 1 * k });    if (v > 0) api.line(G.x, y, G.x + G.w, y, { w: 0.6 * k, dash: [2 * k, 5 * k] });    api.text(log ? `${v}` : fmt(v, Number.isInteger(v) ? 0 : 1), G.x - 7 * k, y + fs * 0.35, { size: fs, align: "right", font: "mono" });  }  for (const t of [0, tmax / 2, tmax]) { const x = G.x + (t / tmax) * G.w; api.line(x, G.y + G.h, x, G.y + G.h + 4 * k, { w: 1 * k }); api.text(fmt(t, Number.isInteger(t) ? 0 : 1), x, G.y + G.h + 15 * k, { size: fs, align: "center", font: "mono" }); }  api.text(s.model === "turing" ? "時間(千・無次元)" : "時間", G.x + G.w, G.y + G.h - 6 * k, { size: fs, align: "right" });  // 細菌: よく混ぜた液の中で同じ反応が進んだ場合(ロジスティック曲線)と上限  if (log && s.K > 0) {    const r = s.n0, K = s.K, B0 = s.B0, pts: [number, number][] = [];    const tEnd = (tmax * 60) / COL.MIN;    for (let i = 0; i <= 80; i++) { const t = (tEnd * i) / 80, Bt = K / (1 + (K / B0 - 1) * Math.exp(-r * t)); pts.push([X(t), Y((Bt / K) * 100)]); }    api.poly(pts, { w: 1.2 * k, dash: [4 * k, 4 * k] });    const lx = X(Math.min(tEnd * 0.5, (Math.log(K / B0) / r) * 1.15));    api.label("よく混ぜた液なら", Math.min(G.x + G.w - 60 * k, lx + 6 * k), Y(100) + 15 * k, { size: 10 * k });  }  if (n >= 2) {    const pts: [number, number][] = [];    for (let i = 0; i < n; i++) pts.push([X(H.t[i]), Y(H.v[i])]);    const area: [number, number][] = [[pts[0][0], G.y + G.h], ...pts, [pts[n - 1][0], G.y + G.h]];    api.hatch(area, { gap: 5 * k, angle: 60, w: 0.6 * k });    api.poly(pts, { w: 2 * k });    api.dot(pts[n - 1][0], pts[n - 1][1], 3 * k);  }}function overlay(api: Api, s: S, L: Lay) {  const ctx = api.ctx, k = L.k;  ctx.clearRect(0, 0, api.w, api.h);  // 皿の縁(ガラスの厚み)  api.circle(L.cx, L.cy, L.r + 1 * k, { w: 1.4 * k });  api.circle(L.cx, L.cy, L.r + 5 * k, { w: 0.6 * k });  // 1 cm の物差し(皿の左下の外)  const cm = (L.r * 2 * 10) / DISH, bx = L.cx - L.r * 0.98, by = L.cy + L.r * 0.97;  api.line(bx, by, bx + cm, by, { w: 1.4 * k }); api.line(bx, by - 4 * k, bx, by + 4 * k, { w: 1.2 * k }); api.line(bx + cm, by - 4 * k, bx + cm, by + 4 * k, { w: 1.2 * k });  api.text("1 cm", bx + cm + 6 * k, by + 4 * k, { size: 11 * k, font: "mono" });  const P = (x: number, y: number): [number, number] => [L.cx + x * L.r, L.cy - y * L.r];  const name = s.model === "slime" ? "モジホコリの変形体" : s.model === "colony" ? "枯草菌" : "2 つの物質の反応と広がり";  api.label(name, L.cx - L.r * 0.98, L.cy - L.r * 0.93, { size: 12 * k });  if (s.model === "slime") {    if (s.obst.length) {      const [lx, ly] = P(...geo(35.37, 139.86));      api.label("海(光を当てた所)", lx, ly, { size: 10.5 * k, align: "center" });    }    const big = L.r > 200;    s.foods.forEach((f, i) => {      const [x, y] = P(f.x, f.y), rr = (SLIME.FOOD / (DISH / 2)) * L.r;      const on = s.drag?.i === i;      api.circle(x, y, Math.max(rr + 5 * k, 9 * k), { w: (on ? 1.6 : 1) * k, dash: [2 * k, 3 * k] });      api.rect(x - 4 * k, y - 4 * k, 8 * k, 8 * k, { fill: "ink", stroke: "paper", w: 1.2 * k });      if (f.name && (big || i === 0)) {        const d = 11 * k, fs = 10.5 * k;        if (f.side === "l") api.label(f.name, x - d, y + fs * 0.35, { size: fs, align: "right" });        else if (f.side === "t") api.label(f.name, x, y - d, { size: fs, align: "center" });        else if (f.side === "b") api.label(f.name, x, y + d + fs * 0.8, { size: fs, align: "center" });        else api.label(f.name, x + d, y + fs * 0.35, { size: fs });      }    });  }  // 指の位置の点線の輪(置ける・植えられる所)  if (api.pointer.inside && !api.pointer.down) {    const [x, y] = dishAt(api);    if (x * x + y * y < 0.96 * 0.96) {      const rr = s.model === "slime" ? 9 * k : s.model === "colony" ? Math.max(6 * k, (COL.SIG * 2.6 / (COL.L / 2)) * L.r) : Math.max(6 * k, ((3 * (s.g!.N / 256)) / (s.g!.N / 2)) * L.r);      api.circle(api.pointer.x, api.pointer.y, rr, { w: 1 * k, dash: [2 * k, 3 * k] });    }  }  graph(api, s, L);}

細菌の年輪のような線は、届いた時刻を一定の間隔で区切った所である。線の太さは、隣の画素との時刻の差(fwidth)で割って、どこでも1画素ほどにそろえる。グラフの点線は、同じ反応()が寒天ではなく、よく混ぜた液の中で進んだときの式の解である。は植えた菌の量、は皿の栄養で決まる上限で、には栄養の濃さを使う。皿の上の集まりがこれよりどれだけ遅れるかを比べられる。

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/life-growth.ts・960 行)

読み込んでいます

姉妹サイト

株式会社Game が運営するメディアです。