介绍
之前在高中总结里提到了我最近在做一个基于Unity的SRP的项目(好吧,我就直接把SRP名字照搬过来了,懒得改了),目前已经四个月过去了,项目进展当前来看不算很顺利,但是也零零碎碎写了不少东西,这篇博客主要就是记录和介绍一下当前已经完成了的渲染管线功能和一些问题。
(本人的)SRP管线特点
我希望提供管线能灵活的支持多种光照模型,所以选用了forward渲染路径,同时为了方便实现不少后处理特效(如SSR,SSAO,SSGI这类),又希望同时也输出一份thin G Buffer,所以其实使用的是forward+路径,即在正常进行forward路径的光照计算的同时,使用MRT把Specular等属性输出到GBuffer里。当然了,我们都知道前向路径在同屏大量光源的情况下性能非常差,相当于每个像素,不管有没有被灯光照到,都需要计算和所有灯光的光照情况。然而目前的大部分主机和桌面端游戏都大量地使用了同屏光源来提高画面质量。如果选用延迟管线的话,可以使用Light Volume这一类技术降低直接光照计算的开销,然而这在forward+的管线上实现起来是比较麻烦的。因此,为了支持大量光源的高效渲染,我采用了tile-based light cull技术(我看到好像有人翻译成分块裁剪,反正意思大差不差)。不过由于tile-based技术的局限性,透明物体渲染就成了个问题(和延迟路径的透明渲染是一个问题)。我的目标是支持单层透明度渲染的,即半透明之前会形成前后遮挡,但是会混合背景的不透明色彩,但是目前我只实现了对dither transparency的支持(这个应该怎么翻啊,平常大家交流的时候都打洞、挖洞的叫着,也不知道正常应该叫什么)。同时,管线支持SRP Batcher以减少drawcall。
渲染管线流程
1. Opaque Depth Prepass
第一步先做一个不透明物体的depth prepass。为什么要做这个呢?主要是两个目的:第一个是因为分块裁剪的话为了更加高效,需用通过深度图来生成depth bound,提高裁剪精度(具体原理后面说到裁剪步骤的时候再说)。所以说需要在光照计算前就得拿到深度值,必须得depth prepass一下;第二个就是考虑到forward路径带一般overdraw会比较高,所以做一个depth prepass能在后面光照计算的时候通过early-z技术kill掉所有不透明物体的overdraw,还是比较划算了。当然了,depth prepass也会导致drawcall的增加。我个人的优化手段是,由于画depth的片元着色器开销特别小,在这个阶段overdraw高一点不会特别影响性能,所以在画的时候优先OptimizeStateChange,提高合批的数量,不要过于在意网格穿插的问题。
2. Alpha Test Depth Prepass
第二步就是做透明度测试物体的depth prepass,理由如上。之所以拆开来做是因为众所周知alpha test的片元着色器里的discard操作会导致early-z无效,所以单独开出来画一下,也方便合批。
3. Stencil Prepass
好吧,我承认这是个非常奇怪的操作。一般来说确实没见过啥游戏提前渲一遍stencil的。我做这个的主要目的是为了解决dither transparency带来的“洞洞感”。毕竟用dither transparency的方法做稍微透明一点的效果,分辨率稍微再低一点,就能明显看出屏幕上挺规律的挖的洞。事实上在2k的分辨率下,0.8的alpha值,“洞洞感”已经挺明显的了,所以必须得想办法矫正一下(这事也让我意识到还是要支持常规的半透明,挖洞还是在效果上差强人意的)。矫正的方法也挺简单的,就是在画完dither transparent的物体之后,高斯模糊一下,然后替代dither transparent物体占的像素,所以我们需要写入一下dither transparent像素的stencil标记一下。当然了,你可能会说,为什么不直接在depth prepass的时候写stencil。主要原因是,dither transparecy会有规律的抛弃一些像素,本质是一个alpha test的操作,抛弃像素的片元输出的时候连颜色输出,深度输出带stencil输出全给扔了,所以stencil会和depth一样变成没隔着一点距离就消失一块的情况。然而我们需要让那些挖洞的区域也被高斯模糊替代掉,所以只能单独拿出来画一遍没有discard的dither transparent物体的stencil。考虑到也许还有其他的操作可能会需要提前写stencil,我就干脆单独把stencil prepass单独提出来做一下。
4. Depth Bound Generation
第四步就开始准备分块裁剪需要的数据。单纯把相机视锥依照屏幕空间砍成n块的话是不够精准的。因为很多时候,每一个块里的像素既没有近到近裁剪面,也没有远到远裁剪面,而是在中间的一个位置,所以我们可以找到这一个块里的最大最小深度值来决定这个块的远近面,提高裁剪精度。Depth bound其实就是一张记录每个块最大和最小深度值的贴图,这里我就简单的选用了float2的格式。具体的生成我是扔到compute shader里去算的,compute shader对于uav的访问和写入来说效率比标准的vs-ps流水线要高不少,同时写起来也方便一点。所以说我这个管线至少也需要图形api支持compute shader(理论上pc,主机端和大部分移动端都应该支持的,这年头webgl都支持compute shader了,除了一个异端–mac上的opengl,只支持geometry shader,不支持cs,搞得我难受死了,最后直接干脆用metal)。额外说一句,生成depth bound其实有两种写法,一种是在每一个thread就对应一个块,里面跑一个双层循环,找到这个块里的最大最小深度值,还有一种是每一个thread对应原zbuffer的一个像素,每一个像素分别和存有最大和最小深度值的贴图进行原子操作,InterlockedMax()/InterlockedMin()一下。前者的坏处是每个块里是线性操作逻辑,不能很好的利用gpu的并行计算能力。除此之外,每次比较最大最小值的时候,都会有不少if分支,也挺影响gpu的执行效率差的。而后者的问题就是原子操作是同步的,所以说哪怕是并行计算,在找最大最小深度值的时候都得排队一个个去比,效率不一定就搞到哪去了。我目前采用的是前者,但是并没有特别比较过两者的效率之差,希望有读者能解答一下。
5. Depth Frustum Generation
紧接着就是根据最大最小深度值来生成新的视椎块。具体过程就是生成一下每个视椎块的6个平面,然后塞到一个3d texture里。这里说一下,为了图省事,我塞进去的是一个plane,也就是说只有一个法线和平面所在点两个数据,是没有面积/边界数据的。这个主要是为了方便把每个面简化成一个float4的数据,即float4(normal, -dot(normal, vertex))。对于3d texture来说,xy坐标就是当前块的屏幕空间索引,z坐标就是0~5,即这6个面的索引。
6. Directional Light Shadow Pass
紧接着就是画场景里主要平行光的阴影。这里说一下,目前这套渲染管线只支持一个平行光。当场景里的平行光大于一个的时候,会自动选用找到的第一个平行光。可能有人要问为什么了。主要是几个原因,首先多平行光是个比较少见的需求。平行光一般是太阳光、月亮光这类的,很少说会同时出现。其次每增加一个平行光,所有像素都要多一次光照计算,因为平行光是没法被剔除的,性能上也堪忧。除此之外,单个平行光写起来我也方便点(笑,所以在这个阶段我就先只支持一个平行光了。至于平行光的shadowmap这块我用的挺传统的方法。首先阴影做的是Cascaded Shadowmap(好像应该翻成级联阴影),默认4级。每更高一级的阴影相机视椎是直接延长于上一级的视椎,主要就是写起来方便,如果追求更高效率的话,应该采取视椎切块的方法,毕竟对于更远的shadowmap来说,是不需要包含近处物体的深度信息的。不同级的shadowmap存在一个Texture2DArray里。
7. Spot Light Shadow Pass
做法和平行光的阴影差不多,唯一要注意的是,同一场景里可能会有复数个spot light,同时不是所有的spot light都要画阴影的,所以先遍历一遍所有光源,把需要的画阴影的spot light挑出来,依次渲染shadowmap,同样用一个Texture2DArray来存。考虑到性能,这里的阴影就不做cascaded的操作了。
8. Point Light Shadow Pass
还没实现,碰到了点奇怪的bug,渲染出来的shadowmap(cubemap做的),无法被正确采样(使用 像素-光源 向量),这个bug修了好久了,还是没搞清楚问题在哪。
9. Point Light Cull
现在就开始正式进入分块光源剔除了。创建一个uint型的3d texture来记录剔除之后的点光源。xy坐标相当于当前块的屏幕空间索引,z坐标的话,第一位,即z=0的时候,存的uint是这个块里点光源的数量,方便后面光照计算的时候截停光照循环。从第二位开始就是每个没有被剔除掉的点光源的索引。这些点光源我是存在了一个StructuredBuffer里的,根据索引访问。至于剔除就是对每个块来说,求6个面和所有点光源的球体的相交情况,相交就说明不能剔除掉。这里有个精度问题,就是因为存的数据的是平面,面积是无限的,所以哪怕球没有和视椎块直接相交,也有可能和延长出去的平面相交,导致不能被剔除出去,这个后面再考虑怎么改进。
10. Spot Light Cull
基本过程和问题同上。还有个额外的问题–对于角度比较小的spot light来说,cone会比较狭长,求交用的最小外接球里面有很大一部分都是空的,很多时候无法正确地被剔除掉。值得一提的是,4、5和9、10步其实可以放在一个kernel里算完。但是考虑到分支的复杂情况,以及gpu的寄存器数量限制,把这些任务拆分开来,效率反而更高。