[Noise 入門 #09] Volumetric Clouds — ノイズで空に雲を浮かべる(レイマーチング基礎)

はじめに

ノイズシリーズも気づけば 9回目 (9日目)。

今回は「 Volumetric Clouds — ノイズで空に雲を浮かべる(レイマーチング基礎)」。

前回の記事:

1. さらば「板ポリゴン」 — 表面(Surface)から体積(Volume)へ

これまでの全8回を通して、私たちはノイズという「無限の乱数」を手なずけてきました。

グリッドを作り、勾配を計算し、FBMで複雑さを加え、Domain Warping で空間を歪め、Curl Noise で流れを与えました。 これによって、美しい地形、揺らめく水面、燃え上がる炎のテクスチャを作ることができるようになりました。

しかし、それらには致命的な欠点があります。 すべてが「表面(Surface)」でしかない、という点です。

「書き割り」の空からの脱却

従来のゲームグラフィックスにおいて、雲は長らく「書き割り」でした。 空という巨大なドームに「雲の絵」を貼り付けるか、あるいはカメラの方を常に剥く「板ポリゴン(ビルボード)」に雲のテクスチャを貼って誤魔化すか。

遠くから見る分にはそれでも十分美しい空が描けます。しかし、カメラが空へ飛び立ち、その雲の中に突っ込もうとした瞬間、魔法は解けます。 板ポリゴンは近づけばただのペラペラの紙切れになり、通り抜けた瞬間に消失します。そこには「厚み」も「密度」もありません。

現実の雲は、ペラペラの板ではありません。 それは微細な水滴の集合体であり、確かな「体積(Volume)」を持っています。 光は表面で反射するだけでなく、雲の中へと潜り込み、乱反射(散乱)し、厚みに応じて減衰しながら、あの柔らかい陰影を作り出します。

数学的な視線を「飛ばす」

今回、私たちはついにポリゴンの皮を捨てます。 三角形のメッシュにテクスチャを貼るのではなく、何もない虚空の空間そのものに、数学の力で密度を定義します。

使う武器は レイマーチング(Raymarching) です。

カメラから一本の「視線(Ray)」を放ち、その光線に沿って空間を少しずつ進んでいく(Marching)。 一歩進むごとに問いかけるのです。 「ここには何があるか?」「密度はどれくらいか?」

もしそこにノイズがあれば、密度を加算する。 密度が高ければ光を遮り、低ければ光を通す。

これを画面の全ピクセルに対して行うことで、私たちは「中に入って飛ぶことができる雲」を描き出すことができます。 それはもはや「絵」を描いているのではありません。光と密度の物理シミュレーションを行っているのです。

さあ、空間に「厚み」を与えましょう。

2. レイマーチング(Raymarching)の直感的な理解 — 霧の中を手探りで進む旅

「レイマーチング」という言葉を聞くと、SDF(Signed Distance Function)を使った距離関数の描画(Sphere Tracing)を思い浮かべる人も多いかもしれません。 しかし、雲のような「気体」を描く場合、アプローチは少し異なります。

通常のレイトレーシングが「弾丸が壁に当たる位置を計算(方程式)で一発で求める」ものだとしたら、ボリューメトリック・レイマーチングは「霧の中を少しずつ歩きながら、空気の濃さを記録(積分)していく旅」です。

空間をスライスする

雲には明確な「表面」がありません。どこからが雲で、どこまでが空気なのか、その境界は曖昧です。 そのため、「表面との衝突判定」を行うことは不可能です。

代わりに、私たちは視線(Ray)上の点を一定間隔でサンプリングし、その地点の「密度」を足し合わせていきます。これをレイマーチング(光線進行法)と呼びます。

Step by Step: 雲を描くアルゴリズム

具体的な処理の流れを見てみましょう。GPU(シェーダー)の中で、ピクセルごとに以下の処理が行われます。

  1. Ray Cast(視線の発射) カメラの位置(ro: Ray Origin)から、各ピクセルの方向(rd: Ray Direction)へ向けて、見えない直線を飛ばします。
  2. Marching(進行) レイの上を少しずつ進みます。 for ループを使い、t(進んだ距離)を step_size(歩幅)ずつ増やしていきます。 現在の座標は pos = ro + rd * t で求まります。
  3. Sampling(密度の計測) 「今いるこの座標 pos は、雲の中か?」 ここで、前回までに学んだ 3D Noise (FBM) を呼び出します。 ノイズ関数が 0.8 を返せば「かなり濃い」、0.0 なら「ただの空気」です。この値を「密度(Density)」として取得します。
  4. Accumulation(蓄積と判定) 取得した密度を、変手に足し込んでいきます(積分)。
  • 密度が高ければ、その分だけ視界が遮られ、色が白くなります(不透明度の上昇)。
  • ある程度の密度(または不透明度)が 1.0 に達したら、それ以上奥は見えないのでループを打ち切ります(Early Exit)。

「CTスキャン」のようなもの

イメージとしては、病院のCTスキャンやMRIに近いかもしれません。 3次元の空間を、視線方向に沿って輪切りにし、その断面の値を合計することで、最終的な「一枚の絵」を作り出しています。

この「足し合わせる」という処理こそが、ボリューメトリック(体積)表現の正体です。 薄い霧が何層も重なって、初めて厚みのある「雲」に見えるのです。

3. ノイズの役割:3D FBM で「塊」を作る

レイマーチングはただの「仕組み」であり、それ自体は何の形も作りません。 そこに「雲」という実体を与えるのが、これまで私たちが磨き上げてきた ノイズ関数 です。

これまでは平面(Surface)にテクスチャを貼っていたため、ノイズ関数には vec2(x, y) を渡していました。 しかし、今回は空間そのものを満たす必要があります。 そこで、ノイズ関数を 3次元(Volume) へと拡張します。

3D Noise への拡張

コード上の変更はシンプルですが、概念的な変化は劇的です。

// 今までの2Dノイズ
float n = noise(vec2(x, y));

// 今回の3Dノイズ
float n = noise(vec3(x, y, z));

引数に z が加わったことで、ノイズは「平面の模様」から「空間の密度分布」へと進化します。 空間内のあらゆる座標 に対して、その地点の濃度(0.0 〜 1.0)を問い合わせることができるようになります。

FBM(Fractal Brownian Motion)で「雲」らしくする

単体の 3D Perlin Noise だけでは、ただの「モコモコした球体」や「滑らかな煙」にしかなりません。 現実の雲のような、複雑で荒々しいディテールを表現するには、やはり FBM が不可欠です。

  • Octave(オクターブ)を重ねる: 大きな塊(低周波)に、小さな粒(高周波)を足し合わせていきます。 第1オクターブで雲の大まかな形を決め、第2・第3オクターブで細かい「ちぎれ」やエッジのザラつきを作ります。

この 3D FBM の戻り値をそのまま「密度(Density)」として扱います。 値が高ければ濃い雲、低ければ薄い霧、あるいは晴れ間になります。

Domain Warping で「風」を感じさせる

さらに、第6回・第7回で学んだ Domain Warping(空間の歪み) をここで投入します。 FBM に渡す座標 p そのものを、別のノイズ関数で少しだけずらしてやるのです。

vec3 q = vec3( fbm(p + vec3(0.0)),
               fbm(p + vec3(5.2)),
               fbm(p + vec3(1.3)) );

float density = fbm(p + 4.0 * q);

こうすることで、雲は単なるランダムな塊ではなくなります。 見えない風に流され、引きちぎられ、渦を巻くような「流体的な動き」を帯び始めます。 特に動画にした際、この歪みが雲に 「命」 を吹き込みます。

静止画で見ても、歪みによって生じる複雑な陰影は、現実の積乱雲や筋雲のディテールそのものです。

4. 光の計算:なぜ雲は白く、底は暗いのか

3Dノイズによって、空間に「雲の形(密度の塊)」が生まれました。 しかし、このままではただの「灰色の煙」か、あるいは「真っ白な発光体」です。

現実の雲を見上げてください。 太陽に近い上部はまばゆいほど白く輝き、逆に底の方は重く、暗い灰色をしています。 この 「光の減衰」 こそが、雲に巨大なスケール感とボリューム(体積)を与える鍵です。

Beer’s Law(ランベルト・ベールの法則)

光が霧や煙の中を進むとき、その強さは距離と密度に応じて指数関数的に弱まります。これを Beer’s Law(ビアの法則) と呼びます。

懐中電灯で濃い霧を照らす様子を想像してください。光は奥に行けば行くほど、霧の粒子にぶつかって散乱・吸収され、急速に暗くなります。 数式で書くと のような形になりますが、直感的には 「濃い場所ほど、深く潜るほど、光は死ぬ」 ということです。

レイマーチングの各ステップで、私たちは2つの視線(Ray)を考えます。

  1. Eye Ray(カメラからの視線): 私たちが雲を見る視線。
  2. Light Ray(太陽への視線): その地点から太陽を見たとき、どれくらい雲に遮られているか?

「Light Ray」の方向にノイズ(密度)をサンプリングし、密度が高ければ高いほど、「そこには光が届いていない(=影である)」と判定します。 これが、雲の底が暗くなる理由です。上の雲が太陽光を遮断しているからです。

Henyey-Greenstein 位相関数(銀の縁取り)

もう一つ、雲を美しくする重要な要素があります。散乱(Scattering) の方向性です。

光は粒子に当たると四方八方に散らばりますが、均等ではありません。 水滴や氷の粒でできた雲は、前方散乱(Forward Scattering) の性質が強いです。つまり、光が入ってきた方向に強く抜けやすいのです。

逆光で雲を見たとき、太陽に近い縁の部分が神々しく輝いているのを見たことがあるでしょう(Silver Lining)。 あれは、光が雲の中を突き抜けて、私たちの目に直接飛び込んできているからです。

これを数学的に近似するのが Henyey-Greenstein 位相関数 です。 「太陽の方向」と「視線の方向」の角度(内積)を計算し、太陽に近いほど明るく、遠いほど(あるいは逆方向ほど)散乱を弱める係数を作ります。

密度 × 光 × 散乱 = ボリューム

これらをすべて掛け合わせます。

  • 密度 (Density): そこに雲があるか?(3D Noise)
  • 透過率 (Transmittance): そこまで光が届いているか?(Beer’s Law)
  • 位相 (Phase): 光がこちらに向かって散乱しているか?(Henyey-Greenstein)

この3つが揃った瞬間、ただのノイズの塊は、内部に光を宿し、複雑な陰影を落とす「物質」へと昇華します。 平面的だった画像に、圧倒的な奥行きと「空の広さ」が生まれるのです。

5. 実装への橋渡し(GLSL / Three.js)

理論は完璧です。しかし、これをそのまま実装しようとすると、GPUは悲鳴を上げ、ブラウザはクラッシュするかもしれません。
ボリューメトリック・レンダリングは、シェーダーの中で最も計算コストが高い処理の一つだからです。

The Loop: 空間を歩く for

レイマーチングの心臓部は、フラグメントシェーダー内に記述される for ループです。
通常のラスタライザなら頂点を処理するだけで終わりますが、ここでは 1ピクセルごとに ループを回し、数十回〜百回以上のノイズ関数(これ自体が重い)を呼び出します。

擬似的な GLSL コードは以下のようになります。

// 視点(ro)から視線方向(rd)へ進む
float t = 0.0;
vec4 sum = vec4(0.0); // 蓄積された色と不透明度

for(int i = 0; i < 64; i++) {
    vec3 p = ro + rd * t;

    // 1. 密度を取得 (3D Noise + FBM)
    float density = map(p);

    // 2. 密度がある場合のみライティング計算
    if(density > 0.01) {
        float diffuse = GetLight(p); // ここでさらにLight Rayを飛ばす(重い!)
        vec4 col = vec4(vec3(diffuse), density);

        // 3. 色の蓄積 (Alpha Blending)
        col.rgb *= col.a;
        sum = sum + col * (1.0 - sum.a);
    }

    // 4. 早期終了 (Early Exit)
    if(sum.a > 0.99) break;

    // 5. 歩を進める
    t += max(0.05, 0.02 * t); // 遠くほど歩幅を大きくする等の工夫
}

パフォーマンスの壁:いかに「サボる」か

3D FBM は計算コストが高いです。それをループ内で何度も呼び出し、さらに影を計算するために「光へのレイ」も飛ばすと、計算量は爆発します( に近づく)。

そこで、いかに見た目を損なわずにサンプリング回数を減らすかが重要になります。

  1. Early Exit(早期終了): 不透明度(Alpha)が 1.0(または 0.99)になった時点で、それ以上奥は描画されません。ループを break します。分厚い雲にぶつかったら、後ろの計算は不要です。
  2. Jittering(ディザリング): ループ回数(ステップ数)を減らすと、雲が断層のようにスライスされて見えてしまいます(バンディング現象)。 そこで、レイの開始位置(t)をピクセルごとにランダムに少しだけずらします(Blue Noise などを使用)。 ノイズは増えますが、人間の目は規則的な縞模様よりも、不規則なザラつき(フィルムグレイン)の方を自然に受け入れる性質を利用するのです。
  3. LOD(Level of Detail): 遠くの雲には詳細なノイズ(高オクターブ)は不要です。距離 t に応じて FBM のループ回数を減らしたり、歩幅を大きくしたりして計算を省略します。

「正しく計算する」のではなく、「それっぽく見えれば勝ち」。 この泥臭い最適化こそが、リアルタイム・グラフィックスの神髄であり、プロシージャル・アートを支える技術です。

6. まとめ — 空は「背景」から「場所」になった

今回の旅で、私たちの「空」は決定的に変わりました。

これまでは、空とは「背景画像(Skybox)」であり、世界の果てにある壁紙でした。 しかし、3Dノイズとレイマーチング、そして光の物理(Beer’s Law)を手に入れた今、空は 「奥行きのある場所」 になりました。

カメラはもはや地面に縛り付けられる必要はありません。 雲の中に突っ込み、視界が白く遮られる感覚を味わい、雲海を抜けた瞬間に広がる青空の開放感を感じることができます。 これらはすべて、テクスチャを描いたわけではなく、数学的なルール(密度関数と光の計算) によってその場で生成されたものです。

巨人の肩に乗る

このボリューメトリック・クラウドの技術は、現代のハイエンドゲームにおいては標準となりつつあります。 特に、Guerrilla Gamesの『Horizon Zero Dawn』で採用された “Nubis” というクラウドシステムは、この分野の金字塔です。

彼らは、3Dノイズのテクスチャを巧みにレイヤー化し、パフォーマンスを維持しながら、あの圧倒的にリアルでドラマチックな空を描き出しました。 私たちが今回学んだ「FBMで形を作り、レイマーチングで光を計算する」という基礎は、まさにその技術の根幹と同じものです。

次回予告:地平線の彼方へ

空は完成しました。 しかし、私たちのプロシージャルな世界には、まだ「大地」が足りません。

次回は、視線を空から地面へと戻しましょう。 2Dノイズで作る単純な「ハイトマップ」から一歩進んで、浸食(Erosion)シミュレーションや、バイオーム(植生)の自動配置など、「世界そのものの生成」 に挑みます。

空と大地が揃ったとき、それはもはや「デモ」ではなく、一つの「世界」になります。

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

旅はまだまだ続きます。