基于CSM和PCF的软阴影实现

断断续续花了两个多礼拜才把这个问题完全搞定,比开始预想的时间多多了,一开始也没想到会碰到这么多的状况,不过好在是都解决了。

阴影技术是三维渲染里面的一个非常重要的课题,实现方式多种多样,最基本的是从光源方向渲一张ShadowMap,简单易行,但是效果很差,锯齿像牛一样大。想要获得更精细的阴影,唯一的办法就是加大SM的分辨率。
基于CSM和PCF的软阴影实现_第1张图片
事实上我们对远处的阴影要求并没有近处那么高,粗糙点无所谓,反正离得远也看不见,于是在此之上,出现了Cascaded ShadowMap,简称CSM,它的做法是把相机的可视范围从近裁到远裁分割成N个子视锥,每个视锥渲一张ShadowMap。
基于CSM和PCF的软阴影实现_第2张图片
这样做的好处很明显,我们希望的是近处的阴影比远处的更精细,这种切分很好的实现了我们的要求。

我这次用的就是这个方法,具体的代码就不贴了,说下大概的实现吧。我们首先要拿到被切分相机的远近裁,然后计算出子视锥的八个顶点坐标。
下图中,C0是近裁,Cm是远裁,Ci代表分割线,假如我们想要分割出N个子视锥,那我们就需要求出N-1个Ci。
基于CSM和PCF的软阴影实现_第3张图片
通用算法是:The Algorithm
使用混合因子λ将平均切分和指数切分融合起来。
基于CSM和PCF的软阴影实现_第4张图片
其中,指数切分公式是:这里写图片描述
平均切分的公式是:这里写图片描述
其中,n为近裁,f为远裁,i为切分线,m为子视锥总数。
代入最上面的公式,就能得到最终的切分公式。

得到Ci之后,就能根据这些切分线算出子视锥的投影矩阵,他们的View矩阵和主相机是一致的。

确定了子视锥的投影矩阵,我们就可以求出子视锥在世界空间下的八个顶点,计算过程很简单,用一个范围为[(-1, -1, 0), (1, 1 ,1)]的包围盒的八个顶点,乘以子视锥的(VP)^-1矩阵,就是子视锥的八个顶点了。

注意:这里讨论的只适用于灯光是平行光的情况。

然后使用子视锥的八个顶点,计算出这个子视锥在世界空间的包围球,至于这里为什么用包围球而不是包围盒,是为了保持ShadowMap照射区域大小的稳定,后面会详细讲到。把这个包围球转换到灯光空间,然后给它套一个包围盒,这个包围盒在灯光坐标系的XY面上的投影,就是灯光矩阵的XY轴的范围了。包围球中心向光源方向移动一段距离(足够大),作为灯光相机的坐标,包围球中心作为灯光相机的Look方向,Up方向可以用Look方向和Right相乘得到,这样就拿到了灯光相机的View矩阵。

接下来就是求出灯光的远近裁了,如果这时候能拿到阴影接受体的包围盒,可以把这些包围盒统一到一个大的包围盒里面,然后将这个大的接受体包围盒转换到子视锥的NDC空间,与前面的顶点包围盒求交,再把求出来的新包围盒转换到灯光空间,用这个包围盒的z的最大值作为之后的灯光视锥的远裁剪面。
下图中,假如怪物是唯一的接受体,那么怪物在灯光空间的最远端显然就是灯光矩阵的远裁面了。
基于CSM和PCF的软阴影实现_第5张图片
阴影投影体的包围盒也统一成一个大包围盒,再转换到灯光空间,离灯光最近的一个点作为近裁面。

到现在,构造灯光相机的投影矩阵的所有参数我们都已经拿到了,这里面还有许多的小优化,不过都属于是锦上添花的事情,各位有空可以研究一下。

接下来就是用这两个矩阵设置灯光相机,然后渲染出ShadowMap,再在主相机渲染的时候,比较一下深度,就完事儿了。

如果各位做出来了,会发现效果并不好,问题有这么几个:1、阴影边缘的锯齿很明显,没有软阴影;2、相机移动锯齿也会跟着动,因为灯光相机一直跟着主相机在移动。3、不同层级的阴影之间有明显的交界;

第三个问题很好解决,只需要在分割主视锥的时候,两相邻的两个视锥有一定的重叠,然后再shader里面进行线性插值就能解决,我这里用的是让下一级子视锥覆盖上一级视锥的20%范围,效果很不错。

第二个问题和第一个问题我一度以为是无解的,后来看了unity的Cascaded Shadow的效果,发现他们的阴影边缘的锯齿是不会跟着动的,这起码证明这个问题是有解的,经过多次尝试,终于找到了解决方案。锯齿会跟着相机动,是因为灯光相机在跟着主相机在动,我们不可能让灯光相机停住不动,但是只要让SM里的每一个像素移动走之后,其他位置移动过来的像素刚好和上一个像素的位置一致,锯齿位置也就能固定下来。简单来说,就是灯光相机每次移动整数个像素的位置(在灯光空间的xy平面),且像素的大小无论怎么移动都要保持不变。我们在灯光空间找一个固定的锚点(比如世界空间的0点转换到灯光空间的位置),把这个锚点投影到xy平面,然后计算出SM中每个像素的大小(投影区域已知,SM分辨率已知,就可以求到每个像素的大小),灯光相机也投影到xy平面,用灯光的x,y坐标与像素的长宽求模,得到的余数就是灯光需要偏移的值,在构造灯光相机的投影矩阵的时候把这个偏移加上,就可以保证SM每次都是移动像素大小的整数倍距离了。前面提到的使用包围球替代包围盒,就是为了方便这里的计算,使用包围球可以保证主相机转动,SM的投影区域大小保持不变。

第一个问题,是最麻烦的。软阴影一直都是很多业界专家研究的对象,现有的最佳解决方案是VSM,渲染SM的时候,除了保存深度值以为,使用另外一个通道保存深度的平方,没错,就是深度的平方,深度*深度。然后使用硬件进行线性过滤,这样每个像素里的两个通道代表的分别是深度的期望和深度的方差,最后利用切比雪夫不等式,可以计算出主相机里的每个像素在阴影中和不在阴影中的概率,从而实现非常完美的软阴影。不过我这里用的是PCF的方式实现的软阴影。

在OpenGL中,只要开启了
GL_TEXTURE_COMPARE_MODE = GL_COMPARE_PEF_TO_TEXTURE
这个状态之后,就可以使用sampler2DShadow纹理了,对于shadow纹理,OpenGL在采样的时候,不再是返回颜色,而是返回采样点在阴影中的概率。函数是:
texture(sampler2DShadow, vec3);
其中,vec3中,x有放的是uv坐标,z放的是需要比较的深度,这个函数会使用采样点周围的四个点来比较,返回值是一个float, 代表通过测试的概率(25%、50%、75%、100%)。
使用两个for循环,分别在x,y方向上进行偏移4次,一共采样16次,平均下来的值就是这个像素在阴影中的概率了。

for(float y=-1.5; y<=1.5; y+=1.0)
    for(float x=-1.5; x<=1.5; x+=1.0)
        float fProbability += texture(s2sCascadedShadowMap, vec3(
                            v4TexCoord.x + x * fCascadedSMTexelSize,
                            v4TexCoord.y + y * fCascadedSMTexelSize,
                            fDepthCompare));
fDepth *= 0.0625;

用了PCF之后,阴影边缘的锯齿模糊了很多,半阴影效果还算可以。

眼见的人可能会发现,一些和光源方向夹角很小的平面上,会出现很多条纹,这是因为PCF多次采样导致的,在shader的pixel shader里面计算的时候,因为我们是用当前点在灯光空间中的深度与当前点在SM中对应像素附近像素深度进行的深度比较,在和光源方向垂直的面上,整个三角形在灯光空间中深度变换基本一致,但是随着三角形和光源方向的夹角逐渐增大,当前点和附近点在灯光空间中的深度差距越来越大,这时候还用当前点的深度去和SM里面多次采样的深度比较,显然是错误的。
基于CSM和PCF的软阴影实现_第6张图片
拿上图来说,在主相机渲染的时候,pixel shader里面现在正在处理某个像素,这个像素转换到灯光空间是右图中的位置D,我们用PCF把D附近的深度也采样出来并且和这个像素的深度比较,这显然是错误的,我们应该计算出D附近问号像素对应的,在当前正在处理的三角形上的坐标,再把这个坐标转换到灯光空间进行深度测试。

OpenGL里面有这么一个函数可以实现我们的想法:dFdx和dFdy,这两个函数可以返回当前像素和附近像素的梯度,我们把当前像素的纹理坐标放进去,他会返回当前像素的纹理和周围像素的纹理的的变化量,用这个变化量就可以计算出当前三角形在灯光空间的斜率,从而消除平面上的条纹。
基于CSM和PCF的软阴影实现_第7张图片

啰啰嗦嗦一大堆,其实还有很多细节没有讲,就把这些当成一个线索,知道大概是怎么弄出来的,出了问题大概知道从哪一方面入手去解决,也就够了。

你可能感兴趣的:(三维渲染技术)