ゲーム制作技術録

主にUnity/Godot4関連の技術ブログです

【Unity】【URP12】自作Unlitシェーダーでセルフシャドウ

今回はURP対応の自作HLSLシェーダーでのセルフシャドウ実装に焦点を置いた話をします。

Unity 2021.3
UniversalRP 12.1
Forward

はじめに

ここまでの話は前回の記事をご覧ください。

pinomatcha-gamedev.hatenablog.com

 

ノーマル(法線)とライト方向の内積を使ってセルフシャドウを実装していきます。
仕組みは割と単純なので意外とすんなりできると思います。

必要なデータを取得する

セルフシャドウを実装するにはノーマルとライト方向のデータが必要なので順番にやっていきます。

ノーマル情報の取得

Attributes

Attributes構造体に一行追加して、ノーマルを扱えるようにします。
OSというのはObject Space(オブジェクト空間)の略でオブジェクト自身のデータということです。
ここでは、オブジェクトから見たノーマルということですね。

struct Attributes {
    float4 positionOS : POSITION;
    float2 uv : TEXCOORD0;
    float3 normalOS : NORMAL;   // 追加
};
Varyings

同時にVaryingsの方にも二行追加します。
オブジェクト空間のノーマルとワールド空間のノーマルです。
それぞれOS、WSを末尾に付けておきます。

これらは頂点シェーダーから値を設定してフラグメントシェーダーで使用するためのものです。

struct Varyings {
    float4 positionHCS : SV_POSITION;
    float2 uv : TEXCOORD0;
    float fogFactor : TEXCOORD1;
    float3 normalOS : NORMAL0;   // 追加
    float3 normalWS : NORMAL1;   // 追加
};
頂点シェーダーにノーマル処理を追加する

頂点シェーダーのvert()に以下の処理を追加します。

オブジェクト空間のノーマル情報をそのまま格納する処理

// オブジェクト空間のノーマルはそのままVaryingsのnormalOSに格納
OUT.normalOS = IN.normalOS;

オブジェクト空間のノーマル情報をワールド空間に変換して格納する処理

// TransformObjectToWorldNormal()でノーマルをオブジェクト空間からワールド空間へ変換して格納
OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);

追加後

Varyings vert(Attributes IN) {
    Varyings OUT;
    
    // TransformObjectToHClip()で頂点位置をオブジェクト空間からクリップスペースへ変換
    // UnityObjectToClipPos()がTransformObjectToHClip()になった
    OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
    // TRANSFORM_TEX()マクロでタイリングなどを計算する
    OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex);
    // fog factor の計算
    OUT.fogFactor = ComputeFogFactor(IN.positionOS.z);
    
    // オブジェクト空間のノーマルはそのままVaryingsのnormalOSに格納
    OUT.normalOS = IN.normalOS;
    // TransformObjectToWorldNormal()でノーマルをオブジェクト空間からワールド空間へ変換して格納
    OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);
    
    return OUT;
}
確認

セルフシャドウの計算に必要なのはnormalWS、つまりワールド空間のノーマル情報なのでfrag()normalWSのxyzをRGBとして出力してみます。

// return をワールド空間のノーマルxyzにしてみる。
// だけど色情報はRGBAのfloat4なのでAには1を入れておく。
// XYZ(RGB) + A
return float4(IN.normalWS.xyz, 1);

xyzをRGBとして出力しているので、x+が赤色、y+が緑色、z+が青色となっており、正常にノーマルがワールド空間で取得できていることが分かりました。

ワールド空間なので、オブジェクトを回転してみてもノーマルの表示は変わりません。

ちなみにnormalOSを出力してみると、オブジェクト空間なので回すと回ります。

return float4(IN.normalOS.xyz, 1);

確認出来たら元に戻しておきます。

reutn color;

ライト情報の取得

#include

Lighting.hlsl を include します。
Core.hlsl の下にでも書いておきましょう。

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// ↓ 追加
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
MainLightの取得

frag()の中にメインライト、いわゆるDirectionalLightを取得する処理を記述します。
前述のLighting.hlslのインクルードをしておかないとLight型がエラーになります。

Light mainLight = GetMainLight();

frag()全体

float4 frag(Varyings IN) : SV_Target {
    // テクスチャをサンプリングして、カラーを乗算する
    float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * _MainColor;
    
    // Fogを適用する
    color.rgb = MixFog(color.rgb, IN.fogFactor);
    
    // MainLightの取得
    Light mainLight = GetMainLight();
    
    return color;
}

mainLight.colorでライトの色、mainLight.directionでライトの方向を取得できます。

陰影をつける

必要なデータは揃ったので、実際に陰影処理をしていきます。

ノーマルとライト方向の内積

内積について詳しくは説明しませんが、ここで内積を使って求めるのは2つのベクトルの角度差です。
1つのベクトルをノーマル、1つのベクトルをライトの方向としてその2つのベクトル間の角度を0~1で取得することで光が当たっているか、影になっているかを算出します。
詳しい説明は高校数学の解説でも見たほうが早いと思います……。

というわけで実際の処理はこんな感じです。

// メインライト取得の下に記述します。

// ノーマル(法線)とライト方向の内積
float NdotL = saturate(dot(IN.normalWS, mainLight.direction));

ノーマル(Normal)とライト方向(Light Direction)の内積(Dot Product)なので NdotL と表記することが多い気がします。
今回もそれにならって NdotL と命名しています。

計算は手動じゃないので記述は単純で、dot()内積を計算してsaturate()で0~1に固定しています。

ここでNdotLを出力してみます。

return NdotL;

ノーマルがライトの方向に向いているほど1(白色)、ライトの方向とは逆に向いているほど0(黒色)が取得できているのが確認できました。
(ベクトル的にはライト方向と逆が1、ライト方向が0という表現になるのかな?)

ここまで来たら、color とライトの色と NdotL を掛け合わせて出力してみると

return float4(color.rgb * mainLight.color.rgb * NdotL, 1);

正常に出力されているのが分かります。

ここまでのfrag()全体
float4 frag(Varyings IN) : SV_Target {
    // テクスチャをサンプリングして、カラーを乗算する
    float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * _MainColor;
    
    // Fogを適用する
    color.rgb = MixFog(color.rgb, IN.fogFactor);
    
    // MainLightの取得
    Light mainLight = GetMainLight();
    
    // ノーマル(法線)とライト方向の内積
    float NdotL = saturate(dot(IN.normalWS, mainLight.direction));
    
    return float4(color.rgb * mainLight.color.rgb * NdotL, 1);
}

影をトゥーンにする

このままだとLitシェーダーとあまり変わらないので、影をトゥーン調にします。
やり方は何通りかあると思いますが、今回はsmoothstep()を使用して実装してみます。
NdotL の計算式の下に lightIntensity として処理を書きます。

float NdotL = saturate(dot(IN.normalWS, mainLight.direction));
float lightIntensity = saturate(smoothstep(0.005, 0.01, NdotL));

以下は lightIntensity のみ出力したものです。

きれいに二極化されています。

最後に、NdotL を掛けていた部分を lightIntensity に置き換えて出力するとトゥーン調になっているのが分かります。

return float4(color.rgb * mainLight.color.rgb * lightIntensity, 1);

コード全文

参考