概要
前回までUnityのSRPで複数のDirectionalLightのデプスシャドウを書く方法を解説しました。
ここから改良型のデプスシャドウ技法について解説・実装していきたいと思います。 今回は「パースペクティブシャドウマップ」(以下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前方のモデル行列」であるからこの式を入れています。
- .inverse … モデル行列 → ビュー行列(world→camera)に直す
- 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を実装していこうと思います。























































