• Vulkan Cascade Shadow Map的故事


    你只需做你自己,做你想做的事,不要沦为人海中的沧海一粟。
    ——《沉默的多数派》

    最近几周,看了看知乎上关于阴影的文章,虽然我记得大二的时候好像也看过那本《Unity Shader入门精粹》里的阴影方法,但是时间隔得太久了,几乎都忘完了,并且也懒得去看英文的资料(虽然我也下载了4、5篇英文的论文,哈哈哈哈),所以你懂得,知乎上什么都有,直接在知乎大学修一下就好了。

    主要就是实现了一个CSM,并且在CSM的基础上,改了一个级联的EVSM出来,先看看效果:

    Vulkan-AirEngine内实现的的CSM与CascadeEVSM83 播放 · 0 赞同视频正在上传…重新上传取消​

    仓库还是这个,实在是懒得再倒腾到新仓库了。。。

    FREEstriker/Air_TileBasedForward (github.com)​github.com/FREEstriker/Air_TileBasedForward

    Forward+和CSM

    Forward+和CSM

    CSM

    CSM

    Cascade-EVSM

    Cascade-EVSM

    自我评价一下:勉强能看。

    和之前的场景比,把一个小板子换成了25个巨大的板子作为底面,还在除了中间的大底面上,每个底面上放了9个球体,用来验证大场景远处的阴影正确性。除此之外,把Forward+渲染器中之前可配置的流程都固定下来,AO效果使用的是SSAO,OIT用的是A-Buffer,阴影用的是CSM。并且,新添加了一个渲染器,用来在屏幕空间内可视化阴影的效果,方便Debug的时候快速的看到阴影的效果。

    Shadow Map

    Shadow Map的原理是非常简单的,就是将灯光当成一个相机,进行Depth-Only的渲染,获得一张阴影贴图,之后在真正的相机进行渲染片元时,会通过转换矩阵将片元的投影坐标转换至灯光相机坐标系下的投影坐标,进行透视除法获得uv之后,对阴影贴图进行采样,就可以获得这个片元在灯光相机的视角下是否被遮挡了,被遮挡了就说明灯光照不到,这个片元就是黑色的,反之就是片元的颜色。

    老生常谈的问题就是acne问题和peter-panning问题,分别可以使用bias和正面剔除来解决。

    acne问题产生的原因就是像素化深度值得问题,当单个阴影贴图的像素在实际进行比较的过程中,会出现同时覆盖多个片元的现象,这就导致渲染的结果充满交替的黑白色条纹,虽然增加阴影贴图的分辨率可以缓解,但显然是不能够完全解决的,只要使用阴影贴图,就会有这种问题。

    这时候只要在比较时,手动的添加一个偏移值bias,让片元离得近那么一丢丢,避免出现条纹现象。

    当然,当相机的平截头体过大或阴影贴图分辨率过低的情况下,就会出现非常差的效果,许多许多片元使用同一个阴影贴图的像素点,满眼尽是马赛克,光栅化真是难高啊。

    马赛克的问题解决起来有三种类办法,一种是Fitting、一种是Warp方法,还有一种是Partition方法。这位佬写的非常详细易懂:

    杨鼎超:图形学基础 - 阴影 - ShadowMap及其延伸194 赞同 · 12 评论文章正在上传…重新上传取消

    Fitting方法就是使灯光相机的平截头体尽可能的小,尽可能提高阴影贴图的利用率,这么做显然需要有场景管理的组件来加速,才能较快的压缩体积。虽然我之前也曾经想过要写个松散八叉树的场景管理,但是感觉挺麻烦的,就不了了之了,所以这种方法就只是看了看而已。

    Warp方法针对的点是:相机近处的点会占用渲染结果更多的像素,而远处的点只会占用较少的像素,那么在渲染阴影贴图的时候,通过某种方法,对其进行扭曲,让近处的占有更多阴影贴图的像素,这样近处的渲染效果就会变好一点,具体的方法有PSM和、LiSPSM。因为这类方法好像用的比较少,几乎没有游戏用这种方法(也有可能是我没找到),所以我也是就大概看了看,没有实际实现出来。

    而Partition方法用的很多了,CSM方便又简单,效果又好还不用推复杂的公式,我的数学能力,拉中之拉,唉,真实太适合我了,真好。

    这类方法就是把相机的平截头体给分割了,一个子平截头体用一个阴影贴图,简单又暴力,挺nb的。

    Cascade Shadow Map

    瞎哔哔

    上面整那么一大堆,其实我都没干过,就是看了看罢了,无中生有搞这么长,挺累的。

    其实CSM也没有那么困难,只是说把平截头体分割了而已,最后可能要对多张阴影贴图进行混合,有点麻烦,但是不难。我是主要学习了一下这两篇文章,看明白了自己才写的,感谢下大佬:

    我好饱啊:Cascade Shadow Map 实现记录68 赞同 · 4 评论文章正在上传…重新上传取消

    宋开心:用DX11实现Cascaded shadow map141 赞同 · 27 评论文章正在上传…重新上传取消

    大佬们真强啊,哈哈哈哈,我也想当大佬。

    虽然不太难,但我实现的时候也是因为手残疯狂踩坑,包括但不限于:改半天Shader没反应,发现改错文件了;透视除法直接用View空间坐标做的;忘了View空间的Z坐标是负数;灯光相机的视椎体裁剪不小心用成了相机的相交检测器。。。反正就是状况频出,挺离谱的。

    流程图就是下面这样子的:

    计算平截头体

    这部分非常简单,既然已经有了相机的相关数据矩阵,那么很容易就可以使用NDC空间的八个角点,像在Shader里面一样就可以反算出来View空间下的角点坐标了。

    构建子平截头体

    我是使用了一个4级的CSM,并通过一个分割比例数组来确定各级占整个平截头体的大小。

    分割比例数组

    接下来就是使用这么几个比例,对前面的八个角点进行线性插值就可以得到各个子平截头体了。

    为了防止之后采样各个阴影贴图的时候在边界出现跳变,就需要将各个子平截头体设置一部分重叠区域,我是使用了一个重叠比系数的变量来对其进行设置。重叠区域应该还可以避免由于精度误差造成的边界漏光问题(我推测的)。

    其实就是除了第一个子平截头体的近平面都向相机移动一段距离罢了。

    移动每个近平面

    计算包围球

    这一步就是对每个子平截头体整一个包围球,方案有很多,具体的可以看这篇文章:

    zilch:Cascade Shadow进阶之路78 赞同 · 1 评论文章正在上传…重新上传取消

    我是使用了最大球形包围盒,使用这种方案的一个隐藏好处是处理阴影闪烁的问题比较方便,因为这个包围球尺寸是不变。

    具体的计算方法就是参考的上面的文章,可以自己看下。

    计算转换矩阵

    因为涉及到将片元从相机的View空间转换到灯光相机的Projection空间,所以需要算一个转换矩阵,我是这样计算的:

    灯光的投影矩阵很简单,和正交相机一样,相机的View空间到灯光的View空间可以使用lookat函数来计算,不过需要注意的是最好是再算一个灯光经过Model变换后的Up向量作为参数,因为我自己写的时候就发现,当挡光方向和相机方向成直角的时候,如果使用vec3(0, 1, 0)来作为Up,那么算出来的矩阵就会是NaN,就挺离谱的。

    接着使用灯光Projection矩阵*转换矩阵*相机View矩阵,就可以获得从世界空间转换到灯光相机的Projection空间的矩阵了。

    渲染

    确定虚拟灯光相机位置

    首先根据各个平截头体的外接圆,以及一个补偿距离来建立虚拟灯光相机,之所以使用补偿距离来确定虚拟灯光相机的位置,是因为我没有类似于八叉树那样的场景管理系统,可以快速的得到阴影投射物的最远坐标,所以只能通过手动设置的方法,保证阴影投射正确。(着实莫得办法才能这样)

    有了虚拟灯光相机的位置,就可以构建平截头体进行视椎体(其实是视方体)剔除了:

    构建虚拟灯光相机的视椎体相交检测器

    进行剔除,并绑定资源

    剔除完直接画就好了,没啥可说的:

    第一级

    第二级

    第三级

    第四级

    看起来没有问题。有了阴影贴图,接下来在实际渲染的时候采样就可以了。

    需要用到这么一些参数:

    thresholdVZ是子平截头体的近平面和远平面的View空间的Z坐标,而且由于是交错分布的,所以就可以找到片元所在的一个或是两个平截头体。我将bias也分成了多份,近处的bias和远处的bias应该是不同的,因为一个阴影贴图的像素所占的世界坐标的大小是不同的,所以我感觉还是分开比较好。matrixVC2PL就是之前计算出来的转换矩阵,将片元从相机的View空间转换到灯光相机的Projection空间。后面两个参数就是用来确定PCF采样的。

    既然已经知道了各个平面的Z坐标,那么拿片元的View空间的Z坐标比较一下就好了:

    注意View空间下Z坐标是负数

    如果是-1,就说明在最近的平面和最远的平面之外,否做就是右侧的平面的索引值。如果这个值是偶数,那么他就使用(cascadIndex + 1) / 2来使用对应平截头体的数据;如果他是奇数,那么说明这个片元处于两个平截头体的重叠部分,就再使用cascadIndex / 2使用另一个平截头体的数据,最后在对两个平截头体进行混合就好了。

    而关于混合重叠部分的方法,就是获得重叠部分的大小,通过所占的比例,线性混合就好了:

    这样基本上就可以了,但是这样的阴影是非常硬的,不好看,需要让他软一点才好看。

    直接PCF,简单暴力,我是直接用的box-filter:

    但box-filter肯定效果不是最好的,它必然是会出现这种条纹状的现象的:

    首先就是因为光纤和球体外围是相切的,这就导致阴影贴图会出现跳变,那么采样平均后就会出现这种条纹状,相当于把跳变拉伸了。底部的阴影的边缘也不是那么完美,也是带点纹的,没办法,毕竟是box-filter。考虑使用随机旋转的泊松采样,应该会改善这种效果,不过泊松生成有点麻烦,并且随机旋转的部分写SSAO时也写了,就懒得改了。

    Cascade Exponential Variance Shadow Map

    找资料的时候还发现了一种EVSM的方法,好像效果也挺好的,正好前面也实现了CSM,干脆就摸了个级联的EVSM出来。

    EVSM属于是预先模糊的一类方法,还有ESM、VSM、LVSM等,具体的思想就是避免在渲染片元的时候多次和附近的深度贴图的值进行比较获得阴影值,而是通过近似函数或是概率的方式,将比较得过程分离,在渲染前统一进行预模糊,之后通过单次采样就获得软点的阴影。

    演变

    先说下ESM,他就是通过纳皮尔常数的幂来近似替换阴影比较的阶跃函数,这样直接使用 ec(x−d) (c为负数,x为片元深度值,d为预模糊的深度贴图值)就可以得到阴影了。

    阶跃和e的-80x次方

    VSM用的是一种完全不同的思路,它是将模糊区域内的阴影值当做一个概率值,使用单边切比雪夫不等式,获得阴影贴图的深度比片元深度更深的概率,概率越大,说明片元越亮,反之越暗。单边切比雪夫的推导可以去B站上找一找。

    单边切比雪夫,t为片元深度,mu为阴影贴图一定区域内的均值,sigma是阴影贴图一定区域内的标准差

    具体的计算就是对ShadowMap进行采样,记录深度与深度的平方,接着进行模糊,这样就可以得到一阶矩 μ 和二阶矩 μ2 ,之后就用一阶矩和二阶矩来计算方差,然后就使用上面的函数进行计算就可以了,非常巧妙。

    而EVSM则是VSM的改进版,它主要针对VSM的漏光问题进行解决,考虑VSM的下图场景:

    当A与B的距离非常大,这导致上式的方差很大,然后均值又接近B,而C又很接近B,这就会导致在渲染C的斜线区域时,上式计算出来的概率接近1,导致出现漏光现象。灯光相机使用的投影矩阵是正交矩阵,所以阴影贴图上的深度就是线性深度,所以考虑:如果使用某种函数,让这个切比雪夫不等式在应用时,减小深度变化造成概率大幅变化的情况,所以使用了一个 ecx 函数,来对线性深度值进行扩展,将它扩展了以后,就可以减轻微小变化造成的影响。这就是所谓的EVSM方法。

    但是在实际应用时,还是使用了另外一个函数 −e−cx 对其进行限制,避免扩展后造成的凹凸放大现象。

    实现

    所以这么来说,EVSM的流程相比VSM,只是将模糊的对象变为了 ecx 与 −e−cx ,其他都是与VSM一样的。而VSM与普通的ShadowMap方法相比,也只是增加了一阶矩二阶矩模糊和单边切比雪夫不等式的计算,流程是很清晰的。

    和CSM相比,前面的都是一样的,我将右面的前两步分成了Blit和Blur两部,使用不同的Shader,比较常规的:

    Blit

    Blur

    已经预模糊过后,在渲染实际的片元的时候,就不用PCF进行多次采样了,直接带入公式计算就好了,其他的重叠混合之类的都是一样的:

    代入公式

    混合

    其实和CSM都是差不多的。但是实际的效果只能说是一般,经过两次高斯模糊之后,它并没有变得非常软,只能说比PCF3*3差不太多,并且球与光线方向相切的部分出现了非常难看的锯齿状,我也没太想明白这是怎么个情况。但是我好像在哪里看过一种说法是什么阴影投射体和阴影接受体得是同一个?记不太清了,也找不到是在哪里看到的了,可能有点道理,我也确实没太想明白。可能不太适合自遮挡的情况吧(我猜的)。

    球面与光线切面处的锯齿

    我还发现,当没有底面的时候,EVSM的效果非常差,因为深度值直接是1,放大之后在混合,几乎就都是底面的值占主导,这就导致马赛克非常严重,结合上面的式子也大概可以推断EVSM是对相对距离变化不大的场景是比较有效的(不负责推断)。

    马赛克

    还有就是有的文章里说RGBA16SFLOAT的纹理就足够了,但是我实际测试的使用效果很差,必须得使用RGBA32SFLOAT的纹理才可以,2048*2048再加上级联化和高斯模糊,帧率极低。

    总结

    写了两三周,效果就是这样,流程麻烦,但是不算很难,CSM简单粗暴,但是效果就是很好,EVSM虽然看起来很好,但是情况还是很多的,并且还需要考虑到模糊与级联的消耗,所以实际上也不是那么方便,所以还是使用CSM+PCF的方案简单些,并且不用考虑非常复杂的情况。

    这几天突然觉得,写过的这些东西的原理虽然都比较简单,但是和那种效果好的实现比起来还是云泥之别,有非常多的细节需要注意与优化,还是挺困难的。

    其实我还看了看PCSS的方法,不过实在懒得去写了,一直写一个还是挺枯燥的。下面可能会去看看PBR、球谐光照啥的,整个光照探针啥的?还想提升一下绘制的效率,看看Unity的合批到底在干啥,简单整个合批的功能到AirEngine里,最后还想封成DLL,用AirEngine的支持做个简单的FPS的DEMO出来。智能指针可能一堆好好想想怎么才能放到里面,之前一直都是纯手动管理,感觉还是得用下智能指针,现在有地方的内存肯定已经泄露了。。。

    后记

    原神3.0版本都快结束了,我都还没怎么探索地图,只是把主线过了过,雕琢童心一点没做。。。剧情还是挺有趣的,迪希亚也挺好看的,哈哈哈哈。希望过两天的3.1加大力度。

    斯巴拉西

    好久没在宿舍做饭了,回来想想整个什么活儿,还是挺想试试海鲜饭的。

    最近开始试着写力扣了,虽然算法都很妙,但就是刷不动,没有动力,只能说:“霓佳达霉达,霓佳达霉达”。。。

    霓佳达霉达

    想吃广式早茶的虾饺了,所以周四和aty一起去Kevin' lab去整点汉堡吃,那天正好汉堡买一送一,哈哈哈哈,吃爆。

    口扫的新设备又来了,得开始干点活儿了,难顶。

    想摆,但是摆不动。。。

    最近一直在听欅坂46的歌,真好听,《沉默的多数派》、《不协和音》、《黑羊》、《避雷针》都超好听,欅共和果的live也超好看,平手友梨奈真帅啊哈哈哈哈,那首《渋谷からPARCOが消えた日》的气场太足了,可惜就是早就解散了,这回又没吃上热乎的。。。

  • 相关阅读:
    波函数:描述量子世界的数学工具
    udp通信socket关闭后,缓存不清空
    【HarmonyOS】鸿蒙入门学习
    MacOS如何查询5000端口是否被占用
    [oeasy]python0016_编码_encode_编号_字节_计算机
    最长上升子序列---(acwing 1014, acwing 182 ,acwing 1012,acwing 1016)
    业务数据分析-常见业务指标
    不必购买Mac,这款国产设计工具能轻松替代Sketch!
    行业“卷不动”、市场“换不动”,家电赛道又跑回“老路”
    Vue3使用递归组件封装El-Menu多级菜单
  • 原文地址:https://blog.csdn.net/qq_30180107/article/details/127134410