HTML 実験室 #1
1 ファイルの HTML だけで動く水槽を作りました。魚が群れるのに必要なルールは、たった 3 つです。ところが AI に書かせた初稿を開くと、53 匹の魚が全員、左上の隅から出てこない。この記事は、その原因を突き止めて「魚に見える」ところまで詰めた記録です。
この連載でやること
HTML で作れるものを毎回ひとつ選んで、実際に作って、動くものを置いていきます。主役は完成品そのものではなく、AI と一緒に作っている途中で何が起きたかのほうです。何を頼んで、何が返ってきて、どこが足りなくて、どう直したか。うまくいった話だけを並べても、たいして役に立たないからです。
このブログの他の記事は解説が中心で、前回は MCP のステートレス化を図解しました。この連載はそれとは別の枠で、手を動かした記録を残していきます。
第 1 回はアクアリウムです。理由は単純で、「うまく動いていない」が一目で分かる題材だからです。魚が魚に見えなければ失敗、群れが群れに見えなければ失敗。判定に議論の余地がありません。
魚が群れるのに必要なルールは 3 つだけ
水槽の中の魚には、リーダーも、全体の隊列図も、目的地もありません。1 匹 1 匹が近くの仲間だけを見て、3 つの力を足し合わせて進む方向を決めています。1986 年に Craig Reynolds が発表した boids という考え方で、群れの動きはこれだけで再現できます。
分離 / separation
近づきすぎた仲間から離れる。これが無いと、全員が同じ 1 点に潰れます。
整列 / alignment
近くの仲間と向きを合わせる。これが無いと、同じ場所にいても全員がバラバラの方向を向きます。
結合 / cohesion
近くの仲間の中心へ寄る。これが無いと、群れは解けて水槽中に散ります。
面白いのは、この 3 つが互いに矛盾していることです。分離は離れろと言い、結合は寄れと言う。群れらしく見えるのは、その綱引きが釣り合っている狭い範囲だけで、少し傾けるとすぐ壊れます。水槽のスライダーで実際に壊せるようにしてあるのは、そのためです。
AI に投げた指示と、返ってきた 36 KB
指示はだいたいこんな内容でした。1 ファイルの HTML で完結すること。Canvas 2D を使うこと。魚は 3 種類で、小魚は密に群れ、中くらいの魚は緩く群れ、大きい魚は群れずに単独で泳ぐこと。小魚は大きい魚から逃げること。クリックしたら餌が落ちて魚が集まること。水草・泡・水面から差す光を入れること。画面サイズに応じて匹数を変えること。
返ってきたのは 36 KB の HTML 1 枚です。外部から読むのはフォントだけで、ライブラリはゼロ。読む限り、ロジックは素直に書けていました。
そして開くと、水槽には魚が 1 匹しかいませんでした。
全部の魚が、左上の隅から出てこない
こういうときに一番やってはいけないのが、コードを眺めて原因を当てにいくことです。群れのアルゴリズムは力の足し算なので、「ここの符号が逆かもしれない」という仮説はいくらでも作れてしまいます。作った仮説の数だけ、間違った修正ができあがります。
なので、まず事実を取りました。
1. canvas のピクセルを数える
魚の体色は水の青とはっきり違います。描かれた結果を直接読めば、魚がどこにいるかは分かります。
const img = ctx.getImageData(0, 0, canvas.width, canvas.height)
// 魚の体色に該当するピクセルだけ拾って、座標の範囲を出す
結果は x: 0〜58 / y: 84〜156。canvas は 2560×1440 です。つまり画面の 0.1% 未満の領域に、全部の魚がいる。
2. 描画の呼び出しをフックして座標を取る
ピクセルからは「重なった塊」しか分かりません。1 匹ずつの座標が欲しいので、魚を描くときに必ず通る ctx.translate を差し替えて、引数を記録しました。
const orig = CanvasRenderingContext2D.prototype.translate
CanvasRenderingContext2D.prototype.translate = function (x, y) {
seen.push([x, y])
return orig.call(this, x, y)
}
1 フレーム分で 38 匹、座標は x: 4〜23 / y: 47〜66。全員です。しかも x = 4 は、コードが位置を water の内側に押し込むときの下限値そのものでした。魚は「壁に押し付けられて」いたわけです。
ここまでで、「群れのアルゴリズムがおかしい」という筋は消えました。壁に押し付けられているなら、犯人は壁の側です。
原因は「まだ幅が 0 の水槽」に魚を入れていたこと
初稿のサイズ確定処理は、こう書かれていました。
W = Math.max(rect.width, 1)
H = Math.max(rect.height, 1)
if (fish.length === 0) populate() // ここで魚を生成
else {
fish.forEach(f => { // リサイズ時は押し戻すだけ
f.x = clamp(f.x, 10, W - 10)
f.y = clamp(f.y, surfaceY() + 10, floorY() - 10)
})
}
Math.max(rect.width, 1) が効いています。レイアウトが確定する前にこの処理が走ると、rect.width は 0 です。0 は 1 に置き換えられ、幅 1px の水槽が用意され、そこに 53 匹が生成されます。
そして本来のサイズが決まった 2 回目以降、コードは魚を作り直さず clamp で水槽の内側に押し込むだけ。1px の水槽に生まれた魚は、1280px の水槽の左上隅に押し込まれ、そこから出てこられなくなります。
Math.max(..., 1) は、ゼロ除算を避けるための、ごく普通の防御的な一行です。それが今回は、異常な状態をエラーにせず「幅 1px の正常な水槽」として通してしまう役割を果たしていました。直し方は 3 つです。
0px のときは何もしない
幅か高さが 2px 未満なら、サイズ確定処理をその場で打ち切る。1px の水槽を作らせない。
要素側のサイズ変化を見る
window.resize だけだと、レイアウト確定の瞬間を取りこぼす。ResizeObserver で canvas 自身を監視する。
リサイズは clamp でなく比率で
押し込むと「隅に寄った状態」が保存されてしまう。前回のサイズとの比を掛けて、位置関係を保ったまま持ち越す。
3 つ目は、バグそのものとは別の話に見えますが、実は同じ根です。狭い水槽を一度でも経由した群れが、広い水槽に戻れないという性質があるかぎり、原因が何であれ同じ症状が再発します。
直しても、まだ魚に見えない
魚は散りました。それでも、まだ水槽に見えません。ここからは仕様の問題ではなく、見え方の問題です。実際に直したのは次の 4 箇所でした。
| 直したところ | 初稿 | 修正後 | 何が起きていたか |
|---|---|---|---|
| 小魚の速度 | 46〜62 px/秒 | 88〜116 px/秒 | 群れは正しく動くのに、全体が緩慢で作り物に見える |
| 尾の振り | 速度に比例(係数が過大) | 速度 ÷ 体長に比例 | 小魚の尾が毎秒 4 往復し、痙攣しているように見える |
| 胸びれ | 腹のラインの外まで振る | 内側で振る | 鰭が胴体から離れ、千切れた破片が並走しているように見える |
| 水草の描画順 | 全部を魚より奥 | 手前の株は魚より前 | 全魚が同じ 1 枚の壁に貼り付いて見え、奥行きが出ない |
どれも群れのアルゴリズムとは無関係です。「群れが正しく計算できていること」と「魚に見えること」の間には、まだ距離があるということです。この距離は仕様には書けません。実際に見て、違和感の正体を言語化して、直す。ここだけは往復の回数がそのまま質になります。
尾の振りは分かりやすい例です。速度に比例させる方針自体は正しくて、そうしないと止まった魚も尾だけ振り続けます。問題は係数で、初稿の式では小魚が毎秒 4 回近く尾を振っていました。実際の小魚もそのくらい振るのですが、画面の中では速すぎるものは「速い」ではなく「壊れている」に見えます。
群れは、数字で壊せる
完成した水槽では、3 つの力の強さをスライダーで変えられます。群れがどのくらい壊れるかは、目で見て分かりますが、数字でも出ます。小魚だけを対象に測ったものです。
| 設定 | 最近接個体との平均距離 | 重心からの平均距離 |
|---|---|---|
| 初期値(分離 1.6 / 整列 1.0 / 結合 0.9) | 23.7 px | 124.6 px |
| 分離を 0 に | 9.3 px | 146.2 px |
| 整列と結合を 0 に | 45.0 px | 207.0 px |
分離を切ると、最近接距離が体長より短くなります。魚同士が重なり、群れというより 1 匹の塊になります。整列と結合を切ると逆に、重心からの距離が 1.7 倍近くに広がり、群れが解けます。3 つのうち 1 つ落とすだけで、群れは群れでなくなるわけです。
処理の重さも測りました。53 匹で 1 フレームあたり 0.25 ms です。魚同士の距離をすべての組み合わせで計算する素朴な実装(O(n²))ですが、この規模なら 60 fps の予算 16.7 ms に対して十分な余裕があります。最初から空間分割を入れなくてよかった、というのはこういう測り方でしか分かりません。
触ってみてください
分離を 0 にして魚が団子になるところと、結合を 0 にして群れが解けるところは、文章で読むより 5 秒触るほうが早いです。「仲間の線を見る」を入れると、どの魚とどの魚が互いを見ているかも表示されます。
この水槽は briefroom の共有ルームに置いてあります。ページの要素を選んでそのままコメントを書けるようになっているので、気づいたことがあれば残していってください。
