头图

本篇分为上下两篇,上篇内容请关注:
游戏中的动态阴影(上)


六、基于Shadowmap实现软阴影

1. Percentage-Closer Filtering(PCF)
采样Shadowmap时,我们往往这样来实现一些软阴影的效果:在目标采样点周围,进行四次采样,然后取平均值,作为最终结果。注意这里的取平均值,并不是取平均值后进行比较,而是对四个采样点,分别进行深度测试,然后每个采样点的0或1的结果值进行平均,这样在半影区域就能得到软阴影效果。

这种将采样结果进行平均的方式叫做Percentage-Closer Filtering(PCF)[6],PCF通过将目标点附近的采样结果平均,来模拟出半影的效果。

现在的硬件都直接提供周围四点采样的加权PCF深度测试,比如OpenGL中的sampler2DShadow,DirectX中的SampleCmp。这种采样的加权方式类似于普通像素采样时的双线性采样,在目标位置附近2X2像素中,逐像素进行深度比较,得到结果值0或1,然后将结果按照相对周围像素位置进行加权平均。

直接使用硬件PCF,只能采样到2X2的像素点,得到的半影过渡,往往不够柔和。如果想要更加柔和的阴影过渡,或者把半影区域扩大,就需要将采样点分布范围扩大,也需要增加采样点的个数。

简单的方式,是直接在目标点周围按照Grid模式进行采样,但是这样往往会在半影中看到分层的瑕疵。

因此我们更加常用的方式,是使用预计算好的Possion分布的采样点,来进行采样。为了使结果进一步平滑,我们还可以使用逐像素的噪声值,对采样点位置进行旋转,这样每两个相邻的像素点,采样的模式都是不同的,可以有效地平滑半影区域。

从左到右依次是:4X4的Grid采样
12点Possion采样
12点Possion采样+旋转
Possion分布图

2. PCF软阴影的Bias问题
在前面我们已经讲过Bias的问题,在PCF采样中,因为PCF采样Shadowmap的范围会比较大,因此会进一步暴露出Shadow Acane的问题。当然我们也有响应的手段来解决这些问题。

一种简单的方式,是根据PCF filter kernel的大小,来动态改变Shadow Bias的大小,当然这样做的缺点也很明显,就是PCF kernel越大,就会损失越多的阴影精度信息。

另外一种方式是Bias Cone,根据当前采样点到采样中心的位置,来缩放Bias的大小,如下图左边所示。是一种相对简单有效的缓解Bias问题的方案。

左:Bias Cone;右:Receiver Plane Depth Bias

上图右边显示的一种逐采样点来做精确Bias的算法:Receiver Plane Depth Bias。这种方式需要假定接受阴影的是一个平面,然后会根据每个阴影采样点到中心的位置,来计算偏移。一般能产生非常好的结果[7]

3. Percentage-Closer Soft Shadows(PCSS)
PCF阴影的一个缺点,就是半影的宽度非常固定,无论产生阴影的位置距离光照有多远,半影的宽度都是一样的。

PCSS[8]通过判断半影到遮挡物和半影到光源的距离,来动态确定半影的宽度。半影宽度越大,采样阴影的模式分布也越大,就能得到越柔和的阴影。这样就能得到如下图右边所示的,随距离变化的阴影效果。

左:PCF硬阴影,中:PCF软阴影,右:PCSS阴影

PCSS算法分成这样几个步骤:

  1. 计算出区域内平均Blocker深度;
  2. 根据Blocker深度,计算出需要的半影宽度;
  3. 用半影宽度,作为PCF kernal的大小,计算出阴影。

PCSS的计算其实很简单,就是根据三角形相似,来计算出采样所需的分布距离,然后将距离内的采样值进行平均。

不过当半影宽度非常大时,就需要非常多的采样点,这样采样Shadowmap的开销也会变大。因此PCSS是一种不太稳定的软阴影方案,在游戏中的实际应用并不是特别多。

七、基于Shadowmap的逐物体阴影/Per Obejct Shadow

1. Modulated shadow的实现
前面我们讲到的平面阴影,只能投射阴影到平面上,在使用Shadowmap保存深度后,就可以将阴影投射到任意的曲面上,具体放方法如下:

首先我们得到需要渲染阴影物体的AABB,然后将AABB转换到Light Sapce,得到新的 Orthogonal Light Space ABB。然后我们将物体的深度渲染到一张Shadowmap中。

我们将Light Sapce的AABB沿着光照方向进行延长,就得到了一个Shadow Volume。

接下来我们就可以使用这个Shadow Volume来得到投射阴影了。将Shadow Volume作为几何体进行渲染,在Shader中读取当前位置的Depth值,反算出世界坐标,再通过投影矩阵算出光照空间下的深度值,在Shadowmap中进行采样,得到阴影。将最终输出结果的混合方式为DstColor Zero,这样,被遮挡区域有阴影的位置,颜色都这样乘以一个阴影系数,得到一个染色的效果,也就实现了Modulated shadow。

注意,为了防止在不需要阴影的区域渲染出阴影,我们需要在代码中进行clip,如果计算出Shadowmap中对应的uv坐标超出0~1的范围,就不再渲染阴影。在Unity中实现的Shader代码大致如下:

                float4 frag (v2f i) : SV_Target
{
float2 uv = i.vertex.xy * (_ScreenParams.zw - 1);
float depth = tex2D(_CameraDepthTexture, uv).r;


#if !UNITY_REVERSED_Z
depth = depth * 2 - 1;
#endif

#if UNITY_REVERSED_Z
uv.y = 1 - uv.y;
#endif
float4 clipPos = float4(2.0f * uv - 1.0f, depth, 1.0);//
//反算出世界空间坐标
float4 worldSpacePos = mul(UNITY_MATRIX_I_VP, clipPos);
worldSpacePos /= worldSpacePos.w;//
//得到shadowmap中uv坐标
float4 projectorPos = mul(_WorldToProjector, worldSpacePos);


#if UNITY_REVERSED_Z
projectorPos.z = clamp(projectorPos.z, 0.0001, 1);
#else
projectorPos.z = clamp(projectorPos.z, 0, 0.9999);
#endif
// uv不在0~1范围内,不需要阴影
clip(projectorPos.xy);
clip(1 - projectorPos.xy);
projectorPos.xy = projectorPos.xy * _ShadowmapTex_ST.xy + _ShadowmapTex_ST.zw;
float shadow = SAMPLE_TEXTURE2D_SHADOW(_H3D_GroundShadowmapTex, sampler_H3D_GroundShadowmapTex, projectorPos.xyz).r;
return shadow;
}

Modulated shadow有这样两个明显的缺点:

  1. 无法完全正确还原阴影效果,因为Modulated shadow是通过将原色乘以某个系数来实现的阴影,而非遮蔽光照形成阴影,因此效果会有误差。而且多个Modulated shaodw会多次叠加在一起。
  2. 在特定的观察角度下,Modulated shadow可能会穿过被投射阴影的物体。


UE4中Modualted shaow的效果
可以看到两个人物的阴影出现错误的叠加


《天涯明月刀》手游中的Modualted Shadow,错误地穿过了树干

在游戏实践中,最常用到Modulated shadow的地方,就是将人物投影在地面上。我们知道,Modulated shadow的效果是有偏差的,特别在人物身上这种非常高频的区域,就会非常明显。因此我们通常会使用模板的方式,将人物身上的Modulated shadow剔除掉,只显示在地面上。对于人物身上的自阴影,我们会按照正常的Shaodwmap来渲染。

这样一来,我们为人物单独生成一张Shadowmap,会同时在两个地方用到:一是用于产生人物身上的自阴影,二是用于地面投射的Modulated Shadow。这也是手机游戏中常用的一种处理人物角色阴影的方案。

比如在《天涯明月刀》手游中,就是使用这样的实现方式。如下图所示,左边是Shadowmap,右边是渲染的结果,Shadowmap同时用来实现人物身上的自阴影和地面上的Modualted Shadow。

左边是仅针对人物生成的Shadowmap、同时用于人物的自阴影,和地面的投影

2. 使用Shadowmask混合多种阴影
在游戏开发中,我们常常会同时使用不同种类的阴影,或者使用多个PerObject阴影。如果在一个Pass中同时判断多个阴影,那么处理起来会非常麻烦。一种通用的解决方案,是将阴影预先绘制到Shadowmask上,然后再进行相应的光照计算。

比如我们使用了Stationary模式的灯光,对于静态的物体,我们使用了烘焙的阴影。而对于动态的物体,我们就需要实时来渲染阴影了。这样,我们可以先将静态的阴影输出到一张Shadowmask上,然后在绘制动态物体的阴影,实现两种类型阴影的叠加。

3. 逐物体阴影的几种应用场景
这里,我们来小结一下逐物体阴影的常用应用场景。

  1. Stationary模式的光照,烘焙静态物体的阴影;
  2. 高精度角色阴影,和场景阴影分离;
  3. 超出CSM阴影范围的物体,单独处理阴影;
  4. 移动端廉价的Modulated Shadow实现。

八、基于Z-Buffer的Filterable Shadowmap

前面介绍的是使用PCF来得到软阴影,在每次计算阴影时,需要进行很多次的采样和计算,如果想要更加柔和的阴影过渡,就只能通过增加采样次数来实现。在这里,我们将介绍一些可以预过滤的阴影技术,这些技术可以将得到的Shadowmap进行模糊预处理,来得到软阴影,这样可以降低计算软阴影的开销。

1. Variance Shadow Map(VSM)[9]
VSM使用两张Shadowmap,分别存储深度值和深度值的平方,具体原理如下:

已知切比雪夫不等式为:

这样,我们使用两张Shadowmap分别存储深度值和深度值的平方。这样,将两张Shadowmap进行Filter处理(使用Mipmap或者双Pass高斯模糊),就可以直接得到E(x)和E(x2),已知方差σ2 = E(x2)-E(x)2,这样,我们可以直接将得到的P(x)值作为阴影系数值来使用,方便地得到软阴影。

当然,从上面的切比雪夫不等式我们可以看出,这里的P(x),其实只是一个概率值的上界,我们这里是直接使用这个上界来作为最终的阴影系数来使用了。

下面,我们就来证明下,在简单的光照环境下,这种直接使用上界得到的阴影系数是合理的。

现在有一个深度值为d1的平面,投射阴影到深度值为d2的平面上。现在我们在采样阴影时进行Filter,设p为未被遮挡的比例,也就是我们期望得到的阴影系数值,由此我们可以得到:

我们从切比雪夫不等式中得到的概率上界为:

和我们的期望值是相等的,证明我们这样来使用切比雪夫不等式的概率上界是正确的。

这样,我们就可以通过对Shadowmap进行预处理,来得到软阴影。

我们的实现过程大致如下:

  1. 在光照空间下,将深度值和深度值的平方分别存储到两张flaot格式的Shadowmap中;
  2. 将两张Shadowmap进行Mipmap处理,或者使用双Pass高斯模糊;
  3. 在渲染时进行阴影计算,如果当前像素点的深度值小于平均深度μ,说明该点没有被阴影遮挡。如果深度值大于平均深度,就是用前面的公式来计算阴影系数。


左:直接进行VSM计算
右:先进行Mipmap处理,再计算VSM</center>

在一些复杂光照环境下,VSM可能会出现一些瑕疵。

左边是正常的VSM计算
在右边,添加了一个三角形后,造成了明显的漏光

2. Exponential Shadow Map(ESM) [10]
ESM也是一种类似VSM的Filtered Shadow Map,在空间中有一点x,设d(x)为x到光源的距离,z(p)表示当前方向上最近的遮挡物的距离,这样我们得到阴影函数为:

s(x)得到的结果是0或者1,表示当前的点是否被阴影遮挡。

现在,我们使用指数函数来代替函数f,定义这样一个指数的函数,来代替原来的0或1的大小判断:

从下图中我们可以看出,当d-z>0时,新的函数f和原来的阴影判断函数s是非常接近的,且c的值越大,就越接近。

在使用原始的阴影函数计算软阴影时,得到的过滤后的结果为:

这也就是我们熟悉的PCF计算软阴影的方式。现在,我们使用指数函数来代替上述的计算过程:

观察最后这个公式,我们发现左边的部分是可以直接在计算阴影时求得,右边的部分,其实是可以通过预过滤的方式计算出来。也就是说,我们生成的Shadowmap中存储eczi的值,然后对Shadowmap进行Filter处理,就可以得到右边的部分:

从前面的图像中我们知道,c的值越大,指数函数的图形就和真实的阴影判断越接近。不过在实际计算时,由于精度的限制,我们不能把c的值设置的过大,通常选择一个合适的值即可。

一个针对ESM的改进,是对深度值的编码做出一些改进,将结果保存在log空间中,这样可以使用更大的c值[11],得到的结果精度自然也会更高:

3. Exponential Variance Shadow Map(EVSM)
EVSM是一种对VSM的改进[12],人们发现,在使用VSM的时候,可以将深度使用一个 wrap函数进行处理,然后直接对wrap后的结果进行VSM中同样的计算处理,可以得到更好的阴影结果。

借鉴ESM中的做法,这个wrap函数就是ecx。在实践中,会使用-e-cx再求出一个wrap值,然后取两个结果的最小值。

因此这种方法叫做EVSM,结合了ESM和VSM的优点,缺点就是使用的Buffer存储较多,需要4通道。

4. Moment Shadow Map(MSM)[13]
Moment意思是矩[14],表示变量的分布特征,比如一阶矩就是平均值,二阶矩就是方差。

5. Filterable Shadowmap的小结
各个方案的概览如下,一般来说,使用的通道数越多,效果也越好。

方案使用通道数保存的参数

一个网络上的不同Shadowmap技术的示例[15]

相对于普通的PCF阴影,Filterable Shadowmap拥有一些模糊阶段的固定开销。在采样非常软的阴影的时候,相对普通的PCF是有性能优势的。但是在硬阴影下,性能反而会下降。

除此之外,Filterable Shadowmap要产生类似类似PCSS的可变柔和度的阴影,实现起来要复杂很多[16]

而且Filterable Shadowmap还有考虑硬件兼容,数值溢出,以及一些漏光等边界条件。因此个人不是非常推荐使用Filterable Shadowmap来代替普通的PCF阴影,特别是在移动平台上。不过在使用静态烘焙阴影时,因为可以进行预处理模糊阴影,使用Filterable Shadowmap是一个可以用来尝试降低运行开销的方案。

九、Contact Shadow

前面我们讲过,仅仅使用CSM阴影的话,在近距离观察人物的时候,精度往往是不够的。除了使用PerObjectShadow之外,另外一种提供近处高精度阴影的方式是使用Contact Shadow[17]

Contact Shadow的原理比较简单,是在屏幕空间进行逐像素的RayMaraching,来得到高质量的近距离阴影。因为RayMarching的开销较大,Contact Shadow RayMatching的距离一般都很短,大约在0.1m~0.5m左右。

Contact Shadow对CSM阴影通常是近距离细节补充的关系,一般不会直接使用Contact Shadow来代替普通的阴影计算。

Contact Shadow的另外一个用途,是用于使用了Parallax Occlusion Mapping的场景。此时无法在Shadowmap中算出精确的偏移值,就可以使用Contact Shadow。

十、基于SDF的阴影

前面我们说的阴影,都是通过处理模型来实现的。

SDF(Signed Distance Field)是一种保存空间中信息的场,保存空间中当前位置到最近的模型表面的距离。在物体外部时使用正数,在物体内部时使用负数。

由于SDF信息和模型的面数无关,因此我们可以使用非常大范围的SDF信息,并且使用 Clipmap来做LOD处理。CSM阴影,在距离较远处,由于需要处理的模型较多,开销也会增大。而SDF阴影就没有这个问题。

SDF的另外一个优势,就是可以非常方便地实现Cone Tracing[18],进而方便地实现软阴影和面光源阴影效果。相对于Shadowmap阴影,SDF阴影更加柔和。


SDF软阴影和普通阴影的效果对比

十一、环境光照的阴影

环境光照的阴影,其实就是我们常说的SSAO,即环境光照遮蔽。关于这部分,在我的专栏中[19]已经有详细的介绍。

十二、Capsule AO和Capsule Shadow

使用SSAO时,得到的AO效果范围较小,会导致人物的AO效果不是很好。而人物是动态的,又无法使用烘焙AO。Capsule AO将人物模型简化成胶囊体形状,并进行AO计算,来得到范围更大,更加柔和的AO。

同样,经过前面的分析我们知道,要使用Shadowmap来实现大范围软阴影,是非常困难的。Capsule Shadow是一种用于实现柔和的人物投影的阴影。

十三、基于光线追踪的阴影技术

阴影是实时光追中比较简单的应用,实现起来也非常简单。使用光线追踪,可以非常方便地实现一些面光源的软阴影效果,只需要在面光源上,采样很多个点,然后和目标点之间进行连线并计算遮挡。

十四、体阴影

前面我们讲的阴影,都没有考虑半透明物体的阴影。在考虑半透明阴影后,事情就会变得非常复杂。关于这方面的内容,会在未来的文章中详细讲解。

参考
[6] https://developer.nvidia.com/gpugems/gpugems/part-ii-lighting...
[7] https://jcgt.org/published/0003/04/08/paper-lowres.pdf
[8] https://developer.download.nvidia.com/shaderlibrary/docs/shad...
[9] https://developer.download.nvidia.com/SDK/10.5/direct3d/Sourc...
[10] http://www.cad.zju.edu.cn/home/jqfeng/papers/Exponential%20So...
[11] http://www.klayge.org/2013/10/07/%e5%88%87%e6%8d%a2%e5%88%b0esm/
[12] Rendering antialiased shadows using warped variance shadow maps
[13] http://momentsingraphics.de/I3D2015.html
[14] https://baike.baidu.com/item/%E7%9F%A9/22856460
[15] https://github.com/TheRealMJP/Shadows
[16] https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-a...
[17] https://docs.unrealengine.com/5.0/en-US/contact-shadows-in-un...,greater%20than%200.%0AClick%20for%20full%20image.%20More%20
[18] https://advances.realtimerendering.com/s2015/DynamicOcclusion...
[19] https://zhuanlan.zhihu.com/p/194198670


这是侑虎科技第1380篇文章,感谢作者张亚坤供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/people/tc130-52

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


侑虎科技
33 声望12 粉丝

UWA官网:[链接]