[Noise 入門 #47] Object Distribution — ノイズを「確率マップ」にして植生を群生させる

はじめに

Noise 入門シリーズ第47回。地形と洞窟という「広大なキャンバス」に、草や木、花といったオブジェクトを自然に散布し、世界に緑を芽吹かせます。単純なランダム配置を脱却し、ノイズを「確率マップ(Probability Map)」として用いることで、自然界特有の「群生(Clustering)」を表現するテクニックを解説します。

前回の記事:

1. 完全なランダムは「不自然」である

広大なボクセル地形と無限に続く地下洞窟が完成し、次はいよいよ「地表に木や草を生やす」フェーズです。 プログラミングにおいて、オブジェクトを散布する際に最も直感的で簡単な方法は、標準の乱数を使うことでしょう。

// チャンク内の地表(Surface)ブロックを走査するループ内で...
if (Math.random() > 0.95) {
    placeTree(x, y, z); // 5%の確率で木を配置する
}

しかし、このアプローチで世界を描画すると、木が等間隔にバラバラと散らばった非常に人工的で不自然な景観になってしまいます。なぜでしょうか?

Math.random() が生成するのは、隣り合う座標間でまったく相関関係を持たない純粋な乱数、すなわちホワイトノイズだからです。ホワイトノイズで木を配置すると、マップ全体に均一に「ふりかけ」をまぶしたようになり、どこまで歩いても同じような密度の景色が永遠に続くことになります。

実際の自然界では、植物は環境に依存して特定のエリアに「群生(Cluster)」します。 植物の生育は、土壌の質、水分の偏り、日照条件といった要因に強く影響を受けます。そして重要なのは、これらの環境要因は「ここから1歩先で急にゼロになる」のではなく、空間的に滑らかに(連続的に)変化するということです。

だからこそ、条件の整った場所では木が密集して「深い森」となり、条件から外れた場所は「見渡す限りの平原(時折ポツンと木が立つ程度)」になります。

この自然界特有の「連続的に変化する確率の偏り」を数学的にシミュレーションするためには、空間的な連続性を持つ滑らかなランダム、つまり Perlin Noise や Simplex Noise が再び必要不可欠となるわけです。

2. ノイズを「確率マップ(Probability Map)」として使う

これまで、連続的で滑らかな値を出力する Perlin Noise や Simplex Noise を、主に地形の高さ(Height)を決定するために使ってきました。今回はその出力を空間の高さではなく、「植物が生える確率(Probability)」として解釈し直します。

2Dノイズ関数 noise2D(x, z) が 0.0 から 1.0 の範囲の値を返すと仮定しましょう。この値を「森の密度」や「植生のポテンシャル」として捉え、特定の閾値(Threshold)を設定することで、自然な環境の偏りを表現できます。

  • 値が 0.8 以上: 密集した深い森(高確率で木を配置)
  • 値が 0.4 ~ 0.8: まばらな木立ち(低確率で木を配置)
  • 値が 0.4 未満: 木が生えない平原(配置しない)

地形生成の際に見た「ハイトマップの等高線」を思い出してください。あの白黒のグラデーションマップを、そのまま上空から見下ろした「植生の分布図(ヒートマップ)」として扱うのが、確率マップのアプローチです。

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

// 座標 (x, z) における植生ポテンシャルを取得 (0.0 ~ 1.0)
// ※scaleを小さくして、なだらかで大きな模様を作る
let plantPotential = noise2D(x * 0.01, z * 0.01);

if (plantPotential > 0.8) {
    // 深い森のエリア:非常に高い確率で木を生やす
    if (Math.random() < 0.8) placeTree(x, y, z);
} else if (plantPotential > 0.4) {
    // まばらなエリア:低い確率でポツポツと木を生やす
    if (Math.random() < 0.1) placeTree(x, y, z);
} else {
    // 平原エリア:木は生やさず、時折草を配置する程度
    if (Math.random() < 0.3) placeGrass(x, y, z);
}

このように、純粋な乱数(Math.random())と連続ノイズ(noise2D())を組み合わせることで、「森というエリアが滑らかに存在し、その中で木がランダムに生えている」という、大局的な秩序と局所的なランダム性を両立させることができます。

3. 多重ノイズによる「群生」の表現

前節のアプローチでもそれなりの分布は作れますが、より自然界の複雑さに近づけるためには、単一のノイズではなく周波数(Frequency)の異なる複数のノイズを掛け合わせるのが定石です。

これは、地形生成の際にフラクタル・ブラウン運動(FBM)で大きな大陸の起伏と小さな岩肌のディテールを合成したのと同じ考え方です。植生の配置においても、スケールの異なるノイズを組み合わせることで、有機的なまとまりを作ることができます。

① バイオームマスク(低周波ノイズ)

まず、大まかに「深い森のエリア」と「平原のエリア」を分けるための、スケールが大きくゆっくり変化するノイズを作ります。これが分布の大枠を決めるマスク(Mask)として機能します。

② 配置ノイズ(高周波ノイズ)

次に、細かく値が上下するスケールの小さなノイズを作ります。こちらは、実際の木一本一本の局所的な配置の「揺らぎ」を生み出します。

③ 2つの掛け合わせ(乗算)

この「マクロな分布」と「ミクロな配置」の2つを乗算(掛け算)することで、「巨大な森のエリアが存在し、さらにその森の中でも、木が密集している場所とポッカリとひらけた空き地が存在する」という、極めて自然な群生パターンが完成します。

// 1. 森か平原かを決める大きなマップ(低周波:scaleを小さく)
let forestMask = noise2D(x * 0.01, z * 0.01);

// 2. 局所的な配置の揺らぎ(高周波:scaleを大きく)
let placementNoise = noise2D(x * 0.5, z * 0.5);

// 3. マスクをかけて最終的な配置確率を算出
let treeProbability = forestMask * placementNoise;

// 確率が一定の閾値を超えた場所にだけ木を配置
if (treeProbability > 0.6) {
    placeTree(x, y, z);
}

この「乗算」の素晴らしいところは、forestMask の値が低い(平原の)場合、どれだけ局所的な placementNoise が高くなっても、最終的な確率が閾値(0.6)を超えることはない点です。逆に forestMask が高い(深い森の)エリアでは、placementNoise の高い箇所が次々と閾値を突破し、見事な木々の群生(クラスター)を形成します。

4. 植生を配置するための「空間的な条件」

ここまでのステップで、「X, Z 座標のどこに木が生えやすいか」という2Dの確率マップは完成しました。しかし、私たちが作っているのは高さ(Y軸)を持つ3Dのボクセル世界です。

単に X, Z 座標の確率マップだけを見てオブジェクトを配置してしまうと、「空中に木が浮いている」「地下の洞窟の中に草が生えている」「海の底に森ができている」といった不具合が容易に発生してしまいます。

そのため、ボクセル世界においては「空間的な条件判定」が必須となります。植生を配置する座標 (x, y, z) は、最低でも以下の条件をすべてクリアしなければなりません。

  1. 足元が「土」または「草ブロック」であること 岩肌の露出した山頂や、乾燥した砂浜の上には通常の木は生えません。配置する座標の「直下のブロック」の材質(ID)をチェックします。
  2. その座標の空間が「空気」であること 土の中や石の中に木が埋まってはいけません。配置予定のブロックが、何もない空間(Air)であることを確認します。
  3. 水(海面)の中ではないこと 地形生成の段階で定義した海面水位(Water Level)よりも高い位置である必要があります。

実装上は、チャンク内の各 (X, Z) 座標において、Y軸の最も高い位置(Surface / 地表)を走査して見つけ出します。そして、その地表ブロックが上記の条件を満たしている場合にのみ、先ほど作成した「確率マップ」のゲートを通す、という流れになります。

// (X, Z) 座標における一番高い地表のY座標を取得
let surfaceY = getSurfaceHeight(x, z);

// 空間条件の判定
let isGrassBlock = (getBlock(x, surfaceY - 1, z) === BLOCKS.GRASS); // 足元は草ブロックか?
let isAir = (getBlock(x, surfaceY, z) === BLOCKS.AIR);              // 配置場所は空気か?
let isAboveWater = (surfaceY >= WATER_LEVEL);                       // 海面より上か?

// 全ての空間条件をクリアした場合のみ、確率判定に進む
if (isGrassBlock && isAir && isAboveWater) {

    // ノイズから植生の確率を計算
    let treeProbability = forestMask * placementNoise;

    // 確率の閾値を超えたら木を配置
    if (treeProbability > 0.6) {
        placeTree(x, surfaceY, z);
    }
}

このように、「ノイズによる環境のポテンシャル(確率)」と「物理的な空間の整合性(条件)」を掛け合わせることで、初めて説得力のある自然な景観が立ち上がるのです。

5. Three.js における描画最適化(InstancedMesh の再登板)

地形ブロックの生成(第42回)でも触れましたが、数万本の草や木を一つ一つの THREE.Mesh としてシーンに追加して描画すると、瞬く間にGPUのドローコール(描画命令の回数)が限界を迎え、FPSが急落してしまいます。

そのため、広大な世界に植生を描画する際も THREE.InstancedMesh の利用が必須となります。 特に木を表現する場合、「木の幹(シリンダー等)」と「木の葉(球体や複数の平面等)」で別々のマテリアルを持つことが多いため、それぞれのパーツごとに独立した InstancedMesh を用意して管理する必要があります。

これまでに構築した Chunk Manager の仕組みと連携させ、地形を非同期生成するタイミングで植生の配置座標も同時に計算します。そして、算出された配置データ(位置・回転・スケールを持たせた Matrix4)の配列だけを一気に構成して GPU に渡す設計にすることで、見渡す限りの深い森が広がる世界でも、快適なパフォーマンスを維持することが可能になります。


木々が群生し、大地に緑が広がりました。 しかし、まだこの世界は無風で、静止したジオラマのようです。

次回は、【Noise 入門 #48】Wind & Flow — 風向ノイズで草木を揺らす。 時間軸(Time)と空間ノイズを組み合わせた「風のベクトル場」を作り出し、Vertex Shader(頂点シェーダー)を用いて10万本の草木を波打つように揺らします。世界に「空気の流れ」を感じさせるVFXテクニックに迫りましょう。お楽しみに!