[Noise 入門 #46] Procedural Caves — 3DノイズとPerlin Wormsで「地下世界」を削り出す

はじめに

空間の「密度」を操る準備は整いましたか?
前回、Web Workersによる非同期処理を導入し、私たちはXとZの平面(2D)だけでなく、Y軸(高さ)を含めた3次元空間全体の「詰まり具合」を計算できる基盤を手に入れました。

Noise 入門シリーズ第46回。
今回はこの3Dノイズの数式を応用し、光の届かない地下深く、複雑に絡み合う「プロシージャルな洞窟」を錬成します。単なるランダムな空洞から、どこまでも続く地下通路(Perlin Worms)まで、地形を「削る」アルゴリズムの真髄を直感的に理解していきましょう。

前回の記事:

1. 2Dの高さ(Height)から、3Dの密度(Density)による「くり抜き」へ

これまでの地形生成(#41〜#44)の主役は、2Dノイズを用いたハイトマップ(Heightmap)でした。

「この $(X, Z)$ 座標における、地面の高さ(Y)はいくつか?」を計算し、その高さより下をすべてブロックで埋め尽くす。これは非常に効率的で王道のアプローチですが、数学的な構造上、致命的な弱点を抱えています。

それは、「オーバーハング(せり出した崖)」や「地下の空洞(洞窟)」が絶対に作れないということです。

2Dハイトマップは関数で表すと $Y = f(X, Z)$ となります。つまり、ある平面座標 $(X, Z)$ に対して、結果となる高さ $Y$ は常に「1つの値」しか持ち得ません。空洞を作るためには「地面、空間、そしてまた地面」というように、特定の $(X, Z)$ において複数の状態が重なる必要がありますが、2Dノイズの評価ではそれを表現できないのです。

そこで、前回の #45 で手に入れた 「3Dノイズによる密度(Density)」 の概念が、世界を拡張する鍵となります。

考え方を根底から覆しましょう。高さを求めるのではなく、3次元空間内の任意の点 $(X, Y, Z)$ に対してノイズ関数を評価し、その戻り値を「その空間における岩の密度(詰まり具合)」として扱うのです。

  • 密度が閾値(Threshold)より高い = ブロックを配置する(Solid)
  • 密度が閾値(Threshold)より低い = ブロックを配置しない(Air / Cave)

コードの構造で見比べると、パラダイムシフトが直感的に分かります。

// 【これまでの2Dハイトマップ】
// XとZから高さを算出し、それより下を埋める
const surfaceHeight = fbm2D(x, z) * maxHeight;
if (y < surfaceHeight) {
    return BLOCK_TYPE.STONE;
}

// 【これからの3D密度判定】
// X, Y, Zの3次元座標すべてを使って「そこが岩かどうか」を判定する
const density = noise3D(x, y, z);
if (density > THRESHOLD) {
    return BLOCK_TYPE.STONE;
} else {
    return BLOCK_TYPE.AIR; // 閾値以下なら削り取られて空洞になる
}

このシンプルなルールを、Chunk内のすべてのボクセル座標に対して総当たりで適用します。

結果として何が起きるか。空間全体に満たされた見えない「密度のムラ」に対して、閾値(Threshold)という見えない彫刻刀を入れることで、不要なブロックがごっそりと削り落とされます。これが、プロシージャルな洞窟や浮遊島を生み出す「空間のくり抜き」の基礎原理です。

2. スイスチーズ・ケーブ(Swiss Cheese Caves)

最もシンプルで直感的な洞窟の作り方は、通常の3D Simplex Noiseなどをそのまま密度の判定に使うことです。

ノイズ関数は通常、-1.0 から 1.0 の間で滑らかに波打つ値を返します。この性質を利用して、「ノイズ値が特定の閾値(Threshold)を超えた空間を空洞(Air)にする」というルールを設けてみましょう。

例えば、以下のような判定を行います。

// 空間の座標から3Dノイズ値(-1.0 〜 1.0)を取得
const frequency = 0.05;
const noiseValue = snoise3D(x * frequency, y * frequency, z * frequency);

// ノイズ値が 0.4 以上なら空洞(AIR)、それ以外は岩(STONE)
if (noiseValue > 0.4) {
    return BLOCK_TYPE.AIR;
} else {
    return BLOCK_TYPE.STONE;
}

このアルゴリズムを実行すると、岩盤の中のあちこちに、球状に近いランダムな気泡(空洞)が生まれます。その見た目から、この手法で作られた洞窟は「スイスチーズ・ケーブ(Swiss Cheese Caves)」と呼ばれます。

実装が非常に簡単で処理負荷も低いのですが、ゲームやプロシージャル生成の世界において、この手法には決定的な問題点があります。

それは、「洞窟が孤立しやすい」ということです。

本物のスイスチーズの断面を想像してみてください。一つ一つの穴は独立しており、隣の穴と繋がっているとは限りません。これを地形生成に適用すると、プレイヤーはせっかく地下の空洞を見つけても、そこから先へ進む道がなく、完全に閉じ込められた「ハズレの空間(閉鎖空間)」ばかりが生成されてしまうのです。

「じゃあ、空洞判定の閾値を 0.4 から 0.1 に下げて、穴を大きくすれば繋がるのでは?」と思うかもしれません。確かに繋がりやすくはなりますが、今度は大地の中身がスカスカになりすぎてしまい、地形全体が崩壊して不自然になってしまいます。

私たちが本当に求めているのは、単なる「岩盤のランダムな欠損」ではなく、プレイヤーが松明を片手にどこまでも奥深くへと探検できるような「長く連続した地下通路(ネットワーク)」です。

この「独立した気泡」という問題を数学的にエレガントに解決するために生み出されたのが、次項で解説する「Perlin Worms(パーリンの虫)」というテクニックです。

3. Perlin Worms(パーリンの虫)— 繋がるトンネルの魔法

孤立した気泡である「スイスチーズ」の問題を解決し、地下世界を探索しがいのある「繋がったネットワーク」へと進化させる。そのために生み出されたのが、Perlin Worms(パーリンの虫)と呼ばれるエレガントな数学的アプローチです。

これは文字通り、「巨大な虫が土の中を這いずり回って食べた跡」のように、管状(チューブ状)の連続した空洞を作り出すテクニックです。

実装の鍵となるのは、通常のノイズをそのまま使うのではなく、「絶対値(Absolute Value)」と「反転」の計算を挟むことです。実はこれ、第10回で山脈を作った「Ridge Noise(尾根ノイズ)」と数学的なアプローチは全く同じです。山を隆起させる数式を、今度は「空間をくり抜く彫刻刀」として使います。

ゼロ交差(Zero-Crossing)が道になる

3Dノイズ関数は、空間の中で $-1.0$ から $1.0$ の間を行ったり来たりしながら滑らかに変化しています。マイナスからプラスへ変化するということは、必ずどこかで $0$ を通り抜けるということです。

このノイズ値が $0$ になる境界線(等値面)は、空間の途中でプツッと途切れることはありません。空間内をどこまでも連続してうねうねと続く、見えない「面」や「線」を形成しています。

この性質を利用し、以下の数式を適用します。

$$Worm(x, y, z) = 1.0 - |Noise(x, y, z)|$$

この計算によって何が起こるでしょうか? 元のノイズ値が $0$ だった場所(ゼロ交差の境界)は、$|0| = 0$ となり、$1.0 - 0 = 1.0$、つまり最大値になります。そして、そのゼロの地点から離れれば離れるほど、値は $1.0$ から減少していきます。

チューブ状の空間を削り出す

この数式で作られた空間に対して、「値が $0.9$ 以上なら空洞にする」という閾値を設定してみましょう。

// 空間の座標から3Dノイズ値を取得
const frequency = 0.03;
const noiseValue = snoise3D(x * frequency, y * frequency, z * frequency);

// 1. 絶対値をとって反転させる(Perlin Wormの数式)
const wormValue = 1.0 - Math.abs(noiseValue);

// 2. 閾値(トンネルの太さ)を設定し、空洞か岩かを判定
const tunnelThreshold = 0.9;

if (wormValue > tunnelThreshold) {
    return BLOCK_TYPE.AIR;   // 中心に近い場所は削り取ってトンネルにする
} else {
    return BLOCK_TYPE.STONE; // それ以外は岩を残す
}

このわずかな計算の工夫だけで、結果は劇的に変わります。 孤立していたランダムな穴は消え去り、ノイズのゼロ交差に沿って、なめらかに分岐し、うねり、どこまでも続く巨大な地下通路が一瞬にして削り出されます。

tunnelThreshold の値を $0.95$ にすれば細い抜け道になり、$0.8$ にすれば巨大な地下空洞を伴う太い洞窟ネットワークになります。さらに、FBM(Fractal Brownian Motion)を組み合わせてノイズを重ね合わせれば、壁面がゴツゴツとした自然でリアルな洞窟を錬成することも可能です。

4. 地形(Surface)と洞窟(Cave)の融合

スイスチーズの孤立した穴を克服し、Perlin Wormsによって無限に続く地下の道を手に入れました。しかし、これらはあくまで「空間全体の密度」を計算したに過ぎません。

実際のワールド生成において最も重要なのは、これまで作ってきた「地表(Surface)」の2Dハイトマップと、この「地下(Cave)」の3Dノイズを矛盾なく融合させ、1つのチャンクとして矛盾なく描画することです。

Web Worker内で実行されるチャンク生成のループ(for文でX, Y, Zを回す処理)の中で、以下の3つのステップを踏んで最終的なブロック(ボクセル)の状態を決定します。

融合の3ステップ

  1. 地表の決定 (2D Noise): まずは $(X, Z)$ 座標に対して2D FBMノイズを評価し、基準となる地面の高さ(surfaceHeight)を算出します。
  2. 基本の岩盤配置: 現在評価しているボクセルの高さ($Y$)が、surfaceHeight より上なら空気(AIR)、下なら岩盤(STONE)と仮決定します。ここまでは従来の地形生成と同じです。
  3. 洞窟のくり抜き (3D Noise): もしボクセルが岩盤の中($Y < surfaceHeight$)だった場合、追加で 3D Perlin Worms ノイズを評価します。その評価値がトンネルの閾値を超えていたら、強制的に空洞(AIR)で上書きします。

このロジックをJavaScriptのコードに落とし込むと、以下のようになります。

// Chunk内の各ボクセル座標 (x, y, z) における判定処理

// 1. 2Dノイズで地表の高さを計算
const surfaceHeight = getSurfaceHeightFBM(x, z);

// 2. 基本配置の判定
if (y > surfaceHeight) {
    // 地表より上は常に空気
    return BLOCK_TYPE.AIR;
} else {
    // 地表より下(岩盤)の場合は、洞窟の判定を行う

    // 3. 3DノイズでPerlin Wormsの値を計算
    const wormValue = getPerlinWormValue(x, y, z);
    const tunnelThreshold = 0.85; // トンネルの太さ

    if (wormValue > tunnelThreshold) {
        // 虫の通り道(洞窟)なら、岩盤を削り取って空気に上書きする
        return BLOCK_TYPE.AIR;
    } else {
        // 洞窟でなければ、そのまま岩を残す
        // (※深さに応じてDIRTやSTONEを変える処理をここに入れてもOK)
        return BLOCK_TYPE.STONE;
    }
}

この「単純な引き算(上書き)」のロジックを組み合わせるだけで、地表にはぽっかりと自然な洞窟の入り口が開き、そこから地下へと続く複雑な迷宮が広がります。

2Dノイズが「大地を盛り上げる力」だとするなら、3Dノイズは「大地を削り出す彫刻刀」です。この2つの相反するアプローチを掛け合わせることで、プロシージャルな世界は真の立体感と深みを獲得するのです。

まとめと次回予告

今回は、3D空間における「密度」という概念を用いて、地下世界を削り出すアルゴリズムを学びました。

  • 2Dハイトマップでは表現できないオーバーハングや空洞は、3Dノイズの密度(Density)による閾値判定で作る。
  • 単純な3Dノイズの切り抜きは「スイスチーズ」のような孤立した穴(閉鎖空間)になりやすい。
  • Perlin Worms(ノイズの絶対値の反転)を利用することで、ノイズの「ゼロ交差」に沿った、探索可能な「連続したチューブ状の洞窟」を錬成できる。
  • 地表(2D)の判定を行った後に、地下(3D)の判定で「上書きして削る」ことで、矛盾のないシームレスな世界が完成する。

見えない地下にアルゴリズムが迷宮を掘り進める感覚は、プロシージャル生成の醍醐味のひとつです。パラメータを少し変えるだけで、アリの巣のような細い通路から、地底湖が広がる巨大空間まで、あらゆる地下世界をデザインできるようになります。

次回【Noise 入門 #47】Object Distribution — 植生の配置

地形と洞窟という「広大なキャンバス」が完成しました。
次回は、この大地にノイズを「確率マップ(Probability Map)」として適用し、草や木、花を自然に散布して世界に緑を芽吹かせます。規則正しく並べるのではなく、自然な「群生」を表現するテクニックに迫ります。
お楽しみに!