[Noise 入門 #33] Procedural Biome と大気散乱 — ノイズ惑星に海と森、そして「青い空」を纏わせる

はじめに

Noise 入門シリーズ第33回。前回、Three.jsとGLSLを用いて手で回せる「地形の素体(デコボコの球体)」を錬成しました。今回はこの無機質な星に「生命の息吹」を与えます。

ノイズによって計算された「高さ(Height)」と「傾斜(Slope)」から生態系(バイオーム)を割り出し、深い海、緑の森、頂の雪を動的に塗り分けます。さらに、フレネル反射を応用して星を包み込む「青い大気の層」をシミュレーションし、宇宙空間に浮かぶ美しい惑星を完成させましょう。

1. Procedural Biome — 「高さ」が地形の色を決める

現実の地球を想像してみてください。海から上がり、砂浜を歩き、森を抜け、やがて草木も生えない雪山へと至る。標高(高さ)が変われば、気温や環境が変わり、そこに根付く生態系(バイオーム)が変化します。

手続き型生成(Procedural Generation)の世界では、この自然界のルールを数学的に模倣します。

頂点からピクセルへ(データのリレー)

これをShaderで実装するには、まず Vertex Shader でノイズを使って頂点を隆起(Displacement)させた後、その「変位量(どれくらい盛り上がったか)」を varying 変数として Fragment Shader に渡します。

ここではその変数を vHeight としましょう。Fragment Shaderは、受け取った vHeight の値だけを見て「自分が今何色になるべきか」をピクセルごとに決定します。

魔法の関数 smoothstep と mix の連携

もし、色分けを if 文で書いてしまうとどうなるでしょうか? 「高さが $0.1$ 以上なら森、以下なら砂浜」とパキッと分けてしまうと、マイクラのようなボクセル表現ならともかく、リアルな地形としては境界線が不自然(エイリアス)になってしまいます。自然界の境界は常に「グラデーション」です。

ここで活躍するのが、ノイズの世界で幾度となく登場するGLSLの最強ツール、smoothstep と mix のコンボです。

// Fragment Shader の一部

// Vertex Shader から渡された高さ(例: -1.0 ~ 1.0)
varying float vHeight;

void main() {
    // 1. 各バイオームのカラー定義 (RGBを0.0~1.0で指定)
    vec3 deepWaterColor    = vec3(0.02, 0.12, 0.35); // 深い青
    vec3 shallowWaterColor = vec3(0.15, 0.45, 0.65); // 浅瀬の水色
    vec3 sandColor         = vec3(0.75, 0.70, 0.50); // 砂浜
    vec3 forestColor       = vec3(0.15, 0.35, 0.15); // 森の緑
    vec3 snowColor         = vec3(0.95, 0.95, 0.95); // 雪の白

    // 2. ベースカラーを深海の色で初期化
    vec3 color = deepWaterColor;

    // 3. 高さに応じて色を上塗り(mix)していく

    // 深海 -> 浅瀬 (高さ -0.2 から 0.1 にかけてブレンド)
    float blendShallow = smoothstep(-0.2, 0.1, vHeight);
    color = mix(color, shallowWaterColor, blendShallow);

    // 浅瀬 -> 砂浜 (高さ 0.1 から 0.15 にかけてブレンド)
    // 水際を少しシャープにするため幅を狭くする
    float blendSand = smoothstep(0.1, 0.15, vHeight);
    color = mix(color, sandColor, blendSand);

    // 砂浜 -> 森 (高さ 0.15 から 0.4 にかけてブレンド)
    float blendForest = smoothstep(0.15, 0.4, vHeight);
    color = mix(color, forestColor, blendForest);

    // 森 -> 雪山 (高さ 0.6 から 0.8 にかけてブレンド)
    // 境界を広く取り、まばらな雪積もりを表現する
    float blendSnow = smoothstep(0.6, 0.8, vHeight);
    color = mix(color, snowColor, blendSnow);

    gl_FragColor = vec4(color, 1.0);
}

なぜこのコードで自然に見えるのか?

このコードの美しさは、下から上へと順番に「色を上塗りしていく構造」にあります。

例えば、砂浜の計算 smoothstep(0.1, 0.15, vHeight) の部分に注目してください。

  • 高さが $0.1$ 以下の場所では 0.0 を返すため、砂浜の色は全く混ざりません(浅瀬のまま)。
  • 高さが $0.15$ 以上の場所では 1.0 を返すため、完全に砂浜の色に上書きされます。
  • その間の $0.1 \sim 0.15$ の領域では、S字カーブ(エルミート補間)によって水色と砂色が滑らかに混ざり合い、「波打ち際の湿った砂浜」のような自然な境界線を生み出します。

🎨 パラメータ設計のコツ

  • ブレンド幅(smoothstepの2つの引数の差)の調整: 水と陸の境界(海岸線)は幅を狭くしてクッキリさせ、森と雪山の境界は幅を広く設定します。これにより「くっきりした海岸」と「グラデーションでまばらに雪が積もる山肌」という質感の違いを表現できます。
  • カラーパレットの彩度: 自然物の色は、思っている以上に彩度が低いです。バキッとした緑や青を使うとプラスチックのおもちゃのようになってしまうので、少しグレーを混ぜたような落ち着いた色味(低彩度)を設定するのが「本物っぽさ」の秘訣です。

これで、無機質だった球体が「地球のような美しいグラデーション」を纏いました。 しかし、これだけではまだ不十分です。この状態では「急な崖」にまで雪や森がベッタリと張り付いてしまい、どこか不自然な見た目になってしまいます。

次項では、この問題を解決するための「傾斜(Slope)」の概念を導入します。

2. Slope Blending — 「傾斜」が岩肌を露出させる

前項の「高さ(Height)」による色分けで、私たちの惑星は美しい環境のグラデーションを手に入れました。しかし、カメラを近づけて地形を観察すると、ある不自然な点に気づくはずです。

「ほぼ垂直に切り立った崖にまで、雪や森がベッタリと張り付いている」のです。

現実の地球において、重力は絶対です。傾斜が急な場所では、積もった雪は雪崩となって落ち、植物は根を張る土壌を保持できません。結果として、そこには硬い「岩肌(Rock)」がむき出しになります。 この物理法則をShader内でシミュレーションするのが「Slope Blending(傾斜ブレンド)」です。

法線ベクトルと内積が描く「重力」

ある場所が「平坦(地面)」なのか、「垂直(崖)」なのかを数学的に判定するにはどうすればよいでしょうか?ここで活躍するのが、面が向いている方向を表す「法線ベクトル(Normal)」です。

法線($\vec{N}$)と、真上を向くベクトル($\vec{Up}$)の内積(Dot Product)を計算します。 内積は、2つのベクトルが「どれくらい同じ方向を向いているか」を示す指標($\cos\theta$)になります。

  • 平坦な地面: 法線は真上を向くため、$\vec{Up}$ と一致する。内積は $1.0$。
  • 垂直な崖: 法線は横を向くため、$\vec{Up}$ と直交する。内積は $0.0$。
  • オーバーハング(えぐれた崖): 法線は下を向くため、内積はマイナスになる。

これをGLSLで記述し、マイナスの値を max 関数で $0.0$ に切り捨てる(Clampする)ことで、扱いやすい「傾斜パラメータ」を作ります。

$$Slope = \max(\vec{N} \cdot \vec{Up}, 0.0)$$

GLSLでの実装:地形を引き締める岩肌

それでは、前項で作った color(高度によってブレンドされた森や雪の色)に対して、この傾斜パラメータを使って「岩肌」を上塗りしてみましょう。

// Fragment Shader の続き

// Vertex Shaderから渡された法線(正規化を忘れずに!)
varying vec3 vNormal;

void main() {
    // --- 前項の高度による color 計算がここにある前提 ---

    // 1. 真上を向く Up ベクトルを定義
    vec3 upVector = vec3(0.0, 1.0, 0.0);
    vec3 normal = normalize(vNormal);

    // 2. 内積で傾斜を計算(1.0 = 平坦, 0.0 = 垂直の崖)
    float slope = max(dot(normal, upVector), 0.0);

    // 3. 崖と判定する閾値を smoothstep で設定
    // slope が 0.4(やや急) 〜 0.6(なだらか) の間でブレンド
    float flatMask = smoothstep(0.4, 0.6, slope);

    // 4. 岩肌の色を定義(少し暗めのグレー)
    vec3 rockColor = vec3(0.35, 0.35, 0.35);

    // 5. 最終的な合成
    // flatMask が 0.0 (崖) の時は rockColor になり、
    // flatMask が 1.0 (平坦) の時は 前回の color (森や雪) になる。
    color = mix(rockColor, color, flatMask);

    gl_FragColor = vec4(color, 1.0);
}

なぜこれが劇的な変化を生むのか?

この数行のコードを追加するだけで、地形のディテールは激変します。

高度による色分けは、どうしても横方向の「縞模様(バンディング)」を生み出しがちです。しかし、ノイズで隆起した地形の法線は複雑に入り組んでいるため、この Slope Blending を適用すると、縞模様が縦方向の複雑な「岩のひび割れ」や「険しい山肌」によって断ち切られます。

⚠️ 法線再計算の重要性
ここで一つ注意点があります。Vertex Shaderでノイズを使って頂点(地形)を動かした場合、元の球体のツルツルした法線をそのまま使ってはいけません。
地形の起伏に合わせて法線を正しく計算し直す(Normal Recomputation)必要があります。
これを怠ると、見た目はデコボコなのに、Shaderは「ここは平坦だ」と勘違いしてしまい、岩肌が正しく露出しません。


平坦な場所には雪が積もり、切り立った峰には黒々とした岩肌がのぞく。 これで、私たちが錬成した惑星の大地は完成です。

次は、この大地を真空の宇宙から保護する「青いヴェール」、大気(Atmosphere)の層を展開しましょう。

3. Atmosphere(大気散乱) — 星を包み込む青い光

大地が完成し、海と森、そして険しい岩肌を持つ星が生まれました。
しかし、これを真っ暗な画面(宇宙空間)に置いてみると、なぜか「巨大な惑星」というより「精巧に作られたジオラマのボール」のように見えてしまいます。

圧倒的なスケール感が足りないのです。 その原因は、星を包み込む「大気(Atmosphere)」の不在にあります。

なぜフチに行くほど大気は濃く見えるのか?

現実の地球を宇宙から見ると、大気の層は星の輪郭(フチ)に行くほど青く、分厚く発光して見えます。中心付近の大気は透けて地表が見えますよね。

これは単純な物理のハナシです。 真上から地表を見下ろすとき、視線が通過する大気の層は「薄い」ですが、星のフチ(地平線スレスレ)を見るとき、視線は丸い大気層を斜めに横切るため、非常に「長い(分厚い)」距離の空気を通過することになります。空気の層が厚いほど、光は強く散乱して青く光るのです。

この現象を、ガチガチのレイキャスティング(体積計算)なしに、非常に軽い処理で擬似的に再現できるのが「フレネル(Fresnel)効果」です。

視線と法線が交差する魔法の数式

フレネル効果をShaderで実装するには、カメラからの「視線ベクトル($\vec{V}$)」と、地表の「法線ベクトル($\vec{N}$)」を使います。

  1. 内積を取る: $\vec{N}$ と $\vec{V}$ の内積(Dot Product)を計算します。真正面(中心)では視線と法線が向かい合うので値は $1.0$ に近く、星のフチでは視線と法線が直交するので $0.0$ になります。
  2. 反転させる: 欲しいのは「フチに行くほど強い($1.0$ になる)値」なので、1.0 から内積の値を引き算して反転させます。
  3. 輪郭を絞る: そのままだと星全体が白っぽく霞んでしまうため、得られた値を累乗(Power)して、フチのギリギリだけが強く光るようにカーブを調整します。

$$\text{Fresnel} = (1.0 - \max(\vec{N} \cdot \vec{V}, 0.0))^p$$

※ $p$(Power)の値を $3.0$ や $4.0$ に大きくするほど、大気は薄く鋭くなります。

GLSLでの実装:星に呼吸をさせる

それでは、前項までに作った地形の色(color)の上に、この大気の層をフワッと被せてみましょう。

// Fragment Shader の続き

// Vertex Shaderから渡されたワールド座標と法線
varying vec3 vPosition;
varying vec3 vNormal;

// Three.js が自動で渡してくれるカメラのワールド座標
uniform vec3 cameraPosition;

void main() {
    // --- 前項までの color(地形+岩肌)計算 ---

    // 1. 視線ベクトル(V)の計算:カメラ位置から現在のピクセル位置への向き
    vec3 viewDir = normalize(cameraPosition - vPosition);
    vec3 normal = normalize(vNormal);

    // 2. フレネル値の計算(1.0 - 内積)
    float fresnel = max(1.0 - dot(normal, viewDir), 0.0);

    // 3. 累乗でフチだけが強く光るように絞る (p = 3.0)
    float atmosphereIntensity = pow(fresnel, 3.0);

    // 4. 大気の色を定義(美しい地球のようなライトブルー)
    vec3 atmosphereColor = vec3(0.3, 0.6, 1.0);

    // 5. 加算合成(Additive Blending)で光として足し合わせる
    // mix(塗りつぶし)ではなく、光として「加算(+=)」するのがポイント!
    color += atmosphereColor * atmosphereIntensity;

    gl_FragColor = vec4(color, 1.0);
}

加算合成(Additive Blending)の重要性

ここで最も重要なのは、最後の行を mix 関数ではなく += (加算)で処理している点です。 大気はペンキのような物理的な層ではなく「散乱した光」です。光は重なるほど明るくなるため、元の地形のピクセルカラーに対して、青い光の成分をそのまま足し算してあげるのが正解です。

これによって、ただのデコボコしたノイズの球体が、青いオーラを纏った「呼吸する惑星」へと完全に昇華しました。マウスでグリグリと回してみてください。フチの青い輝きが常にカメラ側を向き、巨大な星を周回しているかのような圧倒的な没入感を生み出しているはずです。

4. 全てを束ねる GLSL の魔法

ここまでの要素をすべて Fragment Shader で統合します。 バラバラだった数学のピース(高度、内積、フレネル)が一つに組み合わさったとき、ブラウザの上に「あなただけのプロシージャルな宇宙」が誕生します。

全体の流れは以下の4ステップです。

  1. 地形のベースカラー: 頂点の高さ(Height)で海・砂浜・森・雪を塗り分ける。
  2. 傾斜(崖)の露出: 法線と上方向ベクトルの内積(Slope)で岩肌を上書きする。
  3. ライティングの計算: 太陽光を想定したランバート反射(Diffuse)を乗算して、地形に立体的な陰影をつける。
  4. 大気の合成: 視線と法線の内積(Fresnel)で計算した青い光を最後に加算(Add)する。

これらをまとめた、最終的な Fragment Shader の骨格がこちらです。

// --- 統合版 Fragment Shader (抜粋) ---

varying vec3 vPosition;
varying vec3 vNormal;
varying float vHeight;
uniform vec3 cameraPosition;

void main() {
    // 法線の正規化(必須)
    vec3 normal = normalize(vNormal);

    // 【Step 1】 Procedural Biome (高度によるグラデーション)
    vec3 color = mix(vec3(0.02, 0.12, 0.35), vec3(0.15, 0.45, 0.65), smoothstep(-0.2, 0.1, vHeight)); // 海
    color = mix(color, vec3(0.75, 0.70, 0.50), smoothstep(0.1, 0.15, vHeight)); // 砂浜
    color = mix(color, vec3(0.15, 0.35, 0.15), smoothstep(0.15, 0.4, vHeight)); // 森
    color = mix(color, vec3(0.95, 0.95, 0.95), smoothstep(0.6, 0.8, vHeight));  // 雪山

    // 【Step 2】 Slope Blending (傾斜による岩肌の露出)
    vec3 upVector = vec3(0.0, 1.0, 0.0);
    float slope = max(dot(normal, upVector), 0.0);
    float flatMask = smoothstep(0.4, 0.6, slope);
    vec3 rockColor = vec3(0.35, 0.35, 0.35);
    color = mix(rockColor, color, flatMask); // 崖の部分はrockColorになる

    // 【Step 3】 Lighting (太陽光による陰影)
    vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0)); // 右上奥からの光
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 lighting = vec3(0.1) + diff * vec3(0.9); // 環境光(0.1)を下乗せして真っ暗を防ぐ
    color *= lighting; // 色に陰影を掛け合わせる

    // 【Step 4】 Atmosphere (フレネルによる大気散乱)
    vec3 viewDir = normalize(cameraPosition - vPosition);
    float fresnel = max(1.0 - dot(normal, viewDir), 0.0);
    float atmosphereIntensity = pow(fresnel, 3.0);
    vec3 atmosphereColor = vec3(0.3, 0.6, 1.0);
    color += atmosphereColor * atmosphereIntensity; // 大気を「光として」加算

    gl_FragColor = vec4(color, 1.0);
}

このShaderをThree.jsの ShaderMaterial に適用し、OrbitControls などでカメラを回せるようにしてみてください。

暗黒のCanvas上に、起伏に富んだ大地と、深い海、そしてうっすらと青く発光する大気を纏った惑星が浮かび上がります。ただの「乱数(ノイズ)」の塊から始まった私たちの旅は、ついにここまで到達しました。

次回予告:空を流れる「雲海」の錬成

海と大地、そして大気を纏った私たちの惑星。 しかし、宇宙から見下ろす星の美しさは「地形」だけでは語れません。まだ空には「気象」が足りないのです。

次回(#34)は、この惑星の周囲に 「流れる雲海(Procedural Clouds on Sphere)」 を展開します。

FBMノイズを時間軸(4D)で動かし、地形とは独立したもう一つの少し大きな球体として大気圏に配置。アルファブレンド(透過)を用いてノイズの層を重ね合わせ、地表に動く影を落とす「生きた気象システム」の実装に挑みます。

自らの手で回す惑星に、雲が渦を巻きながら流れていく瞬間をお見せします。 次回の更新も、どうぞお楽しみに!