[Noise 入門 #18] Procedural Lighting — ノイズから法線を捏造し、光と影をシミュレーションする

はじめに

Noise 入門シリーズ第18回。前回までで、異なるノイズを融合させ、細胞と無機物が交差するような複雑な模様(Procedural Texture)を作成しました。しかし、それらはまだ画面に張り付いた「ただの平坦な絵」に過ぎません。

今回は、その模様を「触れそうな立体(3Dマテリアル)」へと昇華させる魔法、Procedural Lighting(プロシージャル・ライティング)の世界へ足を踏み入れます。

ノイズの計算結果からリアルタイムに法線(Normal)を捏造し、Shader内で光と影をシミュレーションする手法を解説します。

前回の記事:

1. なぜ「法線(Normal)」が必要なのか? — 光を騙すための「向き」の概念

ここまで作成してきた FBM や Domain Warping などの高度なノイズは、いくら複雑であっても、本質的には「0.0 から 1.0 までの数値(スカラー値)」の集まりに過ぎません。

これをそのまま画面に出力すると、単なる白黒のデータ(ハイトマップ)になります。値が 1.0 なら白(高い)、0.0 なら黒(低い)という、画面に張り付いた平坦な模様です。

この平坦な模様を、思わず手を伸ばしたくなるような「立体的な物質」として人間の脳に錯覚させるためには、「光と影」が絶対に必要です。

光の反射を決めるのは「表面の向き」

現実世界の光を想像してみてください。 太陽の光が岩肌に当たる時、光の方向を真正面から受ける斜面は明るく白飛びし、反対側を向いている斜面や溝の奥は暗い影になります。

つまり、3D空間における光の計算(ライティング)において最も重要なのは、「その地点の表面が、空間のどの方向を向いているか」という情報なのです。この「表面が向いている方向」を、表面に対して垂直に伸びる長さ1の矢印(単位ベクトル)として数学的に表したものを、法線ベクトル(Normal Vector)、または単に法線(Normal)と呼びます。計算式などではよく $\vec{N}$ と表記されます。

ポリゴンの世界と、Shaderの世界の違い

Three.js などの3Dエンジンで通常のモデル(BoxやSphereなど)を扱う場合、この法線データは「頂点情報」として最初から組み込まれています。そのため、シーンにライトを配置するだけで自動的に美しい陰影がつきます。

しかし、私たちが Shader(GLSL)内でピクセルごとに描画しているプロシージャルノイズには、頂点も、面(ポリゴン)も存在しません。あるのは、数式がその座標で弾き出した「高さの数値」だけです。

ジオメトリを持たない世界で、光を「捏造」する

法線がないのなら、どうやって光を反射させればいいのでしょうか? 答えは、ノイズの高低差(値の変化)から「ここは急な斜面だ」「ここは平坦だ」という傾きを読み取り、自力で法線を計算(捏造)することです。

実際のポリゴンを隆起させて凹凸を作る(Displacement Mapping)のではなく、ピクセルが反射する光の計算だけを騙し、あたかもそこに凹凸があるかのように見せかけるのです。

これが、計算負荷を抑えたまま、無限の解像度とディテールを生み出す Procedural Lighting(バンプマッピング技術の根幹)の魔法です。では、その「傾き」をどうやって数式で見つけ出すのでしょうか?

2. 中心差分法:近隣のピクセルから「傾き」を知る — 勾配(Gradient)の算出

法線(表面の向き)を知るためには、その場所の「傾き(勾配:Gradient)」を計算しなければなりません。しかし、ある1点のピクセル座標(X, Y)のノイズ値をポンと取得しただけでは、そこが山の頂上なのか、急斜面の途中なのか、あるいは平地なのかは絶対に分かりません。

傾きを知るためには、「周囲との比較」が必要になります。

目隠しをして山の斜面に立っている状況を想像してみてください。自分がどちらに傾いているかを知るにはどうしますか? きっと、つま先(少し前)と、かかと(少し後ろ)の地面の高さを探って、その差を感じ取るはずです。

これと全く同じことを数学的に行うのが、中心差分法(Central Difference)と呼ばれる手法です。

微小なズレ $\epsilon$(イプシロン)を使って傾きを測る

ある座標 $(x, y)$ の傾きを知るために、ほんの少しだけX方向・Y方向にずれた場所のノイズ値(高さ)をサンプリングして比較します。この「ほんの少しの距離」を、数学ではよく微小値 $\epsilon$(イプシロン)として表します。

X方向(横方向)の傾きを知りたければ、現在の位置から右に $\epsilon$ だけ進んだ場所の高さと、左に $\epsilon$ だけ戻った場所の高さを取得します。

$$\text{Gradient}_x = \frac{\text{noise}(x + \epsilon, y) - \text{noise}(x - \epsilon, y)}{2\epsilon}$$

この式が何をしているかというと、非常にシンプルです。 「右の高さ」から「左の高さ」を引き算し、その2点間の距離である $2\epsilon$ で割っているだけです。これは中学校の数学で習う「変化の割合( $\frac{yの増加量}{xの増加量}$ )」と全く同じ、傾き(微分)の計算です。

同様に、Y方向(縦方向)の傾きも、上下に $\epsilon$ だけずらして計算します。

$$\text{Gradient}_y = \frac{\text{noise}(x, y + \epsilon) - \text{noise}(x, y - \epsilon)}{2\epsilon}$$

2Dの傾きを「3Dの法線ベクトル」に変換する

これで、X方向の傾き( $\text{Gradient}_x$ )と、Y方向の傾き( $\text{Gradient}_y$ )という2つの値が手に入りました。

しかし、法線ベクトルは3D空間の向きを表すため、$(X, Y, Z)$ の3つの成分を持つ 3次元ベクトル(vec3)である必要があります。私たちが描画しているノイズ平面は、カメラから見て真正面(Z軸方向)を向いていると仮定します。

もし完全に平坦な場所であれば、法線は手前を真っ直ぐ向くため $(0, 0, 1)$ になります。 ここに、先ほど計算したXとYの傾きを組み込みます。傾きが急であればあるほど、法線はX方向やY方向に大きく倒れ込むことになります。

最終的に、このベクトルが「長さ1」になるように正規化(Normalize)を行うことで、完璧な法線ベクトル $\vec{N}$ が完成します。

数学的な理屈はここまでです。一見難しそうに見えるかもしれませんが、Shader のコード(GLSL)に落とし込むと、驚くほどシンプルに実装できます。

3. GLSLでの法線生成の実装 — GPUに最適化されたスマートなコード

理論が分かったところで、いよいよこれをGLSLのShaderコードに落とし込みます。

ここでは、ベースとなるノイズ(これまでに作ってきたFBMやSimplex Noiseなど)を出力する関数を、便宜上 map(vec2 p) と定義して進めます。この map 関数が、私たちの世界の「地形の高さ(Height Map)」を返す役割を担います。

// ノイズによる高さマップ(任意のノイズ関数を使用)
float map(vec2 p) {
    return fbm(p); // 例としてFBMを使用(0.0〜1.0の値を返す想定)
}

// 中心差分法による法線の計算
vec3 getNormal(vec2 p) {
    // 微小なオフセット値(解像度に合わせて調整)
    vec2 e = vec2(0.01, 0.0);

    // X方向とY方向の傾きを計算
    float dx = map(p + e.xy) - map(p - e.xy);
    float dy = map(p + e.yx) - map(p - e.yx);

    // 法線ベクトルの生成(Z方向の強さで立体の「深さ」を調整可能)
    float normalDepth = 0.15; // 値が小さいほど凹凸が激しくなる
    return normalize(vec3(-dx, -dy, normalDepth));
}

この短い関数の中には、Shaderを書く上で非常に実用的でスマートなテクニックがいくつか詰まっています。1つずつ紐解いていきましょう。

GLSL特有の美学「Swizzling(スウィズリング)」

コードの中で vec2 e = vec2(0.01, 0.0); という変数を定義しています。 X方向の傾きを計算する時は e.xy を足し引きし、Y方向の傾きを計算する時は e.yx を足し引きしています。

これはGLSLのSwizzlingと呼ばれる機能です。e.yx と書くだけで、中身のXとYが反転し vec2(0.0, 0.01) として扱われます。わざわざX用とY用で2つの変数を定義する必要がなく、コードが極めて簡潔になります。

あえて「割り算」を省略するGPU最適化

先ほどの数学の解説では、傾きを出すために「距離( $2\epsilon$ )で割る」と説明しました。しかし、コードの中には割り算が存在しません。ただ高さを引き算しているだけです。

これはバグではなく、意図的な最適化です。 最終的に normalize() 関数を通すことで、ベクトルの長さは強制的に「1」にスケーリングされます。つまり、事前に定数で割り算をしてもしなくても、向き(比率)さえ合っていれば最終的な法線の結果は同じになるのです。不要な割り算を省くことで、GPUの計算負荷をわずかに減らしています。

normalDepth による「質感」のコントロール

最後の行にある vec3(-dx, -dy, normalDepth)normalDepth(Z方向のベクトル成分)は、生成される凹凸の「鋭さ」を決定する極めて重要なパラメータです。

  • 値を小さくする(例: 0.05):Z方向の張りが弱くなるため、少しの傾き(XやYの変化)でも法線が大きく横を向きます。結果として、非常に彫りが深く、鋭い岩肌のようなバンプ(凹凸)になります。
  • 値を大きくする(例: 1.0):Z方向(手前)を向く力が強くなります。多少のノイズの変化があっても法線は手前を向きやすくなるため、なだらかで滑らかな、うっすらとした起伏になります。

また、dxdy にマイナス(-)をつけているのは、高い場所が手前に出っ張って見え、低い場所が凹んで見えるように光の計算の辻褄を合わせるためです。

これで、平面上の任意の座標 p における「擬似的な立体の向き(法線)」を取得できるようになりました。準備は万端です。次はこの法線を使って、実際に光を当ててみましょう。

4. 光を当てる:Lambert反射モデル — 内積(Dot Product)が描く陰影

表面の向き(法線)が手に入れば、あとはそこに「光」を当てるだけです。 3Dグラフィックスの世界には様々なライティングモデルがありますが、ここでは最も基礎的で、かつノイズの凹凸を美しく見せてくれるランバート反射(Lambertian reflectance)を使用します。

ランバート反射は、チョークや粘土のような「光沢のない(マットな)表面」における光の乱反射をシミュレーションするモデルです。このモデルのルールはただ一つ。 「光の差す方向と、表面の向いている方向(法線)が一致しているほど明るくなる」ということです。

Shaderの必須魔法「内積(Dot Product)」

この「2つの方向の一致度」を測るために、Shaderプログラミングにおいて息をするように使われる数学のテクニックが内積(Dot Product)です。GLSLでは dot() 関数として標準搭載されています。

光の方向(Light Direction)を表すベクトル $\vec{L}$ と、先ほど求めた法線ベクトル $\vec{N}$ の内積を計算します。

$$\text{Diffuse} = \max(\vec{N} \cdot \vec{L}, 0.0)$$

内積の計算結果( $\vec{N} \cdot \vec{L}$ )は、2つのベクトルの間の角度によって以下のように変化します。 (※前提として、$\vec{L}$ と $\vec{N}$ はどちらも長さが1に正規化されている必要があります)

  • 角度が 0度(真っ正面から光を受けている): 結果は 1.0(最も明るい)
  • 角度が 90度(光が表面をかすめている): 結果は 0.0(真っ暗)
  • 角度が 90度以上(光が裏側から当たっている): 結果は マイナスの値になる

なぜ max(…, 0.0) が必要なのか?

光が裏側から当たっている場合、内積の結果は -0.5 などのマイナスになります。しかし、現実世界に「マイナスの光(色を暗く吸収する光)」は存在しません。裏側は単に「光が当たらない(=0.0)」だけです。

もしマイナスの値をそのまま色に掛け合わせてしまうと、意図しない黒ずみや描画の破綻を引き起こします。そのため、max(..., 0.0) を使って、「計算結果が0.0を下回ったら、強制的に0.0(真っ暗)として切り捨てる」という処理を挟んでいます。これをディフューズ(Diffuse:拡散反射光)と呼びます。

GLSLコードの解説

それでは、この理論を組み込んだ Fragment Shader の main() 関数を見てみましょう。Three.js の ShaderMaterial でそのまま動く構成になっています。

void main() {
    vec2 uv = vUv; // Three.jsなどから渡されるUV座標

    // 1. 法線の取得
    // uvを5倍して、ノイズのスケールを調整しています
    vec3 normal = getNormal(uv * 5.0);

    // 2. 光の方向を定義(左上・手前から照らす)
    // 光の向きも必ず normalize() して長さを1にします
    vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));

    // 3. ランバート反射の計算(ディフューズ光)
    float diffuse = max(dot(normal, lightDir), 0.0);

    // 4. 環境光(Ambient Light)の加算
    float ambient = 0.2;
    float lighting = diffuse + ambient;

    // 5. ベースカラー(例として岩のような色)に光を乗算
    vec3 baseColor = vec3(0.6, 0.5, 0.4);
    vec3 finalColor = baseColor * lighting;

    gl_FragColor = vec4(finalColor, 1.0);
}

【コードのポイント】

  • lightDir の設定: vec3(1.0, 1.0, 1.0) は、X(右)、Y(上)、Z(手前)のすべての方向から均等にやってくる光、つまり「右斜め上、かつ手前」から差し込む太陽光のようなディレクショナルライト(平行光源)を意味します。
  • ambient(環境光)の役割: 宇宙空間でない限り、現実世界の影は完全に「真っ黒(0.0)」にはなりません。地面や大気で乱反射した光が回り込むからです。これを擬似的に再現するために、光が当たっていない(diffuseが0.0の)場所でも最低限の明るさ 0.2 を担保しています。
  • 光の乗算: 最後に、ベースとなる色(baseColor)に対して、計算した光の強さ(lighting)を掛け算(乗算)します。光が強く当たる場所は本来の色が見え、影になる部分は暗い色へと沈み込みます。

この計算を経ることで、ノイズは初めて「立体的な物質」として画面に描き出されるのです。

5. ノイズが「物質」になった瞬間 — 数学が質量を持つとき

ここまでのコードを組み合わせ、Shaderを実行してみてください。

これまでただの「モヤモヤした白黒のグラデーション」だった画面上のピクセルが、突然、岩肌や古代のレリーフのような、確かな質量を持った物質として目の前に現れるはずです。

光を動かし、影の「生きた変化」を確かめる

この Procedural Lighting が、あらかじめPhotoshopなどで作成された静的な画像(ベイクされたテクスチャ)と決定的に違う点は、「その場でリアルタイムに光と影を計算している」という事実です。

それを最も強く実感できるのが、光の方向をアニメーションさせた時です。 Three.js から uTime(経過時間)を Uniform 変数として渡し、先ほどの lightDir の計算を次のように書き換えてみてください。

// uTimeを使って、光の方向を円状に回転させる
vec3 lightDir = normalize(vec3(sin(uTime), 1.0, cos(uTime)));

光の光源がゆっくりと旋回し、それに伴ってノイズの凹凸に落ちる影が、ぬるぬるとリアルタイムに変形していく様子が確認できるはずです。画面の中には一枚の平面ポリゴン(PlaneGeometry)しか存在しないにもかかわらず、本当にそこに複雑な彫刻が存在しているかのような強烈な錯覚を生み出します。

画像ファイルを一切読み込むことなく、純粋な数学的計算だけで、光と影を持つ無限のディテールを生成する。しかも、カメラをどれだけ近づけても絶対にピクセルがぼやけず、無限の解像度を保ち続ける。

これこそが、Procedural Texture(手続き型テクスチャ)の真の醍醐味であり、GPUの計算力が生み出す視覚的な魔法なのです。


次回予告:Procedural Material — ノイズをマスクにした質感のブレンド

平面だったノイズに光が当たり、見事な立体感を獲得しました。 しかし、今の状態は「単一の色の塊(ただの茶色い岩盤)」に過ぎません。

もし、この数学的に作られた地形に対して、以下のようなルールを与えられたらどうなるでしょうか?

  • 「高さ(Height)が低い場所には、水が溜まる」
  • 「高さ(Height)が高い場所には、雪が積もる」
  • 「法線(Normal)が真上を向いている平坦な場所には、苔が生える」
  • 「法線(Normal)が横を向いている急斜面は、岩肌が剥き出しになる」

次回(#19)は、今回計算した「高さ」や「傾き(法線)」をマスク(合成の境界線)として利用し、異なる複数の質感(土、岩、水、草など)を動的に塗り分ける Procedural Material の構築 に挑みます。

単一の模様を越え、ノイズが複雑な生態系を持つ「世界の一部」へと進化する瞬間をお見せします。お楽しみに!