はじめに
長きにわたる「Noise 入門」シリーズも、ひとつの巨大な到達点を迎えます。
第1集の基礎、第2集のアルゴリズム、第3集のShaderの魔法、第4集の天体生成を経て、この第5集では「Minecraft的な無限の世界」の基盤を構築してきました。
今回は、散り散りになっていたすべてのピースを結合します。非同期で無限に生成される大地、環境に適応するバイオーム、風に揺れる草木、そして空を舞う鳥の群れ。あなたが数式から紡ぎ出した「終わらない世界(Infinite Procedural World)」にカメラを落とし、自らの足で歩いてみましょう。
前回の記事:
[Noise 入門 #49] Boids × Flow Field — 風に乗る「鳥の群れ」を空に放つ
空間を漂う生命の自律性を定義する「Boids」と、環境の物理的なうねりである「Flow Field(Curl Noise)」を融合。Three.jsとGLSLを用いて、風に流されながらも群れをなして舞う鳥たちを実装するプロシージャルな生態系構築の手法を解説しま …
https://humanxai.info/posts/noise-intro-49-boids-flow-field/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を被って文字通り自らの足でこの空間にダイブするか。すべては創造主であるあなたの自由です。
長きにわたるノイズ探求の旅にお付き合いいただき、本当にありがとうございました。 あなたが数式から錬成したこの美しく広大な「ノイズの箱庭」を、どうぞ心ゆくまで歩き回ってみてください。
💬 コメント