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

はじめに

長きにわたる「Noise 入門」シリーズも、ひとつの巨大な到達点を迎えます。

第1集の基礎、第2集のアルゴリズム、第3集のShaderの魔法、第4集の天体生成を経て、この第5集では「Minecraft的な無限の世界」の基盤を構築してきました。

今回は、散り散りになっていたすべてのピースを結合します。非同期で無限に生成される大地、環境に適応するバイオーム、風に揺れる草木、そして空を舞う鳥の群れ。あなたが数式から紡ぎ出した「終わらない世界(Infinite Procedural World)」にカメラを落とし、自らの足で歩いてみましょう。

前回の記事:

1. 世界を統括する「World Manager」の設計

これまでの実装では、地形、植生、鳥の群れ(Boids)といった各要素を、それぞれ独立した小さなシーンで個別にテストしてきました。

しかし、これらをひとつの Three.js シーンに統合しようとした瞬間、私たちは残酷な現実を突きつけられます。数千万に及ぶボクセル密度計算、数万本の草木の配置、何千羽もの鳥のベクトル計算……これらをすべてメインスレッド(CPU)で愚直に処理しようとすれば、ブラウザは悲鳴を上げ、画面は完全にフリーズしてしまうでしょう。

破綻なく「終わらない世界」を動かすには、すべての処理を統括し、負荷を分散させる「頭脳」が必要です。 それが、今回設計する WorldManager クラスです。

WorldManager は、メインループ(requestAnimationFrame)の中で以下の4つのライフサイクルを一元管理します。

① プレイヤー座標の監視とチャンクの特定

無限生成の世界では、「今、自分がどこにいるか」がすべての基準になります。 プレイヤーの現在の 3D 座標 (x, y, z) をチャンクサイズで割り、切り捨てることで、現在属しているチャンクのインデックス (chunkX, chunkZ) を特定します。

② Chunk Managerへの指示(生成と破棄のキュー)

現在位置が判明したら、ChunkManager に現在のチャンク座標を渡します。 ChunkManager は、プレイヤーの周囲 N チャンク(描画距離)のリストを作成し、「新しく入った領域」の生成キューを追加し、「遠ざかって見えなくなった領域」のメモリ(キャッシュ)を破棄します。この「必要な時だけ作り、不要になれば捨てる」サイクルが、無限の世界を支える基盤です。

③ Web Workerのオーケストレーション(非同期処理)

ここが最も重要です。3D ノイズ(Simplex Noise)を用いた洞窟のくり抜きや、地形の密度計算は極めて重い処理です。 WorldManager は WorkerPool(複数の Web Worker を管理するクラス)に対し、新規チャンクの計算タスクを委譲します。これにより、メインスレッドは描画やカメラの移動に専念でき、世界が広がる瞬間の「カクつき(フレームドロップ)」を防ぎます。

④ 描画(InstancedMesh)の更新

Worker から「チャンクの計算結果(BiomeのID、高さ、密度データ)」が非同期で返ってくると、WorldManager はそれを各レンダラーに分配します。地形用の TerrainRenderer は土や石のボクセルを組み上げ、FloraRenderer は確率マップに基づいて草木を配置し、InstancedMesh の Matrix を更新して画面に反映させます。


実装コードの全体像

これらの役割をコードに落とし込むと、以下のようなシンプルで美しい構造になります。

// WorldManagerの概念的な構造
class WorldManager {
  constructor(scene, player) {
    this.scene = scene;
    this.player = player;

    // システムの初期化
    this.chunkManager = new ChunkManager(CHUNK_SIZE, RENDER_DISTANCE);
    this.workerPool = new WorkerPool(4); // 4スレッドで地形計算を並列化

    // 世界を彩る各レンダラーの初期化
    this.terrainRenderer = new TerrainRenderer(scene);
    this.floraRenderer = new FloraRenderer(scene);
    this.boidSystem = new BoidSystem(scene);
  }

  // requestAnimationFrame から毎フレーム呼ばれる
  update(deltaTime) {
    // 1. プレイヤーの現在チャンク座標を計算
    const currentChunkX = Math.floor(this.player.position.x / CHUNK_SIZE);
    const currentChunkZ = Math.floor(this.player.position.z / CHUNK_SIZE);
    const currentChunk = { x: currentChunkX, z: currentChunkZ };

    // 2 & 3. チャンクの更新とWorkerへのタスク送信
    this.chunkManager.updateChunks(currentChunk, this.workerPool, (chunkData) => {
      // 4. Workerからデータが返ってきたら描画を構築
      this.terrainRenderer.build(chunkData);
      this.floraRenderer.distribute(chunkData); // 確率マップに基づく植生配置
    });

    // 5. 環境(Shader)と生命のアップデート
    this.floraRenderer.updateWind(deltaTime);   // 頂点シェーダーの風向ノイズ更新
    this.boidSystem.updateFlowField(deltaTime); // Curl Noiseによる鳥の群れのベクトル更新
  }
}

このように WorldManager を設計することで、どれだけ複雑なノイズアルゴリズムを追加しても、メインスレッドのループは常にクリーンな状態に保たれます。

2. 描画負荷との戦い — 3つの最適化レイヤー

世界を構成するすべての要素(地形、植生、海、生命)を統合した瞬間、私たちはプロシージャル生成における最大の敵と対峙することになります。それが「描画負荷(Draw Calls)」と「計算負荷」です。

無邪気に数万個のブロックや草木を Scene.add() していけば、どれほどハイエンドなゲーミングPCであってもブラウザは即座に悲鳴を上げ、画面は1FPSの紙芝居と化すでしょう。60FPSの滑らかな世界を維持したまま、地平線の彼方まで広がる大地を描き出すためには、これまで私たちが一つずつ積み上げてきた「最適化技術」が真価を発揮します。

終わらない世界を支える、3つの強固な最適化レイヤーを振り返りましょう。

レイヤー1: InstancedMeshによるドローコールの極限圧縮

Three.js において、個別の Mesh を描画するたびにCPUからGPUへ「これを描け」という命令(ドローコール)が飛びます。10万個の土ブロックを個別に配置すれば、10万回の命令が発生し、CPUがボトルネックとなって処理が詰まります。

これを解決するのが、第42回で導入した InstancedMesh です。 ジオメトリ(頂点情報)とマテリアル(質感)を1つだけメモリに置き、「どこに、どんな向き・大きさで配置するか」という変換行列(Matrix4)の配列だけをGPUに一括で送り込みます。これにより、10万個のブロックであっても、1回のドローコールで瞬時に描画が完了します。地形のボクセル、群生する草木、空を舞う鳥の群れまで、この世界の「数で圧倒する要素」はすべて InstancedMesh によって構築されています。

レイヤー2: Vertex Shaderへの重力と風のオフロード(GPUの解放)

草木を揺らす風(Wind & Flow)や、海面のうねり(Gerstner Wave × FBM)を表現する際、毎フレーム数万の頂点座標をJavaScript(CPU)で計算してはいけません。

第11回の水面や第48回の風の実装で学んだ通り、空間の変形はすべて Vertex Shader(GPU)に丸投げします。CPU側がやるべき仕事は、毎フレーム uniform float uTime(経過時間)をシェーダーに送るだけです。並列計算の怪物であるGPUは、受け取った時間とノイズ関数を元に、数百万の頂点座標を一斉にねじ曲げ、自然界の滑らかなうねりをリアルタイムに描き出します。

レイヤー3: Web Workerによる非同期チャンク生成(メインスレッドの保護)

プレイヤーが未知の領域へ歩みを進めるたび、前方のチャンクがリアルタイムに生成されます。しかし、第46回で実装した「3D Simplex Noiseによる洞窟のくり抜き(Perlin Worms)」は、空間の密度を計算するための数学的負荷が極めて高い処理です。

これをメインスレッドで行うと、チャンクが生成される瞬間にUIや描画のループがブロックされ、世界が「カクッ」と止まってしまいます(フレームドロップ)。これを防ぐため、第45回で構築した Web Worker の非同期処理が機能します。重いノイズ計算はバックグラウンドのスレッドに逃がし、計算が終わった純粋なデータ(TypedArray)だけをメインスレッドに返却させます。これにより、プレイヤーは世界が生成されている計算の重さを微塵も感じることなく、滑らかに歩き続けることができるのです。

この3つの最適化レイヤーが完璧に噛み合って初めて、単なる「技術デモ」は、プレイヤーが没入できる「終わらない世界」へと昇華されます。

3. 大地に降り立つ — 数式が導く「当たり判定(Collision)」と歩行

これまでの連載では、OrbitControls などを使い、いわば「神の視点」からノイズの造形美を鑑賞してきました。しかし第5集の最終目標は「終わらない世界を歩く」ことです。

ここで Three.js の PointerLockControls を導入し、FPS(一人称)視点のカメラへと切り替えます。自ら創り出したこの広大な世界へ、ひとりの住人として降り立つ瞬間です。

物理エンジン不要の「数学的Collision」

プレイヤーが大地を歩くために不可欠なのが「地形との当たり判定(Collision)」です。 通常の3Dゲーム開発では、複雑なポリゴンメッシュに対して Raycaster を飛ばしたり、Cannon.js などの重い物理エンジンを導入して壁や床との衝突を計算します。広大なオープンワールドになるほど、この計算負荷は甚大なものになります。

しかし、私たちが構築したプロシージャルな世界では、地形に対する複雑なメッシュ交差判定は一切不要です。

なぜなら、この世界の大地は「ノイズ関数」という純粋な数式でできているからです。 関数 $f(x, z) = y$ にプレイヤーの現在の $x$ 座標と $z$ 座標を渡せば、その地点の正確な「地面の高さ $y$」が O(1) の計算量で即座に返ってきます。「足元に地面があるか?」を物理エンジンに尋ねるのではなく、「数式に直接答えを聞く」という非常にエレガントで高速なアプローチをとることができます。

プレイヤーコントローラーの実装

実際に WorldManager のアップデート・ループ内で、重力(Gravity)とジャンプ、そして地形との衝突判定を行うコードの骨格を見てみましょう。

// FPSコントローラーのシンプルな物理更新ループ
updatePlayer(deltaTime) {
  // 1. 重力を適用してプレイヤーを落下させる
  this.player.velocity.y -= GRAVITY * deltaTime;
  this.player.position.addScaledVector(this.player.velocity, deltaTime);

  // 2. 現在のX, Z座標から、ノイズ関数(またはキャッシュ)で地形の高さを取得
  const terrainHeight = this.chunkManager.getHeightAt(
    this.player.position.x,
    this.player.position.z
  );

  // 3. プレイヤーの足元が地面にめり込んだ場合の着地処理
  // (playerHeightはプレイヤーの背の高さ)
  if (this.player.position.y < terrainHeight + this.player.playerHeight) {
    // 地面の上に押し上げる
    this.player.position.y = terrainHeight + this.player.playerHeight;
    this.player.velocity.y = 0; // 落下速度をリセット
    this.player.canJump = true; // ジャンプ可能フラグをオン
  }
}

たったこれだけのコードで、プレイヤーは FBM ノイズで隆起した山脈の斜面を正確になぞって歩き、谷底へと滑り降りることができます。

3Dノイズ(洞窟)との衝突判定

さらに、第46回で実装した「Procedural Caves(地下世界)」へ潜る場合はどうなるでしょうか? 地下世界では、頭上にも地面(天井)が存在するため、2Dのハイトマップ関数だけでは判定できません。

この場合も考え方は同じです。現在地 $(x, y, z)$ の「空間の密度(Density)」を 3D Simplex Noise から取得(またはチャンクのボクセルデータを参照)し、density > 0 (ブロックが存在する)であれば衝突とみなして押し戻す処理を入れます。

数学が描いた「見えない確率の壁」を、私たちは当たり判定という形で「触れる現実」へと変換するのです。

4. 終わらない世界、ノイズが描く宇宙

すべてのコードを書き終え、ローカルサーバーを立ち上げてブラウザをリロードする。 画面がフェードインした瞬間、そこにはあなたが数式とコードだけでゼロから立ち上げた、無限の広がりを持つ世界が呼吸しています。

Wキーを押して、まっさらな大地へ歩き出してみてください。

遠くには、FBMが気の遠くなるようなオクターブを重ねて削り出した山脈が連なっています。足元に視線を落とせば、確率マップが芽吹かせた数万の草花が、Curl Noiseが描く目に見えない風のベクトル場に揺れています。 空を見上げれば、レイマーチングとフレネル反射が作り出した大気散乱の青が広がり、Boidsアルゴリズムで自律的に動く鳥の群れが、流体シミュレーションの気流に乗って彼方へ飛んでいきます。 もし足元に深く穿たれた穴を見つけたら、迷わず飛び込んでみてください。そこには3D Simplex Noiseが這い回って削り出した、Perlin Wormsの無限の洞窟がどこまでも続いているはずです。

「ただの滑らかな乱数」だった白黒の画像は、次元を超え、時間を超え、ついにひとつの生態系になりました。

乱数という「混沌(カオス)」に、数学という「秩序(コスモス)」を与える。 それこそが、プロシージャル生成という技術の真髄であり、最大の魔法です。私たちはこの50回の連載を通して、グリッドと勾配ベクトルの基礎から始まり、GPUの限界を引き出すShaderの最適化まで、その魔法のすべてを解き明かしてきました。

第5集「Procedural World編」、そして連載企画としての「Noise 入門」は、これにてひとつの巨大なマイルストーンに到達しました。

しかし、あなたの旅に終わりはありません。 この世界にどんな建築物を生み出すか、ボクセルを削ってどんなゲーム性を持たせるか、あるいはWebXRの技術を接続し、HMDを被って文字通り自らの足でこの空間にダイブするか。すべては創造主であるあなたの自由です。

長きにわたるノイズ探求の旅にお付き合いいただき、本当にありがとうございました。 あなたが数式から錬成したこの美しく広大な「ノイズの箱庭」を、どうぞ心ゆくまで歩き回ってみてください。