[Next.js #55] Boids × Flow Field — 10万の鳥が「狂気の空」を舞う

はじめに

前回の記事[Procedural Wind & Flow — Three.jsで10万の草木を風に揺らす]で、私たちのプロシージャル世界には「風」が吹きました。

今回は、今朝の理論編 [[Noise 入門 #49] Boids × Flow Field] )に基づき、この風のベクトル場に「自律的に動く鳥の群れ(Boids)」を放ちます。

穏やかで美しい自然の風景を想像して実装を始めましたが、パラメータのチューニングと圧倒的な物量(10万羽)が交差した結果、完成したのは「ヒッチコックの『鳥』」か、あるいは「ドラッグ オン ドラグーン」のような、絶望と狂気に満ちた凄まじい空でした。

今回は、この異形の空をThree.jsで実装する際の核となる技術と、パラメータ調整の罠についてまとめます。


モデルデータはsketchfabから著作権フリーをお借りしています。

制作者:БʟøᴏᴅTɪɢᴇʀWᴏʟFSketchFab様

 {
  name: "BarnOwl",
  actor: "БʟøᴏᴅTɪɢᴇʀWᴏʟFSketchFab",
  url: "https://sketchfab.com/BloodTigerWolfSketchF",
  License: "Free Standard",
  path: "./barnowl.glb",
  scale: 0.5,
  rotation: [Math.PI, 0, Math.PI],
}

スクリーンショット:

動画(YouTube):

動画(PC):

1. 10万羽を描画する InstancedMesh と GPUの力

10万羽という途方もない数の鳥をブラウザで動かすため、CPUでのループ計算(JavaScript側での毎フレームの座標更新)は完全に放棄し、Three.js の InstancedMesh とカスタムシェーダーを利用します。

まずは非常に軽量な4頂点の「V字型」ポリゴンをベースジオメトリとして定義しました。すべての座標計算、風のうねり(Curl Noise)、そして羽ばたきのアニメーションを Vertex Shader(頂点シェーダー) 内の数式だけで完結させています。

// わずか4頂点で構成される軽量なV字ジオメトリの定義
const birdGeoBase = new THREE.BufferGeometry();
const birdVertices = new Float32Array([
  0, 0, -1.0,   // 頭 (進行方向)
  -1.0, 0, 1.0, // 左羽
  1.0, 0, 1.0,  // 右羽
  0, -0.2, 0.8  // 尻尾
]);
const birdIndices = [0, 1, 3,  0, 3, 2];
birdGeoBase.setAttribute('position', new THREE.BufferAttribute(birdVertices, 3));
birdGeoBase.setIndex(birdIndices);
birdGeoBase.computeVertexNormals();

Three.js標準のマテリアルに対し onBeforeCompile を用いて独自のシェーダーを注入することで、10万羽であっても描画のドローコールは「たった1回」に抑えられます。

結果として、PCのGPUが悲鳴(ファンの爆音)を上げながらも、60FPSに近い滑らかさで空を埋め尽くす圧倒的なパフォーマンスを実現できました。

2. 空間を二分するバグ — 空洞化を防ぐ「Y軸のバイアス」解除

実装を進める中で、一つ奇妙な現象に遭遇しました。シミュレーションを動かしてしばらく時間が経つと、「鳥の群れが上空と地表に真っ二つに分かれ、中央の空間がすっぽりと空洞になってしまう」のです。

原因を調査した結果、これはベースとなる速度(baseVelocity)の設定と、空間をループさせるための高度制限(clamp)の衝突によって引き起こされていました。

上昇し続ける力と、見えない天井

初期の実装では、鳥が前進しつつ少しだけ上に向かって飛ぶように、進行方向のベクトルにY軸のプラス成分(上昇バイアス)を加えていました。

// 修正前:Y軸に 0.1 の上昇バイアスがかかっている
vec3 baseVelocity = vec3(0.5, 0.1, 1.0) * uBoidSpeed;

この数式では、すべての鳥が常に「上へ、上へ」と飛ぼうとします。そのため、時間が経過すると群れ全体が少しずつ空高くへと移動していきます。しかし、無限の彼方へ消えてしまわないよう、シェーダー内には高度 40.0 〜 150.0 の制限(見えない天井と床)を設けていました。

// 空間のラップアラウンド(ループ)と高度制限
vec3 diff = currentWorld - uCameraPos;
vec3 wrappedPos = uCameraPos + mod(diff + uWrapSize / 2.0, uWrapSize) - uWrapSize / 2.0;

// 上下はこの範囲に強制的に押し込める
wrappedPos.y = clamp(wrappedPos.y, 40.0, 150.0);

この結果、上昇し続けた鳥たちは「天井(150.0)」に押し付けられて張り付き、逆に乱気流(Curl Noise)によって下へ強く押し戻された個体だけが「床(40.0)」に張り付くという、極めて不自然な二層構造が生まれてしまったのです。

バイアスをゼロにし、風に委ねる

解決策は非常にシンプルでした。鳥自身の「上昇しようとする意志(バイアス)」を消し去り、上下の移動はすべて「環境の風(Flow Field)」に委ねるのです。

// 修正後:Y軸のバイアスを 0.0 に変更
vec3 baseVelocity = vec3(0.5, 0.0, 1.0) * uBoidSpeed;

このわずかな数式の変更だけで、10万羽の鳥たちは天井と床にこびりつくのをやめ、ドローンの周囲の空間全体にふわっと均等に分布するようになりました。気流に乗ってフワリと上昇し、渦に巻き込まれて急降下する。プロシージャルな環境においては、「個体に余計な力を加えず、環境のノイズに身を任せること」がいかに自然な振る舞いを生むかを教えてくれる興味深いバグでした。

3. パラメータが命 — 「こんにゃく」か「鞭」か

プロシージャル生成において、数式は単なる「骨組み」に過ぎません。そこに命を吹き込み、作品の世界観を決定づけるのは「パラメータの調整」です。

今回の羽ばたきアニメーションでは、翼の先端が根元よりも遅れて動く「しなり(Wing Whip)」を計算式に組み込みました。しかし、この数値を少しでも間違えると、鳥の全身が伸び縮みする「こんにゃく」のような不気味な物体になってしまいます。

これを防ぎ、かつ「異形の群れ」としての説得力を持たせるため、シェーダーに以下の2つの工夫を施しました。

① 胴体の固定と、円運動の近似

鳥のモデルの中心(胴体)までぐにゃぐにゃと曲がらないように、smoothstep を用いて「X座標が中心に近いほど動かなくなるマスク(flapMask)」を作成しました。さらに、単純なY軸の上下運動ではなく「角度(angle)」を用いた円運動の近似を取り入れることで、翼が伸びきってしまう不自然さを防いでいます。

// 翼のしなやかさ(uBoidWhip)と胴体の固定マスク
float flapDist = abs(position.x);

// 胴体付近(0.3以下)は動かさず、翼の先端(0.8以上)はフルで動かす
float flapMask = smoothstep(0.3, 0.8, flapDist);

// uBoidWhip で位相をズラし、先端が遅れて動く「しなり」を作る
float phase = uTime * flapFreq - (flapDist * uBoidWhip * 2.0);
float wave = sin(phase);

// 単純な上下移動ではなく、角度をつけて翼の長さを保つ(こんにゃく化防止)
float angle = wave * flapMask * 0.5;
localPos.y += sin(angle) * flapDist;
localPos.x *= cos(angle);

② 狂気を演出するチューニング

GUI(lil-gui)を用いて、この uBoidWhip(しなり具合)をリアルタイムに操作できるようにしました。

数値を 0.0 にすれば、翼が一切曲がらないメカニカルな板になります。数値を 0.1〜0.2 に設定すれば、大型の鳥のような優雅な羽ばたきになります。 しかし、この数値をさらに上げ、基本速度(Fly Speed)を極端に高め、10万羽という圧倒的な物量と掛け合わせたとき……空は無数のミミズや鞭がうごめく、まるで『ドラッグ オン ドラグーン』のような絶望と狂気に満ちた空間へと変貌しました。

作者の意図をも超えて、パラメータの掛け合わせが「誰も見たことのない異形の事象」を生み出す。これこそがプロシージャル・アートの最大の醍醐味と言えるでしょう。

4. GLBモデルの統合と「本来の色」を守るハック

プロシージャルなV字モデルだけでなく、外部の美しい3Dモデル(今回はSketchfabのフリーモデルのフクロウ)も群れとして扱えるように、JSONでモデル情報を構造化して読み込む仕組みを構築しました。

// 🕊️ GLBモデル情報
const model_birds = {
  isActive: true,
  name: "BarnOwl",
  actor: "БʟøᴏᴅTɪɢᴇʀWᴏʟFSketchFab",
  path: "./barnowl.glb",
  scale: 0.5,
  rotation: [Math.PI, 0, Math.PI], // モデルごとの初期軸ズレを補正
};

しかし、ここで 「モデルが灰色一色に塗りつぶされてしまう」 という問題が発生しました。

今回の実装では、個体ごとに羽ばたきのタイミング(位相)をズラすため、InstancedMesh の setColorAt() を用いて、ランダムなシード値を色情報(RGB)に偽装してGPUに送っています。Three.jsの標準マテリアルは親切にも、この instanceColor を読み取って本来のテクスチャカラーと乗算(合成)してしまいます。結果として、フクロウの美しい羽の模様が失われ、無機質なグレーの塊になってしまったのです。

これを解決するため、マテリアルの onBeforeCompile を用いて Fragment Shader(ピクセルを塗る処理) に介入し、テクスチャカラーを保護するハックを行いました。

// 🎯 GLBのテクスチャ色を instanceColor で上書きさせない処理
shader.fragmentShader = shader.fragmentShader.replace(
  '#include <color_fragment>',
  `
  #ifdef USE_COLOR
    diffuseColor.rgb *= vColor;
  #endif

  // 💡 instanceColor(シード値)を色として反映させないようコメントアウト
  #ifdef USE_INSTANCING_COLOR
    // diffuseColor.rgb *= vInstanceColor;
  #endif
  `
);

この一行の書き換えによって、シード値としての役割は Vertex Shader(頂点シェーダー)で維持しつつ、画面にはモデル本来の鮮やかなフルカラーを描画することに成功しました。

おわりに

地形を隆起させ、洞窟を穿ち、草木を群生させ、風を吹かせる。
そして今回、その環境のうねり(Flow Field)に乗って飛ぶ10万の命(Boids)を大空へ放ちました。

当初は穏やかで美しい自然の風景を想像していましたが、数式のパラメータが噛み合い、GPUの限界を引き出した10万羽が空を覆い尽くしたとき、そこに現れたのは作者の意図をも超えた「事象」とも呼べる圧倒的な狂気の空でした。プロシージャル生成という手法が持つ、底知れないポテンシャルと恐ろしさを垣間見た気がします。

次回予告:[Noise 入門 #50] 第5集完結 — 終わらない世界を歩く(Procedural Worldの統合)

長きにわたるプロシージャル生成の旅路も、いよいよ次の一歩でひとつの区切りを迎えます。

次回は第5集「Procedural World編」の完結、記念すべき第50回です。
無限のチャンク生成、バイオーム、大気散乱、植生、そして今回放った異形の群れ。これまで個別に錬成してきたすべてのShaderとアルゴリズムを結合し、ひとつの「終わらない世界(Infinite Procedural World)」として最適化・統合します。

あなた自身の手で数式から構築した、無限に広がるノイズの箱庭。 その大地に実際に降り立ち、歩き出しましょう。