[Noise 入門 #20] Shaderの最適化とミクロなディテール — 遠景のLODと近景のBump Mapping

はじめに

これまでの連載で、私たちはノイズを何重にも重ね(FBM)、空間をねじり(Domain Warping)、法線を捏造して光を当て(Procedural Lighting)、質感を与えてきました(Procedural Material)。

しかし、ここで一つの大きな問題に直面します。「Shaderが重すぎる」 という事実です。 第2集(アルゴリズム編)の完結となる今回は、ノイズの計算負荷を抑えつつ、クオリティを底上げする「最適化」と「ミクロなディテール」の技術を解説します。

前回の記事:

魔法の代償 — なぜ Shader は重くなるのか?

これまでの実装で、私たちはノイズをただの模様から「リアルな地形」や「うねる流体」へと進化させてきました。しかし、クオリティを上げれば上げるほど、GPUのファンは唸りを上げ、フレームレート(FPS)は落ちていきます。

なぜ、ノイズを利用した Procedural(手続き型)な Shader はこれほどまでに重くなるのでしょうか? その原因は、「処理の掛け算」による計算量の爆発にあります。

1. FBM による「ループ」の代償

単一の snoise() などの呼び出しだけでも、内部では内積、ハッシュ関数、曲線の補間(Quinticなど)といった複雑な算術演算(ALU命令)が行われています。 これを自然な複雑さにするため、FBM(Fractal Brownian Motion)を導入しました。

// 6 Octaves の FBM の場合
float fbm(vec2 p) {
    float value = 0.0;
    // ...中略...
    for (int i = 0; i < 6; i++) {
        value += amplitude * snoise(p); // ここで6回ノイズを呼ぶ
        // ...
    }
    return value;
}

この時点で、1ピクセルあたりのノイズ計算コストは 「6倍」 に跳ね上がっています。

2. Domain Warping による「入れ子」の代償

さらに、空間を歪ませる Domain Warping を導入しました。Warping はノイズの結果を次のノイズの座標として使います。

// 2段階の Domain Warping
vec2 q = vec2(fbm(p), fbm(p + vec2(1.0)));
vec2 r = vec2(fbm(p + q), fbm(p + q + vec2(2.0)));
float finalValue = fbm(p + r);

このコード、パッと見はシンプルですが、内部の呼び出し回数を数えると絶望的です。 fbm() 1回につき6回のノイズ計算が走るとして、q で2回、r で2回、最後に finalValue で1回。合計5回の fbm() が呼ばれます。 つまり、1ピクセルあたり $6 \times 5 = 30$ 回のノイズ計算が行われています。

3. Procedural Lighting(中心差分法)による「サンプリング」の代償

そして極めつけが、前々回(#18)で実装した「光と影」です。 ノイズ地形から法線(Normal)を捏造するために、中心差分法を用いました。

// 法線を求めるために、上下左右の高さを取得する
float hC = getElevation(p);           // 中央
float hR = getElevation(p + vec2(e, 0)); // 右
float hL = getElevation(p - vec2(e, 0)); // 左
float hU = getElevation(p + vec2(0, e)); // 上
float hD = getElevation(p - vec2(0, e)); // 下

getElevation() の中身が、先ほどの「WarpingされたFBM」だった場合どうなるでしょうか? 法線を1つ計算するだけで、地形生成関数を 5回 も呼び出すことになります。

絶望の計算式:毎秒何回のノイズが生まれるのか?

これらをすべて掛け合わせてみましょう。1ピクセルを描画するためのノイズ関数の呼び出し回数 $N_{total}$ は、以下のように膨れ上がります。

$$N_{total} = (\text{Warping} \times \text{FBM}) \times \text{Normal Sampling}$$

$$N_{total} = (5 \times 6) \times 5 = 150 \text{ 回/ピクセル}$$

もし、一般的なフルHDモニター(1920×1080 = 約200万ピクセル)で、60FPS(1秒間に60回描画)を維持しようとした場合、1秒間に行われるノイズ計算の総数は……

150回 × 2,000,000ピクセル × 60フレーム = 18,000,000,000(180億回)

テクスチャ画像をメモリから読み込む(Texture Fetch)だけの一般的な描画とは異なり、Procedural な手法はすべて「リアルタイムな数学の計算(ALU Bound)」で世界を創り出します。毎秒180億回もの複雑な数学関数を解き続ければ、最新のグラフィックボードであっても悲鳴を上げて当然です。

だからこそ、私たちは「すべてを正直に計算する」ことをやめなければなりません。 魔法を破綻させず、いかに計算を“サボる”か。それが、次に解説する「LOD(Level of Detail)」の極意です。

LOD (Level of Detail) による「引き算」の美学

毎秒180億回にも及ぶノイズ計算の連鎖。このままではリアルタイム描画の魔法が破綻してしまいます。この問題を解決する最もスマートで、かつ3Dグラフィックスにおける絶対的な鉄則が LOD(Level of Detail) です。

LODの基本思想は非常にシンプルで、「カメラから遠い場所の計算はサボる」 というものです。

現実世界で遠くにある山脈を眺める時、私たちは山の巨大なシルエット(低周波)は認識できますが、岩肌の小さな凹凸や砂粒(高周波)まで視認することはできません。 つまり、画面上の数ピクセルにしか満たない遠景に対して、微細なディテールを作るための高いオクターブ(高周波)のノイズを真面目に計算するのは、完全にGPUリソースの無駄捨てなのです。

距離を用いた計算の「間引き」

カメラ位置 $\vec{p}{camera}$ と描画対象のピクセル(または頂点)位置 $\vec{p}{surface}$ の距離 $d$ を計算し、それに応じてFBMのループ回数を動的に減らします。距離はベクトルの長さを取ることで求められます。

$$d = |\vec{p}{camera} - \vec{p}{surface}|$$

この距離 $d$ を基準に、計算すべきオクターブ数 $O$ を以下のように減衰させます。

$$O = \max(1.0, O_{max} - c \cdot d)$$

(※ $O_{max}$ は最大オクターブ数、$c$ は距離に応じた減衰係数です。距離が離れるほど $O$ の値は小さくなり、最低でも1回のノイズは計算するように max() で制限をかけます。)

実装の落とし穴:LOD Popping(カクつき)を防ぐ

これをGLSLで愚直に実装し、「距離が遠いからループを break して途中で計算を打ち切る」という処理を書くとどうなるでしょうか?

// ⚠️ 悪いLODの実装例(カクつきが発生する)
float dist = length(cameraPos - p);
int activeOctaves = int(max(1.0, 6.0 - dist * 0.05));

float value = 0.0;
for (int i = 0; i < 6; i++) {
    if (i >= activeOctaves) break; // 遠いから計算ストップ!
    value += amplitude * snoise(p);
    // ...
}

このコードは確かに軽くなりますが、カメラが前進・後退したときに致命的な視覚的バグを引き起こします。 オクターブ数が 4 から 3 へと整数で切り替わる瞬間、そのレイヤーのノイズが「パッ」と突然消滅(または出現)するため、地形や雲のディテールが不自然にポップしてしまうのです(これを LOD Popping と呼びます)。

プロの技:Smooth Fade(滑らかなフェードアウト)による最適化

この Popping 現象を防ぐためには、ループをブツッと切るのではなく、「限界が近づいたオクターブの振幅(Amplitude)を、距離に応じて滑らかに 0.0 へフェードアウトさせる」 というアプローチをとります。

// ✨ 美しいLODの実装例(Smooth Fade)
float dist = length(cameraPos - p);
// 少数点を含んだ動的なLOD値を計算(例: 4.3)
float lod = max(1.0, 6.0 - dist * 0.05);

float value = 0.0;
float amp = 1.0;

for (int i = 0; i < 6; i++) {
    // 現在のループインデックスが lod を超えたら終了
    if (float(i) > lod) break;

    // 最後のオクターブ(端数)は、振幅を滑らかにゼロへ向かわせる
    float currentAmp = amp;
    if (float(i) + 1.0 > lod) {
        // 例: lodが 4.3 なら、i=4 の時 weightは 0.3 になる
        float weight = lod - float(i);
        currentAmp *= weight;
    }

    value += currentAmp * snoise(p);

    // 次のオクターブへの準備
    p *= 2.0;
    amp *= 0.5;
}

この「Smooth Fade」手法を取り入れることで、カメラが動いてもディテールがシームレスに溶けるように消えていきます。

遠景のピクセルでは fbm のループが1〜2回で終了し、Warping や Normal 計算の負担も劇的に下がります。180億回あった計算量は、画面の多くを占める遠景の処理が省略されることで、体感として半分以下にまで削ぎ落とすことができるのです。

これこそが、Procedural な世界構築における「引き算の美学」です。

Bump Mapping — 近景に宿る「ミクロなディテール」

地形を生成する際、大きな山脈や谷といった「マクロな形状」は、頂点シェーダー(Vertex Shader)でポリゴンを上下に動かすことで作ります。 しかし、カメラが地面に近づいたときに見える「岩の表面のザラザラ」「砂粒」「ひび割れ」といった「ミクロな形状」まで頂点の移動で表現しようとするとどうなるでしょうか?

数センチ単位の凹凸を作るために、地面を数ミリ間隔のメッシュで分割しなければならず、ポリゴン数が天文学的な数字に膨れ上がってしまいます。これではどれだけLODで最適化しても全く意味がありません。

そこで、「形状(ポリゴン)は平らなまま、光の反射だけを騙す」 というアプローチをとります。

脳を騙す「法線の捏造」

私たちが「表面がザラザラしている」と感じる理由は、そこに当たる光の反射(陰影)が細かく変化しているからです。3Dグラフィックスにおいて、光の反射方向を決めるのは法線(Normal)ベクトルです。

つまり、実際のポリゴンが真っ平らであっても、フラグメントシェーダー(Fragment Shader)内で「ピクセルごとに法線の向きを細かく揺らす」 ことができれば、人間の目にはそこに見事な凹凸があるように錯覚させることができます。

#18 の「Procedural Lighting」では、地形の大きな起伏から法線を計算しました。今回はそれに加えて、非常に周波数の高い(細かい)ノイズを生成し、微細な法線の乱れとして合成します。

  • マクロな形状(頂点): 低周波のノイズで頂点を動かす(実際の山の形を作る)
  • ミクロな形状(ピクセル): 高周波のノイズで法線を揺らす(岩肌のザラザラ感を作る)

GLSLでの実装イメージ:法線のブレンド

具体的なコードのイメージを見てみましょう。ベースとなる地形の法線に対し、高周波ノイズから作った「微細な傾き(Bump)」を足し合わせます。

// 1. マクロな法線(地形全体の傾き)を計算
vec3 baseNormal = getTerrainNormal(p);

// 2. ミクロなディテールのための高周波ノイズ(例:周波数を50倍に)
float bumpFreq = 50.0;
float bumpIntensity = 0.2; // 凹凸の強さ

// 微小なずらし(epsilon)でミクロな勾配(傾き)を計算
vec2 e = vec2(0.001, 0.0);
float bumpX = snoise(p * bumpFreq + e.xy) - snoise(p * bumpFreq - e.xy);
float bumpY = snoise(p * bumpFreq + e.yx) - snoise(p * bumpFreq - e.yx);

// 3. XとZ方向の法線を揺らす(Yは上向きを維持)
vec3 bumpNormal = vec3(bumpX, 0.0, bumpY) * bumpIntensity;

// 4. ベースの法線に微細な凹凸を合成し、再正規化する
vec3 finalNormal = normalize(baseNormal + bumpNormal);

// この finalNormal を使って光の計算(ランバート反射など)を行う
float light = max(dot(finalNormal, lightDir), 0.0);

この処理を、先ほどのLODと組み合わせます。 「カメラから近い距離のピクセルでのみ、この bumpNormal の計算を行い、遠景では bumpIntensity0.0 にフェードアウトさせる」のです。

遠景の雄大さと、近景のリアリティの両立

これらを組み合わせることで、完全な手続き型(Procedural)でありながら、以下のような魔法の景観が完成します。

  • 遠くを眺めれば、LODによって軽く処理された雄大な山脈のシルエットが広がる。
  • 足元を見下ろせば、Bump Mappingによって光を乱反射するリアルな岩肌や砂の粒が克明に浮かび上がる。

テクスチャ画像を一切使わず、数式とノイズの組み合わせだけで、ミクロからマクロまで無限の解像度を持つ「世界」を創り出す。これこそが、Procedural World 生成における究極の目標であり、アルゴリズムの到達点です。

第2集(アルゴリズム編)完結!次なる舞台へ

#11の Procedural Water から始まった第2集。単なる滑らかな乱数だったノイズが、強力なアルゴリズムという骨格を得て、ついに一つの「世界」として立ち上がりました。ここで一度、私たちが手に入れた武器を振り返ってみましょう。

  • #11〜#13:Voronoi と距離の幾何学 「一番近い点はどこか?」というシンプルな問いから、水面、細胞、ひび割れ、クリスタルといった自然界の「構造」を生み出しました。
  • #14〜#17:Simplex Noise と 4D(時間)への拡張 空間を歪ませる(Skewing)という数学の魔法で高次元の計算コストを抑え、ついにノイズに「時間軸」という命を吹き込みました。
  • #18〜#20:光、質感、そして最適化 計算から法線を捏造して光と影を描き、バイオームによる質感をブレンドし、最後に LOD と Bump Mapping で「遠景の壮大さ」と「近景のミクロなリアリティ」を両立させました。

これにて、全10回にわたる「アルゴリズム編」は完結です。

平面のキャンバスに描画されていたただの白黒のモヤは、空間をねじ曲げ(Domain Warping)、時間を超え(4D)、光を浴びて、リアルタイムで鼓動する「プロシージャルな世界」の基礎へと進化を遂げました。

いざ、ノイズが「アート」になる領域へ

次回(#21)からは、いよいよ 第3集「Shader(GLSL)実践編」 に突入します。

これまでは「世界をどう作るか」「計算をどう最適化するか」という基礎と理屈の世界でした。しかしここからは違います。これまでひたすらに積み上げてきた「数学」と「アルゴリズム」の知識を総動員し、GPUの圧倒的な力で本格的な“魔法”を具現化していきます。

  • 燃え盛るプロシージャルな炎
  • 空間をねじ曲げるブラックホール
  • 幾何学とノイズが交差する輝く魔法陣
  • 画面を覆い尽くす吹雪やパーティクル

基礎から応用へ。ノイズという技術が、あなたの手によって「アート(作品)」へと昇華する、最高にエキサイティングな領域へ足を踏み入れましょう。

それでは、第3集でお会いしましょう。お楽しみに!