[工作积累] shadow map问题汇总
1.基本问题和相关
Common Techniques to Improve Shadow Depth Maps:
https://msdn.microsoft.com/en-us/library/windows/desktop/ee416324(v=vs.85).aspx
Cascaded Shadow Maps
https://msdn.microsoft.com/en-us/library/windows/desktop/ee416307(v=vs.85).aspx
Soft shadow
PCSS: http://developer.download.nvidia.com/shaderlibrary/docs/shadow_PCSS.pdf
PCSS shader sample: http://developer.download.nvidia.com/whitepapers/2008/PCSS_Integration.pdf
Translucent shadow
http://www.crytek.com/download/Playing%20with%20Real-Time%20Shadows.pdf
Shadows & Transparency
Translucency map generation:
- Depth testing using depth buffer from a regular opaque shadow map to avoid back projection/leaking
- Transparency alpha is accumulated only for objects that are not in “opaque” shadows
- Alpha blended shadow generation pass to accumulate translucency alpha (sorted back to front)
- In case of cascaded shadow maps, generate translucency map for each cascade
- Shadow terms from shadow map and translucency map are both combined during deferred shadow passes with max() operation S
2.实现细节和问题
Hardware shadow map:
D16/ D32 / D24S8 作为rendertarget.关闭color wirte以加速渲染.
Hardware PCF:
- 纹理采样开启双线性过滤
- d3d9用tex2Dproj, d3d11用SampleCmpLevelZero
这两个指令跟普通双线性采样不同的是, 会先执行比较(深度比较),然后基于比较后的结果(阴影强度)进行双线性插值
Depth Bias
常规的shadow acne通过depth bias就可以修复, 如果是自阴影, slope scaled bias也很重要.
如果需要用RFloat来绘制深度, color buffer没办法用bias, 那么就需要模拟slope scaled bias. 根据 https://msdn.microsoft.com/en-us/library/windows/desktop/cc308048(v=vs.85).aspx
对于dx可以这样模拟:
(shadow slope scaled depth pass, rendering to a RFloat)
float dx = ddx(depth); float dy = ddy(depth); const float bias = max(abs(dx), abs(dy)) * YOUR_SLOPE_SCALED_BIAS; return depth + bias;
OpenGL 也有类似的功能 https://www.opengl.org/sdk/docs/man/html/glPolygonOffset.xhtml
原理都一样,spec没有说具体公式,但是通常有个偏移就可以正确显示了,不需要精确的一致
最小视锥(minimal frustum)
在shadow map贴图大小固定的情况下, 视锥越小, shadow map上的内容越少(有效内容不变), 所以利用率和分辨率越高.
naive 实现:
1.计算场景包围盒
2.根据场景包围盒计算最小视锥
3.渲染shadow map
更好的实现:
1.计算场景包围盒
2.用场景相机裁剪这个包围盒
3.用裁剪后的凸包体,计算最小视锥
4.渲染shadow map
由于场景包围盒被相机裁剪(求交), 所以包围盒变小, 那么生成的视锥也变得更小.
因为视锥是不规则六面体, 视锥和AABB求交得到的是一个凸包.求交方法可以看Ogre, Ogre有convex实现.
我用的另外一个方法: 用aabb 的12条边和视锥的6个面求交, 视锥的12个边和aabb的6个面求交, 得到的交点如果同时在aabb和视锥内就是凸包体的顶点.
如果要计算凸包在Light space的包围盒, 那么应该先将每个顶点变换到light space, 再求包围盒.
如果先求包围盒, 在把包围盒变换到light space, 因为AABB的轴对齐特性, 变换以后通常会变大.
我尝试过的其他的实现:
将场景bounding (minz, maxz)渲染到小纹理(比如32x32), 再download到CPU, 最后得到硬件裁剪后的最后可见的bounding box.
特点: shadow map利用率高, 对于自阴影, 拉得非常近时, 效果仍然非常清晰. 这种方式一般情况下没有必要使用.
这个就是类似 SDSM (Sample Distribution Shadow Maps) (一开始不知道叫这个名字,自己想的方法,后来在龚敏敏大大的文章里才知道), 这里有个例子,是将深度读回并down sample得到minz和maxz: https://github.com/TheRealMJP/Shadows
Depth bias based on frustum depth range
因为使用的是Hardware shadowmap, 所以需要指定depth bias和slope scaled bias.
由于frustum根据根据convex的顶点计算来. 如果frustum的深度范围(znear, zfar)不固定的话, 那么相同的bias值, 对应的误差会有浮动.
所以可以根据视锥的深度范围来计算bias, 这样的误差值是固定的. 方法:
渲染shadow map depth的时候只指定slope scaled bias, 不指定depth bias.
渲染物体的时候, shadow matrix = M * light_projection * light_view , 也就是再乘以一个参数矩阵 M (DepthBias):
float offset = isD3D9 ? 0.5f / (float)ShadowMapSize : 0f; //half texel offset float bias = -0.0015f / (DepthRange) * adjustScale; DepthBias[0] = Vector4(1, 0.0f, 0.0f, offset)); DepthBias[1] = Vector4(0.0f, 1, 0.0f, offset)); DepthBias[2] = Vector4(0.0f, 0.0f, 1.0f, bias)); DepthBias[3] = Vector4(0.0f, 0.0f, 0.0f, 1.0f));
同时由于计算出的uv要采样shadowmap, 对于dx9来说, 同时可以预先应用half pixel offset.
PCSS:
PCSS的原理比较简单, 也有很多变种. 目前用的是标准的实现. 遇到的问题:
PCSS的半影采样范围是根据相似三角形计算出来的
float PenumbraSize(float zReceiver, float zBlocker) //Parallel plane estimation { return (zReceiver - zBlocker) / zBlocker; }
如果zblocker的深度太小(接近0), 那么半影采样范围就变得非常大, 难以接受.
这种情况在做Self shadow的时候会遇到, 因为视锥是很小的. 解决办法:
1.用户指定半影的大小范围(shader constant, lightWidthMin, lightWidthMax), 然后根据深度(距离)做线性插值.
或者 2.放大depth range, 这样最小的depth也不会接近0
如果产生阴影的物体和接受阴影的物体靠得太近, zReceiver - zBlocker 太小, 导致半影范围接近于0, 导致锯齿:
解决办法可以用上面的线性插值, 因为线性插值最小值是lightWidthMin, 保证有最小的半影范围.
或者: 在半影范围上加一个常量值, 比如1.0/shadowMapSize
个人使用lightwidth_min和max线性插值, 这样也方便美术调控参数.
Translucent shadow(not alpha test):
上面已经贴出的Crytek和StarCraftII的方法了, 方式比较类似.
我这里的简单实现:
R8 + Depth16, alpha和depth同时绘制, 一个color buffer保存alpha, 一个depth buffer保存深度.
opaque: Depth test - less, disable color write.
transparency: Depth test - less, disable Z write, output alpha, enable color blending(addative)
这种方式比较简单, 一次绘制没有render target切换, 先画不透明物体再画半透明物体. shader中对于alpha的阴影判断也比较hacky: 如果alpha值(R8.color.r)不为0, 则认为有阴影, 不需要比较深度. 因为能写alpha值的时候, 说明深度测试less通过了. 对于PCSS需要深度的, 可以模拟一个深度值.
问题: 不支持自阴影(doesn't support self shadow). 产生阴影的半透明物体本身, 如果要计算阴影, 根据alpha!=0这个判断, 也是有阴影的...
改进方式:
基于上面的方式, 给transparent objects再加上一个depth pass, 绘制阴影时采样两张深度图. 或者将前面的R8改为RGBA, A保存透明度, RGB打包深度, 单独混合alpha通道, 这样不用切换render target.
两个depth pass的话多一张D16的贴图, 显存占用要比RGBA小.
另外, alpha值越大, 光线穿透越少, shadow值应该越小, 我用crytek的max()结果是不对的,我用的是min(sample(ShadowMap), 1-alpha), 怀疑crytek使用的是 1- max(1-sample(ShadowMap), alpha).
即crytek paper所说的shadow term是(1-sample(shadowMap)), 改值越大,阴影就越大。
问题: 自阴影错误
| | |
a1 a2 O
如上, O是不透明物体, a1和a2是透明物体.
当出现多层透明物体的时候, O的阴影是对的, 因为a1和a2的alpha 会混合.
基于上面的改进, 因为有了深度信息, 再加上bias, 所以a1不会有阴影, 也是对的.
但是a2的阴影和O的阴影是一样的, 都是基于同一个alpha计算出来的.由于a2是半透明物体, 阴影表现没有那么明显, 这里的问题可以忽略.
或者: 用深度来做线性插值进行:
shadow(uv.xyz) = lerp(1-alpha_a1a2, 1, saturate( (depth_O - uv.z) / (deoth_O - depth_a1) ) );
其中uv.z是shader中当前物体的深度. alpha_a1a2是alpha混合的结果. depth_O是opaque shadow map采样出的深度. depth_a1是transparent shadow map采样出来的深度.
这其实还是不对的, 因为a2的阴影透明度是1-alpha_a1, 跟距离无关, 但是可以解决透明物体和非透明物体靠的太近时的z fighting和shadow acne.
上面是工作中主要遇到的问题. 另外简单记录一下其他东西.
CSM
如果所有阴影都产生在一张shadowmap上,那么近处的分辨率也会比较低.CSM主要是通过多个cascade来提高近处的阴影分辨率.
之前工作中也做过PSSM, 一般会把多个shadowmap合到一张上, 比如4张1024x1024的shadowmap, 可以用2048x2048的贴图, 通过viewport来绘制四个区域. 上面Crytek也提到了.
还有CSM边界分割处也需要blend处理, 不然会有缝隙.
PSM
perspect最大的特点是近大远小, 所以使用pserspective 投影, 来提高近处阴影的分辨率.
但一般方向光都是平行光, 需要用orthographic (parallel)投影, 但由于一般场景相机都是perspective, 视锥不是box, 但在投影以后(post perspective space)是一个box, D3D是z[0,1]的扁盒子, OGL是一个cube. 在这个空间下, 因为方向会有切变(比如世界空间下的视锥四条射线, 到了这个空间是4条平行线), 所以原本世界空间的方向光到了post perspective space就变成了点光源, 可以用perspective projection了.
原理大致是这样, 实现的话会比较tricky.
另外一个Light space perspective shadow map (LiSPSM), 也是一种PSM, 还是利用perspective 投影来提高近处投影的精度. 主要的改变是不在用场景相机的post perspective space. 因为perspective投影下大部分方向都会有形变, 但是垂直于视方向(平行于xy平面)的方向不会有改变. LiSPSM就是利用这一点, 使用一个垂直于光照方向的透视投影, 来渲染深度. 如果直接用这个视锥渲染的话, y值就是沿着光照方向的深度值. 所以先把光空间变换到垂直于光照方向, 应用透视投影, 再变换回来, 得到的z就是深度了. 这个实现上要比PSM简单得多. 我也尝试了一下, 但是和一般的shadow map差别不大(比LiSPM的demo视频和代码效果差多了), 可能是实现上有点问题, 或者是只渲染了一个角色的自阴影的问题.
更新:关于自阴影的问题,如果要支持全方向的光源,遇到了一些问题。
1.一般阴影用depth bias效果已经不算差, 自阴影需要slope scaled bias 才能更好的去掉acne
2.自阴影的projective aliasing(投影锯齿,最前面第一个链接有)非常严重:
projective aliasing是因为光照方向和表面切线平行(与法线垂直)的时候,这个面上的的所有点只共享了一个投影点造成的阴影精度不够。shadowmap分辨率和利用率提高,以及好的sampling比如NxN的PCF可能会缓解这种问题。
我目前的处理方式,是根据它产生的特点,做一个纠正
#define SELFSHADOW_COS_MAX 0.00872653549837393496488821397358 //cos 89.5 degree shadow = sampleSahdowMap(...); shdaow = min( saturate(NdotL-SELFSHADOW_COS_MAX ), shadow);
即在接近垂直的时候,clamp为0,省得各种黑白交错。这么做的结果是,0.5 degree precision loss,但是效果好了很多。
注意在计算spot/point light 的NdotL时, L = normlalize(lightPos.xyz - worldPos.xyz * lightPos.w); 是光源位置到当前点位置的向量, 这跟绘制shadowmap的direction并不一样,需要使用实际的光源方向。
为了方便使用,可以封装为一个简单的宏或者函数:
//anti projective alias #define SHADOW_APA(worldNormal, shadow) min(saturate(saturate(dot(worldNormal.xyz,Uniform_SelfShadow_Direction[Uniform_LightIndex].xyz)) - SELFSHADOW_COS_MAX), shadow)
这里Uniform_SelfShadow_Direction就是方向光的方向或者spotlight的方向,是渲染shadowmap时使用的方向。
更新:
对于toon shading,由于非真实光照的缘故,大多不能使用NdotL,所以上边方法不可行,有两个方案:
1.使用HW shadowmap,HW PCF fliter能够很大程度减少projective alias
2. 使用VSM。经过测试,VSM能过很大程度减少与projective alias,比HW shadowmap要好很多
更新笔记2:
前面提到的LiSPSM,效果不好的原因找到了,因为LiSPSM在计算光空间包围盒的时候有点tricky,用了视锥的点往场景做raycast,得到更紧凑的包围盒。简单点用ascii图示。。:
E F +-----------------------+ |\ /| | \ / | | \ / | | \ / | | \ / | | \ / | | \ / | | \ / | +--------+-----+--------| A B C D
上图为光空间的,垂直于光方向的截面,BCEF是相机视锥投影,BC是近平面的点,EF是远平面的点。LiSPSM需要用BCEF(实际上在3D空间有8个点)作为原点,光的(反)方向作为方向,做raycast,于场景包围盒相交,得到的点用于计算土凸包和最小包围盒,这么做是为了扩展包围盒,将场景不可见的,但是影子可见的物体加入包围盒。
我之前用的是足够大的光的正交视锥,和场景包围盒相交,其实就是ADEF,这样会将近平面BC放大到AD。很明显用正交视锥来算的话,包围盒和标准shadow map一样大,主要是近平面被放大,会导致LiSPSM的透视投影变很大。。。
其实LiSPSM的raycast方法,在极端情况下也会有问题:
R F' Q +-----------+-------------------+ | \ | G +H \ | | \ | |C' \ | |\ \ | | \ \ | | \ \ | | \ \ | +----+---------------+----------+ O C F P
比如上图,OPQR是场景包围盒,CF是视锥的近平面和远平面上的点,FF'是光的反方先, raycast得到的点是C‘和F’。 raycast场景包围盒,相交的凸包点为CFF‘C’,得到的光空间包围盒为CFF’G,F‘G垂直于光方向并与场景包围盒相交于H。 那么F’ R H, 这个区域内的物体是可能没有投影的。 这种情况比较极端,在大场景下基本看不到,在查看模型的时候,模型的内部边缘看(比如sponza场景的墙里面),就会出现。