[Noise 入門 #27] Normal Recomputation — 歪んだ世界に正しい光と影を取り戻す

はじめに

前回の #26 では、Vertex Shaderでノイズを用いて頂点を移動(Displacement)させ、静的な3Dモデルに「鼓動」を与えることに成功しました。形がうねり、生き物のように動く様は感動的だったはずです。

しかし、同時にある「違和感」に気づきませんでしたか?

形はボコボコと波打っているのに、表面の陰影(光と影)が元のツルツルの球体のままで、どこか「のっぺり」としたプラスチックのような不自然さがあったはずです。

今回は、この歪んだ世界に「正しい光と影」を取り戻すための数学的アプローチ、法線の再計算(Normal Recomputation)を深掘りします。偏微分と外積という数学の力を使って、あなたが錬成したプロシージャルな造形美を極限まで高めましょう。

1. なぜ光と影が狂うのか?(法線の置き去り問題)

3Dグラフィックスの世界において、オブジェクト表面の「光と影(陰影)」を決定づける最重要パーツは、頂点座標ではなく「法線(Normal)」です。

法線とは、一言で言えば「その面がどの方向を向いているかを示す、垂直なベクトル(矢印)」のことです。Shader(正確にはFragment Shader)は、この法線の向きと、光源(Light)からやってくる光の向きを照らし合わせることで、「ここは光を強く受けるから明るくしよう」「ここは裏側だから影にしよう」と計算しています。

Vertex Shader で起きている悲劇

前回のDisplacement(頂点変形)で、私たちは次のようなコードを書きました。

新しい位置 = 元の位置 + 法線方向 * ノイズの値

これにより、頂点の position(位置)は見事に変形し、トゲトゲの岩や波打つ海面のような形状が生まれました。

しかし、ここで重大な見落としがあります。私たちは position を動かしただけで、normal(法線)には一切手を加えていないのです。

「ツルツルの球体」だと勘違いしているShader

元のジオメトリが「完璧に滑らかな球(Sphere)」だったとしましょう。 頂点をノイズで乱した結果、見た目は「ゴツゴツの隕石」へと変貌しました。

しかし、Shaderに渡されている法線のデータは、「滑らかな球だった頃のツルツルの法線」のままです。

違和感の正体
形状は「ゴツゴツの隕石」なのに、光の反射(陰影)の計算は「ツルツルの球」として行われている。

これが、変形したオブジェクトがどこか「のっぺり」とした、安っぽいプラスチックのように見えてしまう理由です。ジオメトリの形と、光を受け止める法線の向きに致命的な矛盾が生じているのです。

正しい光を取り戻すための「捏造」

この矛盾を解決し、トゲトゲの隕石にはトゲトゲの影を、波打つ海面には波打つハイライトを与えるためにはどうすればいいでしょうか?

答えは一つしかありません。 元のツルツルの法線データは捨てて、「変形した後の新しい形」に合わせて、Shader内で法線をゼロから再計算(捏造)してあげる必要があります。

2. 偏微分による法線捏造のアルゴリズム

3Dソフトがあらかじめ用意してくれた法線に頼れないなら、Shaderの中でリアルタイムに捏造(再計算)するしかありません。

この「法線捏造アルゴリズム」は、大きく以下の3つのステップで構成されています。

捏造のための3ステップ

  1. 近隣の点を探る(サンプリング) 現在の頂点から、ほんの少しだけ離れた2つの点(例えばX軸方向とZ軸方向にわずかにズレた点)を探り、それらが「変形後にどこへ移動するか」を計算します。
  2. 接線(Tangent)と従法線(Bitangent)を作る 現在の変形後の点から、ステップ1で見つけた「2つの近隣の点」へ向かって矢印(ベクトル)を引きます。すると、変形後のボコボコした表面にペタッと張り付いた、2つのベクトルが出来上がります。これが接線と従法線です。
  3. 外積(Cross Product)で直交ベクトルを錬成する 3D数学における最高の魔法「外積」を使います。表面に張り付いた2つのベクトルに対して外積を計算すると、「その両方に対して垂直なベクトル」が飛び出します。これこそが、私たちが求めていた「新しい法線」です。

数学的な構造(数式で見る魔法のレシピ)

これを数式に落とし込んでみましょう。

まず、元の頂点位置を $\mathbf{p}$、元の法線を $\mathbf{n}$、ノイズによる変位を決定する関数を $f(\mathbf{p})$ とします。 このとき、変形後の新しい頂点位置 $P(\mathbf{p})$ は次のように表せます。

$$P(\mathbf{p}) = \mathbf{p} + \mathbf{n} \cdot f(\mathbf{p})$$

ここからが偏微分(正確には「中心差分法」や「前進差分法」と呼ばれる近似手法)の領域です。

極めて微小な距離 $\epsilon$ (イプシロン)を使います。現在の点から、接線方向 $\mathbf{t}$ と従法線方向 $\mathbf{b}$ にそれぞれ $\epsilon$ だけ進んだ「近隣の点の変形後の位置」を計算し、現在の位置との差分をとります。

これにより、表面に沿った2つのベクトル(接線ベクトル $\mathbf{T}$ と従法線ベクトル $\mathbf{B}$)が近似的に求まります。

$$\mathbf{T} \approx P(\mathbf{p} + \mathbf{t} \cdot \epsilon) - P(\mathbf{p})$$

$$\mathbf{B} \approx P(\mathbf{p} + \mathbf{b} \cdot \epsilon) - P(\mathbf{p})$$

これで、波打つ表面の傾きを示す2つのベクトルが手に入りました。 最後に、この2つのベクトルの外積($\times$)をとり、長さを1に揃える(正規化する)ことで、完璧な垂直方向を向いた新しい法線 $\mathbf{N}_{new}$ が錬成されます。

$$\mathbf{N}_{new} = \text{normalize}(\mathbf{T} \times \mathbf{B})$$

直感的な理解
足元の地面が平らか傾いているかを知りたいとき、右足と左足を少しだけズラして踏み込んでみますよね。その「少しズラした足の高さの差」から地面の傾き(法線)を割り出す行為。これがまさにここでやっている「偏微分による近似」です。

3. GLSLでの実装:Shaderに偏微分を組み込む

やるべきことは、「現在の頂点」と「ほんの少しズレた2つの頂点」の計3回、変形関数(Displacement)を呼び出して差分をとるだけです。

// ノイズ関数 (FBMなど、前回までに作ったもの)
float cnoise(vec3 p) { /* ... */ }

// 変形関数 (Displacement)
// 頂点の位置と法線を受け取り、ノイズで変形させた新しい位置を返す
vec3 getDisplacedPosition(vec3 p, vec3 normal) {
    float noiseVal = cnoise(p * 2.0 + time); // 周波数や時間を加味
    return p + normal * noiseVal * 0.5;      // 変形後の座標を返す
}

void main() {
    // 1. 現在の頂点の新しい位置(変形後)
    vec3 displacedPosition = getDisplacedPosition(position, normal);

    // 2. 近隣の点をサンプリングするための微小なオフセット(イプシロン)
    float epsilon = 0.001;

    // 直交する2つのベクトル(Tangent と Bitangent)を簡易的に作成
    // ※球体などの場合、上方向(Y軸)と外積をとることで表面に沿った接線を作ります
    vec3 tangent = normalize(cross(normal, vec3(0.0, 1.0, 0.0)));
    vec3 bitangent = normalize(cross(normal, tangent));

    // 近隣の頂点が「変形後にどこへ移動するか」を計算
    vec3 neighborT = getDisplacedPosition(position + tangent * epsilon, normal);
    vec3 neighborB = getDisplacedPosition(position + bitangent * epsilon, normal);

    // 3. 偏微分(差分)から、変形後の表面に沿った接線ベクトルを求める
    vec3 modifiedTangent = neighborT - displacedPosition;
    vec3 modifiedBitangent = neighborB - displacedPosition;

    // 4. 外積を用いて新しい法線を計算し、正規化する
    vec3 newNormal = normalize(cross(modifiedTangent, modifiedBitangent));

    // 新しい法線をFragment Shaderへ送る(Three.jsの変換行列を掛ける)
    vNormal = normalMatrix * newNormal;

    // 最終的な頂点位置の出力
    gl_Position = projectionMatrix * modelViewMatrix * vec4(displacedPosition, 1.0);
}

実装における3つの重要ポイント

このコードを実際に動かす上で、エンジニアとして知っておくべき重要なポイントがいくつかあります。

  • epsilon(微小量)のジレンマ サンプリングの距離を決める epsilon は、魔法の匙加減です。この値が大きすぎると、大雑把な傾きしか拾えずカクカクとした粗い陰影になります。逆に小さすぎると、浮動小数点精度の限界(計算誤差)に引っかかり、表面に黒いノイズのようなジャギが発生します。環境にもよりますが、0.001 〜 0.0001 あたりが最も美しい結果を生む最適解になることが多いです。
  • 接線(Tangent)の簡易生成について コード内で cross(normal, vec3(0.0, 1.0, 0.0)) として接線を作っていますが、これは簡易的な手法です。球体などではうまく機能しますが、Y軸の頂点(北極や南極)付近ではベクトルがゼロになり破綻する(特異点)可能性があります。より厳密なモデルを扱う場合は、Three.js側で computeTangents() を使ってあらかじめ接線データをAttributeとして渡しておくのが安全です。
  • GPU負荷の代償(ノイズ関数の3回呼び出し) この手法の最大のネックは「計算コスト」です。通常のDisplacementなら getDisplacedPosition を1頂点につき1回呼べば済みますが、この手法では「元の点」「近隣点T」「近隣点B」の計3回呼び出す必要があります。もし内部で重いFBM(Octaveが多いノイズ)を使っていると、GPU負荷が一気に跳ね上がります。ここで、以前の #20 で学んだ「LOD(Level of Detail)」などの最適化技術が、現実的なパフォーマンスを叩き出すための命綱となってきます。

と影を描画し、見違えるような質感を完成させる 「4. Fragment Shaderで光を受け止める」 & 「5. まとめと次なる世界へ」 に進みましょうか?

4. Fragment Shaderで光を受け止める

Vertex Shaderの計算によって導き出された新しい法線 newNormal は、vNormal という変数(Varying変数)を通じてFragment Shaderへと送られてきます。

この vNormal を使って、以前の #18 で学んだ「ランバート反射(Lambertian Reflectance)」を計算してみましょう。コードは驚くほどシンプルですが、その視覚的な破壊力は絶大です。

// Vertex Shaderから送られてきた「新しく捏造された法線」
varying vec3 vNormal;

void main() {
    // 1. 頂点間で補間された法線を再度正規化(長さを1に揃える)
    vec3 normal = normalize(vNormal);

    // 2. 光源の向きを設定(ここでは右上奥からの平行光源を想定)
    vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));

    // 3. ランバート反射の計算(法線と光の向きの内積)
    // 光が正面から当たるほど1に近づき、裏側は0になる
    float diff = max(dot(normal, lightDir), 0.0);

    // 4. 環境光(Ambient Light)を足して、影が完全に真っ暗になるのを防ぐ
    float ambient = 0.15;
    float lighting = diff + ambient;

    // 5. ベースカラー(例えば深い海のような青)にライティングを掛け合わせる
    vec3 baseColor = vec3(0.1, 0.4, 0.8);
    vec3 finalColor = baseColor * lighting;

    gl_FragColor = vec4(finalColor, 1.0);
}

視覚的なカタルシス(ブラウザで起きる変化)

このFragment Shaderを適用してブラウザをリロードした瞬間、あなたは劇的な変化を目撃するはずです。

  • 前回の状態(Displacementのみ): 形は波打っているのに、全体がのっぺりとしたゴムボールのように見えていた状態。
  • 今回の状態(Normal Recomputation適用後): ノイズによって隆起した「山」の斜面には鋭くハイライトが走り、深く沈み込んだ「谷」には濃い影が落ちます。まるで本当にそこに物理的な凹凸が存在しているかのような、圧倒的な実在感が生まれます。

Three.js標準の MeshStandardMaterial のShaderをフックしてこの法線を渡せば、さらに物理ベースレンダリング(PBR)の恩恵を受け、金属の反射やザラザラとした質感まで表現できるようになります。

波打つノイズの山と谷が、計算された光を正確に弾き返す瞬間。これこそが、数学(偏微分と外積)が視覚的な魔法へと昇華された証明です。

5. まとめと次なる世界へ

  • Displacementは半分の魔法: 頂点の位置(Position)を動かすだけでは、視覚的な説得力は生まれません。それはまだ「形が変わっただけ」の不完全な状態でした。
  • 光の世界は「法線」が支配する: Shaderが陰影を計算する基準となる法線(Normal)は、頂点を動かしても自動では更新されません。変形後の形に合わせて、私たち自身の手で法線を「捏造」する必要がありました。
  • 偏微分と外積という最強の鉾: 未知の変形に対して「近隣の点をサンプリングして差分(偏微分)をとり、外積で直交ベクトルを求める」という数学的アプローチを用いることで、完璧に正確な新しい法線を計算することができました。

この技術(Normal Recomputation)をマスターしたことで、あなたは「プロシージャルな立体物」を完全にコントロールできるようになりました。

ドロドロと蠢くスライムのような粘性体、内なる熱量で脈打つマグマの隕石、あるいは深海で静かに呼吸する未知の細胞など、あらゆるものを「数学と光の力」だけで、ブラウザ上に描き出すことができます。

次なるノイズの旅路へ

次回、#28 では、いよいよこの究極の造形美に対して「プロシージャルなテクスチャ(色と質感)」を焼き付ける Procedural Material の領域へと進むか、あるいはこの頂点変形の応用として、何万もの粒子をノイズのうねりに乗せる「Particle System × Noise」へと踏み込むか……。

あなたの創造力が赴くままに、次なるノイズの魔法の行き先を選んでください。次回の展開も、ぜひお楽しみに!