阴影对于提高游戏真实感非常总要,简单总结下游戏中的阴影实现.
先来看下阴影的组成部分
1.平面映射
最简单的情况就是物体在一个平面上投射阴影,这种情况下只是需要通过矩阵把产生阴影点面投射到平面上.
从v点映射到p点:
令
推导后写成矩阵的形式:
如果receiver不是一个无穷大的平面,需要通过stencil buffer标记出需要接受阴影的部分.
同样需要注意避免这种情况,这时候不应该绘制出阴影.
如果想要Soft Shadow的效果,可以在光源周围使用多个点进行投射阴影,然后取出一个平均值,但是这样产生的阴影性能消耗很大,一般需要几百次采样平均才能得到较好的效果.
另外一种方法是先得到阴影贴图,然后做一个blur处理.这样比较快,缺点就是不符合实际阴影近处清晰远处模糊的特点.
2.Projector实现
和上面的方法非常类似,区别在于先把阴影从光源的视角先渲染occluder得到阴影,再投射到receiver上,这样就可以把阴影作用到不平坦的面上.
这两种方法的缺点就是需要明确地知道occluder和receiver,只能适用于简单的场景.但是性能消耗相对较小,所以在手游上仍然能看到这些方法的应用.
甚至在手游上可以直接将阴影做成一张固定的贴图,以decal的形式贴到地面上,虽然是很简单的形式,也能极大地增强真实感.
3.Shadow Volumes <参考GPU Gems 3 Chapter11>
一种非常过时的渲染阴影的方法,但是其思想很值得学习借鉴.
1.shadow volume:
就是从光源沿着模型边缘拉伸至无限远处加上前盖后盖形成的形状.
2. z-pass算法:
shadow volume阴影的思想就是取一条从视点到目标点的线,每次进入shdow volume,计数加一,每次离开计数减一,这样计数为0的部分就是无阴影的地方.
通常使用stencil buffer来实现,从视点渲染shadow volume集合体,开启z-test,正面部分+1,背面部分-1,stencil buffer为0的部分就是无阴影部分.
3. z-fail算法:
z-pass算法有个缺陷,当视点在shadow volume中的时候,会产生错误的结果.
所以就有了z-fail的算法,z-fail算法其实就是从物体背面计数,在z-test fail的几何体部分,在进入shdow volume时计数-1,离开时计数+1,这样就可以规避这个缺陷.
不过z-fail算法普遍要比z-pass要慢,因为从背面渲染shadow volume,通常会覆盖更多的像素点,其次从上图可以看出,使用z-fail时必须渲染shadow volume的capping部分(前盖后盖).因此在渲染前可以做一个摄影机是否在shdow volume中的简单判断,来决定使用z-pass或者z-fail算法.
4. 生成阴影体的步骤:
一种最常见的一种生成shadow volume的方法,不过这种方法要求目标模型是封闭的多边形网格(没有空洞,裂隙,自相交).
分为三部分: front capping 前盖-> back capping 后盖-> silhouette 轮廓拉伸成的侧面
front capping就是取模型中面向光源的三角面,方向判断可以通过判断面法线和光源方向的乘积的正负值来判断.
back capping 取模型中背向光源的面,沿光源方向拉伸到无穷远处.
silhouette 判断两个临接面与光源方向不同的边,认为是轮廓边,将每条边扩展拉伸到无穷远处形成一个四边形面.
5. 在无穷远出的渲染:
如何表示无穷远处的点?使用齐次坐标将w分量置为0,xyz表示方向即可.
如何避免图元在摄影机far clip plane外被裁剪掉?
一种方法是使用 GL_DEPTH_CLAMP_NV 扩展, 将far plane外的点clamp到裁剪空间中.不过这个方法好像是只适用于OpenGL 和 NVIDIA显卡.
另外一种方法是稍微修改下摄影机的裁剪矩阵,将far plane设置为无穷远
变成下面这样:
当然精度或有微乎其微的减少.
6. 适用于非封闭模型的方法:
把模型分成两部分,一部分是面向光源的面,一部分是背向光源的面,分别进行拉伸生成shadow volume,就可以支持非封闭模型.缺点是原来的轮廓边相当于生成了两次,造成性能浪费.
7. 使用Geometry shader生成Shadow volume
使用GS可以将生成shadow volume的工作移交给GPU,不过必须用TRIANGLE_STRIP的方式来输入模型.
使用GL_TRINGLES_ADJACENCY_EXT模式来向GS中输入三角形图元,买游戏平台就可以获取三角形的邻接面,以此在GS中进行轮廓边判断,输出Shdow volume等操作.
4. Shadow Map
当下应用最广泛最常见的方法,从光源处出发向光照的方向渲染需要产生阴影的物体,得到保存最近处物体的深度值的shdow map.
对于directional light使用一个足够大的orthographic projection包住所有需要渲染的物体, spot light使用一个和自己光照范围相当的frustrum, omini light沿六个方向生成类似于 cubic environment mapping的 omnidirectional shadow maps.
渲染物体光照时,将像素点代入到光照的矩阵中,和shadow map中该点的深度值比较,如果深度值大于shadow map中深度值,说明该点在阴影中.
因为Shadow map的分辨率限制,可能会出现 self-shadowing,因此需要加上一个小的bias偏移量.
5. Shadow Map 增强
1.Cascaded Shadow Maps(CSM)
参考文献
通常在渲染视角附近的物体时需要更高的shadow map精度,而直接生成的shadow map往往不符合这个条件,所以将frustum分割成数个部分,提高视角附近shadow map的精度.
2. Percentage-Closer Filtering (PCF)
参考文献
在采样点附近周围选取一些点,分别进行depth-test,将测试结果进行平均.
现在的硬件大多提供周围四点采样的加权PCF深度测试(OpenGL中的sampler2DShadow, DirectX中的 SampleCmp)
再向外的PCF就需要手动偏移采样点,简单的方法是使用N*N的Grid方式采样,高级的方法是在一个disk中用预计算好的Possion分布(或者其他带抖动的分布方式)的点采样,然后每个像素采样时对disk进行旋转,产生soft shadow的效果.
3. Percentage-Closer Soft Shadows (PCSS)
参考文献
根据光源到目标点距离和Occluder到光源距离,计算PCF的软阴影程度值,再进行PCF处理,得到近处锐利远处模糊的阴影.
4. Linearized Depth
参考文献
普通的阴影可能会出现在远处精度不足的情况,因为一般的阴影深度z值不是线性的,在近处精度大,远处精度小.所以有线性阴影的做法.
改变普通的FS代码,大致写成这样:
- float4 vPos = mul(Input.Pos,worldViewProj); vPos.z = vPos.z * vPos.w / Far; Output.Pos = vPos;
先将深度值除以Far远平面的值,得到0-1的线性阴影深度值,再乘以w值,这样在光栅化时得到深度值vPos.z / vPos.w,自然是我们得到的0-1的线性阴影深度值.
5. Variance Shadow Map(VSM)
参考文献
用PCF产生软阴影时,每计算一次深度值,需要采样很多个点的深度进行比较然后求和.
VSM是一种Filtered Shadow Map,可以对Shadow map进行blur或者mipmap,每次计算阴影时,不需要采样目标点周围的很多个点,节省性能.
生成两张Shadow Map,一张是普通的深度值,另外一张存储深度值的平方.
对两张Shadow map进行blur,每个新的像素点的值是原来点周围点值的加权平均.
如果求得的目标点的深度值小于ShadowMap中的 E(x)值,认为该点被完全点亮,不渲染阴影,这里和普通的阴影渲染一样.
当目标点深度值大于E(x)值时,根据Chebyshev不等式推导出该点周围点深度值大于目标点深度值的概率:
根据这个概率值,就可以来计算软阴影的程度.
6. Exponential Shadow Map ESM
参考文献
ESM也是一种类似VSM的Filtered Shadow Map
假设d代表shadow map中的深度值,z代表目标点的深度值,得到阴影函数f(d, z) = 0 (当d > z) / 1 ( 当d<=z),f(d,z)叫做Heaviside step function.
ESM就是用一个指数函数来模拟f(d,z):
图中可以看出指数函数和Heaviside step function很相近,而且c值越大,近似效果越好.
Shadow Map中存储exp(c*d)的值,可以进行blur,来产生软阴影.
普通的ESM会有精度限制的问题,会限制c的值不能太大,所以有改进版的ESM,具体比较可以参考这个切换到ESM - KlayGE游戏引擎以及上面的参考链接.
7. Pack To RGBA
某些移动平台不支持浮点数纹理,需要将shadow map的深度值pack到RGBA贴图中
- //Pack:
- vec4 comp = fract(depth * vec4(255.0 * 255.0 * 255.0, 255.0 * 255.0, 255.0, 1.0));
- comp -= comp.xxyz * vec4(0.0, 1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0);
- //UnPack:
- float depth = dot(texture((m_tex), (m_uv)), vec4(1.0 / (255.0 * 255.0 * 255.0), 1.0 / (255.0 * 255.0), 1.0 / 255.0, 1.0))
也有使用256替换255的版本,但是用255比256是要好的.在某些硬件上用256会有表现不一致情况,而且精度略低些.