[Noise 入門 #44] Chunk Manager の実装 — 無限の世界を動的に生成・管理する

はじめに

Noise 入門シリーズ第44回。前回の記事で、無機質だったブロックの配列は色鮮やかなバイオームを持つ「大地」へと進化しました。しかし、現状ではまだ限られた範囲(1つのチャンク)しか描画されていません。

真のプロシージャル・ワールドの醍醐味は、プレイヤーが歩けば歩くほど、世界が無限に広がっていく ことにあります。

今回は、カメラ(プレイヤー)の移動に合わせて新しいチャンクを動的に生成し、遠ざかったチャンクを破棄、またはキャッシュする「Chunk Manager(チャンクマネージャー)」をThree.jsのクラスベース(OOP)で実装する手法を図解とコードで解説します。

前回の記事:

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を維持したままヌルヌルと大地が生成され続ける、プロ仕様の非同期最適化アプローチに挑みます。お楽しみに!