[Next.js 3D #53] プロシージャルな植生配置とGUIによる動的コントロール — 確率マップで森を錬成する

はじめに

前回の記事で無限の地下空間をくり抜いたボクセル世界。
今回はついに、地表に緑を芽吹かせる「植生配置(Object Distribution)」の実装フェーズに入ります。

理論編である [Noise 入門 #47] で解説した「多重ノイズを用いた確率マップ(Probability Map)」のアルゴリズムを、Next.js と Three.js の環境に統合しました。

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

制作者:Miaru3d 様

  model: {
    name: "Pearl Drone - Splatoon Side Order Trailer",
    author: "Miaru3d",
    url: "https://sketchfab.com/miaru3d",
    path: "./pearl_drone_-_splatoon_side_order_trailer.glb",
    scale: 8.0,
  }

スクリーンショット:

動画(YouTube):

動画(PC):

1. Web Worker による確率マップの計算と「自然な分布」の罠

プロシージャルな世界を構築する際、最大の敵は「メインスレッドのブロック」です。 1つのチャンク(例えば $32 \times 32$)には1024個の地表ブロックが存在します。描画距離を8チャンク($17 \times 17 = 289$ チャンク)と設定した場合、約30万ブロックに対して「ここは土か?」「水の中ではないか?」「木を生やす確率は?」という空間判定を走らせることになります。これをメインスレッドで計算していては、世界を移動して新しいチャンクが生成されるたびに画面がフリーズしてしまいます。

そのため、地形や洞窟(Procedural Caves)の生成と同様に、植生判定の重い処理もすべて Web Worker(chunkWorker.js)へ逃がしました。

確率マップのコア・ロジック

Worker 内のループで実装しているのは、Noise 入門 #47 で解説した「多重ノイズの乗算」です。

// ① バイオームマスク(低周波):大きな森のエリアと平原を分ける
const f = fbm((wx + offX + 10000) * params.forestFreq, (wz + offZ + 10000) * params.forestFreq, 3, 0.5, 2.0);
const forestMask = (f + 1.0) * 0.5;

// ② 配置ノイズ(高周波):個別の木が生えるかどうかのミクロな揺らぎ
const c = noise2D((wx + offX) * 0.8, (wz + offZ) * 0.8);
const clusterNoise = (c + 1.0) * 0.5;

// ③ 2つを掛け合わせて最終的な確率を算出
const treeProbability = forestMask * clusterNoise;

この計算によって得られた treeProbability が、GUI で設定した閾値(params.treeThreshold)を超えた場合のみ、その (x, y, z) 座標を配列に格納します。 最終的にこの配列を Float32Array に変換し、メモリのコピーコストをかけずにメインスレッドへ転送(Transferable Objects)することで、無駄のない爆速のデータ連携を実現しています。

「山頂にしか木が生えない」罠と座標オフセット

ここで一つ、実装時に陥りがちな罠に触れておきます。 上記の forestMask の計算式にある + 10000 というオフセット(ズレ)です。

もしこのオフセットがない場合、地形の高さ(Height)を決めるベースノイズと、森のエリアを決めるノイズが 「完全に同じ座標を参照」 してしまうことがあります。すると、「地形が高い場所 = 森の確率が高い場所」という強固な相関関係が生まれてしまい、結果として 「標高の高い山頂付近にしか木が生えない」 という極めて不自然な景色が錬成されてしまいます。

地形計算用のノイズと、環境判定用のノイズの参照座標を大きくズラす。 たったこれだけの工夫で、地形の起伏とは独立した自然な「環境の分布」を作り出すことができます。深い谷間にも鬱蒼とした森ができ、平らな大地を抜けるような草原が生まれるのは、この小さなオフセットのおかげなのです。

2. InstancedMesh による描画最適化と「隙間」の解消

Web Worker が重い確率計算を終え、「ここに木を置くべし」という数千、数万もの座標リスト(Float32Array)をメインスレッドに返してくれました。しかし、これを愚直に一つ一つの THREE.Mesh としてシーンに追加してしまうと、GPUへの描画命令(ドローコール)が激増し、60FPS だった世界が一瞬でコマ送りの地獄へと変貌します。

終わらないボクセル世界を描画するためには、地形生成の時と同様に THREE.InstancedMesh の活用が絶対に欠かせません。

幹と葉の分割管理

木を表現する場合、「茶色い幹(CylinderGeometry)」と「緑の葉(ConeGeometry)」のように、異なるマテリアルを持つ複数のパーツを組み合わせるのが一般的です。InstancedMesh は単一のジオメトリとマテリアルのペアに対して機能するため、今回は幹用と葉用、そして草用と、それぞれ独立した InstancedMesh を用意して管理しています。

// 🌳 木の構築ロジック(一部抜粋)
this.trunkMesh = new THREE.InstancedMesh(trunkGeo, trunkMat, numTrees);
this.leavesMesh = new THREE.InstancedMesh(leavesGeo, leavesMat, numTrees);

for (let i = 0; i < numTrees; i++) {
  // 座標から決定論的なランダム値を生成(シード値代わり)
  const seedVal = Math.abs(tx * 12.3 + tz * 45.6);
  const scale = params.treeMinScale + ((seedVal % 10) / 10) * (params.treeMaxScale - params.treeMinScale);
  const rotY = seedVal % (Math.PI * 2);

  // 幹の配置(行列計算用ダミーオブジェクトを使用)
  dummy.position.set(tx, ty + 1 * scale, tz);
  dummy.rotation.set(0, rotY, 0);
  dummy.scale.set(scale, scale, scale);
  dummy.updateMatrix();
  this.trunkMesh.setMatrixAt(i, dummy.matrix);

  // 葉の配置(幹より高い位置へ)
  dummy.position.set(tx, ty + 2.5 * scale, tz);
  dummy.updateMatrix();
  this.leavesMesh.setMatrixAt(i, dummy.matrix);
}

座標をもとにしたハッシュ計算(seedVal)を用いることで、ランダムでありながら「チャンクを再生成しても全く同じ大きさ・向きの木が生える」という決定論的な配置を実現しています。

【あるあるトラップ】ダミーオブジェクトのスケール汚染

ここで、実装中に見事に引っかかった Three.js の「あるあるトラップ」を共有しておきます。 InstancedMesh に行列(Matrix4)を渡す際、計算用の THREE.Object3D(上記の dummy)を使い回すのが定石です。草や木の大きさをバラけさせるために dummy.scale.set(scale, scale, scale) と縮尺を変更しますが、この縮小されたスケール設定は dummy の中に残ったままになります。

そのため、次のチャンクの「地形ブロック」の行列を計算する前にスケールをリセットし忘れると、地形ブロック自体が縮小されて描画され、地面に等間隔の謎の「隙間」が生まれるという不具合を引き起こします。

// 🌟 必須の対策:前の草木で変更されたスケール・回転をリセットする
dummy.scale.set(1, 1, 1);
dummy.rotation.set(0, 0, 0);

for (let i = 0; i < numBlocks; i++) {
  // 地形ブロックの行列計算へ...
}

このたった2行のリセット処理を追加することで、隙間の不具合は完全に消え去り、ミッチリと密度のある大地と自然な森を描画できるようになりました。

3. lil-gui による「世界観」の動的コントロールと直感的なプロシージャル・デザイン

プロシージャル生成の開発につきまとう最大の苦痛、それは 「パラメータの数値を 0.001 だけ書き換えてブラウザをリロードし、想定外の結果に絶望する」 という途方もないトライ&エラーの反復です。

特に植生のような「分布」を扱う場合、コード上の数値を見ただけでは最終的な景観をイメージするのは困難です。そこで今回は、GUIライブラリである lil-gui を導入し、確率マップのコアとなる変数をスライダーでリアルタイムに操作できるようにしました。

動的パラメータのバインディング

app.js の設定オブジェクト(params)に植生用のパラメータを追加し、GUI にバインドします。

// app.js (一部抜粋)
export const params = {
  // ...既存の地形パラメータ...
  forestFreq: 0.005,      // 森の広がり(マクロな分布)
  treeThreshold: 0.45,    // 木が生える閾値(ミクロな密度)
  grassThreshold: 0.2,    // 草が生える閾値
  treeMinScale: 0.6,      // 木の最小サイズ
  treeMaxScale: 1.4,      // 木の最大サイズ
};

// GUIのセットアップ
const vegFolder = gui.addFolder("Vegetation Settings");

// スライダー操作が終わった瞬間にチャンクを再構築する
const triggerRebuild = () => {
  chunkManager.rebuild();
};

vegFolder.add(params, "forestFreq", 0.001, 0.05).name("Forest Area Size").onFinishChange(triggerRebuild);
vegFolder.add(params, "treeThreshold", 0.1, 0.8).name("Tree Density").onFinishChange(triggerRebuild);
vegFolder.add(params, "treeMinScale", 0.1, 2.0).name("Tree Min Scale").onFinishChange(triggerRebuild);
vegFolder.add(params, "treeMaxScale", 0.5, 5.0).name("Tree Max Scale").onFinishChange(triggerRebuild);

スライダーから手を離した瞬間(onFinishChange)に ChunkManager.rebuild() が発火し、不要なメッシュを破棄して Web Worker に最新のパラメータで再計算を依頼する仕組みです。

パラメータが世界に与える劇的な変化

この GUI 操作によって、リロードなしで瞬時に「世界観」を切り替えられるようになりました。

  • 「ファンタジー世界の巨木林」を作る Tree Min Scale と Tree Max Scale を大きく引き上げます。木が巨大化し、まるでセコイアの森のような圧倒的なスケール感が生まれます。
  • 「まばらなサバンナ」を作る Tree Density(閾値)を 0.65 程度まで上げます。厳しい確率判定をすり抜けた数本の木だけが、広大な草原にポツンと立つ寂寥感のある景色になります。
  • 「果てしない大森林」を作る Forest Area Size(低周波ノイズの周波数)を 0.001 まで下げます。ノイズのうねりが超巨大になり、ドローンでどれだけ飛んでも抜け出せない深い森のエリアが形成されます。

スライダーを動かしたとき、木々がランダムに現れたり消えたりするのではなく、「森という群生(クラスター)の形を保ったまま、ギュッと凝縮されたり広がったりする」 のが分かるでしょうか。 これこそが、完全な乱数(ホワイトノイズ)を捨てて「連続的なノイズによる確率マップ」を採用した最大の理由であり、数学が自然界の有機的な分布をシミュレーションできている証拠でもあります。

次回予告

無機質だった起伏のある大地に、緑が芽吹きました。 しかし、この世界はまだ静止したジオラマのようです。

次回は、【Next.js 3D #54】Wind & Flow — 頂点シェーダーで10万本の草木を揺らす。(※連載番号に合わせて調整) 時間軸(Time)と空間ノイズを組み合わせた「風のベクトル場」を作り出し、GPUの力(Vertex Shader)で世界に「空気の流れ」を感じさせるVFXテクニックに迫ります。お楽しみに!