最近在预研一个pbr模型的时候接触到了阴影渲染的部分,因为之前一直用的是Unity内置的宏去生成阴影,并且需要用到平行光。对于阴影的制作有各种各样的办法,而shadowmap就是最典型生成阴影的一种方法,在Unity内部也是用shadowmap的变种 **CSM(Cascaded Shadowmap)和SSSM(Screen Space Shadowmap)**的两种阴影。在Unity内部如果目标平台不支持SSSM的话,它会转为CSM的阴影。下面就简单说一下shadowmap的原理和实现过程。
shadowmap的原理非常简单,首先它在光源处放置一台相机,通过该相机生成一张深度图,然后在渲染需要接受阴影的物体时把当前渲染的点的深度与深度图里的深度比较。当然,当前点的深度需要转换到光源视角的坐标下,不然就变成了游戏主相机的深度了。总结一下,可以归为两个步骤,下面便一一说明。
1.深度图首先,我们在需要阴影的地方放置一台相机,相机的位置即光源的位置,因为本demo不需要平行光,所以需要调整相机摆放的位置。首先我们需要设置相机的参数,一般对于平行光来说,我们使用正交投影相机,因为它跟透视没有关系,而点光源或聚光灯等就使用透视投影。
在参数设置好后,我们就需要确定它摆放的位置了,因为距离的远近和远近平面即视锥体的大小,都会影响到实际贴图的输出质量从而影响到阴影的精度。那我们该如何抉择呢?这里微软给出了两种方法,第一种为适应当前场景,即视锥体刚好包含整个场景,第二种为适应当前视角,即视锥体包含当前主相机视角的所有地方。具体在文章末会给出链接,可以继续深入了解。
在放置好相机后,我们就要编写它的脚本,我们可以使用获取相机内置的深度图的方法,也可以编写一个shader返回它的深度,这里我选择了通过shader输出它的深度,拿到它的RT。另外我们需要把光源位置的矩阵和得到的RT传入需要的接受阴影的材质shader里,核心代码如下:
Matrix4x4 GetLightProjectMatrix(Camera lightCam)
{
//将裁剪空间的XY坐标系[-1,1]映射到uv坐标[0,1]
Matrix4x4 posToUV = new Matrix4x4();
posToUV.SetRow(0, new Vector4(0.5f, 0, 0, 0.5f));
posToUV.SetRow(1, new Vector4(0, 0.5f, 0, 0.5f));
posToUV.SetRow(2, new Vector4(0, 0, 1, 0));
posToUV.SetRow(3, new Vector4(0, 0, 0, 1));
//世界坐标系 -> 摄像机的摄影坐标系
Matrix4x4 worldToView = lightCam.worldToCameraMatrix;
//摄像机的摄影坐标系 -> 摄像机的投影坐标系
Matrix4x4 projection = GL.GetGPUProjectionMatrix(lightCam.projectionMatrix, false);
return posToUV * projection * worldToView;
}
void Start()
{
mCam = this.transform.GetComponent();
if (mCam != null)
{
Matrix4x4 lightMatrix = GetLightProjectMatrix(mCam);
mCam.SetReplacementShader(Shadowmap,"RenderType");
mCam.Render();
mCam.enabled = true;
Size = new Vector4((float)1.0 / depthTexture.width,(float)1.0 / depthTexture.height,0,0);
Shader.SetGlobalMatrix("Light_ProjectionMatrix", lightMatrix);
Shader.SetGlobalTexture("_DepthTexture", depthTexture);
Shader.SetGlobalVector("_DepthTexture_TexelSize",Size);
}
}
这里有一个坑,如果我们选择在矩阵里就把裁剪空间的xy坐标系映射到(0,1)的话,在shader里转化后的采样的uv就不需要处理了。其次,我们使用了SetReplacementShader这个api来对物体的shader进行替换得到RT,在该api的第二个参数,我们注意要设置好其对应的Tag,一般地板不渲染进深度图里,只作为显示阴影的存在,而其他需要写入深度图的就需要设置对应的Tag了。另外这里还传入了一个RT的size大小,这是为了后续优化阴影做的准备。
而代替相机的shadowmap的shader也很简单,就直接输出顶点的z / w值就可以得到该点的深度值了,然后我们把该深度值转换为RGBA值进行存储,在采样时再把值进行还原,实现低精度的存储,高精度的还原。而因为考虑到手机平台的opengl下的深度取值范围是(-1,1),而转换RGBA的api只接收(0,1)的Float值,所以我们把它做一次映射,核心代码如下:
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.depth = o.vertex.zw;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float depth = i.depth.x / i.depth.y;
return EncodeFloatRGBA((depth * 0.5 + 0.5));
}
2.对比深度
因为我们在脚本里传入了全局的一个RT贴图参数和一个光照矩阵,所以接下来我们在需要接受阴影的shader里进行深度的比较。首先定义需要的全局变量。
float4x4 Light_ProjectionMatrix; sampler2D _DepthTexture; float4 _DepthTexture_TexelSize;
然后我们在片元着色器里,通过把世界坐标转换到光源空间下的坐标,然后拿到该点的深度,因为我们在深度图里做了映射处理,所以这里也需要对当前点的深度做处理,转化成(0,1)。接下来,我们再用xy作为uv对定义的全局深度图进行采样,获得深度图下的深度。然后通过深度的比较就可以获得阴影的区间了。核心代码如下:
float4 proj = mul(Light_ProjectionMatrix,i.worldpos);
proj.xyz = proj.xyz / proj.w;
float currentDepth = proj.z * 0.5 + 0.5;
//矩阵处已做处理
//proj.xy = proj.xy * 0.5 + 0.5;
float atten = 1;
float depth = DecodeFloatRGBA(tex2D(_DepthTexture,proj.xy));
#if defined(UNITY_REVERSED_Z)
if(currentDepth < depth)
{
atten = 0.45;
}
#else
if(currentDepth > depth)
{
atten = 0.45;
}
#endif
col *= atten;
然后这里是为了处理opengl和dx之间的坐标原点在左上角和左下角的区别进行深度判断,最终我们把最后的颜色乘以阴影系数就可以得到最终带阴影的物体了。
结果图如下:
3.优化我们可以看到在阴影边缘锯齿非常严重,这是因为我们使用的是if语句进行判断,阴影边缘之间没有合理的过渡,属于硬阴影。在常见的抗锯齿算法里,pcf算法是最为常见的,简单来说就是通过多次采样点的周围来判断该点阴影位置的权重,最后进行阴影系数的改变。核心代码如下:
float PCF(float2 uv,float currentDepth)
{
float offset = 0;
float minus_step = 0.55 / 9.0;
float shadow = 1;
//return shadow;
for(int i = -1; i < 2; i ++)
{
for(int j = -1; j < 2; j ++)
{
offset = float2(i,j) * _DepthTexture_TexelSize;
float depth = DecodeFloatRGBA(tex2D(_DepthTexture,uv + offset));
if(currentDepth < depth)
{
shadow -= minus_step;
}
}
}
return shadow;
}
经过抗锯齿pcf算法后的软阴影如图:
放大后我们可以发现,它实际是在边缘处堆叠了好几层不同权重的阴影。
我们得到更加柔和的软阴影,但为之要付出的代价是多次采样带来的性能损耗,而在unity有三种采样次数不同的pcf算法,分别是3x3、5x5、7x7,所以我们要在画质和性能上进行一个权衡,另外在抗锯齿上也有很多其他优秀的算法,在后续我会继续学习了解。
其次,还会出现一种叫阴影失真的情况。但在本例暂时没有出现,简单来说就是因为深度图的精度问题,导致同一个深度图里的深度图和多个点进行比较导致阴影断断续续。这里提供的解决办法除了提高深度图的精度外,我们还可以通过对当前深度给定一个偏移值,调节当前深度,最后获得自己想要的效果。
代码如下:
if(currentDepth + _ShadowM < depth)
{
shadow -= minus_step;
}
最后总结一下,在阴影部分还有许许多多的地方要学习,光是shadowmap的变种就有十几种不止,而阴影抗锯齿的问题也是一个十分重要的问题,据我所知SDF就可以用来产生柔和的软阴影,这要以后继续学习再进行相关的总结。
参考:1.基于ShadowMap的场景静态阴影
2.Richbabe/ShadowMap_Unity
3.改进阴影深度映射的常见技术
4.Shadow Map 原理和改进
5.Unity Shader - Custom DirectionalLight ShadowMap 自定义方向光的ShadowMap
6.影子传说——三种Shadowmap改进算法的原理与在Unity中的实现



