0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

光に集まり、飽きて、離れる250体のエージェントをp5.jsで実装する

0
Last updated at Posted at 2026-09-13

はじめに

以前、インスタレーション作品「baited」の制作記録を書きました。

前の記事では企画、展示設計、制作体制、インタラクション全体の設計を中心にまとめています。本稿ではメイン画面に表示される250体の「虫」の挙動だけを扱います。

実装はこちらです。

スクリーンショット 2026-08-30 180148.png

baited は、鑑賞者が懐中電灯を操作し、画面上の虫へ光を当てるインタラクティブ作品です。虫は光へ集まり、周囲を旋回し、同じ光を浴び続けると次第に飽きて離れていきます。光を消すと反応性が戻り、再び光へ寄ってくるようになります。

以下では、この映像で見えている群れの動きを sketch.js の Bug クラスに沿って、1体ごとの処理に分解して説明します。


設計方針

この実装は、昆虫の行動を生物学的に厳密に再現することを目的としたものではありません。

制作時にはAIによる調査支援も使いながら、走光性、探索行動、間欠運動、馴化などに関する資料を参照しました。ただし参照の度合いは一様ではなく、大きく3種類に分かれます。

  • 公開されている観察や記述を参照し、そこから振る舞いだけを取り出したもの(間欠運動、光源周辺の滞留、馴化)
  • エージェント実装の一般的な作法に沿ったもの(操舵、個体間分離、フレーム更新)
  • 生物側に対応する根拠を置かず、作品の見え方から決めたもの(周回半径の分布、各パラメータの値域)

数理モデルをそのまま移植した箇所はありません。それぞれを作品に必要な振る舞いに合わせて単純化し、ひとつの Bug クラスにまとめています。作品テーマである「注意」の移り変わりを、虫1体ごとの内部状態と群れ全体の時間的な変化として見せることを優先しました。

本文中の図1〜3は展示時の実測ログではなく、実装のパラメータから生成した可視化です。数値設定が時間や分布としてどう現れるかを確認するために載せています。記事の後半では、展示時に記録した操作ログとこの時間スケールを照合します。時間の換算はすべて60fpsを前提にしています。

参照した資料は記事末尾にまとめています。


システム構成

毎フレームの処理は draw() の中で全個体の update() と display() を呼んでいるだけです。

for (let bug of bugs) {
  if (lightJustOn) bug.onLightOn();
  bug.update(bugs, lightPos, r, isLightOn);
  bug.display();
}

1体の虫は、次の3層の状態を持っています。

「どれだけ光に興味があるか(interest)」「走っているか止まっているか(運動状態)」「どこへ向かうか(操舵モード)」を別々に持ち、同じフレームの中で組み合わせて位置・向き・速度を更新しています。


集団挙動

画面内には CONFIG.numBugs = 250 体の虫がいます。

群れのシミュレーションではBoidsが定番で、結合(cohesion)・整列(alignment)・分離(separation)の3つのルールで群れそのものを形作ります。baited で入れているのは実質的に分離だけで、結合と整列に当たる処理はありません。

それでも群れに見えるのは、250体が同じ光にそれぞれ独立に反応しているからです。近づきすぎた個体同士だけが距離を取り、あとは各自が光に向かったり飽きたりしている。群れの形はどこにも書いておらず、個体ごとのルールが重なった結果として出てきます。

image.png


個体差

constructor() で、動きに関わるパラメータを個体ごとにランダムで決めています。

this.baseSpeed = random(1.8, 4.5);
this.turnRate = random(0.03, 0.08);
this.fixationAngle = random(10, 40) * (PI / 180);
this.handedness = random() > 0.5 ? 1 : -1;
this.antennaSpread = random(6, 14);
this.tropotaxisGain = random(0.02, 0.06);
this.detectionMult = random(1.5, 4.0);
this.reactionDelay = floor(random(0, CONFIG.reactionDelayMax));
this.decayRate = random(CONFIG.decayRateMin, CONFIG.decayRateMax);
this.levyMu = random(1.8, 2.2);
this.millingSpeed = random(0.008, 0.03);
this.baseSize = random(CONFIG.bugSizeMin, CONFIG.bugSizeMax);
プロパティ 範囲 役割
baseSpeed 1.8〜4.5 基本の移動速度(px/frame)
baseSize 14〜24 文字サイズ。個体間分離の距離にも使う
turnRate 0.03〜0.08 曲がりやすさ
fixationAngle 10°〜40° 光をどれだけ斜めに見るか
handedness ±1 左右どちらに寄るか
antennaSpread 6〜14 左右センサーの間隔
tropotaxisGain 0.02〜0.06 左右センサーの効き具合
detectionMult 1.5〜4.0 光に気づく距離の倍率
reactionDelay 0〜300 frame 光がついてから動き出すまでの遅れ
decayRate 0.0004〜0.0012 飽きる速さ
levyMu 1.8〜2.2 探索時に遠くへ飛ぶ頻度
millingSpeed 0.008〜0.03 光の周りを回る速さ

光の半径は CONFIG.lightRadius = 220 なので、光に気づく距離 lightRadius * detectionMult は330〜880pxに散らばります。

光に向かうときの速度倍率 approachSpeedMult だけは、一様分布ではなく3タイプの混合にしています。

this.approachSpeedMult = random() < 0.15 ? random(1.2, 1.5)
                       : random() > 0.85 ? random(0.5, 0.7)
                       : random(0.8, 1.1);

15%がせっかちな個体、約13%が鈍い個体、残り約72%が普通の個体です。速度、感度、反応の遅れ、飽きる速さなどをあちこちでずらすことで、250体が同時に同じ速さで同じ軌道を描く状態を避けています。


状況の判定

update() では、まず今の状況を3つのフラグにまとめています。

let d = p5.Vector.dist(this.pos, lp), dr = lr * this.detectionMult;
// ...
let aw = on && this.hasReacted;
let att = aw && this.interest > CONFIG.boredomThreshold && d < dr;
let ins = aw && this.interest > CONFIG.boredomThreshold && d < lr;
  • aw:光がついていて、反応の遅延も終わっている
  • att:まだ飽きておらず、光の検知範囲 dr の中にいる
  • ins:まだ飽きておらず、光の円 lr の中にいる

dr は lr の1.5倍以上あるので、ins が真なら att も必ず真です。以降の分岐はこの包含関係を前提に書いています。

aw に含まれる hasReacted は、光がついた瞬間に全員が一斉に振り向かないための仕組みです。

onLightOn() {
  this.reactionTimer = this.reactionDelay;
  this.hasReacted = false;
  this.interest = constrain(this.interest + 0.2, 0, 1);
}
if (on && !this.hasReacted) { this.reactionTimer--; if (this.reactionTimer <= 0) this.hasReacted = true; }
if (!on) this.hasReacted = false;

reactionDelay は最大300フレーム、約5秒です。点灯から数秒かけて、気づいた個体から順に光へ寄っていきます。


探索行動

Lévy型探索

光に反応していない個体は、ランダムに目的地を決めて歩き回ります。目的地までの距離を決めているのが levyStep() です。

function levyStep(min, max, mu) {
  return Math.min(min * Math.pow(Math.random() + 0.001, -1 / (mu - 1)), max);
}

mu = 2 のときは 20 / (乱数 + 0.001) になり、乱数が0.8なら約25px、0.5なら約40px、0.1なら約200pxです。乱数が0に近いときだけ一気に大きな値になるので、ほとんどは近場をうろうろし、たまに遠くへ飛ぶ動きになります。

この「たまに」の頻度を決めているのが mu です。打ち切りと +0.001 を無視すると、ステップ長 $\ell$ が $x$ を超える確率は

P(\ell > x) = \left(\frac{x}{20}\right)^{-(\mu-1)}

になります。確率密度にすると $\ell^{-\mu}$ に比例するべき乗分布で、mu は分布の裾の重さを決める指数です。mu が小さいほど長い移動が出やすくなります。

べき乗分布に従う探索行動は生物の移動研究で広く扱われていますが、その解釈には議論があります。本稿では特定のモデルへの準拠は主張せず、「短い移動を基本に、低い確率で長い移動を混ぜる」という性質だけを、逆関数法によるサンプリングとして実装しています。そのままだと画面外まで飛んでしまうので500pxで打ち切っており、厳密なLévy過程ではなくLévyっぽい探索という位置づけです。

mu を1.8、2.0、2.2にして、それぞれ20万回サンプリングした分布が次の図です。縦軸は対数表示で、右端の500pxに値が溜まっているのは打ち切りの影響です。

01_levy_step_distribution.png
図1. levyStep() から生成したステップ長の分布。各条件20万サンプル。

目的地が未設定のとき、または目的地まで15px未満に近づいたときに、次の目的地を引き直します。

levyWander() {
  if (!this.levyTarget || p5.Vector.dist(this.pos, this.levyTarget) < 15) {
    let s = levyStep(20, 500, this.levyMu), a = random(TWO_PI);
    this.levyTarget = createVector(this.pos.x + cos(a) * s, this.pos.y + sin(a) * s);
  }
  let da = p5.Vector.sub(this.levyTarget, this.pos).heading();
  return atan2(sin(da - this.heading), cos(da - this.heading)) * this.turnRate * 2;
}

戻り値は「目的地の方向と今の向きの差」に曲がりやすさを掛けた、1フレームあたりの旋回量(rad)です。atan2(sin(x), cos(x)) は角度の差を −π〜π に収めるための定番の書き方で、これがないと「右に350°曲がる」ような遠回りが起きます。以降の操舵処理もすべてこの「角度差 × 係数」の形で書いています。

一度に向き直るのではなく毎フレーム少しずつ曲がるので、軌跡はなめらかなカーブになります。

Run / Stop / Tumble

一定の速さで歩き続ける虫は機械的に見えます。そこで、移動と停止を繰り返す昆虫の間欠運動(Bazazi et al., 2012)を参考に、「走る・止まる・向きを変える」の3状態を持たせました。

nextRun()    { this.state = 'run';    this.stateTimer = floor(random(30, 150) * (random() < 0.15 ? 2 : 1)); this.speed = this.baseSpeed * random(0.8, 1.1); }
nextStop()   { this.state = 'stop';   this.stateTimer = floor(random(5, 25)); this.speed = 0; }
nextTumble() { this.state = 'tumble'; this.stateTimer = floor(random(3, 10)); this.tumbleAmount = random(-PI * 0.7, PI * 0.7); this.speed = this.baseSpeed * 0.15; }
状態 継続時間 速度 動き
RUN 30〜150フレーム(15%で2倍) 基本速度の0.8〜1.1倍 操舵モードに従って進む
STOP 5〜25フレーム 0 止まる
TUMBLE 3〜10フレーム 基本速度の0.15倍 数フレームかけて向きを変える

TUMBLEでは開始時に ±0.7π(約±126°)の範囲で tumbleAmount を決め、継続中は毎フレーム tumbleAmount / 8 ずつ曲がります。実際にどれだけ向きが変わるかは継続フレーム数と後述の旋回上限で決まり、瞬間的に反転するのではなく少し回り込むような動きになります。

タイマーが切れたときの遷移はこうなっています。

if (this.state === 'run' && this.stateTimer <= 0) {
  if (att) { random() < 0.1 ? (this.nextStop(), this.stateTimer = floor(random(3, 10))) : this.nextRun(); }
  else if (ins) { random() < 0.3 ? this.nextStop() : random() < 0.15 ? this.nextTumble() : this.nextRun(); }
  else { random() < 0.6 ? this.nextStop() : this.nextTumble(); }
} else if (this.state === 'stop' && this.stateTimer <= 0) { att ? this.nextRun() : random() < 0.7 ? this.nextTumble() : this.nextRun(); }
else if (this.state === 'tumble' && this.stateTimer <= 0) this.nextRun();

前述のとおり ins なら att も真なので、光の円の中にいる個体も最初の att の分岐に入ります。2つ目の else if (ins) は実際には通らない分岐で、光を検知している間の遷移はすべて1つ目で決まります。

探索中(光を検知していないとき)の遷移は次のとおりです。

光を検知している間は、RUNが終わっても90%でそのままRUNを続け、残り10%で止まっても3〜10フレームで歩き出します。STOPが終われば必ずRUNに戻ります。

光に向かっている間はほとんど止まらず一直線に進み、探索中は停止と方向転換が頻繁に挟まる。この対比を遷移確率だけで作っています。


走光性

光に向かう操舵は phototaxisSteering() にまとめています。中身は「光の方向を見て曲がる」処理と、「左右センサーの明るさの差で曲がる」処理の2つです。

Compass steering

let to = p5.Vector.sub(lp, this.pos), d = to.mag(), la = to.heading();
let dh = la - this.handedness * this.fixationAngle;
let ce = atan2(sin(dh - this.heading), cos(dh - this.heading));
let cs = map(constrain(d, 10, 800), 10, 800, 1.5, 0.4);
let compass = ce * cs * this.turnRate * 4;

光の方向 la をそのまま目標にせず、fixationAngle の分だけ左右どちらかにずらした方向 dh を目標にしています。光を真正面ではなく斜めに見ながら進むので、軌道が螺旋を描くように光へ近づきます。生物学的な光コンパス反応の再現ではなく、この螺旋状の接近軌道を得るための処理として使っています。

cs は距離による強さの調整です。800px以上離れていると0.4倍、10pxまで近づくと1.5倍になり、遠くでは大まかに向きを合わせ、近づくほど強く光を追います。

左右センサー

もう1つは、虫の前方左右に仮想の触角を置く処理です。

let hv = p5.Vector.fromAngle(this.heading), pv = p5.Vector.fromAngle(this.heading + HALF_PI);
let ls = p5.Vector.add(this.pos, p5.Vector.add(p5.Vector.mult(hv, this.antennaSpread), p5.Vector.mult(pv, -this.antennaSpread * 0.5)));
let rs = p5.Vector.add(this.pos, p5.Vector.add(p5.Vector.mult(hv, this.antennaSpread), p5.Vector.mult(pv, this.antennaSpread * 0.5)));
let li = 1 / (p5.Vector.dist(ls, lp) ** 2 + 1), ri = 1 / (p5.Vector.dist(rs, lp) ** 2 + 1);
let tropo = (ri - li) * this.tropotaxisGain * 1500;

p5.Vector のメソッドが入れ子になっていて読みにくいので、式で書き直します。$\mathbf x$ が虫の位置、$\mathbf h$ が進行方向の単位ベクトル(hv)、$\mathbf p$ がそれに直交する単位ベクトル(pv)、$a$ が antennaSpread、$\mathbf L$ が光源の位置です。

\mathbf s^{L,R} = \mathbf x + a\,\mathbf h \mp \frac{a}{2}\,\mathbf p,
\qquad
E^{L,R} = \frac{1}{\|\mathbf s^{L,R} - \mathbf L\|^2 + 1},
\qquad
F^{T} = 1500\,g\,(E^{R} - E^{L})

前方 $a$ の位置から左右に $a/2$ ずつずらした点がセンサー ls / rs です。各センサーの明るさ $E$ は光源からの距離の2乗に反比例させました(+1 はゼロ除算よけです)。$g$ は tropotaxisGain です。右のほうが明るければ tropo が正になって右へ、左が明るければ左へ曲がります。左右の感覚器の差で刺激源へ向かう、tropotaxis(転向走性)の単純化です。

係数1500はスケール合わせのための値です。逆二乗の明るさの差は、光から数百px離れると 1e-5 前後の小さな値になります。tropotaxisGain * 1500 を掛けて compass と同じくらいの大きさまで引き上げ、次の合成が意味を持つようにしています。

操舵の合成

let tw = constrain(map(d, 10, 600, 0.6, 0.1), 0.1, 0.6);
return compass * (1 - tw) + tropo * tw;

センサーの比重 tw は、600px以上離れていると1割、近づくと最大6割まで上がります。遠くでは光の方向を見て大きく向きを決め、近くではセンサーで細かく修正する役割分担です。

走光中の速度は次のように決めています。

this.speed = this.baseSpeed * this.approachSpeedMult * map(d, lr, dr, 1, 0.7) * random(0.9, 1.1);

検知範囲の縁では0.7倍、光の円の縁まで来ると1倍になり、光に近づくほど足が速くなります。


光源内旋回

光の円の中に入ると、中心へ突っ込むのをやめて周りを回り始めます。

millingBehavior(lp, lr) {
  this.millingAngle += this.millingDir * this.millingSpeed;
  if (random() < 0.005) this.millingDir *= -1;

  // Preferred orbit radius: Gaussian around 0.5 * lr
  if (this.preferredOrbitRadius === 0 || random() < 0.005) {
    let u1 = random(0.001, 1), u2 = random(0.001, 1);
    let z = sqrt(-2 * log(u1)) * cos(TWO_PI * u2);
    this.preferredOrbitRadius = constrain((0.5 + z * 0.12) * lr, lr * 0.15, lr * 0.85);
  }
  this.millingRadius = lerp(this.millingRadius, this.preferredOrbitRadius, 0.02);

  let t = createVector(lp.x + cos(this.millingAngle) * this.millingRadius, lp.y + sin(this.millingAngle) * this.millingRadius);
  let e = atan2(sin(p5.Vector.sub(t, this.pos).heading() - this.heading), cos(p5.Vector.sub(t, this.pos).heading() - this.heading));
  return e * this.turnRate * 2.5;
}

虫の位置を円周上に直接置くのではなく、「光の周りを回る見えない目標点 t を追いかける」形にしています。millingAngle を毎フレーム進めることで目標点が円周上を回り、虫は探索時と同じ「角度差 × 係数」の操舵でそれを追います。回転方向は毎フレーム0.5%の確率で反転します。

円の半径 preferredOrbitRadius は、光の半径の半分(110px)を中心とした正規分布から決めています。u1、u2 の2行はBox–Muller法という、一様乱数から正規乱数を作る定番の変換です。ほとんどの個体は84〜136pxあたりを回り、たまに縁寄りや中心寄りを回る個体が出ます。値は33〜187pxの範囲に収め、初回に加えて旋回中も毎フレーム0.5%の確率で引き直します。

実際の半径 millingRadius は lerp() で2%ずつ目標に寄せているので、光に入った瞬間に軌道へ吸い付くのではなく、ゆっくりと自分の軌道に落ち着きます。

旋回中の速度は基本速度の0.3〜0.6倍に落とし、光の中ではゆったり回るようにしています。個体ごとに半径、速さ、回転方向が違うので、光の周りにいくつもの軌道が重なった滞留状態になります。

飛翔昆虫が光源の周囲を旋回する現象は、光へ向かう接近反応ではなく、背面を光へ向ける姿勢制御の結果として説明されています(Fabian et al., 2024)。本稿ではその姿勢制御自体は実装しておらず、周回するという結果だけを目標半径の分布として単純化しています。


馴化

interest

刺激の反復によって反応が漸減し、刺激が止むと回復するという性質は、馴化の行動的特徴として整理されています(Rankin et al., 2009)。本稿ではこれを、一定量ずつの減衰と加算という最小限の形に単純化しています。

各個体は interest という0〜1の値を持っていて、初期値は1です。更新は次の3行だけです。

if (on && d < lr) this.interest -= this.decayRate;
else if (!on) this.interest += CONFIG.recoveryRate;
this.interest = constrain(this.interest, 0, 1);
条件 interest
光ON、光の円の中 decayRate ずつ減る
光ON、光の円の外 変化しない
光OFF 0.006ずつ戻る

減るのは光の円の中にいる間だけで、戻るのは光が消えている間だけです。

この書き方の帰結として、飽きて光から離れた個体は、光がついている限り interest が戻らず、再び寄ってくることはありません。鑑賞者が一度ライトを消さないと虫が集まり直さない、という作品のインタラクションは、この else if (!on) の1行から直接生じています。

閾値

行動の切り替えは CONFIG.boredomThreshold = 0.15 で行っています。interest は連続値ですが、0.15を上回っている間は光に向かい、0.15以下になると後述の忌避に切り替わる閾値型の遷移です。

飽きるまでの時間

光の中に居続けた場合、interest が1から0.15まで落ちるまでのフレーム数は、decayRate を $\delta$ として

n = \frac{1 - 0.15}{\delta}

です。$\delta$ は個体ごとに0.0004〜0.0012で、数字だけ見ると差は小さいですが、秒に直すと

  • 速い個体(0.0012):0.85 ÷ 0.0012 ≈ 708フレーム、約11.8秒
  • 中間(0.0008):0.85 ÷ 0.0008 ≈ 1063フレーム、約17.7秒
  • 遅い個体(0.0004):0.85 ÷ 0.0004 = 2125フレーム、約35.4秒

と、20秒以上の開きになります。

02_habituation_curves.png
図2. 光を浴び続けた場合の interest の減衰。破線は閾値0.15。

この時間差があるので、光に集まった群れは一斉に解散せず、飽きた個体からぽつぽつと離れていきます。


忌避

飽きた個体の操舵は次の分岐です。

} else if (aw && this.interest <= CONFIG.boredomThreshold) {
  steer = d < dr ? -this.phototaxisSteering(lp) * 0.35 + random(-0.06, 0.06) : this.levyWander();
  this.speed = this.baseSpeed * random(0.5, 0.9);

検知範囲の中にいる間は、走光性の操舵を符号反転して0.35倍に弱め、少しノイズを足したものを使います。光に向かう処理をそのまま裏返しているので、専用の「逃げる」ロジックは書いていません。弱めているのは、一目散に逃げるのではなく、興味をなくしてふらふら離れていく感じにしたかったからです。

検知範囲の外に出たら、通常のLévy型探索に戻ります。速度はどちらの場合も基本速度の0.5〜0.9倍です。


回復

光が消えると、interest は毎フレーム0.006ずつ戻ります。0.15から満タンまでは約142フレーム(約2.4秒)、0からでも約167フレーム(約2.8秒)です。

さらに再点灯した瞬間には、onLightOn() で interest に0.2が加算されます。同時に反応遅延のタイマーもリセットされるので、回復した個体もすぐには動かず、再び個体ごとの遅れを経てから光へ向かいます。

decayRate = 0.0008 の個体が光の中に居続けたと仮定し、20秒点灯 → 1.5秒消灯 → 再点灯とした場合の推移が次の図です。

03_interest_on_off_on.png
図3. 点灯 → 消灯 → 再点灯に対する interest の推移。実装のパラメータから生成したシミュレーション。

飽きるのはゆっくり、戻るのは速い。この非対称な時間設計によって、ライトを一度消せば、虫たちは短時間でまた光に反応できる状態へ戻ります。

刺激への反応は、次の循環になります。


個体間分離

separate(all) {
  let st = createVector(0, 0), n = 0, sd = this.baseSize * 0.9;
  for (let o of all) { let d = p5.Vector.dist(this.pos, o.pos); if (o !== this && d > 0 && d < sd) { st.add(p5.Vector.sub(this.pos, o.pos).normalize().div(d)); n++; } }
  if (n > 0) { st.div(n); let e = atan2(sin(st.heading() - this.heading), cos(st.heading() - this.heading)); return e * 0.08; }
  return 0;
}

分離距離 sd は文字サイズ baseSize の0.9倍、つまり12.6〜21.6pxです。文字同士が重なりかけたときだけ反応します。

近くの個体 $j$ から自分 $i$ への反発は、normalize().div(d) の部分を式にすると

\mathbf f_{ij} = \frac{\mathbf x_i - \mathbf x_j}{\|\mathbf x_i - \mathbf x_j\|^2}

です。単位ベクトルを距離で割っているので大きさが距離に反比例し、近い相手ほど強く押されます。これを近傍の個数で平均しています。

この反発ベクトルは位置に直接足すのではなく、他の操舵と同じく「反発方向と今の向きの角度差 × 0.08」という旋回量に変換しています。光の周りに集まった個体が完全に重なるのを防ぐための処理で、ここでも群れの形そのものは指定していません。


フレーム更新

update() の中は次の順で進みます。

  1. interest の更新
  2. 反応遅延タイマーの更新
  3. 状況フラグの判定と、run / stop / tumble の遷移
  4. 操舵量の計算
  5. 向きの更新
  6. 速度と位置の更新、画面端の処理

操舵量の計算は、運動状態ごとに分かれています。

let steer = 0;

if (this.state === 'tumble') {
  steer = this.tumbleAmount / 8;

} else if (this.state === 'run') {
  if (ins) {
    steer = this.millingBehavior(lp, lr);        // 光源内旋回
    this.speed = this.baseSpeed * random(0.3, 0.6);

  } else if (att) {
    steer = this.phototaxisSteering(lp);         // 走光性
    this.speed = /* 走光性の速度 */;

  } else if (aw && this.interest <= CONFIG.boredomThreshold) {
    steer = /* 忌避 or 探索 */;
    this.speed = this.baseSpeed * random(0.5, 0.9);

  } else {
    steer = this.levyWander();                   // 探索
  }

  steer += this.separate(all) + random(-0.01, 0.01);
}

(コメント部分は前述の各セクションのとおりです)

TUMBLE中は方向転換だけで、分離とノイズは加えません。STOP中は steer が0のまま、速度も0なので静止します。RUN中は4つのモードのどれか1つを基本の操舵とし、そこに分離と微小なノイズを足します。忌避モードでは、忌避側のノイズとこのノイズの両方が加わります。

分岐の順番にも意味があります。ins を att より先に判定しているので、光の円に入った個体は走光性ではなく旋回に切り替わります。

最後に向き・速度・位置を更新します。

this.heading += constrain(steer, -this.turnRate * 5, this.turnRate * 5);
this.vel.x = cos(this.heading) * this.speed;
this.vel.y = sin(this.heading) * this.speed;
this.vel.mult(map(noise(this.tremorPhase * 0.3 + frameCount * 0.03), 0, 1, 0.91, 0.98));
this.pos.add(this.vel);
let b = this.baseSize;
if (this.pos.x < -b) this.pos.x = width + b; if (this.pos.x > width + b) this.pos.x = -b;
if (this.pos.y < -b) this.pos.y = height + b; if (this.pos.y > height + b) this.pos.y = -b;

1フレームで曲がれる量は turnRate * 5、つまり0.15〜0.4ラジアン(約9°〜23°)までに制限しています。どの操舵がどれだけ大きな値を返しても、虫が瞬間的に反転することはありません。

向きを先に更新してから速度ベクトルを作るので、そのフレームの移動には更新後の向きが使われます。

速度にはPerlin noiseで0.91〜0.98倍の係数を掛けています。上下に対称な揺らぎではなく、基本速度の91〜98%の間をゆっくり行き来する減衰で、random() と違ってなめらかに変わるため、足取りが少しずつ緩んだり戻ったりする感じになります。

画面端は上下左右がつながっていて、端から出た個体は反対側から出てきます。これを250体ぶん、毎フレーム繰り返しています。


描画

虫は画像ではなく、「虫」という漢字1文字で描いています。

display() {
  let a = map(this.interest, 0, 1, 80, 240);
  this.tremorPhase += this.tremorFreq;
  this.legPhase += this.speed * 0.15;
  let rot = this.heading + HALF_PI + sin(this.tremorPhase) * 0.08 + sin(this.legPhase) * 0.04 * this.speed;
  if (this.state === 'stop' && random() < 0.15) rot += random(-0.25, 0.25);
  if (this.state === 'tumble') rot += random(-0.12, 0.12);
  fill(245, 245, 245, a); noStroke();
  textSize(this.baseSize + sin(frameCount * 0.03 + this.tremorPhase));
  push(); translate(this.pos.x, this.pos.y); rotate(rot); text(this.char, 0, 0); pop();
}

透明度は interest に連動していて、飽きていない個体は不透明度240、飽ききった個体は80まで薄くなります。飽きて離れていく個体は、動きだけでなく見た目の存在感も薄れていきます。

文字の回転には、進行方向に加えて2種類の揺れを足しています。tremorPhase は常にある小さな震え、legPhase は速く歩くほど大きくなる左右の揺れです。STOP中はときどきピクッと向きを変え、TUMBLE中は小刻みに揺れます。これらは見た目だけの揺れで、移動の計算には影響しません。


全体の構造

ここまでの要素の関係をまとめると次のようになります。

interest と位置から操舵モードが決まり、運動状態と合わせて旋回量が決まり、向きと速度を経て位置が更新される。その位置が次のフレームの操舵モードを決める、というループです。各個体は前フレームの自分の状態、光のON/OFFと位置、近くの個体の位置、乱数、そして自分固有のパラメータだけから次の状態を計算しています。


展示ログとの照合

ここまで扱ってきた馴化時間は、実装したパラメータから計算した理論上の時間です。最後に、この時間スケールを展示時の操作ログと照合します。

展示では、懐中電灯型デバイスのON/OFF時刻を記録していました。前の記事と同じ基準で、瞬間的な入力を除くため5秒未満のONを除外し、4月2日〜5日の110セッションを対象とします。

この110セッションの操作時間の中央値は 41.35秒 でした。

一方、前述のとおり、光の円の中に居続けた個体が閾値0.15に達するまでの時間は、decayRate によって約 11.8〜35.4秒 です。

image.png

図4. 展示時の有効セッションの継続時間と、モデル上の馴化閾値到達時間の比較。半透明部分は個体ごとの馴化時間11.8〜35.4秒を示す。

実測した操作時間と比べると、110セッション中90件(81.8%)が最速の馴化時間11.8秒を、60件(54.5%)が最も遅い馴化時間35.4秒を上回っていました。中間の decayRate = 0.0008 に対応する約17.7秒を上回ったセッションは79件(71.8%)でした。

ただし、この比較は「実際に何体の虫が飽きたか」を示すものではありません。ログに記録されているのはデバイスがONだった時間で、interest が減るのは各個体が光の円の中にいる間だけです。また、光へ近づくまでの時間や反応遅延もあります。

そのため、ここで確認できるのは、馴化が起こるのに必要な時間と、実際の来場者の操作時間が同じくらいの範囲にあったということです。少なくとも操作がモデル上の馴化に対して一律に短すぎたわけではなく、長く操作されたセッションでは、飽きて離れるところまで進む時間的な余地がありました。

逆に短いセッションでは、飽きるのが遅い個体が閾値に届く前に操作が終わります。結果として、操作時間によって「光へ集まるところまでを見る体験」と「その後に離れていくところまでを見る体験」が分かれうる時間設計になっていました。


まとめ

baited の虫1体は、次の要素の組み合わせでできています。

  • Lévy型の目的地選択による探索
  • run / stop / tumble の3状態
  • 光を斜めに見ながら向かうcompass steering
  • 左右センサーの明るさの差による補正(tropotaxis)
  • 光の円の中での旋回
  • interest による馴化と、消灯による速い回復
  • 走光性を裏返した忌避
  • 文字同士が重ならないための分離
  • 個体ごとのパラメータの差

画面上では「光に集まる虫の群れ」に見えますが、群れ全体を制御するコードはどこにもありません。250体がそれぞれ自分の状態とパラメータを持ち、同じ光に対してばらばらに反応しているだけです。

生物行動に関する記述を参照しながら作品に必要な粒度まで単純化し、1つのクラスにまとめました。参照した範囲と実装上の判断で決めた範囲は分けて書いています。飽きるまでの時間や回復の速さまで含めて数値を調整することで、「注意」の移り変わりを、個体の内部状態と群れの時間的な変化として実装しています。


参考資料

実装時に参照した資料のうち、全文が公開されているものです。前述のとおり数理モデルをそのまま再現したものではないため、各項目に実装のどの部分へ対応するかを併記します。

  • 光源周辺の滞留

    • Fabian, S. T. et al. (2024) Why flying insects gather at artificial light. Nature Communications 15, 689.
      https://www.nature.com/articles/s41467-024-44785-3
      飛翔昆虫が光源へ直進的に引き寄せられるのではなく、背面を光へ向ける姿勢制御の結果として光源の周囲を旋回する、という観察です。「光源内旋回」は、この周回という結果のみを参照しています。
  • 間欠運動

    • Bazazi, S. et al. (2012) Intermittent motion in desert locusts: behavioural complexity in simple environments. PLoS Computational Biology 8(5), e1002498.
      https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3349720/
      サバクトビバッタの移動が、連続的ではなく停止と移動を繰り返す離散的な構造を持つことを示した研究です。run / stop / tumble の状態分割は、この構造を参照しています。
  • 馴化

    • Rankin, C. H. et al. (2009) Habituation revisited: an updated and revised description of the behavioral characteristics of habituation. Neurobiology of Learning and Memory 92, 135–138.
      https://pmc.ncbi.nlm.nih.gov/articles/PMC2754195/
      馴化の行動的特徴を整理した総説です。刺激の反復による反応の漸減と、刺激の停止による回復の2点を、interest の減衰と回復に対応させています。
  • 操舵と群れの実装

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?