之前在工作总汇总了shadowmap的各种问题 [工作积累] shadow map问题汇总

最近有点时间再仔细研究了shadowmap的一些算法。主要修复了LiSPSM(上面链接里后面有更新),实现了TSM和CSM阴影。

总的来说,CSM只是结构上的不同,多了拆分和几个pass,实现起来相对比较简单。比较花时间的是LiSPSM和TSM的调试。至于为什么要研究LiSPSM和TSM,主要是在不能使用CSM的时候(比如低配,mobile之类),可以有更好的效果。另外,CSM和LiSPSM、TSM并不冲突,每个pass可以选择不同的方式。

LiSPSM和TSM都是透视算法,改变阴影的分布,因为透视的视点点离场景相机比较近,所以离场景相机近的物体,投影的面积大,分辨率高,远处分辨率低,但因为远,所以并没有效果损失,相反,如果不用透视,反而有点浪费。LiSPSM比较tricky,比如光空间包围盒的计算,和近平面的选择。TSM从原理和实现上都比较简单,效果也比LiSPSM好,唯一的缺点是需要在fragment shader里写入深度,而且需要另外一个矩阵计算深度,与标准shadow map (standard shadow map, SSM)和LiSPSM的shader编写/维护的兼容性不好,需要用一堆宏来整合和嵌入到不同的shader里面。

渲染流程

通常为了提高阴影质量,不管哪种阴影技术,会用最小包围盒来计算投影矩阵,这依赖于场景的包围盒。对于大的场景,可以根据地形等大件物体预估一个包围盒,因为实时更新精确的包围盒不划算,也没太大必要。

另外一种方式与场景包围盒解耦,但是要分多个步骤 (渲染所有类型的shadow map都适用):

1.使用足够长/远的视锥(view/projection)剔除场景 (渲染shadow map总是要剔除的,所以先用足够远的视锥,保证所有可见阴影的shadow caster都在)

2.根据剔除后的包围盒,代替场景包围盒,做凸包体相交
3.重新计算并设置渲染用的view/projection

也就是说scene culling和rendering用的view/projection是不一样的,后者基于前者的culling结果重新计算。

以下图为例,求红色的光源视锥,可以不需要场景包围盒,而是把这个额光源视锥拉长到足够远,使用对场景剔除得到的包围盒与足够远的光源视锥相交。

Depth clip / Depth clamp

这个方法在M$的 CSM demo里叫pancake。

如果不想计算最小包围盒,直接用可见物体包围盒的凸包体(上图中的绿色视锥部分)来计算投影矩阵,看起来也可以,问题是场景不可见的物体,投影可能可见。比如天上有一只鸟,虽然相机没看到,但是地上的影子要看到。如果用场景的可见包围盒来计算投影,阴影投影的近平面在这只鸟前面,所以鸟被clip掉了(位于绿色视锥之外,红色视锥之内)。D3D10+和OGL提供了方法可以关闭clip。设置D3D11_RASTERIZER_DESC.DepthClipEnable = FALSE, glEnable(GL_DEPTH_CLAMP) 就可以关闭depth clip,或者启用depth clamp (depth clamp 到[-1,1]所以不会被clip掉)。

这种方式同时会减少projection的Z range,只能提高shadow depth的精度范围(绿色的视锥明显比红色短很多),但是并不能提高分辨率。

D3D9 在vertex shader里面clamp z 也是可以的:

float NDCZ = clipPos.z / clipPos.w;
NDCZ = clamp(NDCZ, , );
clipPos.z = NDCZ * clipPos.w;

这种方式对于正交和透视都有效。

坑:

depth clip 乍一看没什么问题,但问题还是有的:

                          + B
/
/
Z- /
^ /
| /
| /
+--------------------+ +B'
| / |
| / |
| A / |
| + |
| |
| |
| |
| |
+--------------------+

上图中的box是NDC空间,AB是三角形的两个顶点,A在NDC cube内,B在NDC cube外, depth clamp以后B的位置在B‘。因为在vertexshader里面做clamp,整个三角形的几何都变了,线性插值以后深度会沿着AB’,

这条线的深度比以前的AB相比,整个深度都变大了,会导致有的阴影消失。 同样,如果B在z=1的下方,Bz>1,那么会导致这条线深度变小,会导致没有阴影的地方出现阴影。

我不知道D3D11的关闭depth clip怎么实现的,但是很有可能也是在vertex stage做的处理,会出现同样的问题。

解决的方法是手动输出fragment depth,在pixel shader里面clamp。。或者放弃这种方法。我目前的选择是放弃这种方法。因为本身它不是特别必要,又不适用于perspective shadow(shader可以实现,但是由于透视的原因问题更多),而且输出fragment depth会使Hi-Z和early Z失效,虽然shadow depth pass没输出颜色,但是这样的话,绘制深度就要做per-fragment depth test了。当然这点效率可以忽略,最大的问题是目前对于shadow depth pass的集成变得很不方便,需要加宏放到vsoutput里输出depth。因为有很多shader的depth pass是直接写到每个shader里面的,而不是统一的一个shader, 统一的一个shader对于普通静态模型倒是可以,直接用mvp矩阵,但是对于顶点有改变的情况(比如skinned mesh或者LOD morphing的地形)都还需要单独写,所以没太大意义。

TSM

LiSPSM的原理在前面已经简单做备忘了,下面笔记记录TSM的原理

TSM是Trapezoid shadow map (梯形shadow map),所谓的梯形,是场景相机视锥,在光空间横截面上的投影,大部分情况下都是梯形。通过一系列变换,把这个梯形填满整个截面。因为变换中包含了透视变换,实际上TSM是原理上非常类似LiSPSM的一种透视方法,只不过实现思路不一样。

TSM也是需要在垂直于光的方向做透视投影,因为只有垂直于光的方向的透视投影,才不会因为透视而改变光照方向,前面的问题汇总里面已经记录。 假设在做梯形变换之前,用一个垂直于光方向的正交投影(想象这里有一个正交视锥的box)渲染阴影深度,那么在梯形变换以后,这个正交投影会随着梯形变成box的过程中,变成透视投影(box变成了透视frustum)。

具体的变换步骤不复制了,在这里: http://www.comp.nus.edu.sg/~tants/tsm/TSM_recipe.html  需要记录的一点是,如果选取的视点和方向好的话,前两步变换是可以跳过的。

如果只用上面的变换,实现出的结果会很有问题,另一个关键是80%rule

80% rule

TSM的特色在于用80%rule来调节阴影质量,详细的分析在 http://www.comp.nus.edu.sg/~tants/tsm.html 里面的ppt链接里 (ppt:http://www.comp.nus.edu.sg/~tants/tsm/EGSR_TSM_presentation.ppt

为什么要用80%rule?不使用80% rule,照着前面的体型变换做完,会发现透视得非常厉害,稍微远点阴影就非常模糊。因为这个时候渲染shadow map的透视投影的透视强度和场景相机的相关度是1:1,假如场景相机的水平FOV是90,远处分辨率太低了。

使用了80%rule以后就好了很多,本来我以为80%rule是为了提高近处的阴影分辨率,事实上是为了提高远处的分辨率。

80%rule 是指定某一个距离F,将其投影到shadow map上的80%处(保证质量), 来反算近平面距离/视点,因为近平面的宽度是固定的,这也就相当于调整了透视强度(fov),从而调整了分辨率分布。

根据80%rule 计算视点距离,方法上面的ppt里有,是用透视投影矩阵,和投影后的位置,反算近平面距离zn.

NDCz = - +  * 0.8f;
perspective projection along Z+: to [-, ]
|F+zn,| * |(zn+zf)/(zn-zf) -| = | -NDCz*(F+zn), -(F+zn)|
|-*zn*zf/(zn-zf) | zn = zn, zf= zn+lambda
(F+zn,) * projection = (-NDCz*(F+zn), -(F+zn))
(F+zn)*(zn*+lambda)/(-lambda) - *zn*(zn+lambda)/(-lambda) = -(F+zn)*z

其中lambda (λ)是视锥的深度(maxZ-minZ), 矩阵展开后上面最后一个等式,可以求出zn (η)。乍一看是二次方程,展开消元以后是一次方程。

TSM Fragment Depth

因为TSM的透视性,相机近处的Z range比较小,远处的Z range比较大,导致深度的范围分布不是固定的,所以depth bias和slope scaled bias都没办法工作。

depht bias的问题,可以在shadow/shading pass用bais matrix,并且在shadow 的view space做 bias(固定值,跟深度范围无关),但是slope scaled bias好像没办法解决,因为深度的坡度是基于屏幕空间变化率ddx/ddy来计算的。

解决方法是在pxiel shader里输出custom depth,这个depth用的是standard shadow map的depth,是均匀分布的,所以没有问题,但是缺点也有,一是要另外一个shadow depth matrix来计算深度,另外更重要的是前面提到过的,集成不太方便,要加一堆宏。

其实LiSPSM也是透视的,理论上也有一样的问题。但是LiSPSM透视强度没有TSM那么“激进”,所以没有出现类似的问题,这同时也是LiSPSM的质量没有TSM高的原因。

CSM

不管是TSM还是LiSPSM,在场景相机和光照方向平行的时候:TSM在光空间横截面上投影的梯形会变成box,没办法做透视;而LiSPSM的透视方向跟场景相机视方向垂直,投影出的“近处”高分辨率离相机视点并不近。所以两种方式的阴影质量还是很低。

这种情况叫做dueling frusta(视锥决斗)(场景相机的视锥和光源视锥平行正对着,很形象),很多阴影技术都无能为力,这个时候,CSM (Cascade Shadow maps)能改进。

使用CSM将视锥分割,这样近处分割的视锥,光空间方向的xy会变小,投影以后的分辨率就会变大。

原理和实现都很简单,但是结果很关键。

如果使用(2048x2048)x(2x2)的CSM,如果使用32位depth stencil,显存占用为64M,开销还是很大的,所以选择16位的depth(比如d3d9的D3DFMT_D16),显存占用降到32M。

CSM for forward shading

CSM在screen space的deferred shadow实现比较方便,但如果是legacy的forward,比如SM 2_a,没有readable depth stencil来defer,又不想渲染浮点颜色精度的深度话,就没有screen space shadow了。

这个时候要集成到每个forward shader里,由于pixel shader profile 2_a 的constant register 只有32个, 又加上local light也是一个pass, 4个光源(blade 不支持更多pass foward shading),这个时候常量寄存器就会不够用,爆掉。

如果关闭所有local lights的支持,或者使用多pass lighting,或者加上非 depth_stencil的depth pass (R32F)来做deferred shadow, 都可以解决问题。 我目前对于forwad shading,暂时关闭CSM,这个直接在配置文件里面,不需要改代码。唯一要改的是shader的宏,如果像Unity那样可以动态开关shader feature,shader也不需要改了

forward shading的优化: 可以在vertex shader里面计算cascade index 和shadow uv/depth,跟SSM的方式一样, 不过需要SM40,因为SM30及其以前的index在插值的时候,会有插值,并没有Interpolation Modifiers ,导致到了pxiel shader stage,前两张shadowmap衔接处的index值介于0~1之间,DX10+可以关闭线性插值。但如果是DX10+的话,不如直接用screen space deferred shadow了,所以这个优化没多大意义。

CSM 的多相机处理

CSM有多个相机渲染,那么更新的策略可能需要微调。 场景相机的可见集,和阴影相机的可见集,有交集,但不完全重合。

比如地形的批次合并,需要支持多个相机;

地形的LOD更新,如果是可见才更新,那么不管哪个相机在计算可见性,更新的源相机都要使用场景主相机,否则阴影深度相机的位置可能和场景相机差别很大,导致阴影的LOD divergence,或者影响场景LOD;
同时,如果是可见才更新,那么多个相机时,可能会有多次可见的事件(多次回调或者更新函数),为了避免冗余的计算,可以使用mask 标记,更新过的就不更新。或者使用FrameID,同一帧只更新一次。

同样模型的更新,比如骨骼动画等,如果是可见才更新骨骼动画,也用类似的方法处理。

不光是CSM,比如planar reflection的相机,总之只要处理好 有多个相机的情况,并尽量保证最优化。具体更新时用哪个相机,是具体模块的逻辑,和渲染框架无关,最好把当前相机和场景主相机都作为参数,这样模块可以决定自己的更新逻辑。

CSM with persepective shadow map

CSM可以和LiSPM/TSM结合使用,

但是有一个严重的问题,而且在高度差比较大的场景(比如飞行游戏/位于高山)中很常见。比如CSM和LiSPSM结合的时候,由于透视的原因,第一级shadowmap和第二级shadowmap的接缝处,第一级的质量反而比第二级的低。

因为第一级的最远处,透视的结果是分辨率更低, 而第二级的最近处,因为透视的原因,分辨率更高。 标准shadow map往往是第一级比第二级更清晰。

所以如果要结合的话,建议方案如下:

方案1

第一级使用TSM,并使用80%rule调整远处阴影分辨率,可以和第二级较好的衔接;

第二级和后面的所有shadow map,都使用standard shadow map,不用透视,或使用TSM调整。

这样可以最大化的提高近处阴影的质量,特别是自阴影,又不会使衔接效果变差。

方案2

每一级都使用LiSPSM/TSM,并且使用最激进的策略将CSM的精度放在近处(如果使用了分割指数,把它调大),比如四级CSM,距离分别为1.86, 17.32, 161.18,1500.00,这样做有两个好处:

第一近处的分辨率非常高,第二近处的分割距离短,透视造成的每级末端的模糊(上图)非常小。虽然最后一级范围很大,但是因为比较远,而且是透视的所以效果不会很差。
这种方案即便是2048的shadow map(1024x4),效果也是可以接受的。如果使用TSM,可以使用1024(512x4)效果应该也可以接受,可能运行在mobile上(纯推测,没有做实际效果测试)。

引擎设计跟踪 ShadowMap 细节和分析的更多相关文章

  1. 引擎设计跟踪(九.14.2a) 导出插件问题修复和 Tangent Space 裂缝修复

    由于工作很忙, 近半年的业余时间没空搞了, 不过工作马上忙完了, 趁十一有时间修了一些小问题. 这次更新跟骨骼动画无关, 修复了一个之前的, 关于tangent space裂缝的问题: 引擎设计跟踪( ...

  2. 引擎设计跟踪(九.14.3.3) Deferred shading的一些小细节

    1.ambient light 之前的shader里面, 方向光会加上ambient 的计算. 但是如果没有方向光, 就没有ambient. 这是把全局方向光改为点光源之后发现的, 因为透明物体的fo ...

  3. 引擎设计跟踪(九.14.2 final) Inverse Kinematics: CCD 在Blade中的实现

    因为工作忙, 好久没有记笔记了, 但是有时候发现还得翻以前的笔记去看, 所以还是尽量记下来备忘. 关于IK, 读了一些paper, 觉得之前翻译的那篇, welman的paper (http://gr ...

  4. 引擎设计跟踪(九.14.2i) Android GLES 3.0 完善

    最近把渲染设备对应的GLES的API填上了. 主要有IRenderDevice/IShader/ITexture/IGraphicsResourceManager/IIndexBuffer/IVert ...

  5. 引擎设计跟踪(九.14.2f) 最近更新: OpenGL ES & tools

    之前骨骼动画的IK暂时放一放, 最近在搞GLES的实现. 之前除了GLES没有实现, Android的代码移植已经完毕: [原]跨平台编程注意事项(三): window 到 android 的 移植 ...

  6. 引擎设计跟踪(九.14.2d) [翻译] shader的跨平台方案之2014

    Origin: http://aras-p.info/blog/2014/03/28/cross-platform-shaders-in-2014/ 简译 translation: 作者在2012年写 ...

  7. 引擎设计跟踪(九.9) 文件包系统(Game Package System)

    很早之前,闪现过写文件包系统的想法, 但是觉得还没有到时候. 由于目前工作上在做android ndk开发, 所以业余时间趁热做了android的移植, 因为android ndk提供的mountab ...

  8. 引擎设计跟踪(九.8) Gizmo helper实现与多国语言

    最近把gizmo helper的绘制做好了. 1.为了复用代码,写了utility来创建sphere, cube, cylinder, plane, ring(line), circle(solid) ...

  9. 引擎设计跟踪(九.14.3.4) mile stone 2 - model和fbx导入的补漏

    之前milestone2已经做完的工作, 现在趁有时间记下笔记. 1.设计 这里是指兼容3ds max导出/fbx格式转换等等一系列工作的设计. 最开始, Blade的3dsmax导出插件, 全部代码 ...

随机推荐

  1. enquire.js-响应css媒体查询的轻量级javascript库

    轻量,没有依赖. 安装: npm install enquire.js API: enquire.register(mediaQuery, handler). mediaQuery: 字符串,需要响应 ...

  2. 安装webstrom2019

    破解webstrom2019 下载地址:https://www.jetbrains.com/webstorm/ 下载安装后进行破解(安装基本上是傻瓜样式的) 可以在线进入  http://idea.l ...

  3. 怎样将virtualbox中的虚拟系统安装到c盘以外的盘

    首先在安装的时候是可以选择虚拟机文件的位置的,如果当时没注意,只能现在移动一下了,很简单 首先将 C:/Users目录下的.VirtualBox和VirtualBox VMs两个文件拷贝到你愿意放的位 ...

  4. Java垃圾回收机制和注解

  5. Angular 2/4/5+ 重复点击菜单刷新界面

    记一下,网上没找到方法 自己搞了好久  通过跳转到别的界面在跳回来的方式进行实现             //再次点击刷新界面       if (this.router.url == item.ur ...

  6. bottle 0.5中的key-value数据库

    import anydbm as dbm import cPickle as pickle class BottleBucket(object): '''Memory-caching wrapper ...

  7. python学习-迭代器,列表解析和列表生成式

    迭代器为类序列对象提供了一个类序列的接口.Python 的迭代无缝的支持序列对象,而且还允许程序猿迭代非序列类型,包括用户定义的对象. 迭代器是一个next()方法的对象,而不是通过索引计数.当需要下 ...

  8. Galera Cluster——一种新型的高一致性MySQL集群架构

    原文链接:https://www.sohu.com/a/147032902_505779,最近被分配定位mysql的问题,学习下. 1. 何谓Galera Cluster 何谓Galera Clust ...

  9. ES6参数默认值

    参数默认值 1.首先,来看一下es5中的函数默认值 var a = a || 10; var b = b || "none" // 当a的值或者b的值为undefined时,根据逻 ...

  10. c++单链表冒泡排序(交换结点),链表增删改查,运算符重载

    #include <iostream> #include <stdlib.h> #include <time.h> #include <fstream> ...