パースペクティブシャドウマップ技法 UnityでSRPからPSM技法で影を描く

概要

前回までUnityのSRPで複数のDirectionalLightのデプスシャドウを書く方法を解説しました。

daiki-eguchi.hatenablog.com

ここから改良型のデプスシャドウ技法について解説・実装していきたいと思います。 今回は「パースペクティブシャドウマップ」(以下PSM技法)について実装していきます。

デプスシャドウ技法は広く使われる手法になりましたが、シャドウマップの解像度がひくいとシャドウの品質が低くなり、ジャギーが目立ってしまうという問題点がありました。 逆にシャドウの品質を上げるにはシャドウマップの解像度を高くする必要がありますが、今度は解像度を高くすればするほど負荷が高くなっていきます。

そこから視点をかえて「視点に近い位置のシャドウマップを高解像度で生成し、遠くに行くにしたがって解像度をそれなりで処理する」という発想で生まれたのが今回お話しする「PSM技法」です。

パースペクティブシャドウマップの概念

デプスシャドウでは以下のような場合の際に視点に近い場所のシャドウマップでは4テクセルで表現されているが、視点に近い場所のシャドウマップは2テクセルでしか表現されていません。

上記の例ではカメラに近く、カメラで大きくとらえられているシャドウのほうが影の精度が落ちてしまいます。

そのためパースペクティブシャドウマップでは以下のようなシーンがあった時、この視界に対応するようにシャドウマップを生成しようといった形です。

こうすることで視点に近い位置のシャドウを表現するテクセル数があがり、カメラに近い影品質が向上します。

Unityでの実装

まずは結果の比較からです。 標準のシャドウマップで書いた影が以下です。

そしてそれをパースペクティブシャドウマップで書いた影が以下です。

カメラに近い影、特に木の葉っぱなどの影を見てもらえればわかるようにかなり精度が良くなっています。

Cubeの影もよく見れば影の輪郭のジャギーが改善されていることがわかります。

カメラから遠いところの木の影を見ると品質は逆にPSMの方はあまり書かれていないからなのか情報量が落ちているように感じます。 ここはトレードオフなのかなと思っています。

実装

先に全コードを載せます。

ProjectSettingsのGraphicsとQualityの項目に新規で制作したRenderPipelineAssetを設定するのはいつもの通りです。

そしてCubeとPlaneにForwardLit.shaderをもとにしたマテリアルをアサインすれば以下のような影が生成されます。

解説

PSMの最大の骨子 行列の計算

標準のシャドウマップは、単純にワールド空間において「光源の視点」からシーンをレンダリングして深度画像を生成します。 そのため、使用される行列は、ワールド空間のオブジェクトを光源のビュー空間に変換し、そこから光源の投影空間へ変換する「ライトのビュー投影行列」となります。

一方PSMでは、ワールド空間から直接シャドウマップを作成するのではなく、一度カメラ(視点)の透視投影変換を適用した後の空間(ポストパースペクティブ空間、または正規化デバイス座標系)でシャドウマップを生成します

これにより、視点に近いオブジェクトほど拡大され、遠くのオブジェクトほど縮小されるため、シャドウマップの解像度不足(エイリアシング)を軽減できるというメリットがあります。

上記のためPSMで使用するライト行列は

LVP = P'_light × V'_light × P_cam × V_cam
行列 役割
V_cam ワールド->カメラビュー空間
P_cam カメラビュー->クリップ/NDC空間
V'_light NDC空間でライト方向をZ軸にそろえる回転
P'_light NDC空間の視錐台AABBを囲む正射影

前提 行列のかける順番の約束 Unityは列ベクトル方式らしく、ベクトルは必ず右からかけます。 複数の変換の合成があるときは右から順番に適用されるということですね。

var LVP = A×B×C

とある場合複数の変換はB×Cを行い、その後にA×(B×Cの結果)の順番で適用されます。

ステップ① カメラVP行列

最初にLVP = P'light × V'light × (P_cam × V_cam)の場所にある(P_cam × V_cam)を計算しています(右から適用なので)

float near = Mathf.Max(cam.nearClipPlane, zNearMin);                                                                                                                 
float far  = Mathf.Min(cam.farClipPlane, shadowDistance);                                                                                                            
                                                                                                                                                                       
Matrix4x4 Pc = Matrix4x4.Perspective(cam.fieldOfView, cam.aspect, near, far); // P_cam                                                                               
Matrix4x4 Vc = cam.worldToCameraMatrix;                                       // V_cam                                                                               
                                                                                                                                                                       
Matrix4x4 PcGpu = GL.GetGPUProjectionMatrix(Pc, false);                                                                                                              
Matrix4x4 camVP = PcGpu * Vc;   // P_cam × V_cam 

これで「ワールド空間 → カメラの NDC 空間」への変換ができます。PSM の肝はこの1発目で、ここでシーンを「カメラの見え方」に歪ませます。

NDC空間とは

正規化デバイス座標であり、画面サイズや解像度に依存しない統一された座標系です。

座標軸 最小値 最大値 備考
x -1 +1 なし
Y -1 +1 なし
Z 0 +1 DirectX / Unity の場合
Z -1 +1 OpenGL の場合

なぜこの変換をかけるのか

ここがPSMの一番の肝になります。 先に述べたようにPSMはカメラに近いオブジェクトに使用する影のピクセルを多くするのが特徴です。

言い換えると「シャドウマップを書く前に、シーンをカメラの透視変換で歪ませる」ことです。

その際以下のように「NDC空間のライト視点で書く」ことでカメラに近いオブジェクトを大きく映せる=影のピクセルを割くことができる のです。

NDCにするとなぜ近い物体ほど大きくなるのか

透視投影の計算式

View空間の点 (x, y, z) → NDC座標は:

x_ndc = fx * x / (-z)                                                                                                                                                                                                 
y_ndc = fy * y / (-z)

-z で割るのは Unity のカメラ前方が -Z 方向だからです(前方の物体は z < 0)。


具体的な数値で確認

同じ大きさ(幅1)の物体が2つ。位置だけ違うとします。

fx = 1 として NDC 座標を計算すると

その時、NDC空間での見え方は以下のようになります。

同じ実サイズでも、近い物体A は NDC 幅 0.5、遠い物体B は NDC 幅 0.1。5倍の差になります。


なぜ -z で割ると近いほど大きくなるのか

x_ndc = x / (-z)

-z は「カメラからの距離」です。

  • 近い(-z が小さい) → 小さい数で割る → x_ndc が大きい → NDC上で広い
  • 遠い(-z が大きい) → 大きい数で割る → x_ndc が小さい → NDC上で狭い

これが透視投影の本質で、人間の目で遠くのものが小さく見えるのと同じ仕組みです。

続き

そのためにNDC空間のライト視点で書くことが必要です。

P_cam×V_camだけを見るとカメラの行列をNDCにもっていくのに意味があるように感じますが、そうではなく、「ライト行列をNDCに変換するために、この(P_cam × V_cam)でNDC空間にしておくことに意味があります」

コード的には以下のようなイメージです。

// ① World → NDC(カメラ透視変換)
var cameraVP = cameraProj * cameraView;

// ② NDC → ライト空間(ライト方向をZ軸に揃える)
var lightViewNDC = ...;

// ③ ライト空間 → シャドウマップUV(正射影)
var gpuLightProj = ...;

// 3つを合成した LVP
return gpuLightProj * lightViewNDC * cameraVP;
//                                   ^^^^^^^
//                                   ここが「NDC化」の担当

もし P_cam × V_cam を入れなかったら

// PSMではなく普通のシャドウマップ
return gpuLightProj * lightViewWorld;
//                    ^^^^^^^^^^^^^
//                    World空間のライト視点だけ

これは「World空間でライト視点のシャドウマップ」 = 普通のシャドウマップです。カメラの透視情報が入っていないので、近景・遠景でテクセル密度が均等になります。

ステップ② ライト方向を NDC 空間に変換

平行光は、カメラの透視を通すと「点光源」と同じ扱いになります。

遠くまでまっすぐ伸びる線路を見ているとき、一つの点に向かって収束するように伸びていきますが、これを消失点と呼び、平行に伸びるものはこの消失点に向かって伸びると考えることができます。

  Vector4 L = new Vector4(lightDirWS.x, lightDirWS.y, lightDirWS.z, 0f); // w=0                                                                                        
  Vector4 ppsLight = camVP * L;                                                                                                                                        
  bool isParallel = Mathf.Abs(ppsLight.w) < 1e-6f;                                                                                                                     

向きベクトルは「無限遠の1点」(線路が収束する先)。それを透視変換にかけると、その点が画面上どこに写るか=消失点が出ます。
そしてその結果は w成分が教えてくれ、
- w ≈ 0 … 透視後もほぼ平行 → 平行光のまま
- w ≠ 0 … 点光源になった → 消失点 = ppsLight.xyz / w
と判定できます。

ふつうの Directional Light は w ≠ 0(点光源)に変換されます。

ステップ③ 「透視後の空間」でライトのカメラを組む

ここから投影行列によって映す範囲を決めます。撮りたいのは「カメラに写っているシーン全部」 = NDC単位立方体ですが、

・範囲が広すぎる -> テクセルが無駄 ・範囲が狭すぎる -> 影が見切れる

なので8頂点がちょうど画面いっぱいに収まるように投影行列を作っています。

囲む対象は「カメラ視錐台=NDC単位立方体の8頂点」。

var cube = new Vector3[8];                                                                                                                                           
GetNdcUnitCubeCorners(cube);  // NDC の [-1,1]³ の8頂点         

ケースA:平行光(w ≈ 0、まれ) — 原点から光方向を見る回転+正射影(Ortho)

Vector3 dir = -(Vector3)ppsLight; 
dir = dir.Normalize();                                                                                                                   
VlPost = MakeViewMatrix(Vector3.zero, dir, PickSafeUp(dir));                                                                                                         
PlPost = BuildOrthoToFitPoints(VlPost, cube);               

PickUpSafe関数は以下のようなもので上方向を安全に取得するものです。安全というのはオブジェクトがVector3.upを見ている時があった場合上方向がVector3.rightになるようにしたものです。

private static Vector3 PickSafeUp(Vector3 dir)                                                                                                                       
{                                                                                                                                                                    
    Vector3 up = Vector3.up;                                  // 基本は世界の上(0,1,0)                                                                               
    if (Mathf.Abs(Vector3.Dot(dir.normalized, up)) > 0.95f)  // 見る向きがほぼ真上/真下なら                                                                          
        up = Vector3.right;                                  // 上を(1,0,0)に切り替え                                                                                
    return up;                                                                                                                                                       
}     

BuildOrthoToFitPoints(VlPost, cube) は写す範囲」を決める(平行投影の投影行列を作る)関数です。

これはステップ③で言った「シーン(8頂点)をちょうど囲む投影行列」を、平行投影(Ortho)で作る関数で以下のようなものです。

  private static Matrix4x4 BuildOrthoToFitPoints(Matrix4x4 view, Vector3[] pointsPost)                                                                                 
  {                                                                                                                                                                    
      // ① 各点を「ライトカメラ視点」の座標に変換して、囲む箱(min/max)を測る                                                                                           
      Vector3 min = +∞, max = -∞;                                                                                                                                      
      foreach (var p in pointsPost) {                                                                                                                                  
          Vector3 pLS = view.MultiplyPoint(p);   // ライト空間へ                                                                                                       
          min = Vector3.Min(min, pLS);                                                                                                                                 
          max = Vector3.Max(max, pLS);                                                                                                                                 
      }                                                                                                                                                                
      // ② 奥行き(near/far)。-Z前方なので depth = -z                                                                                                                   
      float near = Mathf.Max(0.001f, -max.z);                                                                                                                          
      float far  = Mathf.Max(near + 0.001f, -min.z);                                                                                                                   
      // ③ その箱ぴったりの平行投影を作る                                                                                                                              
      return Matrix4x4.Ortho(min.x, max.x, min.y, max.y, near, far);                                                                                                   
  }                                                                                                                                                                    
     

そして最後にMakeViewMatrixですが、これはカメラを置く位置(eye)とカメラが見る先(target)と上を指すVectorを渡してView行列を作成するものです。

private static Matrix4x4 MakeViewMatrix(Vector3 eye, Vector3 target, Vector3 up)                                                                                     
{                                                                                                                                                                    
    Matrix4x4 view = Matrix4x4.LookAt(eye, target, up).inverse;                                                                     
    Matrix4x4 flipZ = Matrix4x4.identity; 
    flipZ.m22 = -1f;       //  +Z前方 → -Z前方 へ                                                                             
    return flipZ * view;                                                                                                                                             
} 
flipZ.m22 = -1f

にする理由ですが、GL.GetGPUProjectionMatrix / Matrix4x4.Perspective / Ortho は全部「-Z前方のビュー空間」を前提にしてる一方、 Matrix4x4.LookAt は「+Z前方のモデル行列」であるからこの式を入れています。

  1. .inverse … モデル行列 → ビュー行列(world→camera)に直す
  2. flipZ … +Z前方 → -Z前方 に流儀を合わせる

上記の2ステップを行うことでfilpZをview関数を-Z前方のビュー空間にする道具として扱っています。

ケースB:点光源(w ≠ 0、通常こっち) — 消失点に目を置き原点を見る+透視投影(Frustum)

Vector3 pp = (Vector3)ppsLight / ppsLight.w;  // 消失点                                                                                              
if (ppsLight.w < 0f) pp.z *= -1f;                                                                                                                                    
Vector3 eye = pp;                                                                                                                                                    
VlPost = MakeViewMatrix(eye, Vector3.zero, PickSafeUp(-eye));                                                                                                        
PlPost = BuildFrustumToFitPoints(ref VlPost, ref eye, cube);        

まず以下の行は消失点を求めています。

Vector3 pp = (Vector3)ppsLight / ppsLight.w;

ppsLight は camVP × (光の向き, w=0) で得た同次座標(Vector4)ですが ppsLight.wによって w で割る(透視除算)ことで同次座標を実際の3D位置に落としています。

その後以下でzの符号を補正します。

if (ppsLight.w < 0f) pp.z *= -1f;   
  • w > 0 … 消失点はカメラの前方で望まれるケースです。
  • w < 0 … 消失点が射影的に後ろ側に来てる(ライトがカメラの方を向いてる系)ため、ここはPSMができないケースになります。

w<0 のとき、透視除算した pp.z の奥行きの符号が逆になってしまうので、pp.z を反転して辻褄を合わせており、ここはw<0の厄介ケースを一応動かすためのハックになってます。

ステップ④ 合成して1本の行列にする

ついに行列の最終合成です。

Matrix4x4 PlGpu       = GL.GetGPUProjectionMatrix(PlPost, true);                                                                                   
Matrix4x4 lightVPpost = PlGpu * VlPost;              // P'_light × V'_light                                                                                          
Matrix4x4 worldToShadowClip = lightVPpost * camVP;   // × カメラVP          

GL.GetGPUProjectionMatrix(PlPost, true) の第2引数が大事です。プラットフォームによってたとえば左下原点のOpenGL用に書いていると左上原点のAPI(Metal/DX/Vulkan)でテクスチャに描画すると、Unityが期待する左下原点と上下が逆さまになってしまうためこの差を吸収します。

影を書く

標準的なシャドウマップでは context.DrawShadows() を使っていましたが、これは Unity内製のライト行列でシャドウを焼く仕組みなので、自前の PSM 行列(LVP)を使えません。

そこで PSM では context.DrawRenderers() に変更し、「ShadowCaster パスを持つオブジェクト」を自分でシャドウマップに描きます。

  private void DrawShadowPSM(ScriptableRenderContext context, CommandBuffer cmd,                                                                                       
                            CullingResults cullingResults, Matrix4x4 worldToShadowClip)                                                                                
  {                                                                                                                                                                    
      cmd.Clear();                                                                                                                                                     
      cmd.SetRenderTarget(LightShadowId);   // 描き先をシャドウマップRTに                                                                                              
      context.ExecuteCommandBuffer(cmd);                                                                                                                               
                                                                                                                                                                       
      // ShadowCaster パスだけを描画                                                                                                                                   
      var sorting = new SortingSettings() { criteria = SortingCriteria.None };                                                                                         
      var draw    = new DrawingSettings(ShadowCasterTag, sorting);                                                                                                     
      var filter  = new FilteringSettings(RenderQueueRange.opaque);                                                                                                    
      context.DrawRenderers(cullingResults, ref draw, ref filter);                                                                                                     
                                                                                                                                                                       
      // 焼いたシャドウマップをグローバルテクスチャとして送る                                                                                                          
      cmd.Clear();                                                                                                                                                     
      cmd.SetGlobalTexture(LightShadow, LightShadowId);                                                                                                                
      context.ExecuteCommandBuffer(cmd);                                                                                                                               
  }

この際_LightVPをShaderで使えるようにグローバルに渡す必要があります。

 // ShadowCaster.shader                                                                                                                                               
  Varyings vert(Attributes i)                                                                                                                                          
  {                                                                                                                                                                    
      Varyings o;                                                                                                                                                      
      // SV_POSITION = _LightVP × unity_ObjectToWorld × 頂点                                                                                                           
      o.positionLVP = TransformObjectToLightViewProjection(i.positionOS);                                                                                              
      return o;                                                                                                                                                        
  }                                                                                                                                                                    

TransformObjectToLightViewProjectionの中身は以下です。

  // Light.hlsl                                                                                                                                                        
  float4 TransformObjectToLightViewProjection(float3 positionOS)                                                                                                       
  {                                                                                                                                                                    
      // グローバル送信した _LightVP を直接かける                                                                                                                      
      return mul(_LightVP, mul(unity_ObjectToWorld, float4(positionOS, 1)));                                                                                           
  }

結果

これによってシャドウマップは以下のように描画されます。

問題

上記によってパースペクティブシャドウマップを書くことができるようになりましたが、PSMには弱点があります。

消失点がカメラの背後にある時に「世話がかかる」ことです。 GPU Gemsでも

 "The usual projective transform moves objects behind the camera to the other side of the infinity plane in post-projective space."                                 
通常の透視変換は、カメラの背後にある物体を、post-projective空間の無限遠平面の向こう側へ移す)                                                                    
                                                                                                                                                                       
"all points behind the infinity plane have w < 0, so they cannot be rasterized. But for another projection transformation caused by a light camera, these points are located behind the camera, so the w coordinate is inverted again and becomes positive."                                                                        
無限遠平面の向こうの点は w<0 でラスタライズできない。しかしライトカメラによる2つ目の投影変換をかけると、それらの点はライトカメラの背後に位置するので、wが再び反転して正に戻る

という工夫が書かれており、Kozlov の解決策として二重の w 反転(負の near 平面)が言及されています。

その証拠にデバッグで以下ようなwが負になっている時にPSMの影が出ません。

[PSM v3] isParallel=False ppsLight.w=-0.2297
     消失点pp=(3.6, -4.9, 0.0) |pp|=6.064 (キューブ内=False)
     clip.w=154.6307  ndc=(-0.959,-0.033,0.384)

また、視線と光源が相対するような位置関係の際、通常のデプスシャドウ技法の影生成で行っていることとほとんど同じになり、やはりジャギーが出てしまうということも起きるとのことです。

ここからは改良型のPSMであるLSPSMにつながるということで次はLSPSMを実装していこうと思います。

参考

news.mynavi.jp

developer.nvidia.com

asura.iaigiri.com

UnrealEngine改造 カスタムシェーディングへ ~ShadingModelの追加~

概要

UnrealEngineで独自のシェーディング表現を使いたいとき、UnealEngineの改造を行うという手法をとれます。 Unityを使用していた時と比べるとパッとRenderingFeatureを追加してパスを増やしたりシェーダーを追加したりすることができる感覚がありましたがUnrealEngineではそうはいきません。

シェーディングモデルを追加すると独自のカスタム表現を行えますし、マテリアルピンの追加・削除を行うことができます。 今回はUE5.4.4からエンジンを改造し、独自のシェーディングモデルを追加していきましょう。

前提条件

この記事では

として改造を行っていきます。

環境整備

まずUnrealEngineのソースコードを取得しなければいけません。以下にアクセスします。

https://www.unrealengine.com/ja/ue-on-github

ここに書いてある通り進めていけば大丈夫です。

EpicとGitHubのアカウント連携が終了すれば以下のようにリポジトリを閲覧することができます。 ここからリポジトリをクローンします。

この後の操作をする前に(容量が大きくなる前に)ブランチは改造を行うブランチに変更しておきましょう。(今回は5.4ブランチにチェックアウト)

その後、Setup.batを叩いて、その後にGenerateProjectFiles.batをたたきます。

VisualStudioで開いた後、UE5をスタートアッププロジェクトに設定し、DevelopmentEditor>Win64を選択した後ソリューションのリビルドを行います。

ビルドは自分のPCでは2-3時間かかったので気長に待ちましょう、、

ビルドを成功したらテンプレートの画面が出てきます。

プロジェクトを新しく作る場合はここから選びますが基本的にエンジンが置かれているディレクトリに配置したほうがパスを通すのが楽です。 プロジェクトを作成してからVisualStudioに戻るとファイルの変更検知が入るので「再読み込み」を選択します。

ソリューションエクスプローラーにプロジェクトが追加されていたらそちらを「スタートアッププロジェクトに設定」します。

ビルドエラー解消

普通にビルドが通ればいいのですが、自分の場合はエラーが出て失敗してしまいました。

いろいろ疑うところは結構多いのですが、一番最初はVisualStudioのバージョンを見てみてください。 自分はVisualStudioが2022 17.11.2を使っていてかなりのエラーが出たのですが、2022 17.11.3にアップデートしたらたくさんのエラーが消えました。

その次にWindowsSDKでWindows11 SDK 10.0.22621.2428をクリーンインストールすることで定数オーバーフローの問題を解決できました。

コードベースでいうと VisualStudioでビルド -> 構成マネージャ で AutoRTFMTests のチェックをすべてはずした状態にしてから以下のコードを追加します。

Engine\Source\Programs\AutoRTFMTests\AutoRTFMTests.Target.cs

// ~~~ (中略) ~~~
    public AutoRTFMTestsTarget(TargetInfo Target) : base(Target)
    {
// ~~~ (中略) ~~~
        bFNameOutlineNumber = true;
        // ビルドエラー解消のため追加
        bBuildInSolutionByDefault = false;
    }
}

参考は以下です。 zenn.dev

ビルドエラーに関しては環境の問題が多いかと思いますが参考になった記事を列挙します。

UE5.4 エンジンビルド時のエラー対応

UE5エラー解決集

5.4 source build errors - #7 by monstercoo - Platform & Builds - Epic Developer Community Forums

Plugin Build fails only on UE5.4 - Platform & Builds - Epic Developer Community Forums

UnrealEngineではまったもの

UE5.1 VS2022 C++ Build Error With MSB3073 - #24 by wr.lex - Platform & Builds - Epic Developer Community Forums

DatasmithAssetUserData.h / UE 5.4.3 / Visual Studio 2022 / Compile error - Programming & Scripting - Epic Developer Community Forums

エンジン改造

ではエンジンを改造していきましょう。

まずはシェーディングモデルの追加です。MaterialShadingModelの略称でMSMをつけるのが慣習となっています。

Runtime/Engine/Classes/Engine/EngineTypes.h

UENUM()
enum EMaterialShadingModel : int
{
    MSM_Unlit                     UMETA(DisplayName = "Unlit"),
    MSM_DefaultLit               UMETA(DisplayName = "Default Lit"),
    MSM_Subsurface               UMETA(DisplayName = "Subsurface"),
    MSM_PreintegratedSkin        UMETA(DisplayName = "Preintegrated Skin"),
    MSM_ClearCoat                UMETA(DisplayName = "Clear Coat"),
    MSM_SubsurfaceProfile        UMETA(DisplayName = "Subsurface Profile"),
    MSM_TwoSidedFoliage           UMETA(DisplayName = "Two Sided Foliage"),
    MSM_Hair                     UMETA(DisplayName = "Hair"),
    MSM_Cloth                    UMETA(DisplayName = "Cloth"),
    MSM_Eye                      UMETA(DisplayName = "Eye"),
    MSM_SingleLayerWater         UMETA(DisplayName = "SingleLayerWater"),
    MSM_ThinTranslucent           UMETA(DisplayName = "Thin Translucent"),
    MSM_Strata                   UMETA(DisplayName = "Substrate", Hidden),

    //// 2025/01/14 @EguchiDaiki
    MSM_ToonLit                  UMETA(DisplayName = "Toon Lit"),
    //// 2025/01/14 @EguchiDaiki

    /** Number of unique shading models. */
    MSM_NUM                      UMETA(Hidden),

    /** Shading model will be determined by the Material Expression Graph,
        by utilizing the 'Shading Model' MaterialAttribute output pin. */
    MSM_FromMaterialExpression   UMETA(DisplayName = "From Material Expression"),

    MSM_MAX
};

シェーディングモデルを取得する関数にも追加したシェーディングモデルを追加しましょう。

Runtime/Engine/Private/Materials/MaterialShader.cpp

FString GetShadingModelString(EMaterialShadingModel ShadingModel)
{
    FString ShadingModelName;
    switch(ShadingModel)
    {
        case MSM_Unlit:                ShadingModelName = TEXT("MSM_Unlit"); break;
        case MSM_DefaultLit:        ShadingModelName = TEXT("MSM_DefaultLit"); break;
        case MSM_Subsurface:        ShadingModelName = TEXT("MSM_Subsurface"); break;
        case MSM_PreintegratedSkin:    ShadingModelName = TEXT("MSM_PreintegratedSkin"); break;
        case MSM_ClearCoat:            ShadingModelName = TEXT("MSM_ClearCoat"); break;
        case MSM_SubsurfaceProfile:    ShadingModelName = TEXT("MSM_SubsurfaceProfile"); break;
        case MSM_TwoSidedFoliage:    ShadingModelName = TEXT("MSM_TwoSidedFoliage"); break;
        case MSM_Hair:                ShadingModelName = TEXT("MSM_Hair"); break;
        case MSM_Cloth:                ShadingModelName = TEXT("MSM_Cloth"); break;
        case MSM_Eye:                ShadingModelName = TEXT("MSM_Eye"); break;
        case MSM_SingleLayerWater:    ShadingModelName = TEXT("MSM_SingleLayerWater"); break;
        case MSM_ThinTranslucent:    ShadingModelName = TEXT("MSM_ThinTranslucent"); break;
    //// 2025/01/14 @EguchiDaiki
        case MSM_ToonLit:            ShadingModelName = TEXT("MSM_ToonLit"); break;
    //// 2025/01/14 @EguchiDaiki
        default: ShadingModelName = TEXT("Unknown"); break;
    }
    return ShadingModelName;
}

1934行に追加です。シェーディングモデルにToonLitが追加されている場合はSetDefineによって定数を立てます。

Runtime/Engine/Private/Materials/HLSLMaterialTranslator.cpp

void FHLSLMaterialTranslator::GetMaterialEnvironment(EShaderPlatform InPlatform, FShaderCompilerEnvironment& OutEnvironment)
{
    if (ShadingModels.IsLit())
    {    
  //// 2025/01/14 @EguchiDaiki
        if (ShadingModels.HasShadingModel(MSM_ToonLit))
  {
     OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_TOON_LIT"), TEXT("1"));
     NumSetMaterials++;
    }
  //// 2025/01/14 @EguchiDaiki
    }
}

Runtime/Engine/Private/Materials/MaterialHLSLEmitter.cpp

ここではTranslatorと同様に処理を行います。

static void GetMaterialEnvironment(EShaderPlatform InPlatform,
    const FMaterial& InMaterial,
    const UE::HLSLTree::FEmitContext& EmitContext,
    const FMaterialCompilationOutput& MaterialCompilationOutput,
    FShaderCompilerEnvironment& OutEnvironment)
{
///// 中略
if (ShadingModels.IsLit())
    {
//// 中略
//// @EguchiDaiki
        if (ShadingModels.HasShadingModel(MSM_ToonLit))
        {
            OutEnvironment.SetDefine(TEXT("MATERIAL_SHADINGMODEL_TOON_LIT"), TEXT("1"));
            NumSetMaterials++;
        }
}
//// @EguchiDaiki

Runtime/RenderCore/Public/ShaderMaterial.h

こちらではToonLitに対応したシェーダーのバリエーションの判定変数を立てます。

struct FShaderMaterialPropertyDefines
{
    //DECLARE_TYPE_LAYOUT(FShaderMaterialPropertyDefines, NonVirtual);
    //void ModifyEnvironment(FShaderCompilerEnvironment& OutEnvironment) const;
    //void WriteFrozenVertexFactoryParameters(FMemoryImageWriter& Writer, const TMemoryImagePtr<FShaderMaterialPropertyDefines>& InPropDefines) const;
    uint8 MATERIAL_ENABLE_TRANSLUCENCY_FOGGING : 1;
    uint8 MATERIALBLENDING_ANY_TRANSLUCENT : 1;
    uint8 MATERIAL_USES_SCENE_COLOR_COPY : 1;
    uint8 MATERIALBLENDING_MASKED_USING_COVERAGE : 1;
    uint8 MATERIAL_COMPUTE_FOG_PER_PIXEL : 1;
    uint8 MATERIAL_SHADINGMODEL_UNLIT : 1;
    uint8 MATERIAL_SHADINGMODEL_DEFAULT_LIT : 1;
    uint8 MATERIAL_SHADINGMODEL_SUBSURFACE : 1;
    uint8 MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN : 1;
    uint8 MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE : 1;
    uint8 MATERIAL_SHADINGMODEL_CLEAR_COAT : 1;
    uint8 MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE : 1;
    uint8 MATERIAL_SHADINGMODEL_HAIR : 1;
    uint8 MATERIAL_SHADINGMODEL_CLOTH : 1;
    uint8 MATERIAL_SHADINGMODEL_EYE : 1;
    uint8 MATERIAL_SHADINGMODEL_SINGLELAYERWATER : 1;
    uint8 SINGLE_LAYER_WATER_SEPARATED_MAIN_LIGHT : 1;
    uint8 MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT : 1;
//// @EguchiDaiki
    uint8 MATERIAL_SHADINGMODEL_TOON_LIT : 1;
//// @EguchiDaiki

Runtime/Engine/Private/ShaderCompiler/ShaderGenerationUtil.cpp

TranslatorやEmitterでSetDefineした場合、ShaderMaterial.hに定義したMATERIAL_SHADING_MODEL_LITにSetDefineした値が格納されます。 マクロであるFETCH_COMPILE_BOOLを立てます。

void FShaderCompileUtilities::ApplyFetchEnvironment(FShaderMaterialPropertyDefines& SrcDefines, FShaderCompilerEnvironment& OutEnvironment)
{
    FETCH_COMPILE_BOOL(MATERIAL_ENABLE_TRANSLUCENCY_FOGGING);
    FETCH_COMPILE_BOOL(MATERIALBLENDING_ANY_TRANSLUCENT);
    FETCH_COMPILE_BOOL(MATERIAL_USES_SCENE_COLOR_COPY);
    FETCH_COMPILE_BOOL(MATERIALBLENDING_MASKED_USING_COVERAGE);
    FETCH_COMPILE_BOOL(MATERIAL_COMPUTE_FOG_PER_PIXEL);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_UNLIT);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_DEFAULT_LIT);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_SUBSURFACE);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_CLEAR_COAT);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_HAIR);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_CLOTH);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_EYE);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_SINGLELAYERWATER);
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT);
//// @EguchiDaikoi
    FETCH_COMPILE_BOOL(MATERIAL_SHADINGMODEL_TOON_LIT);
//// @EguchiDaiki

Runtime/Engine/Private/ShaderCompiler/ShaderGenerationUtil.cpp

バリエーションに対応してシェーディングモデルが使用するGBUFFERスロットを指定します。Slotに格納した情報をもとにGBUFFERのエンコード、デコードしたシェーダコードが生成されます。

static void DetermineUsedMaterialSlots(
    bool Slots[],
    const FShaderMaterialDerivedDefines& Dst,
    const FShaderMaterialPropertyDefines& Mat,
    const FShaderLightmapPropertyDefines& Lightmap,
    const FShaderGlobalDefines& SrcGlobal,
    const FShaderCompilerDefines& Compiler,
    ERHIFeatureLevel::Type FEATURE_LEVEL)
{
    //// 中略
    // doesn't write to GBuffer
    if (Mat.MATERIAL_SHADINGMODEL_THIN_TRANSLUCENT)
    {
    }
//// @EguchiDaiki
    if (Mat.MATERIAL_SHADINGMODEL_TOON_LIT)
    {
        SetStandardGBufferSlots(Slots, bWriteEmissive, bHasTangent, bHasVelocity, bHasStaticLighting, bIsStrataMaterial);
        Slots[GBS_CustomData] = bUseCustomData;
    }
//// @EguchiDaiki

Shaders/Private/Definitions.usf

定義ファイルに未定義の場合の処理を担保させるために追記します。

#ifndef MATERIAL_SHADINGMODEL_UNLIT
#define    MATERIAL_SHADINGMODEL_UNLIT                        0
#endif
#ifndef MATERIAL_SHADINGMODEL_SINGLELAYERWATER
#define MATERIAL_SHADINGMODEL_SINGLELAYERWATER            0
#endif
//// @EguchiDaiki
#ifndef MATERIAL_SHADINGMODEL_TOON_LIT
#define MATERIAL_SHADINGMODEL_TOON_LIT                    0
#endif
//// @EguchiDaiki

Shaders/Private/ShadingCommon.ush

シェーディングモデルIDのナンバリングをEngineTypes.hで定義したEMaterialShadingModelと一致させます。

// SHADINGMODELID_* occupy the 4 low bits of an 8bit channel and SKIP_* occupy the 4 high bits
#define SHADINGMODELID_UNLIT                0
#define SHADINGMODELID_DEFAULT_LIT            1
#define SHADINGMODELID_SUBSURFACE            2
#define SHADINGMODELID_PREINTEGRATED_SKIN    3
#define SHADINGMODELID_CLEAR_COAT            4
#define SHADINGMODELID_SUBSURFACE_PROFILE    5
#define SHADINGMODELID_TWOSIDED_FOLIAGE        6
#define SHADINGMODELID_HAIR                    7
#define SHADINGMODELID_CLOTH                8
#define SHADINGMODELID_EYE                    9
#define SHADINGMODELID_SINGLELAYERWATER        10
#define SHADINGMODELID_THIN_TRANSLUCENT        11
#define SHADINGMODELID_STRATA                12        // Temporary while we convert everything to Strata
//// @EguchiDaiki
#if 0
#define SHADINGMODELID_NUM                    13
#else
#define SHADINGMODELID_TOON_LIT                13
#define SHADINGMODELID_NUM                    14
#endif
//// @ EguchiDaiki
#define SHADINGMODELID_MASK                    0xF        // 4 bits reserved for ShadingModelID    
//// @EguchiDaiki

次にデバッグ用途で使用されるシェーディングモデルIDの色を取得する関数にも色の定義を行います。 PS4ではswitch文の最適化が不適切なためにPS4用にif文の処理とそれ以外で使用するswitchの2種類を用意します。

#define SSS_PROFILE_ID_INVALID 256
#define SSS_PROFILE_ID_PERPIXEL 512
// for debugging and to visualize
float3 GetShadingModelColor(uint ShadingModelID)
{
    // TODO: PS4 doesn't optimize out correctly the switch(), so it thinks it needs all the Samplers even if they get compiled out
    //    This will get fixed after launch per Sony...
#if PS4_PROFILE
         if (ShadingModelID == SHADINGMODELID_UNLIT) return float3(0.1f, 0.1f, 0.2f); // Dark Blue
    else if (ShadingModelID == SHADINGMODELID_DEFAULT_LIT) return float3(0.1f, 1.0f, 0.1f); // Green
    else if (ShadingModelID == SHADINGMODELID_SUBSURFACE) return float3(1.0f, 0.1f, 0.1f); // Red
    else if (ShadingModelID == SHADINGMODELID_PREINTEGRATED_SKIN) return float3(0.6f, 0.4f, 0.1f); // Brown
    else if (ShadingModelID == SHADINGMODELID_CLEAR_COAT) return float3(0.1f, 0.4f, 0.4f);
    else if (ShadingModelID == SHADINGMODELID_SUBSURFACE_PROFILE) return float3(0.2f, 0.6f, 0.5f); // Cyan
    else if (ShadingModelID == SHADINGMODELID_TWOSIDED_FOLIAGE) return float3(0.2f, 0.2f, 0.8f); // Blue
    else if (ShadingModelID == SHADINGMODELID_HAIR) return float3(0.6f, 0.1f, 0.5f);
    else if (ShadingModelID == SHADINGMODELID_CLOTH) return float3(0.7f, 1.0f, 1.0f);
    else if (ShadingModelID == SHADINGMODELID_EYE) return float3(0.3f, 1.0f, 1.0f);
    else if (ShadingModelID == SHADINGMODELID_SINGLELAYERWATER) return float3(0.5f, 0.5f, 1.0f);
    else if (ShadingModelID == SHADINGMODELID_THIN_TRANSLUCENT) return float3(1.0f, 0.8f, 0.3f);
    else if (ShadingModelID == SHADINGMODELID_STRATA) return float3(1.0f, 1.0f, 0.0f);
//// @EguchiDaiki
    else if (ShadingModelID == SHADINGMODELID_TOON_LIT) return float3(1.0f, 0.4f, 0.0f);
//// @EguchiDaiki
    else return float3(1.0f, 1.0f, 1.0f); // White
#else
    switch(ShadingModelID)
    {
        case SHADINGMODELID_UNLIT: return float3(0.1f, 0.1f, 0.2f); // Dark Blue
        case SHADINGMODELID_DEFAULT_LIT: return float3(0.1f, 1.0f, 0.1f); // Green
        case SHADINGMODELID_SUBSURFACE: return float3(1.0f, 0.1f, 0.1f); // Red
        case SHADINGMODELID_PREINTEGRATED_SKIN: return float3(0.6f, 0.4f, 0.1f); // Brown
        case SHADINGMODELID_CLEAR_COAT: return float3(0.1f, 0.4f, 0.4f); // Brown
        case SHADINGMODELID_SUBSURFACE_PROFILE: return float3(0.2f, 0.6f, 0.5f); // Cyan
        case SHADINGMODELID_TWOSIDED_FOLIAGE: return float3(0.2f, 0.2f, 0.8f); // Cyan
        case SHADINGMODELID_HAIR: return float3(0.6f, 0.1f, 0.5f);
        case SHADINGMODELID_CLOTH: return float3(0.7f, 1.0f, 1.0f);
        case SHADINGMODELID_EYE: return float3(0.3f, 1.0f, 1.0f);
        case SHADINGMODELID_SINGLELAYERWATER: return float3(0.5f, 0.5f, 1.0f);
        case SHADINGMODELID_THIN_TRANSLUCENT: return float3(1.0f, 0.8f, 0.3f);
        case SHADINGMODELID_STRATA: return float3(1.0f, 1.0f, 0.0f);
    //// @EguchiDaiki
        case SHADINGMODELID_TOON_LIT: return float3(1.0f, 0.4f, 0.0f);
    //// @EguchiDaiki
        default: return float3(1.0f, 1.0f, 1.0f); // White
    }
#endif
}

Shaders/Private/ShadingModels.ush

ToonLitの独自シェーディングモデルを使用している際のライティング処理を追加します。

独自のライティング処理はこの新しく追加した関数にいろいろと定義していきますが一旦は何もせず黒を返します。

FDirectLighting PreintegratedSkinBxDF( FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, half NoL, FAreaLight AreaLight, FShadowTerms Shadow )
{
    FDirectLighting Lighting = DefaultLitBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
    
    half3 SubsurfaceColor = ExtractSubsurfaceColor(GBuffer);
    half Opacity = GBuffer.CustomData.a;
    half3 PreintegratedBRDF = Texture2DSampleLevel(View.PreIntegratedBRDF, View.PreIntegratedBRDFSampler, float2(saturate(dot(N, L) * .5 + .5), 1 - Opacity), 0).rgb;
    Lighting.Transmission = AreaLight.FalloffColor * Falloff * PreintegratedBRDF * SubsurfaceColor;
    return Lighting;
}
//// @EguchiDaiki
FDirectLighting ToonLitBxDF(FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, half NoL, FAreaLight AreaLight, FShadowTerms Shadow)
{
    FDirectLighting Lighting = (FDirectLighting)0;
    return Lighting;
}
//// @EguchiDaiki

その独自のライティング関数を使用するため以下のように処理を追加します。

FDirectLighting IntegrateBxDF( FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, half NoL, FAreaLight AreaLight, FShadowTerms Shadow )
{
    switch( GBuffer.ShadingModelID )
    {
        case SHADINGMODELID_DEFAULT_LIT:
        case SHADINGMODELID_SINGLELAYERWATER:
        case SHADINGMODELID_THIN_TRANSLUCENT:
            return DefaultLitBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_SUBSURFACE:
            return SubsurfaceBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_PREINTEGRATED_SKIN:
            return PreintegratedSkinBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_CLEAR_COAT:
            return ClearCoatBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_SUBSURFACE_PROFILE:
            return SubsurfaceProfileBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_TWOSIDED_FOLIAGE:
            return TwoSidedBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_HAIR:
            return HairBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_CLOTH:
            return ClothBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
        case SHADINGMODELID_EYE:
            return EyeBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
    //// @EguchiDaiki
        case SHADINGMODELID_TOON_LIT:
            return ToonLitBxDF( GBuffer, N, V, L, Falloff, NoL, AreaLight, Shadow );
    //// @EguchiDaiki
        default:
            return (FDirectLighting)0;
    }
}

Shaders/Private/ReflectionEnvironmentPixelShader.usf

一旦環境光や反射表現の計算をスキップするようにします。 またシェーダー内部の仕様が決まったらこちらも詰めて書いていきます。

void ReflectionEnvironmentSkyLighting(
    in float4 SvPosition : SV_Position,
    out float4 OutColor : SV_Target0
#if STRATA_OPAQUE_ROUGH_REFRACTION_ENABLED
    , out float3 OutOpaqueRoughRefractionSceneColor : SV_Target1
    , out float3 OutSubSurfaceSceneColor : SV_Target2
#endif
    )
{
    uint2 PixelPos = SvPosition.xy;
    float2 BufferUV = SvPositionToBufferUV(SvPosition);
    float2 ScreenPosition = SvPositionToScreenPosition(SvPosition).xy;
    
    OutColor = 0.0f;
#if STRATA_OPAQUE_ROUGH_REFRACTION_ENABLED
    OutOpaqueRoughRefractionSceneColor = 0.0f;
    OutSubSurfaceSceneColor = 0.0f;
#endif
#if STRATA_ENABLED
//// 中略
#else // STRATA_ENABLED
    // Sample scene textures.
    FGBufferData GBuffer = GetGBufferDataFromSceneTextures(BufferUV);
    uint ShadingModelID = GBuffer.ShadingModelID;
//// @EguchiDaiki
#if 0
    const bool bUnlitMaterial = ShadingModelID == SHADINGMODELID_UNLIT;
#else
    const bool bUnlitMaterial = ShadingModelID == SHADINGMODELID_UNLIT || ShadingModelID == SHADINGMODELID_TOON_LIT;
#endif
//// @EguchiDaiki
    float3 DiffuseColor = GBuffer.DiffuseColor;
    float3 SpecularColor = GBuffer.SpecularColor;
    RemapClearCoatDiffuseAndSpecularColor(GBuffer, ScreenPosition, DiffuseColor, SpecularColor);

結果

上記を行ってリビルドを実行します。

ビルドが成功した後、プロジェクトを開きMaterialEditorからシェーディングモデルを確認するとToonLitが追加されていたことを確認します。

ただ、これを使用してもライティングは未実装なので真っ黒になるかと思います。

次はライティングの内部、そしてトゥーンシェーディングの実装をどんどん行っていきます。

参考文献

cgworld.jp

UnityでSRPからデプスシャドウを描く ~複数のDirectionalLightの影を描く~

概要

前記事ではUnityでステンシルボリュームシャドウを描画する方法について書きました。

daiki-eguchi.hatenablog.com

昨今のGPUではテクセルフィルレートが劇的に向上したことで昨今主流となったのがデプスシャドウ技法のようです。 参考: 3Dグラフィックス・マニアックス(24) 影の生成(5)~デプスシャドウ技法 | マイナビニュース

テクセルフィルレートというのはテクスチャを構成する単位のテクセルの処理速度をいい、処理速度が昨今早くなったことから主流になった手法ということですね。

デプスシャドウ技法とは

光源を仮想の視点と捉えて光源から光が当たっているかどうかをシャドウマップに書き込む。 その後、シャドウマップを投影テクスチャマッピングの用法でオブジェクトに貼り付けるイメージで描画することで影が描画される仕組みです。

光源から光が届いているかどうかをバッファに書き込む

ではどのようにして「光が当たっているかどうか」を書き込むかです。

カメラから見て、レンダリングしようとしているピクセルと光源までの距離をdとして、光源からそのピクセルまで飛ばしたRayが衝突したところまでの距離をsとします。

以下のように2点考えると、s=dとなるところはレンダリングしようとしているピクセルと光源の間に遮蔽物がなかったことになるので「影ではない」ということになります。

d>sとなるところではレンダリングしようとしているピクセルと光源の間に遮蔽物があったことになるので「影」ということになります。

Unityでデプスシャドウ技法を行う

Unityでデプスシャドウ技法を行うとき、シャドウマップの描画はUnityがよしなにやってくれます。ただ今回はせっかくなのでSRPからシャドウマップの描画自体も行ってみましょう。

SRPからデプスシャドウ技法を行う

SRPからデプスシャドウ技法を行っていこうと思いますが、素晴らしい記事があるのでこちらを参照してください。 この記事を実装するとDirectionalLight1つのシャドウを描画することができます。

今回は、僭越ながらこの記事を実装した前提で複数のDirectionalLightの影を描くように実装してみます。

qiita.com

qiita.com

成果物

まずは今回の成果物です。DirectionalLightを4つおいて様々な方向を向かせていますがどのライトからのシャドウも描画されています。

シャドウマップの描画

では複数のDirectionalLightの影を落とすときはどのようにしたらよいのでしょうか。

今回、シャドウマップは以下のように描画されています。(シャドウオンのライトが4つの時)

これは、大きなシャドウマップに対して以下のようにそれぞれのライトのシャドウを分割して描画されています。

今回、影を落とせるライトの最大数を4つとしたので4つにシャドウマップを分けています。しかし、最大数が4だとしてもシーンにDirectionalLightが1つのときにこの分割数だとシャドウの品質が下がってしまいます。デプスシャドウによるシャドウの品質はシャドウマップの解像度と直結するからです。

そのため、DirectionalLightが1つの時は以下のように分けずにシャドウを描画します。

実装

コード全文は長いので折りたたみました。大事なところは抽出して説明いたします。

複数のDirectionalLightの影を描画するため、C#側は大きく二つのことを行います。ひとつは上記でお話ししたシャドウマップ描画のタイリング。そしてもう一つはライトのパラメーターを複数シェーダーに渡すために行う配列化です。

シャドウマップのタイリング

上記で説明したようにライトが1つだったときと2~4つだった時でシャドウのタイリングが変わります。Render関数内でその判定を行っています。

ライトの個数が1つの時はRenderPipelineAsset側で指定したシャドウ解像度が分割後のタイルサイズと一致するように。つまりシャドウマップ用のバッファ全体に影を描画できるようにします。

ライトの個数が2つ以上の時はRenderPipelineAsset側で指定したシャドウ解像度を2分割し、タイルのサイズとします。これによって、一枚のシャドウマップ用バッファに4つのライトシャドウ情報を入れることができます。

シャドウのサイズを2分の1にすれば4つのライト情報をシャドウマップ1枚に収めることができる

var lightIndexes = SearchLightIndexes(cullingResults, LightType.Directional);
int splitCount = lightIndexes.Count <= 1 ? 1 : 2;
float tileSize = shadowResolution / splitCount;

シャドウマップへの描画はDrawShadowで行っています。上記で説明したシャドウマップの分割を以下のSetTileViewport(cmd, index, split, tileSize, shadowResolution);で行っています。

private void DrawShadow(ScriptableRenderContext context, CommandBuffer cmd, CullingResults cullingResults,
        int lightIndex, int index, int split, float tileSize, Light light, int shadowResolution, float shadowDistance)
{
    cmd.Clear();
    cmd.SetRenderTarget(LightShadowId);

    // この関数でシャドウマップの描画のタイリングを行っている
    SetTileViewport(cmd, index, split, tileSize, shadowResolution);
   // 以下略
}

SetTileViewportの中身は大事なTile分割です。

各ライトのシャドウに対してoffsetと名付けたVector2をフィールド変数に保存しています。各ライトがシャドウマップのどこに描画しているかを表すものです。これが必要なのはShader側も、どのライトのシャドウ情報が一枚のシャドウマップのどこからどこまでにあるのかを知る必要があるためです。

実際にタイリングしているのはcmd.SetViewport(new Rect(offset.x * tileSize, offset.y * tileSize, tileSize, tileSize));の部分です。

private Vector2 SetTileViewport(CommandBuffer cmd, int index, int split, float tileSize, int shadowResolution)
{
    Vector2 offset = new Vector2(index % split, index / split);
    Vector2 tileOffset = offset * tileSize;
    Offsets[index] = tileOffset / shadowResolution;
    cmd.SetViewport(new Rect(offset.x * tileSize, offset.y * tileSize, tileSize, tileSize));

    return offset;
}

ライト情報の配列化

参考の記事でfloat定義であったLightのパラメーターを配列に変更します。複数のライトがあり、それぞれにBias等の設定があるのでそれを反映するためになります。

private readonly float[] LightShadowBiases = new float[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
private readonly float[] LightShadowNormalBiases = new float[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];
private readonly float[] LightShadowDistanceSqrts = new float[MAX_SHADOWED_DIRECTIONAL_LIGHT_COUNT];

そして、ループを回してすべてのライトのShadowMapを描いた後にパラメーターをShader側に渡すように変更しています。ライトの情報をすべて配列に格納し終えてからShaderに渡すためです。

if (existValidLight)
{
    SetupLightRT(context, cmd, shadowResolution, lightIndexes.Count);
                
    for (int i = 0; i < lightIndexes.Count; i++)
    {
        var lightIndex = lightIndexes[i];
        var light = cullingResults.visibleLights[lightIndex];

        var lightVP = CalcLightViewProjection(cullingResults, lightIndex);
                    
        SetupLightVP(context, cmd, lightVP);
        DrawShadow(context, cmd, cullingResults, lightIndex, i, splitCount, tileSize, light.light, shadowResolution, shadowDistance);   
    }
                
    SetupLightParameters(context, cmd);
}

オブジェクトシェーダー側

オブジェクトにアタッチするシェーダー側の変更点もC#側の変更に合わせて大きく二つです。

一つ目はDirectionalLightが複数あるのでフラグメントシェーダーで複数のライトの処理を行えるようにループ処理させることです。以下はフラグメントシェーダー内でループ処理している部分の抽出です。

for (int t = 0; t < _DirectionalLightCount; t++)
{
    float dotNL = max(0, dot(normalize(i.normalWS), _LightDirs[t]));

    half3 diffuse = _LightColors[t] * dotNL;
    float shadowAttenuation = GetShadowAttenuation(i.positionWS, dotNL, t, _DirectionalLightCount);

    color *= diffuse * shadowAttenuation;
}

そしてもう一つはシャドウマップのサンプリング位置の調整です。以下のコードはシャドウを求めているところですが、参考の記事と比べてライトのパラメーターが配列化しているのでその変更部分。そして各ライトのシャドウがシャドウマップ内のどこにあるのかをC#側からもらっているのでサンプリング位置の調整が入っています。

float GetShadowAttenuation(float4 positionWS, float dotNL, int tileIndex, int split)
{
    // ワールド空間からLVP空間へと変換する
    float4 positionLVP = TransformWorldToLightViewProjection(positionWS, tileIndex);
    // LVP空間からシャドウマップのUV座標へと変換する
    float2 samplingUv = 0;
    float2 uv = positionLVP.xy / positionLVP.w * float2(0.5f, -0.5f) + 0.5f;

    float scale = split == 1 ? 1 : 0.5;
    samplingUv = Offsets[tileIndex] + uv * scale;

    float3 positionVS = mul(unity_MatrixV, positionWS);
    float distanceSqrt = dot(positionVS.xyz, positionVS.xyz);

    float zInLVP = CalcLightViewProjectionDepth(positionLVP);
    float zInShadow = tex2D(_LightShadow, samplingUv.xy).r;
    float bias = _ShadowNormalBiases[tileIndex] * tan(acos(dotNL)) + _ShadowBiases[tileIndex];
    float attenuation = zInLVP - bias > zInShadow ? 0 : 1;

    return samplingUv.x > 0 && samplingUv.x < 1 && samplingUv.y > 0 && samplingUv.y < 1 && distanceSqrt <= _ShadowDistanceSqrts[tileIndex] ? attenuation : 1;
}

サンプリング位置の調整は以下の部分です。uvをだしたところは投影テクスチャマッピングと同じように算出していますが、その値にscaleをかけることとオフセットをつけることでサンプリング位置を調整しています。

float2 samplingUv = 0;
float2 uv = positionLVP.xy / positionLVP.w * float2(0.5f, -0.5f) + 0.5f;

float scale = split == 1 ? 1 : 0.5;
samplingUv = Offsets[tileIndex] + uv * scale;

最後に

上記によって以下のように複数のDirectionalLightのシャドウを描くことができました。

1つのDirectionalLightのシャドウを描くときも同様ですが、うまくシャドウが描画できないときは各ライトコンポーネントのBiasやStrength等を調整してみてください。

参考

catlikecoding.com

news.mynavi.jp

qiita.com

qiita.com

鈴木直美の「PC Watch先週のキーワード」

HDRPでカスタムShader -レイヤーが一致するDirectionalLightの影響を受ける・レイヤーで指定したオブジェクトの影を落とす-

はじめに

以前HDRPでカスタムシェーダーを作成し、DirectionalLightの影を落とす・受け取る方法について書きました。

daiki-eguchi.hatenablog.com

今回はHDRPで複数のDirectionalLightがあった際にレイヤーを設定することでレイヤーが合っているものだけ影響を受ける方法についてです。

開発環境

  • Unity2022.3.4f1
  • HDRP14.08

注意点

まず、HDRPではカスケードシャドウを使用している時、複数のDirectionalLightの影を落とすことはできなさそうです。 なので一灯のDirectionalLightとは別角度の影を落としたいときはSpotLightを使用したりしています。(SpotLightでは複数方向の影を落とすことができる)

DirectionalLightを複数Shadowオンにすると出るエラー

Lightのレイヤー設定

DIrectionalLightにはLayerを設定することができ、そのライトの影響を及ぼすレイヤー分けを行うことができます。 このレイヤーを設定することでどのライトがどのオブジェクトに影響を及ぼすか。ということを設定することができます。

LightのLayerを設定する

このレイヤーはRendererのAdditionalSettings欄にあるRenderingLayerMaskと対応しています。

オブジェクト側のRenderingLayer

もし、Light側のLayer設定がない場合は以下のように「Show All Additional Properties」をクリックし、開いたPreferenceの「CoreRenderPipeline」からVisibilityをAllVisibleに設定することで確認できるかと思います。

実装

今回、以下のようにDirectionalLightを複数用意し、RenderingLayerをそれぞれ「0」と「1」に設定します。

前記事ではDirectionalLightは一つと考えていたので以下のように指定していました。DirectionalLightやスポットライトの情報はStructuredBufferとして登録されています。

DirectionalLightData directionalLightData = _DirectionalLightDatas[0];

DirectionalLightの数はDirectionalLightCountとして設定されているため、DirectionalLightDatasを順番にfor分で回し、レイヤーを判定します。

Shaderのフラグメントシェーダー部分を以下のようにします。

float4 frag (v2f i) : SV_Target
{
    float4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                
    for (int lightCount = 0; lightCount < _DirectionalLightCount; lightCount++)
    {
        if(IsMatchingLightLayer(_DirectionalLightDatas[lightCount].lightLayers, 1 << 1))
        {
            DirectionalLightData directionalLightData = _DirectionalLightDatas[lightCount];

            float NdotL = saturate(dot(i.normal, -directionalLightData.forward));

            HDShadowContext shadowContext = InitShadowContext();
            float3 shadow = GetDirectionalShadowAttenuation(
                        shadowContext,
                        i.positionSS.xy,
                        i.positionWS,
                        i.normal,
                        directionalLightData.shadowIndex,
                       -directionalLightData.forward);

             mainColor.rgb *= directionalLightData.color * NdotL * shadow;
         }
    }
                
    return mainColor;
}

まず、for文でDirectionalLightを回します。最大数は_DirectionalLightCountになります。

for (int lightCount = 0; lightCount < _DirectionalLightCount; lightCount++)

そして、IsMatchingLightLayerメソッドでLightレイヤーとオブジェクトのRenderingLayerとあっているかを判定します。

LightLayerは_DirectionalLightDatas[lightCount].lightLayersになり、複数を設定することが可能です。以下のように書くとレイヤーが1のもののみ影響を受けるようになります。

if(IsMatchingLightLayer(_DirectionalLightDatas[lightCount].lightLayers, 1 << 1))

この関数を使用するときは以下のインクルードを行う必要があることに注意してください。

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonLighting.hlsl"

上記によってLayerが0に設定しているライトを緑、Layerを1に設定しているライトを黄色にしていますが、Layerを1の黄色の影響をうけています。

レイヤーが1の黄色の影響を受けている

しかし、影が落ちていません。

影が落ちていない

影を落とす対象オブジェクトのレイヤーを設定する

これはデフォルトではLightLayerと一致しているレイヤーの影を落とそうとするので影が落ちていないのが原因になります。

HDRPには影を落とす対象のオブジェクトのレイヤーを設定することができるのですが、これがなにもしないとLightLayerの設定値になるので(試してみた感じ)、今LightLayerを1に設定しているためにRenderingLayerが1の影を落とそうとしています。 現在、CubeのRenderingLayerは0なので、0の影を落とす必要があります。

そのため、以下のようにLightコンポーネントのShadow欄にある「CustomShadowLayer」をオンにして影を落としたいオブジェクトのレイヤーを指定します。 今回はCubeやPlaneのレイヤーは0なのでCustomShadowLayerは0にします。

この設定を行うと以下のようにしっかりとシャドウが描画されます。

無事に影が描画された

最後に

これによって複数のDirectionalLightがあったときにレイヤー指定したライトの影響を受けるように。そしてレイヤー分けで狙った影を描画することができました。

次はHDRPのカスタムシェーダーで記載するDepthPrepassについて記事を書こうかと思っています。

HDRPでカスタムShader -影を落とす・影を受け取る-

はじめに

以前HDRPでカスタムシェーダーを作成する方法をUnlit形式で紹介しました。

daiki-eguchi.hatenablog.com

今回はHDRPで影を出す、影を受け取る方法についてです。

最小限ということで影響を受ける光はまず「1つのみ」とします。

開発環境

  • Unity2022.3.4f1
  • HDRP14.08

成果物

最終的に以下のような見た目になります。PlaneとCube二つには同じShaderを使っており、落ち影やほかの落ち影の影響をうけることが確認できます。

Shader

まずはShader全文です。見慣れないincludeファイルがありますが一つ一つ解説していきます。

Shader "Custom/SampleLit"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags 
        {
            "RenderType"="Opaque"
            "RenderPipeline"="HDRenderPipeline"
        }
        LOD 100

        Pass
        {
            Tags {"LightMode" = "Forward"}
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/LightLoop/HDShadow.hlsl"

            #pragma multi_compile SHADOW_LOW SHADOW_MEDIUM SHADOW_HIGH SHADOW_VERY_HIGH
            #pragma multi_compile_fragment AREA_SHADOW_MEDIUM AREA_SHADOW_HIGH
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/Material/NormalBuffer.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 normal : NORMAL;
                float4 positionSS : TEXCOORD1;
                float3 positionWS : TEXCOORD2;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.normal = TransformObjectToWorldNormal(v.normal);
                
                float4 ss = o.vertex * 0.5f;
                ss.xy = float2(ss.x, ss.y * _ProjectionParams.x) + ss.w;
                ss.zw = o.vertex.zw;
                o.positionSS = ss;
                
                o.positionWS = TransformObjectToWorld(v.vertex);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                DirectionalLightData directionalLightData = _DirectionalLightDatas[0];

                HDShadowContext shadowContext = InitShadowContext();
                float3 shadow = GetDirectionalShadowAttenuation(
                    shadowContext,
                    i.positionSS.xy,
                    i.positionWS,
                    i.normal,
                    directionalLightData.shadowIndex,
                    -directionalLightData.forward);

                float4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                mainColor.rgb *= shadow.r;
                return mainColor;
            }
            ENDHLSL
        }
        Pass
        {
            Name "ShadowCaster"
            Tags {"LightMode" = "ShadowCaster"}
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_shadowcaster
            #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
            #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert(appdata i)
            {
                v2f o;
                o.pos = TransformObjectToHClip(i.vertex);
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                return 0;
            }
            
            ENDHLSL
        }
    }
}

影を落とす

影を落とすのはURPなどと同様に影を落とすようのパスを追加します。

LightModeをShadowCasterにしないと判別されないので注意です。この辺はURPと同様ですね。

Pass
{
    Name "ShadowCaster"
    Tags {"LightMode" = "ShadowCaster"}
            
     HLSLPROGRAM
     #pragma vertex vert
     #pragma fragment frag
     #pragma multi_compile_shadowcaster
     #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
     #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

     struct appdata
     {
         float4 vertex : POSITION;
         float3 normal : NORMAL;
         float2 texcoord : TEXCOORD0;
      };

      struct v2f
      {
          float4 pos : SV_POSITION;
      };

      v2f vert(appdata i)
      {
          v2f o;
          o.pos = TransformObjectToHClip(i.vertex);
          return o;
      }

      float4 frag(v2f i) : SV_Target
      {
          return 0;
      }
            
      ENDHLSL
}

普段よく使うような関数は以下のインクルードファイルの中に入っていました。

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl"

影を受け取る

影を受け取るのが少し大変だったのですが一つ一つ見てみます。

URPであったようなGetMainLight()やGetAdditionalLight()というような便利関数がHDRPにはありません。

まずはDirectionalLightの取得ですがHDRPでは_DirectionalLightDatasというStructuredBufferにシーン内のDirectionalLightが入ってきます。 実装されているのは以下で示すようなShaderVariablesLightLoop.hlslです。

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/LightDefinition.cs.hlsl"
#include "Packages/com.unity.render-pipelines.high-definition/Runtime/Core/Utilities/GeometryUtils.cs.hlsl"

// don't support Buffer yet in unity
StructuredBuffer<uint>  g_vBigTileLightList;

StructuredBuffer<uint>  g_vLightListTile;
StructuredBuffer<uint>  g_vLightListCluster;

StructuredBuffer<uint>  g_vLayeredOffsetsBuffer;
StructuredBuffer<float> g_logBaseBuffer;

#ifdef USE_INDIRECT
    StructuredBuffer<uint> g_TileFeatureFlags;
#endif

StructuredBuffer<DirectionalLightData> _DirectionalLightDatas;
StructuredBuffer<LightData>            _LightDatas;
StructuredBuffer<EnvLightData>         _EnvLightDatas;

// Used by directional and spot lights
TEXTURE2D(_CookieAtlas);

// Used by cube and planar reflection probes
TEXTURE2D_ARRAY(_ReflectionAtlas);

// Contact shadows
TEXTURE2D_X_UINT(_ContactShadowTexture);

// Screen space shadows
TEXTURE2D_ARRAY(_ScreenSpaceShadowsTexture);

// Indirect Diffuse Texture
TEXTURE2D_X(_IndirectDiffuseTexture);

今回はDirectionalLightが一つということで進めていくので配列の0番目を取得します。

DiectionalLightが複数の時の実装方法はまた別途書こうと思います。

float4 frag (v2f i) : SV_Target
{
    DirectionalLightData directionalLightData = _DirectionalLightDatas[0];

    HDShadowContext shadowContext = InitShadowContext();
    float3 shadow = GetDirectionalShadowAttenuation(
                    shadowContext,
                    i.positionSS.xy,
                    i.positionWS,
                    i.normal,
                    directionalLightData.shadowIndex,
                    -directionalLightData.forward);
}

DirectionalLightを取得しましたがそこからまた呪文が続いています。

HDShadowContext shadowContext = InitShadowContext();
float3 shadow = GetDirectionalShadowAttenuation(
                    shadowContext,
                    i.positionSS.xy,
                    i.positionWS,
                    i.normal,
                    directionalLightData.shadowIndex,
                    -directionalLightData.forward);

ここがShadowMapから影の情報を取得し、shadowAttenuationを取得する関数です。実はHDRPのLit.shaderの中身での影の受け取りはほかにもいろいろやっているのですが、最小限ではこのぐらいでよさそうです。

InitShadowContext()はHDShadowContext.hlslで定義されている関数でシャドウデータを取得する初期化のような関数です。

以下はHDShadowContext.hlslの切り抜きです。

///////// 中略

StructuredBuffer<HDShadowData>              _HDShadowDatas;
// Only the first element is used since we only support one directional light
StructuredBuffer<HDDirectionalShadowData>   _HDDirectionalShadowData;

HDShadowContext InitShadowContext()
{
    HDShadowContext         sc;

    sc.shadowDatas = _HDShadowDatas;
    sc.directionalShadowData = _HDDirectionalShadowData[0];

    return sc;
}

////// 中略

これらを使用するために必要なファイルをインクルードしています。

#include "Packages/com.unity.render-pipelines.high-definition/Runtime/Lighting/LightLoop/HDShadow.hlsl"

また、以下のマクロを使用しています。

#pragma multi_compile SHADOW_LOW SHADOW_MEDIUM SHADOW_HIGH SHADOW_VERY_HIGH
#pragma multi_compile_fragment AREA_SHADOW_MEDIUM AREA_SHADOW_HIGH

GetDirectionalShadowAttenuationという関数の引数に必要な変数がpositionSS(スクリーンスペース)、positionWS(ワールドスペース)、normal(法線情報)が必要なので頂点シェーダーのほうで格納しています。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    o.normal = TransformObjectToWorldNormal(v.normal);
                
    float4 ss = o.vertex * 0.5f;
    ss.xy = float2(ss.x, ss.y * _ProjectionParams.x) + ss.w;
    ss.zw = o.vertex.zw;
    o.positionSS = ss;
                
    o.positionWS = TransformObjectToWorld(v.vertex);
    return o;
}

最後にShadowの色味をテクスチャの色味に乗算することで今回はシャドウを表現しました。

float4 mainColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
mainColor.rgb *= shadow.r;
return mainColor;

設定

最後にUnityの設定を行います。 上記のシェーダーをCubeとPlaneにアタッチすると以下のようにシャドウが汚く見えます。

これを改善するには、、、!と設定周りを探してみると方法が二つありました。

一つ目はDirectionalLight側の設定を調整する方法です。実はこのシャドウの汚さはShadow Filtering Qualityに由来するものでした。

このShadow Filtering Qualityは以下にドキュメントがありました。

docs.unity3d.com

Highに設定するとパーセンテージ クローザー ソフト シャドウ (PCSS)が働くのでそこが悪さしているのか、、、という感じです。ソフトシャドウの質はAngular DiameterとSampleCountによると記載があるのでそちらを調整します。

スクリーンスペースによるものなのでサンプリング回数を上げれば軽減します。サンプリング回数はライトのほうの設定のShadow欄にある「Filter Sample Count」なのでこちらを上げれば軽減します。

ただ今回はMaxのサンプリング数64にしても軽減しなかったのでAngular Diameter Scale For Softnessを下げました。

もちろんサンプリング回数を上げるということは負荷も上がるということだと思うのでこの辺は二つの変数をいいかんじに調整する必要がありそうですね。

2つ目はProjectSettingsでQuality>HDRP>Shadowsの「Shadow Filtering Quality」をHighからMidiumに変更することです。

HighからMediumにするとそもそもPCSSではなくなるので汚く見えなくなった。というだけになります。

これらの方法のどちらかをとると今回のような見た目に見えます。

おわりに

HDRPで影を落とし、影を受ける方法について書いてみました。

次はライトの影響を受けてシェーディングできる方法について書こうと思います。

Unityでステンシルシャドウボリューム

はじめに

前回、投影テクスチャマッピングシャドウを取り上げました。しかし、投影テクスチャマッピングシャドウは影生成の単位がキャラ単位なので自身の部位から受ける影「セルフシャドウ」を描画することができません。

daiki-eguchi.hatenablog.com

そこでセルフシャドウ表現まで行える技法で比較的互換性が高く、汎用性が高い技法がステンシルシャドウボリュームのようです。

ステンシルシャドウボリュームの理論

まず、視点方向からの深度情報(Zバッファ)を保持しておきます。

その後、以下のようにオブジェクトの輪郭を光源方向(光源からオブジェクトの方向)に伸ばします。

そのあと、以下のように視点からみてシャドウボリュームの表面(前面)をステンシルバッファに+1を書き込みます。 光源が複数あったり、オブジェクトが重なっているときはステンシルバッファでの+1演算が複数になります。

ステンシルバッファは算術処理用のフレームバッファです。

ステンシルバッファはあらかじめ0に初期化しておきます。

そして、視点から見てシャドウボリュームの裏面でステンシルバッファを-1します。

すると影を書く以外が0に戻り、影が生成される場所のステンシルは1以上になります。そのため、ステンシルが1以上の場所に影色つけていきます。

これによってセルフシャドウ表現もでき、モデル同士が影を落としあうこともできます。

成果物

以下のように落ち影が描画できています。

Unityでの実装

今回はURPで描画してみるのでRendererFeatureを使用してみます。 ProjectSettingsで現在使用しているRenderPipelineAssetを探します。

Stencil値の初期化

メニューの下からAdd RendererFeatureをクリックし、RenderObjectsを選択します。

名前をStencilInitializeにしてOverrides内のStencilをオン、ValueをONにしました。 これによって、画面全体のStencilが0になります。

オブジェクトの前面を伸ばし、ステンシル値をあげる

同様にRenderObjectsのRendererFeatureをもう一つ追加します。 命名をStencilIncreaseにして、新しく作成したStencilIncreaseというMaterialをOverrideの欄に入れます。

このMaterialにアサインするShaderは以下のように作成しました。

Shader "Custom/StencilIncrease"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor ("Shadow Color", COlor) = (0, 0, 0, 0.5)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            NAME "StencilFront"
            
            Tags { "LightMode" = "UniversalForward" }
            //カラーチャンネルに書き込むレンダーターゲットを設定する
            //0の場合、全てのカラーチャンネルが無効化され何も書き込まれない
            ColorMask 0
            ZWrite Off
            Cull Back
            ZTest LEqual

            Stencil
            {
                Ref 1
                Comp Always
                Pass IncrSat
            }
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            float _ShadowLength;
            half4 _ShadowColor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                // ライトの取得
                Light light = GetMainLight();

                // 法線をワールド空間に変換
                float3 worldNormal = TransformObjectToWorldNormal(v.normal);
                float nDotL = dot(worldNormal, light.direction);

                // 頂点を押し出す
                float3 vertex2 = v.vertex + TransformWorldToObjectDir(-light.direction) * _ShadowLength;

                o.vertex = TransformObjectToHClip(lerp(v.vertex, vertex2, 1 - step(-0.3, nDotL)));
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return 0;
            }
            ENDHLSL
        }
    }
}

ステンシルと頂点シェーダーがポイントになります。

まずはCullをBack指定し表面を伸ばすようにします。

Cull Back

Stencilを記述するだけなのでカラーチャンネルには何も書き込まれないように設定します。

ColorMask 0

ステンシルは以下のように記述し、必ずステンシルの値を1あげるようにしています。

Stencil
{
    Ref 1
    Comp Always
    Pass IncrSat
}

頂点シェーダー内では自分の頂点からライトの方向に対して伸ばしています。

まずは伸びた先の頂点位置を計算しています。

/ / 頂点を押し出す
float3 vertex2 = v.vertex + TransformWorldToObjectDir(-light.direction) * _ShadowLength;

その後、ライト方向と法線方向の内積から判断して頂点を伸ばしました。

o.vertex = TransformObjectToHClip(lerp(v.vertex, vertex2, 1 - step(-0.3, nDotL)));

オブジェクトの背面を伸ばし、ステンシル値を下げる

RendererFeatureを一つ追加します。RenderObjectsのRendererFeatureを追加し、名前をStencilDecreaseとしました。

Materialを新規で一つ追加し、アサインしたStencilを下げるShaderは以下のように記述しています。

Shader "Unlit/StencilDecrease"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor ("Shadow Color", COlor) = (0, 0, 0, 0.5)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            NAME "StencilBack"
            
            Tags { "LightMode" = "UniversalForward" }
            //カラーチャンネルに書き込むレンダーターゲットを設定する
            //0の場合、全てのカラーチャンネルが無効化され何も書き込まれない
            ColorMask 0
            ZWrite Off
            Cull Front
            ZTest LEqual
            
            Stencil
            {
                Ref 1
                Comp Always
                Pass DecrSat
            }
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };

            float3 _LightPosition;

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            float _ShadowLength;
            half4 _ShadowColor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                // ライトの取得
                Light light = GetMainLight();

                // 法線をワールド空間に変換
                float3 worldNormal = TransformObjectToWorldNormal(v.normal);
                float nDotL = dot(worldNormal, light.direction);

                // 頂点を押し出す
                float3 vertex2 = v.vertex + TransformWorldToObjectDir(-light.direction) * _ShadowLength;

                o.vertex = TransformObjectToHClip(lerp(v.vertex, vertex2, 1 - step(-0.3, nDotL)));
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return 0;
            }
            ENDHLSL
        }
    }
}

Stencilを上げるShaderと比較すると変更点は少ないです。

CullをFront指定することでオブジェクトの背面を伸ばします。

Cull Front

Stencilは以下のように記述してStencilを下げています。変更点はこれぐらいになります。

Stencil
{
    Ref 1
    Comp Always
    Pass DecrSat
}

Stencilをもとに影を描画する

Stencilは上記のRendererFeatureで操作できているので、あとはそれをもとに影を書いていきます。

RendererFeatureをまた一つ増やします。RenderObjectsを追加し、命名をDrawStencilShadowにしました。

Materialを新規で一つ作成して、それにアサインするShaderは以下のように記述しています。

Shader "Unlit/StencilShadowReceiver"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor ("Shadow Color", COlor) = (0, 0, 0, 0.5)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            NAME "StencilShadowReceiver"
            
            Tags { "LightMode" = "ShadowStencil" }
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            
            Stencil
            {
                Ref 1
                Comp Equal
                Pass Keep
            }
            
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            float _ShadowLength;
            half4 _ShadowColor;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex.xyz);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            float4 frag (v2f i) : SV_Target
            {
                return _ShadowColor;
            }
            ENDHLSL
        }
    }
}

Stencilは以下のように記述しており今回は簡単なシーン設定のため、Stencilが1のところに影を書かせています。

Stencil
{
    Ref 1
    Comp Equal
    Pass Keep
 }

フラグメントシェーダーはMaterialから設定した色を描画するようにしています。

float4 frag (v2f i) : SV_Target
{
    return _ShadowColor;
}

最後に

上記によって以下のような影の描画を実現できました。

今回はステンシルシャドウボリュームを書いてみました。

ステンシルシャドウボリュームの弱点は影の生成単位がポリゴン単位なので例えば平面にTextureのアルファ抜きで模様や葉っぱを書いていた時、模様や葉っぱの影はできず平面の影ができてしまいます。

それを解決するために影生成用の引き延ばし用頂点を事前に仕込んでいたそうです。

ただ、これによって頂点シェーダーの負荷が上がるため、それを見越してポリゴン数を下げて品質が下がってしまうということもあったそうです。

参考

news.mynavi.jp

news.mynavi.jp

https://gameprogrammingunit.web.fc2.com/shadow/volume_simple.htm

zenn.dev

qiita.com

Unityで投影テクスチャマッピングシャドウ

はじめに

3Dゲームの影生成は90年代、キャラクターの足元に黒い丸を置いた「丸影」が主流だったようです。

しかし丸影は負荷は軽いですが「影」としてのリアリティはありませんでした。それを進化させたのが「投影テクスチャマッピング」です。

投影テクスチャマッピングの理論

投影テクスチャマッピングによるシャドウは以下のステップで実現できます。

まず、光源から見た(カメラに見立てて)キャラクターのシルエットをRenderTextureに書き出します。

そして、その作成したRenderTextureを影が生成される場所に貼る(マッピング)することで影が落ちているように見えます。

投影テクスチャマッピングの強みと弱み

この技法の強みはほぼすべてのGPUにサポートされることやキャラの影が別のキャラに投影されるようなことも実現する点になることです。

しかし、投影テクスチャマッピングの影の生成単位は「キャラ」であるため自分の部位の影が自分の体に映る「セルフシャドウ」は行えないことが弱点の一つになります。

また、光源からみた影のRenderTextureをどこにマッピングするのか(貼るのか)の判定が甘いと、ありえないところに影が発生してしまうことも弱点の一つのようです。

Unityでの実装例

まずは今回の成果物になります。

まず、光源と同じ位置、同じ回転値にしたカメラを用意します。

そしてこのカメラを以下のように

  • ProjectionをOrthographicに

  • CullingMaskを影を落としたいオブジェクトに設定しているLayerに

  • BackgroundColorをSolidColorにして背景色のアルファ値を0に

という風に設定しました。

スクリプト

そしてカメラにアタッチするC#スクリプトを以下のように作成します。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteAlways]
[RequireComponent(typeof(Camera))]
public class ShadowProjector : MonoBehaviour
{
    [SerializeField] private int renderTextureSize = 512;

    [SerializeField] private RenderTexture renderTexture;

    [SerializeField] private Light directionalLight;

    private Camera camera;

    private int textureSize;

    private void OnEnable()
    {
        camera = GetComponent<Camera>();
        camera.SetReplacementShader(Shader.Find("Custom/ShadowReciever"), null);
        camera.depth = -100000;
        renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
        camera.targetTexture = renderTexture;
        camera.clearFlags = CameraClearFlags.Color;
        camera.backgroundColor = Color.black;
    }

    private void OnDisable()
    {
        if (renderTexture == null)
        {
            return;
        }

        camera.targetTexture = null;
        DestroyImmediate(renderTexture);
    }

    private void LateUpdate()
    {
        textureSize = renderTexture.width;
        #if UNITY_EDITOR
        if (textureSize != renderTextureSize)
        {
            renderTexture.Release();
            renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
            camera.targetTexture = renderTexture;
        }
        #endif
        camera.transform.position = directionalLight.transform.position;
        camera.transform.rotation = directionalLight.transform.rotation;

        var viewMatrix = camera.worldToCameraMatrix;
        // プロジェクション行列のプラットフォームの差を吸収
        var projectionMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
        Shader.SetGlobalMatrix("_ShadowProjectorMatrixVP", projectionMatrix * viewMatrix);
        Shader.SetGlobalTexture("_ShadowProjectorTexture", renderTexture);

        // プロジェクターのポジションを渡す
        var projectPos = Vector4.zero;
        projectPos = camera.orthographic ? transform.forward : transform.position;
        projectPos.w = camera.orthographic ? 0 : 1;
        Shader.SetGlobalVector("_ShadowProjectorPos", projectPos);
    }
}

このC#ではステップ1である光源方向から見た影のRenderTextureを生成するところまでを行い、作成したRenderTextureをShader側に渡しています。

Enable処理では以下コードのようにCameraの設定、RenderTextureの生成、そしてCameraのoutputにあるTargetTextureにRenderTextureを設定するところまでを行っています。

影を描画するカメラは一番初めに描画したいためdepthをかなり小さい値にしています。

private void OnEnable()
{
    camera = GetComponent<Camera>();
    camera.SetReplacementShader(Shader.Find("Custom/ShadowReciever"), null);
    camera.depth = -100000;
    renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
    camera.targetTexture = renderTexture;
    camera.clearFlags = CameraClearFlags.Color;
    camera.backgroundColor = Color.black;
}

LateUpdate内ではShaderに必要な計算を行っています。Shader側でView行列とProjection行列が必要なのでShader側に渡しています。

カメラのView行列はcamera.worldToCameraMatrixで取得し、カメラのProjection行列はcamera.projectionMatrixでとれます。ただ、カメラ空間からクリッピング空間に座標変換(射影変換)はGPUに渡す際、OpenGLとDirectXで差異があるためGL.GetGPUProjectionMatrixでプラットフォームの差異を吸収します。

これは、XYZ座標をW座標で割って正規化する際、Z値を収める範囲がOpenGLは-1~1、DirectXは0~1なのでこのような処理が必要になります。

var viewMatrix = camera.worldToCameraMatrix;
// プロジェクション行列のプラットフォームの差を吸収
var projectionMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
Shader.SetGlobalMatrix("_ShadowProjectorMatrixVP", projectionMatrix * viewMatrix);
Shader.SetGlobalTexture("_ShadowProjectorTexture", renderTexture);

// プロジェクターのポジションを渡す
var projectPos = Vector4.zero;
projectPos = camera.orthographic ? transform.forward : transform.position;
projectPos.w = camera.orthographic ? 0 : 1;
Shader.SetGlobalVector("_ShadowProjectorPos", projectPos);

この状態でカメラのOutputにあるtargetTextureをクリックしRenderTextureの情報を見ると以下のようになっています。

RenderTextureのアルファ値のみを確認すると以下のようになっています。

この白い部分が投影された時の影になる場所になります。

Shader

まずは全文になります。このShaderをアタッチしたMaterialを影を受け取る側のオブジェクトにアタッチすれば影が描画されます。

Shader "Custom/ShadowReciever"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor("ShadowColor", Color) = (0,0,0,0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass
        {
            NAME "ShadowReceiver"
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };
            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 projectorPos : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float3 worldNormal : TEXCOORD2;
            };
            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            TEXTURE2D(_ShadowProjectorTexture);
            SAMPLER(sampler_ShadowProjectorTexture);
            float4x4 _ShadowProjectorMatrixVP;
            float4 _ShadowProjectorPos;
            float4 _ShadowColor;
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex);
                o.projectorPos = mul(mul(_ShadowProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
                o.projectorPos = ComputeScreenPos(o.projectorPos);
                o.worldNormal = TransformObjectToWorldNormal(v.normal);
                o.worldPos = TransformObjectToWorld(v.vertex);
                return o;
            }
            float4 frag (v2f i) : SV_Target
            {
                i.projectorPos.xyz /= i.projectorPos.w;
                float2 uv = i.projectorPos.xy;
                float4 projectorTex = SAMPLE_TEXTURE2D(_ShadowProjectorTexture, sampler_ShadowProjectorTexture, uv);
                float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
                // カメラの範囲外は適用しない
                float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
                float alpha = isOut.x * isOut.y * isOut.z;

                // プロジェクターから見て裏面の面には適用しない
                alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);;
                return lerp(col, 0 , alpha * projectorTex.a);
            }
            ENDHLSL
        }
    }
}

大事になるのは頂点シェーダーです。テクスチャマッピングを行うので流れとしては

  • VP行列を頂点座標にかけて座標変換
  • _ProjectionParams.xをかけることでプラットフォームの違いを吸収する
  • wで除算することで-1~1の範囲に
  • それを0-1に変換する

の流れになります。

(影描画用の)カメラから見たときのCubeやPlaneをTextureに描画させるのでモデル行列に対して影描画用のカメラのビュー行列とプロジェクション行列をかけます。

今回はC#側から渡ってきたVP行列を使って座標変換した後、ComputeScreenPos()を行うことで上の

  • _ProjectionParams.xをかけることでプラットフォームの違いを吸収する
  • それを0-1に変換する(0-wに変換し、後でwでわると0-1の範囲になるようにしている)

を解決しています。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex);
    o.projectorPos = mul(mul(_ShadowProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
    o.projectorPos = ComputeScreenPos(o.projectorPos);
    o.worldNormal = TransformObjectToWorldNormal(v.normal);
    o.worldPos = TransformObjectToWorld(v.vertex);
    return o;
}

ComputeScreenPos()は以下のようにxyが0~wに変換されてyの上下問題を解決してくれるもので、Unityが用意してくれている関数になっています。

inline float4 ComputeNonStereoScreenPos(float4 pos) {
    // この時点でxyは-wからwの値を取る(MVP変換後だから)
    // xyzwに0.5をかけるのでxyは-0.5w~0.5wになる
    float4 o = pos * 0.5f;

    // この処理でxyが0~wになる
    // yがプラットフォームにより上下反転する問題も吸収
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
    o.zw = pos.zw;
    return o;
}

inline float4 ComputeScreenPos(float4 pos) {
    // 通常はこの処理だけ
    float4 o = ComputeNonStereoScreenPos(pos);

// こっちはVR用の処理
#if defined(UNITY_SINGLE_PASS_STEREO)
    o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
    return o;
}

【Unity】【シェーダ】スクリーンに対してテクスチャをマッピングする方法を完全解説する - LIGHT11から引用

yがプラットフォームによって違うのはプラットフォームによって射影行列が反転しているためです。

フラグメントシェーダーでwによる除算を行なってからテクスチャサンプリングを行います。

float4 frag (v2f i) : SV_Target
{
     i.projectorPos.xyz /= i.projectorPos.w;
     float2 uv = i.projectorPos.xy;
     float4 projectorTex = SAMPLE_TEXTURE2D(_ShadowProjectorTexture, sampler_ShadowProjectorTexture, uv);
     float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
     // カメラの範囲外は適用しない
     float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
     float alpha = isOut.x * isOut.y * isOut.z;

     // プロジェクターから見て裏面の面には適用しない
     alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);
     return lerp(col, 0 , alpha * projectorTex.a);
}

カメラの範囲外かどうかの判定

カメラの範囲外かどうかの判定式は以下で行っています。

float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
float alpha = isOut.x * isOut.y * isOut.z;

i.projectorPosはクリップスペース空間になるので、0-1の範囲外の場合はカメラの範囲外という判定になります。

sign(x)は以下の値を返す関数になります。

1.0 (if x>0)、0 (if x=0)、-1.0 (if x<0)を返す

裏面かどうかの判定

裏面かどうかは以下の処理で見ていますが、視線方向と放線方向のベクトルの内積をとり、負である場合は裏面ということになります。

alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);

影の描画

最後に影を描画します。今回は影を0で書いているので真っ黒ですがPropertiesで定義してあげて影色を指定してあげるといいかと思います。

return lerp(col, 0 , alpha * projectorTex.a);

最後に

上記のShaderをアタッチしたMaterialを影を描画する側のオブジェクトにアタッチし、最終的には以下のような絵が作れます。

参考

news.mynavi.jp

light11.hatenadiary.com

tech.drecom.co.jp

soramamenatan.hatenablog.com

light11.hatenadiary.com