[Noise 入門 #10] Procedural Terrain Generation — 数学で大地を隆起させる

はじめに

「ただの凸凹」から「意味のある大地」へ。 Perlin Noise をそのまま適用した地形は、ただの「滑らかな丘」に過ぎません。険しい山脈、平原、そして雪解け水が削ったような谷を作るには、ノイズの「重ね方」に物理的な意味を持たせる必要があります。今回は、頂点シェーダー(Vertex Shader)を使って、平面(Plane)をリアルタイムに隆起させ、Ridge Noise と Masking を駆使して「大陸」をデザインします。

前回の記事:

1. 導入:空から降り立つ

前回、私たちはレイマーチングという強力な武器を手に入れ、数学的に生成された雲の中を自由に飛び回りました。 視界を覆う白い霧、雲海を抜けた瞬間の青空。空はもう「書き割り(スカイボックス)」ではなく、奥行きのある「場所」になりました。

今、パラシュートで地上へ降り立ったと想像してください。 足元に広がっているのは何でしょうか?

今のところ、そこには 何もない「無限の平面」 が広がっています。 Three.js で言えば PlaneGeometry、ただの平らな板ポリゴンです。これでは世界とは呼べません。

神(プログラマー)は言った。「山あれ」と。

私たちはすでにノイズという道具を持っています。 「平面の頂点を、ノイズの値に合わせて持ち上げればいいんじゃないか?」 そう考えるのは自然なことです。

// 頂点シェーダーでの単純な変位
float h = noise(position.xz * frequency);
position.y += h * amplitude;

しかし、これを実行しても、そこに現れるのは「山」ではありません。 それはまるで 「固まったオートミール」 か、あるいは 「溶けかけたアイスクリーム」 のような、ヌルっとした滑らかな丘です。

なぜなら、Perlin Noise や Simplex Noise は「滑らかであること」を目的に作られた関数だから です。 自然界の山岳地帯は、もっと荒々しく、雨風に削られた鋭い尾根(Ridge)を持ち、複雑な侵食(Erosion)の痕跡があります。ただの noise() では、この「険しさ」が表現できないのです。

今回は、この「滑らかな丘」に数学的なメスを入れ、Ridge Noise(尾根ノイズ) や Domain Warping を駆使して、本物のアルプス山脈のような「大地」へと隆起させていきます。

2. Heightmap(ハイトマップ)の基礎

地形生成の最も基本的なアプローチ、それが Heightmap(ハイトマップ) です。
直訳すると「高さの地図」。2次元のグリッド上の各点(ピクセルや頂点)に、「高さ(Elevation)」という情報を割り当てたものです。

色を「高さ」に変換する

通常、ハイトマップはグレースケールの画像として表現されます。
画像の「明るさ」を、そのまま地形の「高さ」として解釈するのです。

  • 黒(0.0): 最も低い場所。海底、あるいは平坦な平原。
  • 白(1.0): 最も高い場所。険しい山頂。
  • グレー(0.5): その中間の斜面や丘。

今回は画像データを使わず、この 0.0 〜 1.0 の値を ノイズ関数(Perlin Noise や FBM) でリアルタイムに計算します。

Vertex Displacement:色から「形」へ

これまでの連載(#01〜#09)では、主に フラグメントシェーダー(Fragment Shader) を使って、平面や空間の「色」を計算してきました。 しかし今回は、バーテックスシェーダー(Vertex Shader) が主役です。

バーテックスシェーダーは、3D空間にある「頂点(Vertex)」の位置を操作するプログラムです。ここでノイズを使うことで、平面の頂点を物理的に持ち上げ、形そのものを変形させます。これを Vertex Displacement(頂点変位) と呼びます。

// Vertex Shader のイメージ
void main() {
    // 1. 現在の頂点のXZ座標(地面の広がり)を取得
    vec2 pos = position.xz;

    // 2. ノイズ関数で「高さ」を計算
    // frequency: 地形の細かさ
    // amplitude: 山の高さ
    float h = noise(pos * frequency) * amplitude;

    // 3. Y座標(高さ)に加算して持ち上げる
    vec3 newPosition = position;
    newPosition.y += h;

    // 4. 変換して描画
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}

注意点:
この手法を使うには、対象となる PlaneGeometry(板ポリゴン)に 十分な分割数(segments) が必要です。頂点が4つしかない板では、どんなに複雑なノイズを計算しても、山を作ることはできません。
new PlaneGeometry(100, 100, 256, 256) のように、細かく分割されたメッシュを用意しましょう。

3. 「山」らしくする魔法:Ridge Noise(尾根ノイズ)

ただの y = noise(x) で作った地形は、どんなにパラメータ(Frequency / Amplitude)を調整しても「丸い」ままです。 現実の山、特にアルプス山脈や火山のような険しい地形には、雨水が削ったような 「鋭い谷」 や、切り立った 「尾根(Ridge)」 が存在します。

普通の Perlin Noise や FBM は「滑らかであること」が特徴なので、この「鋭さ(Sharpness)」が決定的に不足しています。

解決策:絶対値 abs() を使う反転テクニック

ここで、高校数学で習った 絶対値(Absolute Value) が魔法のような効果を発揮します。 サイン波(sin(x))を想像してください。滑らかな波です。 これに絶対値をかけて |sin(x)| にするとどうなるでしょうか? マイナスの部分がプラスに折り返され、0 の地点で 「V字型」の鋭い谷 が生まれます。

この性質を利用して、ノイズの波形を鋭く尖らせるのが Ridge Noise(尾根ノイズ) です。 別名「Swiss Turbulence(スイス・タービュランス)」とも呼ばれるこのテクニックは、以下の3ステップで作られます。

  1. 折り返す: abs(noise(p))
  • これで滑らかな波が、鋭い「谷」を持つ波に変わります。
  1. 反転する: 1.0 - abs(noise(p))
  • 谷(下向きの鋭さ)をひっくり返して、山(上向きの鋭さ=尾根) にします。
  1. 尖らせる: pow(n, exponent)
  • さらに pow(累乗)をかけることで、裾野を広げつつ山頂だけを鋭くピンと立たせます。

数式とコード

GLSLで書くと、たったこれだけの変更です。

// 普通のノイズ(-1.0 ~ 1.0)
float n = noise(p);

// Ridge Noise への変換
// 1. 絶対値で折り返し、反転して「尾根」を作る (0.0 ~ 1.0)
n = 1.0 - abs(n);

// 2. 累乗で鋭さを強調する
// exponent(指数)が大きいほど、壁のように切り立った山になる
n = pow(n, 4.0);

// これを高さとして加算
position.y += n * strength;

この数行の魔法をかけるだけで、先ほどまでの「溶けかけたアイスクリーム」のような丘が、突如として 「人を寄せ付けない険しい岩山」 へと変貌します。 これが、プロシージャル地形生成における最も重要で、最も費用対効果の高いトリックです。

4. Domain Warping 再考:川と浸食

第6回「Domain Warping — 座標をねじると世界が壊れる」で学んだあの技術を、ここで再び召喚しましょう。 ただし今回は、空間をカオスに破壊するためではなく、「自然の複雑さ」を模倣するため に使います。

直線を曲げる

Ridge Noise で作った山脈は、鋭くて格好いいのですが、一つ欠点があります。 それは、「形が整いすぎている」 ことです。 Perlin Noise のグリッド構造の影響で、山脈が妙に直線的だったり、不自然な規則性が見えてしまうことがあります。

自然界の山や川はどうでしょうか? 川は蛇行(Meander)し、地層は圧力で歪み、谷底は水流によって削られながら曲がりくねっています。 この 「ぐにゃりと曲がった地形」 を表現するのに、Domain Warping は最適です。

「安価な浸食(Erosion)」としての利用

本来、リアルな地形を作るには「水力浸食(Hydraulic Erosion)」というシミュレーションを行います。 これは「雨粒を数万個降らせて、土砂を削り、低い場所に運ぶ」という物理計算を行うもので、非常に計算コストが高く、リアルタイム(60fps)で動かすシェーダーには向きません。

そこで Domain Warping の出番です。 地形の高さを計算する 前に、座標(position.xz)を少しだけノイズでずらします。

// 1. 座標を少しずらす(Warping)
vec2 q = vec2(
    fbm(p + vec2(0.0, 0.0)),
    fbm(p + vec2(5.2, 1.3))
);

// 2. ずらした座標を使って、高さを計算する
// p そのものではなく p + q を使う
float height = fbm(p + q * warpStrength);

// 3. その高さで Ridge Noise を適用
height = 1.0 - abs(height);
height = pow(height, 2.0);

たったこれだけで、山脈はまるで巨大な力でねじ曲げられたように歪み、谷底は川のように蛇行を始めます。 これは物理的な浸食シミュレーションではありませんが、「何万年もの風化と地殻変動を経たような見た目」 を、計算コストほぼゼロで手に入れるための、プロシージャル生成における 「安価で効果的なトリック」 です。

5. Biomes(バイオーム):高度と傾斜による塗り分け

頂点シェーダーで「形」ができたら、次はフラグメントシェーダーで「色(テクスチャ)」を塗ります。
手作業でテクスチャを描くわけではありません。ここでも数学的なルール、すなわち バイオーム(Biome) の定義を使って、自動的に色を決定します。

高度(Height)による判定:レイヤーケーキを作る

最も単純なルールは、「高さ」に応じて色を変えることです。
地形の高さを 0.0(底)から 1.0(頂上)としたとき、以下のように層(Layer)を定義します。

  1. Water (0.0 - 0.2): 海や湖(青)
  2. Grass (0.2 - 0.6): 平原や森林(緑)
  3. Rock (0.6 - 0.8): 森林限界を超えた岩場(茶・グレー)
  4. Snow (0.8 - 1.0): 万年雪に覆われた山頂(白)
vec3 color;
if (height < 0.2) {
    color = vec3(0.0, 0.0, 0.8); // Water
} else if (height < 0.6) {
    color = vec3(0.1, 0.6, 0.1); // Grass
} else if (height < 0.8) {
    color = vec3(0.5, 0.5, 0.5); // Rock
} else {
    color = vec3(1.0, 1.0, 1.0); // Snow
}

これだけで、それっぽい山に見えてきます。しかし、これだけでは「塗り分け」が水平すぎて、不自然な縞模様(レイヤーケーキ)になってしまいます。

傾斜(Slope)による判定:リアリズムの鍵

ここでプロシージャル生成の醍醐味、傾斜(Slope) の登場です。
自然界を観察してみてください。草は「高さ」だけで生えているわけではありません。どんなに低い場所でも、切り立った「崖」に草は生えませんよね? そこは岩肌が露出しているはずです。

これをシェーダーで表現するには、法線ベクトル(Normal) を使います。
法線とは「面が向いている方向」のことです。

  • 平らな地面: 法線は真上 (0, 1, 0) を向いている。
  • 垂直な崖: 法線は横 (1, 0, 0) などを向いている。

この性質を利用して、「真上ベクトル」と「現在の法線」の 内積(Dot Product) を取ると、その地面がどれくらい平らか(傾斜度)がわかります。

// 上方向との内積をとる(1.0なら平坦、0.0なら垂直な崖)
float slope = dot(vNormal, vec3(0.0, 1.0, 0.0));

// 崖(slopeが小さい)なら岩、平らなら草
// mix関数で滑らかにブレンドする
vec3 finalColor = mix(rockColor, grassColor, smoothstep(0.3, 0.6, slope));

このロジックを入れることで、「平原の中に隆起した岩山」 や 「雪山の中の切り立った崖」 が自動的に生成され、地形のリアリティが一気に「世界」のレベルへと昇華します。


6. 実装:Three.js + ShaderMaterial

理論は完璧です。あとはコードに落とし込むだけです。
Three.js の ShaderMaterial を使って、これまでのロジック(Ridge Noise, Domain Warping, Biome Color)を統合します。

1. PlaneGeometry:キャンバスを用意する

まず、地形のベースとなる板ポリゴンを用意します。
ここで最も重要なのは 分割数(Segments) です。バーテックスシェーダーで頂点を持ち上げるため、頂点の数が少なければカクカクの粗い地形にしかなりません。

// 幅20, 奥行き20の世界。
// 分割数は 256x256(合計約6.5万頂点)以上推奨。
// PCスペックが許すなら 512x512 にするとディテールが劇的に向上します。
const geometry = new THREE.PlaneGeometry(20, 20, 256, 256);

// マテリアル(シェーダー)
const material = new THREE.ShaderMaterial({
  vertexShader: vertexShader,   // 後述
  fragmentShader: fragmentShader, // 後述
  uniforms: {
    uTime: { value: 0 },
    uColorWater: { value: new THREE.Color('#1a5178') },
    uColorGrass: { value: new THREE.Color('#3a9e3a') },
    uColorRock:  { value: new THREE.Color('#635748') },
    uColorSnow:  { value: new THREE.Color('#ffffff') },
    // Fogの設定はThree.jsのScene設定と連動させるのが少し手間なので
    // ここでは簡易的にUniformで色を渡します
    uFogColor: { value: new THREE.Color('#e0e0e0') },
    uFogDensity: { value: 0.15 },
  },
  // 地形の裏側も見たい場合は DoubleSide
  side: THREE.DoubleSide
});

const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2; // 地面として寝かせる
scene.add(mesh);

2. Vertex Shader:形状を変形する

ここが心臓部です。Ridge Noise を計算し、position.y を書き換えます。
また、フラグメントシェーダーで「傾斜(Slope)」による塗り分けを行うために、変形後の 法線(Normal) を再計算する必要があります。

// --- Vertex Shader ---
uniform float uTime;
varying float vElevation; // 高さをFragmentに渡す
varying vec3 vNormal;     // 法線をFragmentに渡す
varying vec2 vUv;

// ここに noise(), fbm() 関数を定義...

float getElevation(vec2 p) {
    // Ridge Noise の計算
    float h = fbm(p * 2.0);
    h = 1.0 - abs(h);       // 鋭くする
    h = pow(h, 3.0);        // さらに尖らせる
    return h * 2.0;         // 高さの係数
}

void main() {
    vUv = uv;
    vec3 pos = position;

    // 1. 高さを適用
    float h = getElevation(pos.xz);
    pos.y += h;
    vElevation = h; // 高さ情報を保存

    // 2. 法線の再計算(近隣の点をサンプリングして傾きを調べる)
    // これをしないと、形が変わっても影や傾斜判定が正しく出ません
    float offset = 0.01;
    vec3 tangentX = vec3(offset, getElevation(pos.xz + vec2(offset, 0.0)) - h, 0.0);
    vec3 tangentZ = vec3(0.0, getElevation(pos.xz + vec2(0.0, offset)) - h, offset);
    vNormal = normalize(cross(tangentZ, tangentX));

    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

3. Fragment Shader:世界に色を塗る

高さ(vElevation)と傾斜(vNormal)を使って色を決定し、最後に霧(Fog)をかけて遠近感を出します。

// --- Fragment Shader ---
uniform vec3 uColorWater;
uniform vec3 uColorGrass;
uniform vec3 uColorRock;
uniform vec3 uColorSnow;
uniform vec3 uFogColor;
uniform float uFogDensity;

varying float vElevation;
varying vec3 vNormal;
varying vec2 vUv;

void main() {
    // 1. 基本の色を決定(高さベース)
    vec3 color = uColorGrass;

    // 水面
    if(vElevation < 0.1) {
        color = uColorWater;
    }
    // 岩肌(高い場所)
    else if(vElevation > 1.2) {
        color = uColorRock;
    }
    // 雪山(最高地点)
    if(vElevation > 1.8) {
        color = uColorSnow;
    }

    // 2. 傾斜ミックス(崖には草を生やさない)
    // 法線の上方向成分(y)を見る。1.0に近いほど平坦。
    float slope = dot(vNormal, vec3(0.0, 1.0, 0.0));
    // 傾斜が急(slope < 0.5)なら岩の色を混ぜる
    float rockMix = 1.0 - smoothstep(0.3, 0.6, slope);
    color = mix(color, uColorRock, rockMix);

    // 3. 簡易的なライティング(あると立体感が出る)
    vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5));
    float light = max(dot(vNormal, lightDir), 0.0);
    color *= (0.5 + light * 0.5); // 環境光 + 拡散光

    // 4. Fog(霧)を追加:空気遠近法
    // カメラからの距離に応じて白く霞ませる
    float depth = gl_FragCoord.z / gl_FragCoord.w;
    float fogFactor = exp2(-uFogDensity * uFogDensity * depth * depth * 1.44);
    fogFactor = clamp(fogFactor, 0.0, 1.0);

    // 霧の色と合成
    vec3 finalColor = mix(uFogColor, color, fogFactor);

    gl_FragColor = vec4(finalColor, 1.0);
}

これで、遠くの山々が霧に霞み、近くの崖は険しく切り立った、プロシージャルな絶景の完成です。

7. まとめ:世界は計算された

モニターの向こうに広がっている、霧に包まれた険しい山脈。
信じられないかもしれませんが、この風景の中には 「画像データ」は1枚も使われていません。
テクスチャも、モデリングソフトで作ったポリゴンデータもありません。

ここにあるのは、たった数行の 数式(Noise function) と、それを制御する パラメータ(Frequency, Amplitude, Exponent) だけです。
あなたがコードを書き換えたその瞬間に、GPUが数億回の計算を行い、何もない空間に「世界」を隆起させたのです。

無限のキャンバス

この世界の最大の特徴は、「果てがない」 ことです。
画像データで作られた背景(スカイボックスや地形メッシュ)には、必ず「解像度の限界」や「端っこ」があります。近づけばボケるし、遠くまで行けば壁にぶつかります。

しかし、プロシージャル生成されたこの大地には、境界線がありません。
カメラをどこに向けても、どこまで歩き続けても、ノイズ関数は新たな地形を生成し続けます。
微小な岩肌に顕微鏡レベルで近づいても、フラクタルの性質によりディテールが保たれます。

「空」と「大地」の融合

前回学んだ Volumetric Clouds(雲) と、今回学んだ Procedural Terrain(大地)。 この2つが揃った今、あなたのブラウザの中には、もはや「デモ」ではなく、一つの完全な 「世界」 が存在しています。


Noise 入門 第1集(基礎編):完結

これにて、全10回にわたる「Noise 入門シリーズ:第1集(基礎編)」は完結です。
ランダム関数の基礎から始まり、グリッド、勾配、FBM、Warping、そして雲と大地へ。
ここまでお付き合いいただき、本当にありがとうございました。

しかし、世界にはまだ「動き」と「色彩」が足りません。
静止した大地に、命を吹き込む必要があります。

次なる旅へ:第2集(表現編)スタート

次回からは、「Noise 入門 第2集:具体的な表現編」 へと突入します。
基礎理論を卒業し、より実践的で、より高度な「自然現象」の再現に挑みます。

  • Water: 寄せては返す波、深海の色。
  • Time: 移ろいゆく空の色、昼と夜のサイクル。
  • Life: 風に揺れる草花、自動生成される森。
  • Magic: 炎、雷、魔法エフェクト(VFX)。

最初のテーマは「水」です。
ノイズと物理シミュレーション(Gerstner Wave)を組み合わせ、美しく波打つ 「海」 を作ります。

[Noise 入門 第2集 #11] Procedural Water — ノイズで海面を波立たせる(Gerstner Wave vs Noise)

旅はまだまだ続きます。
エンドレスなノイズの海で、またお会いしましょう。

次回予告へのブリッジ(第2集へ)

これで、Noise入門の「第1集(基礎〜世界生成編)」は完結です。
私たちは、0と1の乱数からスタートし、グリッドを描き、雲を浮かべ、そして大地を隆起させるところまで辿り着きました。

しかし、この世界を見渡すと、まだ決定的に足りないものがあります。

  • 水(Water): 寄せては返す波、川のせせらぎ(Flow Map)、深海の青。
  • 時間(Time): 移ろいゆく空の色、昼と夜のサイクル、動的な天候変化。
  • 生命(Life): 風に揺れる草花、自動生成される森(Instanced Mesh)。

静止した「箱庭」から、息づく「世界」へ。
次回からは、より高度なトピック、そして具体的な自然現象の再現にフォーカスした新シリーズ「第2集:表現編」へと足を踏み入れます。

最初のテーマは、生命の源である「水」です。
ノイズと物理シミュレーションを組み合わせ、美しく波打つ海面を作ります。

[Noise 入門 第2集 #11] Procedural Water — ノイズで海面を波立たせる(Gerstner Wave vs Noise)

旅はまだまだ続きます。
エンドレスなノイズの海で、またお会いしましょう。