Shader Graphにおいて、頂点の位置を変更する「Vertex Displacement(頂点変位)」は、風にたなびく旗、水面の波、地形の隆起、キャラクターのモーフィングなどの動的変形を手軽に実装できる非常に強力な手法です。しかし、頂点シェーダーの段階で頂点座標(Position)のみを変形して出力すると、「メッシュの形は変わっているのに、光の反射や影の付き方が変形前の平坦なままになる」という深刻なレンダリング不良に陥ります。本記事では、このライティングの平坦化バグが発生する内部メカニズムと、変形後の形状に合わせて法線(Normal)および接線(Tangent)を正しく再計算・再構築する3つの解決アプローチを実用コード付きで徹底解説します。
1. 根本的な原因:なぜ頂点を動かしても光が追従しないのか
3Dレンダリングにおけるライティング(PBRなど)は、メッシュ表面が「どの方向を向いているか」を示す「法線ベクトル(Normal Vector)」と、その接面方向を示す「接線ベクトル(Tangent Vector)」に基づいて計算されます。光の反射角度やノーマルマップのサンプリングはすべてこれらのベクトルに依存しています。
しかし、UnityのレンダーパイプラインおよびShader Graphの仕様において、Vertexステージの「Position」ポートに新しい座標を入力してメッシュを変形させても、頂点に格納されている法線や接線は自動で再計算されません。
結果として、以下のような不整合が発生します。
- 形状の変形: 頂点座標が変更され、メッシュは波打つようにでこぼこに変形する。
- ライティング計算: 変形前のまっすぐ平らだった頃の法線ベクトル((0, 1, 0) など)がそのままピクセルシェーダーに送られ、ライトの計算に使用される。
- 描画結果: 目で見えるメッシュの立体感は歪んでいるのに、陰影のグラデーションだけは平坦な板と同じになり、極めて不自然な「貼り絵」のようなルックになる。
特に影(Shadow Caster)の生成時にもこの問題は影響し、変形した物体のエッジから落ちる影の輪郭と、本体に付く影(Self-Shadowing)に大きなズレが生じます。このバグを解決するには、頂点の変位量(高さの変化や傾き)に合わせて、法線と接線も同様に回転・変形させる必要があります。
2. 解決法A:偏微分(Analytical Derivatives)による数学的再計算
もし頂点変位が明確な数式(例:サイン波やコサイン波)で定義されている場合、その数式を数学的に微分(偏微分)することで、変形後の法線と接線をピンポイントかつ超軽量に求めることができます。
例えば、X軸方向に進む正弦波による高さ $y$ の変位が以下の式で表されるとします。
y = sin(x * frequency + time) * amplitude;
このとき、X方向への傾き(接線ベクトル)は、上式を $x$ で微分したコサイン波になります。
dy/dx = frequency * amplitude * cos(x * frequency + time);
この傾きベクトルから、新しい接線(Tangent)および法線(Normal)を以下のように組み立てることができます。
// X方向の傾きベクトルからTangentを定義
float3 tangent = normalize(float3(1.0, dy/dx, 0.0));
// Z方向(変形していない方向)のBinormalを定義
float3 binormal = float3(0.0, 0.0, 1.0);
// 外積からNormalを算出
float3 normal = normalize(cross(binormal, tangent));
この手法はテクスチャサンプリングやループ処理を含まないため、動作負荷が極めて低く、モバイルやVR環境でも完璧に動作するという大きなメリットがあります。
3. 解決法B:差分近似(Finite Difference)による汎用的再構築
頂点変位に3Dノイズ(Simplex Noiseなど)や複雑なテクスチャを使用しており、数学的な微分係数を手計算で求めるのが困難な場合は、変形後の隣接する仮想的な2点をサンプリングして差分から法線を計算する「差分近似」が極めて効果的です。
この手法では、現在の頂点位置 $P$ のほかに、Tangent(接線)方向およびBitangent(従接線)方向に極小距離 $\epsilon$(エプシロン)だけ離れた仮想点 $P_{tangent}$ と $P_{bitangent}$ を仮定し、それぞれの変位後の位置を計算します。
1. $P' = P + \\text{Displace}(P)$ (変形後の現頂点)
2. $P_{T}' = (P + \\epsilon \\cdot T) + \\text{Displace}(P + \\epsilon \\cdot T)$ (Tangent方向に離れた点の変形後)
3. $P_{B}' = (P + \\epsilon \\cdot B) + \\text{Displace}(P + \\epsilon \\cdot B)$ (Bitangent方向に離れた点の変形後)
4. $T_{\\text{new}} = P_{T}' - P'$ (新しい接線ベクトル)
5. $B_{\\text{new}} = P_{B}' - P'$ (新しい従接線ベクトル)
6. $N_{\\text{new}} = \\text{normalize}(\\text{cross}(T_{\\text{new}}, B_{\\text{new}}))$ (新しい法線ベクトル)
以下は、Shader GraphのCustom Functionノードに組み込んで使用できる、差分近似を用いた頂点変位および法線・接線再構築のHLSLコード例です。
// 頂点変形を定義する関数(ここでは一例として3Dサイン波)
float3 CalculateDisplacement(float3 pos, float frequency, float amplitude, float time)
{
float wave = sin(pos.x * frequency + time) * cos(pos.z * frequency + time);
return float3(0, wave * amplitude, 0);
}
void ReconstructNormalAndTangent_float(
float3 objectPosition,
float3 objectNormal,
float3 objectTangent,
float frequency,
float amplitude,
float time,
float epsilon, // 差分計算用の微小距離(0.01等推奨)
out float3 displacedPosition,
out float3 reconstructedNormal,
out float3 reconstructedTangent)
{
// 従接線(Bitangent)の算出
float3 bitangent = cross(objectNormal, objectTangent) * objectTangent.w;
// 1. 変形前の3点を定義
float3 p = objectPosition;
float3 pT = p + objectTangent * epsilon;
float3 pB = p + bitangent * epsilon;
// 2. それぞれに変位を適用して変形後の座標を算出
float3 displacedP = p + CalculateDisplacement(p, frequency, amplitude, time);
float3 displacedPT = pT + CalculateDisplacement(pT, frequency, amplitude, time);
float3 displacedPB = pB + CalculateDisplacement(pB, frequency, amplitude, time);
// 3. 変形後の座標の差分から新しい接線と従接線を作成
float3 newTangent = displacedPT - displacedP;
float3 newBitangent = displacedPB - displacedP;
// 4. 外積により新しい法線ベクトルを算出
reconstructedNormal = normalize(cross(newTangent, newBitangent));
reconstructedTangent = normalize(newTangent);
displacedPosition = displacedP;
}
4. Shader Graphでのノード接続手順
作成したHLSLカスタム関数または計算ノードを、Shader Graph上で以下のように正しく接続します。
- Custom Functionノードの配置: 上記の `ReconstructNormalAndTangent_float` 関数を定義したCustom Functionノードを作成します。
- オブジェクトデータの接続: Vertexステージの `Position (Object Space)`、`Normal (Object Space)`、`Tangent (Object Space)` ノードを、それぞれCustom Functionの対応する入力ポートに接続します。
- パラメータの調整: `epsilon` 入力ポートには `0.01` 程度、あるいはメッシュの解像度(ポリゴン密度)に応じた微小値を定数で接続します。
- マスタースタックへの出力:
- Custom Functionの `displacedPosition` 出力を、マスタースタックの Vertex Position ポートに接続します。
- `reconstructedNormal` 出力を、マスタースタックの Vertex Normal(またはFragment Normal)ポートに接続します。
- `reconstructedTangent` 出力を、マスタースタック of Vertex Tangent ポートに接続します。
※注意:法線と接線の再計算は、基本的に Vertex(頂点)ステージ で行う必要があります。Fragmentステージで計算すると、ポリゴン内部の補間処理と矛盾が生じてハイライトがギザギザになる原因になります。