這篇實現來的有點墨跡,前前后后折騰零碎的時間折騰了半個月才才實現一個基本的shadow map流程,只能說是對原理理解更深刻一些,但離實際應用估計還需要做很多優化。這篇文章大致分析下shadow map的基本原理、Unity中實現ShadowMap陰影方式以及一些有用的參考。
1 . Shadow Map 基本原理
基本的shadow Map 原理, 參考 "Unity基礎(5) Shadow Map 概述". 其基本步驟如下:
- 從光源的視角渲染整個場景,獲得Shadow Map
- 實際相機渲染物體,將物體從世界坐標轉換到光源視角下,與深度紋理對比數據獲得陰影信息
- 根據陰影信息渲染場景以及陰影

2. 采集 Shadow Map 紋理
Unity 獲取深度紋理的方式可以參考之前的日記:Unity Shader 基礎(3) 獲取深度紋理 , 筆記中給出了三種獲取Unity深度紋理的方式。 如果采用自定義的方式來獲取深度,可以考慮使用EncodeFloatRGBA對深度進行編碼。另外,可以通過增加多個subshader實現對不同RenderType 陰影的支持。
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float4 vertex : SV_POSITION;
float2 depth: TEXCOORD0;
};
v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.depth = o.vertex.zw ;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float depth = i.depth.x/i.depth.y ;
return EncodeFloatRGBA(depth) ;
}
ENDCG
}
}
3 創建ShadowMap相機
1. 類型
shadow Map的相機會根據光源的不同有所差異,直線光使用平行投影比較合適,點光源和聚光燈帶有位置信息,適合使用透視投影, 這篇文章以平行光和平行投影為例來實現。對於平行投影相機而言,主要關於方向、近平面、遠平面、視場大小。
1. 創建
以光源為父節點創建相機,設置投影方式以及 RenderTexture對象。其方向與父節點保持一致。
2. 視場匹配
陰影實現中shadow map占用的空間是最大的,合適的相機視場設置可以在同樣資源下獲得更好的效果、更高的精度。在Common Techniques to Improve Shadow Depth Maps一文中給出相機參數適應場景的兩種方式:FIt to scene和 FIt to view. 對於Fit to Scene,其實現流程:
- 利用場景中所有物體mesh的bounds計算整個場景的包圍盒AABB,需要注意的是mesh.bounds是相對於模型空間,需轉換到世界空間再計算整個場景AABB
- 將包圍盒轉換到光源空間,這里可以利用transparent.worldToLocalMatrixhguod獲得轉換矩陣
- 相機參數設置:
- 取包圍盒x、y方向最大、最小值,其差值的一半作為相機size;
- 包圍盒中點作為相機位置
- 相機方向與光源方向相同
- 近平面和遠平面使用包圍盒Z方向最大值、最小值
Fit to Scene方式計算整個場景的AABB來攝像 Shadow Map采集相機參數,但如果場景相機視場比較小的情況下,比如FPS游戲中角色,這種方式就不是很合適。對於這種情況,Fit to VIEW 更合適。
4 世界坐標轉換到Shadow Map 相機NDC空間
判斷是否為陰影需要比較場景中物體深度與Shadow Map中深度值,這個過程需要確保二者在一個空間中。深度采集保存在shadow map貼圖中的數值是NDC空間數值,所以渲染物體時會將物體從世界坐標轉換到Shadow Map相機空間下,然后通過投影計算轉換到NDC坐標,也就是原理圖中的\(z_b\) 。投影矩陣參數可以傳遞到shader'中進行,如下:
//perspective matrix
void GetLightProjectMatrix(Camera camera)
{
Matrix4x4 worldToView = camera.worldToCameraMatrix;
Matrix4x4 projection = GL.GetGPUProjectionMatrix(camera.projectionMatrix, false);
Matrix4x4 lightProjecionMatrix = projection * worldToView;
Shader.SetGlobalMatrix ("_LightProjection", lightProjecionMatrix);
}
pixel shadow 中 計算NDC坐標:
fixed4 object_frag (v2f i) : SV_Target
{
//計算NDC坐標
fixed4 ndcpos = mul(_LightProjection , i.worldPos);
ndcpos.xyz = ndcpos.xyz / ndcpos.w ;
//從[-1,1]轉換到[0,1]
float3 uvpos = ndcpos * 0.5 + 0.5 ;
...
...
}
5. 陰影計算
通過比較場景物體轉換到shadow map相機NDC空間深度\(z_b\)與shadow map貼圖中深度值\(z_a\)即可判斷頂點是否在陰影區域。以原理圖為例,如果 \(z_b\)大於\(z_a\), 頂點是在遮擋物體之后,處於陰影區域。需要注意的是對shadow map 紋理采樣坐標需要將場景物體頂點在shadow map相機NDC空間下的坐標轉換到[0,1]的范圍。下面的代碼沒有結合光照:
fixed4 object_frag (v2f i) : SV_Target
{
//計算NDC坐標
fixed4 ndcpos = mul(_LightProjection , i.worldPos);
ndcpos.xyz = ndcpos.xyz / ndcpos.w ;
//從[-1,1]轉換到[0,1]
float3 uvpos = ndcpos * 0.5 + 0.5 ;
float depth = DecodeFloatRGBA(tex2D(_LightDepthTex, uvpos.xy));
if(ndcpos.z < depth ){return 1;}
else{return 0;}
}
6. Shadow acne 與 Peter Panning

深度紋理分辨率的關系,會存在場景中多個頂點對深度紋理同一個點進行采樣來判斷是否為處於陰影的情況,再加上不同計算方式的精度問題就會產生圖上Shadow acne的情況,具體可以參考:https://www.zhihu.com/question/49090321 ,描述的比較詳細。

5.1 shadow bias
最簡單的做法是對場景深度或者貼圖深度做稍微的調整,也就是 shadow bias,

shadow bias的做法簡單粗暴,如果偏移過大就會出現 Peter Panning的情況,造成陰影和物體分割開的情況。

5.2 Slope-Scale Depth Bias
更好的糾正做法是基於物體與光照方向的夾角,也就是Slope-Scale Depth Bias,這種方式的提出主要是基於物體表面和光照的夾角越大, Perspective Aliasing的情況越嚴重,也就越容易出現Shadow Acne,如下圖所以。如果采用統一的shadow bais就會出現物體表面一部分區域存再Peter Panning 一部分區域還存在shadow acne。

更好的辦法是根據這個slope進行計算bias,其計算公式如下,\(miniBais + maxBais * SlopeScale\) , 其中\(SlopeScale\)可以理解為光線方向與表面法線方向夾角的tan值(也即是水平方向為1的情況下,不同角度對應的矯正量)。
float GetShadowBias(float3 lightDir , float3 normal , float maxBias , float baseBias)
{
float cos_val = saturate(dot(lightDir, normal));
float sin_val = sqrt(1 - cos_val*cos_val); // sin(acos(L·N))
float tan_val = sin_val / cos_val; // tan(acos(L·N))
float bias = baseBias + clamp(tan_val,0 , maxBias) ;
return bias ;
}
不過Bias數值是個有點感性的數據,也可以采用其他方式,只要考慮到這個slopescale就行,比如:
// dot product returns cosine between N and L in [-1, 1] range
// then map the value to [0, 1], invert and use as offset
float offsetMod = 1.0 - clamp(dot(N, L), 0, 1)
float offset = minOffset + maxSlopeOffset * offsetMod;
// another method to calculate offset
// gives very large offset for surfaces parallel to light rays
float offsetMod2 = tan(acos(dot(N, L)))
float offset2 = minOffset + clamp(offsetMod2, 0, maxSlopeOffset);
7. Shadow Map Aliasing

解決完shadow acne后,放大陰影邊緣就會看到這種鋸齒現象,其主要原因還在於shadow map的分辨率。物體多個點會采集深度紋理同一個點進行陰影計算。這個問題一般可以通過濾波緊進行處理,比如多重采樣。
Pencentage close Filtering(PCF),最簡單的一種處理方式,當前點是否為陰影區域需要考慮周圍頂點的情況,處理中需要對當前點周圍幾個像素進行采集,而且這個采集單位越大PCF的效果會越好,當然性能也越差。現在的GPU一般支持2*2的PCF濾波, 也就是Unity設置中的Hard Shadow 。
//PCF濾波
float PercentCloaerFilter(float2 xy , float sceneDepth , float bias)
{
float shadow = 0.0;
float2 texelSize = float2(_TexturePixelWidth,_TexturePixelHeight);
texelSize = 1 / texelSize;
for(int x = -_FilterSize; x <= _FilterSize; ++x)
{
for(int y = -_FilterSize; y <= _FilterSize; ++y)
{
float2 uv_offset = float2(x , y) * texelSize;
float depth = DecodeFloatRGBA(tex2D(_LightDepthTex, xy + uv_offset));
shadow += (sceneDepth - bias > depth ? 1.0 : 0.0);
}
}
float total = (_FilterSize * 2 + 1) * (_FilterSize * 2 + 1);
shadow /= total;
return shadow;
}

改進算法
Shadow Map Antialiasing 對PCF做了一些改進,可以更快的執行。Improvements for shadow mapping in OpenGL and GLSL 結合PCF和泊松濾波處理,使用PCF相對少的采樣數,就可以獲得很好的效果OpenGl Tutorial 16 : Shadow mapping也采用了類似的方式。類似的算法還有很多,不一一列舉。
7 其他
7.1 Perspective Aliasing
pixels close to the near plane are closer together and require a higher shadow map resolution. Perspective shadow maps (PSMs) and light space perspective shadow maps (LSPSMs) attempt to address perspective aliasing by skewing the light's projection matrix in order to place more texels near the eye where they are needed. Cascaded shadow maps (CSMs) are the most popular technique for dealing with perspective aliasing.

參考:Cascaded Shadow Maps , 具體實現可以參考:http://blog.csdn.net/ronintao/article/details/51649664
7.2 [Screem space shadow map][]
Unity 5.4版本之后陰影的基本原理類似,但是處理方式有點差異,具體可以查看: [Screem space shadow map][]
8 總結
陰影的處理有很多方式,有本專著《實時陰影技術》對陰影處理做了很多介紹,翻了下果斷放棄了,總是獲得一個效果好、性能好的陰影效果還是需要費點時間。
工程下載:https://github.com/carlosCn/Unity-ShadowMap-Test.git
挺贊的一篇文章:
Unity移動端動態陰影總結
Unity Shadow Map實現
參考
Unity基礎(5) Shadow Map 概述
OpenGL Shadow Mapping
OpenGl Tutorial 16 : Shadow mapping
Shadow Map Wiki
Shadow Acne知乎
Common Techniques to Improve Shadow Depth Maps
Cascaded Shadow Maps
Percentage Closer Filtering
Variance Shadow Map Papper
Shadow Mapping Summary
Improvements for shadow mapping in OpenGL and GLSL
[Screem space shadow map][]
Unity移動端動態陰影總結
[Screem space shadow map]: https://github.com/candycat1992/Unity_Shaders_Book/issues/49)
