このシミュレーションは、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 ; // 皿の直径 mm const 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 es precision 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 es precision 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分に何%薄れるか」のつまみの値d を、1刻みの分数Δ t に直した値( 1 − d /100 ) Δ t である。粒は最後に触れた餌の番号を覚えていて、別の餌に着いたときだけ歩数を0に戻す。歩数が105 mmぶんを超えた粒は、粒の届いている餌の1つへ移す。餌に粒が届いているかは、読み出した粒の数からCPUが決めて、uFoodの4つ目の数で渡す。
菌の流れを、マスの境目ごとに足す
細菌の式∂ b / ∂ t = ∇ ⋅ ( σ nb ∇ b ) + nb の広がりの項は、隣のマスとの境目を通る流れの合計として計算している。境目の流れやすさは両側のσ nb の平均で、流れはb の差に比例する。片側から出た分は、そのまま隣に入るので、菌の量は計算の途中で増えも減りもしない。
細菌と栄養(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つだけを使う場合より向きによる偏りが小さく、集まりが格子にそって菱形にゆがみにくい。σ のばらつき(三角分布)は、寒天の場所ごとに決まった分と刻みごとの揺らぎを半分ずつにしてある。
計算は陽的オイラー法で進める。今の値と変わる速さから、次の刻みの値を求めるいちばん簡単な方法である。刻みΔ t は、この方法で値が発散しない条件Δ t < 0.375 Δ x 2 / D の約半分にした。D の最大はσ 0 n 0 2 /2 ほどなので、栄養が多いほど刻みは細かくなる。菌の密度が初めてしきい値を超えた時刻を3つ目の数に書いておき、描くときに「同じ時刻に届いた所」を結ぶ線にしている。
模様の式
GrayとScottの式も、同じ9点の重みでラプラシアン∇ 2 を計算する。皿の外のマスは、自分と同じ値とみなして使う。これで縁では物質が出入りしない。
模様の式(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); }` ;
広がる速さD u は、格子256のときの値に( N /256 ) 2 を掛ける。格子を細かくしても、模様の大きさが皿に対して変わらないようにするためだ。刻みはD u Δ t ≤ 0.3 になるように決めている。
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の網点で白か黒に落とす。粘菌は匂いの濃さを皿全体の平均との比で、細菌は菌の密度を、模様はv を、それぞれ濃さに直している。
描画(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画素ほどにそろえる。グラフの点線は、同じ反応(nb )が寒天ではなく、よく混ぜた液の中で進んだときの式の解K / ( 1 + ( K / B 0 − 1 ) e − r t ) である。B 0 は植えた菌の量、K は皿の栄養で決まる上限で、r には栄養の濃さn 0 を使う。皿の上の集まりがこれよりどれだけ遅れるかを比べられる。