はじめに
Noise 入門シリーズ第44回。前回の記事で、無機質だったブロックの配列は色鮮やかなバイオームを持つ「大地」へと進化しました。しかし、現状ではまだ限られた範囲(1つのチャンク)しか描画されていません。
真のプロシージャル・ワールドの醍醐味は、プレイヤーが歩けば歩くほど、世界が無限に広がっていく ことにあります。
今回は、カメラ(プレイヤー)の移動に合わせて新しいチャンクを動的に生成し、遠ざかったチャンクを破棄、またはキャッシュする「Chunk Manager(チャンクマネージャー)」をThree.jsのクラスベース(OOP)で実装する手法を図解とコードで解説します。
前回の記事:
[Noise 入門 #43] バイオームと無限の生成 — InstancedMeshに色彩とチャンクを宿す
Three.jsのInstancedMeshを活用し、ノイズから生成した地形に色を与えるバイオームの実装と、シード値によるランダム生成、さらに無限の世界を描画するためのチャンク(Chunk)の概念を図解とコードで直感的に解説します。
https://humanxai.info/posts/noise-intro-43-biome-chunks/1. Chunk Manager の役割とは? — 「見えない世界」をどう扱うか
Minecraftのような無限に広がるプロシージャル・ワールドにおいて、世界全体を一度に生成し、メモリに読み込むことは物理的に不可能です。
昔の64KBしかメモリがないようなハードウェア時代から、限られたリソースで広大なマップを表現するために「必要な部分だけをロードする」という工夫がなされてきました。現代のハイスペックなPCやWebGL(Three.js)の世界においても、その本質は同じです。数百万、数千万というブロックの頂点データやマテリアルをすべてVRAM(ビデオメモリ)に乗せようとすれば、ブラウザは即座にクラッシュしてしまいます。
そこで、世界全体を一定サイズ(例:16×16ブロック)の 「チャンク(Chunk)」 と呼ばれる区画に分割し、必要な部分だけを実体化し、不要な部分は捨てる(または破棄する) という空間の管理システムが必要になります。
この世界の「管理者(神)」として振る舞うのが Chunk Manager です。 Chunk Manager は、毎フレーム(または定期的に)以下の3つの重要なタスクを自動で実行し続けます。
① 現在地の把握(Spatial Awareness)
プレイヤー(カメラ)が今、広大な世界の「どの区画」に立っているのかを常に計算します。連続的な座標(World座標)を、離散的なグリッド(Chunk座標)へと変換し、自分が属する基準点を明確にする作業です。
② チャンクの生成と実体化(Load & Generate)
プレイヤーの周囲、あらかじめ設定した「描画距離(Render Distance)」の範囲内に、まだ生成されていないチャンク空間があれば、新たにノイズを計算して InstancedMesh を生成し、シーンに追加します。 プロシージャルな世界では、プレイヤーが近づくまではそこには「ノイズ関数の数式」という純粋な可能性しか存在しません。 Chunk Manager が計算を指示した瞬間に初めて、数式が「大地」として実体化するのです。
③ チャンクの破棄とメモリ解放(Unload & Dispose)
プレイヤーが移動し、描画距離から遠く外れてしまったチャンクは、もはや画面に映ることはありません。Chunk Manager は素早くそれらをシーンから取り除きます。 Three.js において、これは単に scene.remove() を呼ぶだけでは不十分です。GPU上に確保されたジオメトリやマテリアルのデータを完全に手放すためのメモリ解放(Dispose処理) を徹底しなければ、すぐにメモリリークを引き起こしてしまいます。
このように、Chunk Manager とは単なるデータの配列管理ではなく、「プレイヤーの認識範囲に合わせて、世界の創造と破壊をひたすら繰り返すエンジン」 なのです。
それでは、このエンジンを動かすための最初のステップ、「座標の変換」から具体的なコードの実装に入っていきましょう。
2. 座標系の変換:World座標からChunk座標へ
Chunk Managerを作る上で、多くの開発者が最初につまずく「見えない壁」が座標系の違いです。 広大な Three.js の連続的な空間座標(World Coordinates)と、管理者が世界を分割して扱う離散的なグリッド座標(Chunk Coordinates)は、明確に分けて考える必要があります。
2つの世界のスケール
例として、1つのチャンクの幅(chunkSize)が 16 ブロック分だとしましょう。
- World座標 (15, 0, 5) の場合: Xは15、Zは5です。これはどちらも 0 〜 15 の範囲内に収まっているため、Chunk座標 (0, 0) の空間に属します。
- World座標 (17, 0, -3) の場合: Xは16を超えているので次のチャンクへ移動しています。Zはマイナス方向に踏み込んでいます。したがって、Chunk座標 (1, -1) の空間に属することが確定します。
この「空間上の実座標」から「チャンクの管理ID」を算出する計算式は非常にシンプルです。
// World座標からChunk座標への変換
function getChunkCoord(worldX, worldZ, chunkSize) {
const chunkX = Math.floor(worldX / chunkSize);
const chunkZ = Math.floor(worldZ / chunkSize);
return { x: chunkX, z: chunkZ };
}
なぜ Math.round や parseInt ではなく Math.floor なのか?
ここで非常に重要なポイントがあります。それは 必ず Math.floor()(切り捨て)を使うこと です。
一見すると、小数を消すだけなら parseInt() や Math.trunc() でも良さそうに思えます。しかし、これらを使うと 「原点(0)付近のチャンクだけが、他の2倍の広さになってしまう」 という恐ろしいバグ(Zero-crossing バグ)を引き起こします。
- Math.trunc(15 / 16) は 0
- Math.trunc(-15 / 16) も 0 になってしまう(本当は -1 になってほしい)
Math.trunc は単に小数点以下を切り落とすため、マイナスの値が 0 方向に引っ張られてしまいます。これでは、Chunk座標 (0, 0) の領域が -15.99 〜 +15.99 という広大な歪んだ空間になってしまいます。
一方、Math.floor() は「常にマイナス無限大の方向へ切り下げる」 という数学的な性質を持っています。
- Math.floor(-3 / 16) = Math.floor(-0.1875) = -1
この性質のおかげで、原点(0, 0)をまたいでマイナス座標の世界へ足を踏み入れても、グリッドのサイズが狂うことなく、等間隔に美しいチャンクIDを割り出し続けることができるのです。
3. Map を用いたキャッシュ戦略 — 世界の記憶を瞬時に引き出す
無限のチャンクを管理する際、絶対にやってはいけない実装があります。それは、生成したチャンクのデータを単なる「配列(Array)」に突っ込んでいくことです。
なぜ配列(Array)ではダメなのか?
もし const chunks = [] のように配列で世界を管理した場合、プレイヤーが移動するたびに「今から向かう先の座標(例えば X: 5, Z: -2)には、すでに生成済みのチャンクが存在するか?」を毎回チェックする必要があります。
配列の中から特定の座標のチャンクを探し出すには、chunks.find(c => c.x === 5 && c.z === -2) のように、先頭から順番に中身を確認していくしかありません。生成されたチャンクが10個なら一瞬ですが、世界が広がりチャンクが1万個、10万個と増えていったらどうなるでしょう? 毎フレーム数万回の無駄な検索ループが発生し、ゲームのフレームレートは瞬時に崩壊します(計算量 O(N) の罠です)。
辞書としての Map と「文字列キー」の魔法
そこで圧倒的なパフォーマンスを発揮するのが、JavaScriptの Map オブジェクト(ハッシュテーブル)です。 空間の座標をそのまま「名前(Key)」にして辞書に登録しておくことで、どれだけ世界が膨張しようと「この座標のチャンクある?」と尋ねれば、一瞬で結果を返してくれます(計算量 O(1) のスピード)。
ここで一つ、JavaScript特有の重要な実装テクニックがあります。 座標をキーとして登録する際、オブジェクト {x: 5, z: -2} や配列 [5, -2] をそのまま Map のキーにしてはいけません。JSの仕様上、中身が全く同じであっても「メモリ上の参照元」が違えば、別のキーとして扱われてしまう(つまり「存在しない」と判定されてしまう)からです。
確実に一意のインデックスを作るため、X座標とZ座標をカンマで繋いだ「文字列(String)」 に変換します。
// チャンクを管理・キャッシュするためのMap
const chunkCache = new Map();
// 座標を文字列にして一意のキー(Key)を錬成する
const chunkX = 5;
const chunkZ = -2;
const key = `${chunkX},${chunkZ}`; // "5,-2" という文字列になる
if (!chunkCache.has(key)) {
// 辞書に存在しない場合は、新たな世界(チャンク)を生成して登録
const newChunk = new Chunk(chunkX, chunkZ);
chunkCache.set(key, newChunk);
} else {
// 既に存在する場合は、キャッシュから一瞬で引き出す
const existingChunk = chunkCache.get(key);
}
この “${chunkX},${chunkZ}” というシンプルな文字列のキー設計こそが、無限に広がるボクセル世界のデータを破綻なく、かつ超高速に管理するための「最強のインデックス(索引)」となります。
4. OOPによるクラス設計:Chunk と ChunkManager
無限の世界を1つの巨大なファイル(スパゲッティコード)で管理しようとすると、すぐに破綻します。どこでブロックを作り、どこで不要なデータを消しているのか分からなくなるからです。
そこで、コードの構造をオブジェクト指向(OOP)で設計し、「役割」ごとにクラスを分割します。今回は「1つの区画を作る作業員(Chunk)」と、「プレイヤーの動きを見て作業員に指示を出す現場監督(ChunkManager)」という2つのクラスを作成します。
Chunk クラス(現場の作業員:ミクロの創造主)
Chunk クラスは、自分に割り当てられた特定の領域(例:16×16ブロック)の地形データだけを責任を持って管理します。 以前の記事で学んだ FBMノイズによる高さの計算、バイオームの割り当て、そして InstancedMesh の構築といった「世界を錬成する呪文」は、すべてこのクラスの generate() メソッドの中にカプセル化(隠蔽)されます。
class Chunk {
constructor(chunkX, chunkZ, chunkSize, scene) {
this.chunkX = chunkX; // 自分のチャンクX座標 (例: 5)
this.chunkZ = chunkZ; // 自分のチャンクZ座標 (例: -2)
this.chunkSize = chunkSize; // チャンクの幅 (例: 16)
this.scene = scene;
this.mesh = null; // ここに構築した InstancedMesh が入る
// インスタンス化された瞬間に自分自身で地形を生成する
this.generate();
}
generate() {
// 【注意】ノイズ関数のオフセット計算
// チャンク内のローカル座標(0~15)に、ワールドのオフセットを加算してノイズをサンプリングする
const worldOffsetX = this.chunkX * this.chunkSize;
const worldOffsetZ = this.chunkZ * this.chunkSize;
// ...前回の InstancedMesh 生成処理(worldOffsetX, Z を使ってノイズ計算)...
this.scene.add(this.mesh);
}
destroy() {
// 【超重要】WebGLのメモリ解放処理
this.scene.remove(this.mesh);
// 単にシーンから消すだけでなく、GPU上のVRAMデータを破棄(dispose)する
this.mesh.geometry.dispose();
this.mesh.material.dispose();
}
}
ここで特に重要なのが destroy() メソッドです。 Three.js(WebGL)では、scene.remove() で画面から消しても、裏側(GPUのVRAM)にはジオメトリ(頂点データ)やマテリアルのデータが居座り続けます。必ず .dispose() を呼び出して明示的にメモリを解放しなければ、プレイヤーが移動し続けた瞬間にメモリリークを起こし、ブラウザのタブがクラッシュ(フリーズ)してしまいます。 「正しく壊す」ことは、「美しく作る」ことと同じくらい重要なのです。
ChunkManager クラス(現場監督:マクロの管理者)
ChunkManager は地形の生成(ノイズの計算など)を直接行うことはありません。ひたすらプレイヤーの座標を監視し、描画距離(renderDistance)の範囲をスキャンして、必要なら Chunk を発注(インスタンス化)し、範囲外になったら Chunk に解体(destroy)を命じます。
class ChunkManager {
constructor(scene, chunkSize, renderDistance) {
this.scene = scene;
this.chunkSize = chunkSize;
this.renderDistance = renderDistance; // 例: 3 (周囲3チャンク、つまり7x7のグリッドを描画)
// 生成済みのチャンクを記憶する辞書(キャッシュ)
this.activeChunks = new Map();
}
update(playerX, playerZ) {
// 1. プレイヤーの現在チャンク座標を取得(Math.floorを使った安全な変換)
const currentChunk = getChunkCoord(playerX, playerZ, this.chunkSize);
// 今回のフレームで「維持・生成すべき」チャンクのキーリスト
const chunksInRadius = new Set(); // 検索が速いSetを使うとさらに最適化されます
// 2. プレイヤーを中心に、描画距離の範囲内(グリッド)を走査
for (let x = -this.renderDistance; x <= this.renderDistance; x++) {
for (let z = -this.renderDistance; z <= this.renderDistance; z++) {
const targetX = currentChunk.x + x;
const targetZ = currentChunk.z + z;
const key = `${targetX},${targetZ}`;
chunksInRadius.add(key); // 生存リストに追加
// まだ辞書(世界)に存在していなければ、新たに生成を発注
if (!this.activeChunks.has(key)) {
const newChunk = new Chunk(targetX, targetZ, this.chunkSize, this.scene);
this.activeChunks.set(key, newChunk);
}
}
}
// 3. 範囲外になったチャンクの破棄(Unload)
for (const [key, chunk] of this.activeChunks.entries()) {
// 現在の生存リスト(chunksInRadius)に含まれていない古いチャンクを見つけたら
if (!chunksInRadius.has(key)) {
chunk.destroy(); // メモリを解放させる
this.activeChunks.delete(key); // 辞書から削除する
}
}
}
}
この設計の素晴らしいところは、「メインループを全く汚さない」 という点です。 複雑なループ処理やメモリ管理はすべてこの2つのクラス内に隠蔽されているため、外側(メインファイル)からは非常に美しく扱うことができます。
5. メインループへの組み込み — 世界の心臓を動かす
ここまでクラスをきれいに設計(カプセル化)できたおかげで、メインファイル(main.js など)の記述は驚くほどシンプルになります。
複雑な座標変換、ノイズの計算、そして面倒なメモリの解放(Dispose処理)はすべて裏側で ChunkManager と Chunk が引き受けてくれます。私たちがメインループ(requestAnimationFrame)の中でやるべきことは、ただ一つ。「観測者(カメラ)の現在地をマネージャーに伝え続けること」 だけです。
// シーン、チャンクサイズ(16)、描画距離(半径4チャンク = 9x9の領域)を指定して現場監督を召喚
const chunkManager = new ChunkManager(scene, 16, 4);
function animate() {
requestAnimationFrame(animate);
// controls.update(delta); // FlyControlsやPointerLockControlsなどの更新
// カメラのXZ座標を毎フレーム渡し、世界の生成と破壊を管理させる
chunkManager.update(camera.position.x, camera.position.z);
renderer.render(scene, camera);
}
animate();
さあ、カメラを動かして空間を移動してみてください。(第4集で実装した FlyControls などを組み合わせて上空を飛んでみるのがおすすめです)。
あなたが前へ進むたび、何もない虚空から新たな大地が隆起し、色鮮やかなバイオームが広がっていきます。そして後ろを振り返れば、遠ざかった世界は静かに解体され、メモリの海へと還っていくはずです。
Map による高速なキャッシュ検索、InstancedMesh による描画バッチの最適化、そして FBM ノイズによる自然な地形のうねり。これまでの連載で一つずつ積み上げてきた点と点が繋がり、あなたのブラウザの中に、ついに「無限に続くプロシージャルな世界」が誕生しました。
次回のハイライト:非同期生成(Web Workers)による「カクつき」の解消
ついに枠組みを越えて無限に広がる世界を手に入れました。しかし、カメラの移動速度を極端に上げて高速で空を飛んでみると、新しいチャンクが生成される瞬間にフレームレートが落ち、「カクッ」と一瞬画面が止まる(スパイクが発生する)ことに気づいたかもしれません。
これは、メインスレッド(ブラウザのUIを描画しているのと同じ場所)で、何万ものブロックのノイズ計算とメッシュ生成を同期的に(順番待ちで)行っていることが原因です。
次回【Noise 入門 #45】では、この重たいチャンク生成の計算処理を、裏側の別スレッドに丸投げする 「Web Workers(ウェブワーカー)」 を導入します。どれだけ高速に世界を駆け抜けても、60FPSを維持したままヌルヌルと大地が生成され続ける、プロ仕様の非同期最適化アプローチに挑みます。お楽しみに!
💬 コメント