Difference between revisions of "延迟渲染"

From KlayGE
Jump to: navigation, search
m (实时全动态GI)
Line 342: Line 342:
  
 
=== 实时全动态GI ===
 
=== 实时全动态GI ===
{{main:全局光照}}
+
{{main|全局光照}}
  
 
=== Post process ===
 
=== Post process ===

Revision as of 05:23, 1 May 2012

本文讲述的是KlayGE中使用的延迟渲染方法。

Deferred Lighting的框架

KlayGE 3.11的例子已经从Deferred Shading改成了更节省带宽的Deferred Lighting。这里先对Deferred Lighting作一个简要的介绍,并假设读者已经了解了Deferred Shading。

Deferred Lighting的渲染架构可以分为三个阶段:

1. for each object
   {
      填充G-Buffer
   }
2. for each light
   {
      Lighting pass
   }
3. for each object
   {
     执行shading
   }

与Deferred Shading不同的是,shading(也就是和材质相关)的计算仅仅发生在最后一个阶段。所以,G-Buffer中需要保存的信息得到极大地减小,甚至不再需要MRT。

Lighting pass

Lighting pass在Deferred Lighting框架处于核心地位,在这里我打算先把lighting pass解析清楚。一旦lighting pass表达好了,G-Buffer所需要保存的信息,以及shading pass能得到的信息也都清楚了。

基于物理的BRDF推出了渲染模型总公式:

LaTeX: L_{o}(\mathbf{v})=\pi\rho(\mathbf{l_c}, \mathbf{v})\otimes \mathbf{c}_{light} (\mathbf{n} \cdot \mathbf{l_c})=(\mathbf{c}_{diff} + \frac {\alpha + 2} {8}(\mathbf{n} \cdot \mathbf{h})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_c},\mathbf{h})) \otimes \mathbf{c}_{light} (\mathbf{n} \cdot \mathbf{l_c})

再有N个光源的情况下,每个像素的光照响应就是

LaTeX: L_{o}(\mathbf{v})=\pi\rho(\mathbf{l_{c1}}, \mathbf{v})\otimes \mathbf{c}_{light1} (\mathbf{n} \cdot \mathbf{l_{c1}})

LaTeX: +\pi\rho(\mathbf{l_{c2}}, \mathbf{v})\otimes \mathbf{c}_{light2} (\mathbf{n} \cdot \mathbf{l_{c2}})

LaTeX:  + \ldots

LaTeX: +\pi\rho(\mathbf{l_cN}, \mathbf{v})\otimes \mathbf{c}_{lightN} (\mathbf{n} \cdot \mathbf{l_{cN}})

对于Deferred shading来说,每一个shading pass就是执行一个

LaTeX: \pi\rho(\mathbf{l_cn}, \mathbf{v})\otimes \mathbf{c}_{lightn} (\mathbf{n} \cdot \mathbf{l_cn})

而对于Deferred lighting来说,公式需要重新整理一下:

LaTeX: L_{o}(\mathbf{v})=(\mathbf{c}_{diff} + \frac {\alpha + 2} {8}(\mathbf{n} \cdot \mathbf{h_1})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{c1}},\mathbf{h_1})) \otimes \mathbf{c}_{light1} (\mathbf{n} \cdot \mathbf{l_{c1}})

LaTeX: +(\mathbf{c}_{diff} + \frac {\alpha + 2} {8}(\mathbf{n} \cdot \mathbf{h_2})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{c2}},\mathbf{h_2})) \otimes \mathbf{c}_{light2} (\mathbf{n} \cdot \mathbf{l_{c2}})

LaTeX: +\ldots

LaTeX: +(\mathbf{c}_{diff} + \frac {\alpha + 2} {8}(\mathbf{n} \cdot \mathbf{h_N})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{cN}},\mathbf{h_N})) \otimes \mathbf{c}_{lightN} (\mathbf{n} \cdot \mathbf{l_{cN}})

LaTeX: =\mathbf{c}_{diff}\otimes (\mathbf{c}_{light1} (\mathbf{n} \cdot \mathbf{l_{c1}}) + \mathbf{c}_{light2} (\mathbf{n} \cdot \mathbf{l_{c2}}) + \ldots + \mathbf{c}_{lightN} (\mathbf{n} \cdot \mathbf{l_{cN}}))

LaTeX: + \frac {\alpha + 2} {8}(((\mathbf{n} \cdot \mathbf{h_1})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{c1}},\mathbf{h_1})) \otimes \mathbf{c}_{light1} (\mathbf{n} \cdot \mathbf{l_{c1}})

LaTeX: + ((\mathbf{n} \cdot \mathbf{h_2})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{c2}},\mathbf{h_2})) \otimes \mathbf{c}_{light2} (\mathbf{n} \cdot \mathbf{l_{c2}})

LaTeX: + \ldots

LaTeX: + ((\mathbf{n} \cdot \mathbf{h_N})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{cN}},\mathbf{h_N})) \otimes \mathbf{c}_{lightN} (\mathbf{n} \cdot \mathbf{l_{cN}}))

由于cdiff是到最后的shading pass才计算,所以在每一个light pass里面,diffuse和specular必须分开才能保证结果正确:

LaTeX: Diffuse: \mathbf{c}_{lightn} (\mathbf{n} \cdot \mathbf{l_{cn}})
LaTeX: Specular: ((\mathbf{n} \cdot \mathbf{h_n})^{\alpha} F(\mathbf{c}_{spec}, \mathbf{l_{cn}},\mathbf{h_n})) \otimes \mathbf{c}_{lightn} (\mathbf{n} \cdot \mathbf{l_{cn}})

为了把diffuse和specular放入4个通道的buffer中,就只能牺牲specular的颜色,只剩下亮度,同时cspec也简化成一个标量。所以,lighting pass的计算成了:

LaTeX: float4(1, 1, 1, (\mathbf{n} \cdot \mathbf{h_n})^{\alpha} F(c_{spec}, \mathbf{l_{cn}},\mathbf{h_n})) \times \mathbf{c}_{lightn} (\mathbf{n} \cdot \mathbf{l_{cn}})

G-Buffer的分配

在Deferred框架中,不管是Deferred Shading还是Deferred Lighting,G-Buffer的分配都是非常关键的。前面得出的lighting pass公式如下:

LaTeX: float4(1, 1, 1, (\mathbf{n} \cdot \mathbf{h_n})^{\alpha} F(c_{spec}, \mathbf{l_{cn}},\mathbf{h_n})) \times \mathbf{c}_{lightn} (\mathbf{n} \cdot \mathbf{l_{cn}})

从公式可以看出,在light pass里需要的量有nh,alpha,cspeclc。因为h = (v + lc) / 2(见基于物理的BRDF),而lc = normalize(l - p)(l是光源位置,p是要计算的点位置),所以最终需要G-Buffer提供的量有:np,alpha和cspec。要完整的保存这些量,一共需要8个通道,normal占3个,position占3个,alpha和cspec分别占一个。这样对G-Buffer来说消耗太大了,必须要缩减。

显而易见的是,normal是经过归一化的,只需要保存2个分量。http://aras-p.info/texts/CompactNormalStorage.html比较了多种保存2分量的方法,其中Spheremap transform速度和效果综合起来最佳,Crytek也在用同样的方法,即:

float2 encode(float3 normal)
{
   return normalize(normal.xy) * sqrt(normal.z * 0.5 + 0.5);
}
float3 decode(float2 n)
{
   float3 normal;
   normal.z = dot(n, n) * 2 - 1;
   normal.xy = normalize(n) * sqrt(1 - normal.z * normal.z);
   return normal;
}

下一步是position。实际上像素所在的位置已经提供了x和y,需要保存的仅仅是z。position何以很好地从z和像素位置计算出来。这里保存的是view space的z除以far plane。在lighting pass,pixel shader里拿到像素在view space的位置之后,做这样的计算:

p = view_dir * ((z * far_plane) / view_dir.z);

其中,view_dir是在vertex shader中计算之后传到pixel shader。对于把光源的几何体直接作为光源几何的情况(如果你不熟悉这个,请见下篇),那么view_dir就是顶点乘上world * view矩阵之后的结果。对于用全屏的四边形作为光源几何的情况,view_dir就是把view frustum在far plane上的四个点乘上inverse(projection)矩阵之后的结果。z * far_plane就还原出了该点在view space的z,然后根据相似三角形的定理很容易就能推出这个还原公式。现在,position成功地压缩到了1个通道。

剩下的就是alpha和cspec。如果不需要fresnel,可以直接忽略cspec,留到shading pass再做,这里直接存alpha就可以了。否则,就需要把alpha和cspec放入同一个通道。我用的方法是,floor(cspec * 100)作为整数部分,clamp(alpha, 0, 255) / 256座位小数部分。这样的限制是,alpha取值范围为[0, 256),一般来说够用了。

由此,所有lighting pass需要的信息都被压进4个通道内,G-Buffer只需要1张texture,省去了MRT。

Shading Pass

shading pass需要把前面所有lighting pass积累出来的光照信息和物体本身的材质信息组合起来,得出最后的着色。物体材质中的cspec已经存在G-Buffer,并在lighting pass中计算了,所以shading pass输入的材质有cdiffcspeccemit,alpha。别忘了在前面的公式中,specular号需要乘上归一化系数(alpha + 2) / 8。另一方面,在lighting pass的结果里,rgb存的是积累的diffuse,a存的是积累的specular亮度,如果还有计算AO,那么shading所用的公式就是:

LaTeX: \mathbf{c}_{emit} + (lighting.rgb * \mathbf{c}_{diff} + \frac{\alpha + 2}{8} * lighting.a) * ao

如果在G-Buffer和lighting pass因为不考虑fresnel而至保存了alpha,那么shading pass的公式就变成:

LaTeX: \mathbf{c}_{emit} + (lighting.rgb * \mathbf{c}_{diff} + \frac{\alpha + 2}{8} * \mathbf{c}_{spec} * lighting.a) * ao

Light volume

在Deferred Rendering中,表示一个光源最简单的方法就是一个全屏的四边形。它能让G-Buffer的每一个pixel都参与计算,在pixel shader中才过滤掉多余的像素。虽然可以保证结果正确,但毕竟多余计算太多,效率不高。这里常用的一个优化就是用一个凸的几何形状来表示光源。该几何 形状覆盖的pixel才计算该光源对它的贡献。显而易见的是,spot light用圆锥,point light用球或者立方体,directional light和ambient light用全屏四边形。下图画了一个spot light的volume:

Spot light volume

这样的几何体类似于古老的shadow volume技术所用的几何体,所以我把它叫做light volume。但由于light volume保证是凸几何体,在渲染上比shadow volume简单不少。

优化1:视锥检测

有了light volume,就可以把它和视锥做一个相交检测。light volume完全包住了light能覆盖的范围,所以如果一个light volume在视锥之外,这个光源就可以直接忽略。

优化2:Conditional Rendering

D3D10及以上的显卡都支持conditional rendering,基本用法是这样的:

BeginQuery()
Draw object with simple shader
EndQuery()
...
BeginConditionalRendering()
Draw object with real shader
EndConditionalRendering()

如果第一个Draw没有产生可见的像素,那么第二个Draw就会被忽略。与Occlusion query不同的是,在这个过程中不需要把query的结果返回CPU,流水线不会被打断,效率更高。用这种方法,就可以直接忽略掉不照亮任何一个pixel的光源。

优化3:Stencil Buffer

和shadow volume一样,这里可以用stencil buffer来标记出光源能找到的像素。实际上,在shadow volume上用的优化也可以照搬过来。比如说,双面stencil是最常用的一个方法,在一个pass内就能同时加减正反两面的stencil。同 样,light volume也存在视点进入volume的问题,需要改变depth function,cull mode和back stencil pass。

优化4:Shadowing pass

KlayGE用shadow map渲染阴影。其生成shadow map的过程和普通方法一样,这里就不累赘了。在使用shadow map的时候有两个选择,以前的方法是在lighting pass里计算光照的时候就查询shadow map,同时计算阴影。另一个方法来自Screen space shadow map。在每个lighting pass之前加一个shadowing pass,仅仅查询shadow map和计算阴影本身(结果是个灰度图)。这样的好处是,shadowing可以在更低的分辨率上计算,而不用和lighting pass用同样的分辨率,提高效率。另外,shadowing pass的结果可以像screen space shadow map那样做一次blur,在让lighting pass使用。

Anti-Alias

从Deferred Shading发明的一天起,anti-alias的问题就一直困扰着所有Deferred的方法。虽然很多无良的游戏厂商直接在Deferred Rendering的游戏里不支持AA,但确实AA对提升画面质量很有帮助。

Edge AA

在Deferred的框架里,很自然会想到用Edge AA来处理AA。其过程不外乎:

  1. 边缘检测,得到每个像素“像边缘的程度”
  2. 在shader里根据“像边缘的程度”来控制采样坐标

这本身并不是个复杂的过程,尤其是第二步,非常直截了当了,所以这里集中讨论的是如何进行边缘检测。

GPU Gems 2的“Deferred Shading in STALKER”一文提供了一种边缘检测的方法,通过把周围像素的法线差和深度差的和来判断边缘,由e_barrier这个参数来定义阈值和比例,而这个参数和分辨率有关。GPU Gems 3的“Deferred Shading in Tabula Rasa”改进了这个过程,只判断法线差和深度差最大和最小的两组。由于只是局部的相对量而已,这样就做到了和分辨率无关的边缘检测。KlayGE目前用的也是这种方法,得到的边缘如下:

Edge

另一个可能用于边缘检测的方法是,前面提到了如何恢复出每个pixel的view space position,每个pixel取得周围4个pixel的位置之后,就可以直接cross得出一个normal,姑且称为screen space normal。如果一个像素是连续的,那么这个normal就会很接近于G-Buffer中保存的normal,否则它们的方向就会差别很大。下图为G- Buffer中的normal:

Normal in G-Buffer

这是screen space计算出的normal:

Normal in screen space

把这两个normal做一次dot,小于某个阈值的就认为是边缘,得到:

Screen space normal based edge

利用硬件MSAA作边缘检测

前面提到的边缘检测结果虽然不错,但其实都是是参数相关的。能否就用硬件的MSAA来做边缘检测呢?在Shader model 3.0以上的GPU,vertex attribute插值的时候可以选择centroid这个modifier。开启了centroid的attribute,会选择覆盖到的sample 中心来插值,而不是像素中心。所以,同一个属性,如果即有centroid又有不带centroid的版本都传给pixel shader,在pixel shader里面判断两者不一致,就表示这个pixel在边缘上。这样的话,边缘的情况就和硬件MSAA完全一致了。但其实MSAA会过渡判断边缘,所有三角形的边缘都会被认出来,即便只是物体内部的。所以谨慎使用。

能不能就用MSAA?

前面讨论了那么多都是基于Edge的AA。在Deferred Lighting框架下,难道就不能直接用MSAA?可以!这也是Deferred Lighting比Deferred Shading优秀的方面之一。Deferred Shading不能直接MSAA的本质原因是在G-Buffer之后,物体几何信息全部抛弃了。相比Deferred Lighting,在shading pass,物体会被再次渲染一遍,这个时候还是有几何信息的,如果在shading pass打开了MSAA,就可以像Forward shading那样利用硬件MSAA了。唯一不同的是,光照来自于lighting pass的texture,而不是从光源计算。就算硬件MSAA,也只是每个pixel执行一次pixel shader,在按照覆盖情况写入sample的,所以在这里视觉上几乎和Forward shading一样。

展望未来

shading pass再次渲染物体的改进

Deferred Lighting最受争议的一点应属在shading pass需要再次渲染几何体了。如果物体很多,尤其是有tessellation和GS的,多渲一遍有可能抵消了lighting pass带来的性能提升。改进的方法之一就是在建立G-Buffer阶段,用类似Deferred Shading的fat G-Buffer。除了原先的一张纹理,还需要一张纹理用来存放diffuse信息。但是lighting pass和原来一样,不涉及diffuse。shading pass就变成画一个全屏四边形,从G-Buffer的第二章纹理读取diffuse,进行着色。甚至emit也这么处理。这种方法介于Deferred Shading和Deferred Lighting之间。

彩色的specular

在前文提到过,为了把lighting pass中的diffuse和specular都塞到4个通道里,就只能舍弃specular的颜色,只保存亮度。如果要RGB三个通道的specular,近似的方法是通过diffuse积累结果的颜色来计算specular的颜色。这是个很粗糙的近似,虽然不是正确的,不过能骗骗眼睛:

LaTeX: specular = diffuse(\frac{lum_{spec}}{lum_{diff} + \epsilon})

其中lumspec是累积出来的specular亮度,lumdiff是用累积出来的diffuse颜色计算出的亮度。epsilon是为了避免lumdiff为零。 另一种方法是lighting pass用6个通道。但是如果每个通道都是float 16的,也就是96bpp,带宽开销非常大,就不合适了。我的一个想法是把diffuse和specular都转换到YUV空间。这个空间的一个好处是Y 是float 16的,U和V都只要8 bit就可以了。所以可以这么安排MRT:第一张texture格式为G16R16F,保存diffuse和specular的Y;第二张texture 格式为ABGR8,分别保存两者的U和V。这样只有64bpp,但能保存正确的彩色diffuse和specular。由于YUV格式也是可以相加的,这个地方仍可以用原先的lighting pass积累方法。

inferred lighting

Lighting pass可以借用inferred lighting的核心思想来加速。也就是说,lighting pass不需要全尺寸,只需要在一个比较小的render target上执行即可(比如3/4大小)。G-Buffer仍是全尺寸的,并在G-Buffer生成后作一次边缘检测。Shading pass也是全尺寸的,在采样lighting pass texture的时候,利用边缘检测的结果进行保边缘的插值(一般称为Discontinuity Sensitive Filtering,DSF),得到全尺寸lighting的近似。

DSF

上图是使用了800×450的lighting直接拉伸到1280×720做shading的结果,关闭DSF,锯齿严重。下图打开了DSF,基本解决了锯齿问题。

Anti-alias

前面文章讲了很多AA的方法,但那些都是在空间上做AA,比较适合近处物体。对于远处物体来说,空间上AA得到的收益有限,必须在时间上进行AA。结合上MLAA的威力,应该能有很小的代价实现很接近16xMSAA的结果。

各向异性BRDF

Crytek的“CryENGINE 3: Reaching the speed of light”里提到了在Deferred Lighting框架下加入各向异性BRDF的方法。它用了Spherical Gaussian(SG)来近似出NDF(来自于SIGGRAPH Asia 2009的All-Frequency Rendering of Dynamic, Spatially-Varying Reflectance),但这个SG只是per-object的。在G-Buffer阶段,不保存normal,而保存SG展开成lobe的系数。而 BRDF的其他几个项,Fresnel term、Geometry term,都留到shading pass才计算。这种方法的好处是,对lighting pass来说一切都是透明的,它照样可以按原来的方法累积光照,因为Microfacet BRDF中除了NDF,其他都作为公因数提取出去了(Microfacet BRDF的详细讲解可以参见“基于物理的BRDF”)。实际上,Fresnel term的系数是lh,必须在lighting pass做。这里相信Crytek是用了nv来代替,这样不是物理正确的,只有在高光的中心点,dot(l, h)才等于dot(n, v),其他地方dot(n, v)会更迅速地衰减,到边缘地方就非常明显了。如果不在乎这个,是可以把NDF都用SG来表示,并用统一的方法进行渲染。

保存lobe的G-Buffer是这个样子的:

Lobes in G-Buffer

各向异性BRDF渲染出来的结果:

Anisotropic BRDF

KlayGE 4.0中的改进

KlayGE 4.0中,延迟渲染进入了渲染系统的核心,可以作为更通用更方便的一个渲染封装来使用。

在功能上,KlayGE 4.0中的延迟渲染也有了长足的进步。下文将着重于解析这些新改进。

流水线

先来看看延迟渲染的流水线。

Pipeline

在流水线方面,第一个比较大的变化是,G-Buffer改成了MRT的,用类似Deferred Shading的fat G-Buffer来避免在shading pass再次渲染一遍物体。新G-Buffer的布局将在下文分解。在shading pass阶段,只需要渲染一个全屏quad,在每个pixel上把材质和光照信息结合就可以了。

其次,G-Buffer内已经没有Depth的通道,直接使用D24S8格式的texture来保存depth。这样就需要做一个depth线性化的步骤,把24-bit非线性的depth转到32-bit float的纹理上,方便后面使用。线性化的方法为:

LaTeX: q=\frac{far}{far-near}
LaTeX: depth_{linear}=\frac{near \times q}{q-depth_{non-linear}}

其中far为远平面,near为近平面。这样一来,就能省出一个通道,同时depth的精度也提高了。对于D3D9,也可以用扩展格式来实现D24S8纹理。

第三个改进是,规范化了stencil的使用。如果stencil的最高位为1,就表示那个pixel不会在lighting pass中计算光照。这样就可以挡掉一些不希望接受光照的特殊物体。

另外,在shading pass之后增加一个special shading pass。标记有special shading属性的物体会在这个阶段再画一次。special shading的本意是渲染带emit的物体,其实可以和stencil mask配合,在这里作任何想做的forward shading效果。透明物体的alpha也可以在special shading中给出,请看后文关于透明物体的渲染一段。

新的延迟渲染流水线从G-Buffer上看,像Deferred Shading,而之后的阶段则更像Deferred Lighting。可以算作是两者的结合。

G-Buffer布局

前面提到了G-Buffer改成了MRT,那么现在就来比较一下新老G-Buffer的区别。老G-Buffer的安排如下:

Old G-Buffer

老G-Buffer是4个通道、每个通道都是fp16的RGBA16F格式。其中normal用Spheremap Transform的方式映射到2个通道;depth单独存一个通道;specular和shininess挤在一个通道内,整数部分为specular * 100,小数部分为shininess / 256.0f。

这样的G-Buffer需要占据64-bit,IO开销不小,而且depth精度有限。如果按照新的MRT G-Buffer扩展到2个RT,就需要再增加一个32-bit的RT。对于不支持Independent MRT的D3D9硬件来说,甚至要增加一个64-bit的RT,会很影响性能。

最直接的改进就是把depth去掉,同时把specular和shininess分散到两个通道去,就像这样:

New G-Buffer V1

这么一来,所有的分量都可以存在8-bit之内,2个RT仍用64-bit就能解决,并且空闲了一个通道!但是,由于normal的位数下降了非常多(从原来32-bit变成16-bit),效果也会受到很大影响。例如,原先(2个16-bit通道)的高光是这个样子的:

Normal 32-bit

改用2个8-bit通道就出现了很明显而且丑陋的梯度:

Normal 16-bit

所以说2个8-bit通道没有能力表现出光滑的normal过渡,得把剩余的一个通道用上才行。但需要注意的是,和传统Deferred Shading的G-Buffer不同在于,这种MRT G-Buffer的每个lighting pass只需要读取一次RT0,到了shading pass才读一次RT1。如果把lighting pass需要的信息放到了RT1,就会造成lighting pass的IO加倍,失去Deferred Lighting的有效加速。

因此,我只能作出一个艰难的决定:放弃基于物理的fresnel。原先把specular放在RT0的目的就是,在lighting pass可以用它来计算fresnel:

LaTeX: F_{Schlick}(\mathbf{c}_{spec}, \mathbf{l}, \mathbf{h})=\mathbf{c}_{spec}+(1-\mathbf{c}_{spec})(1-(\mathbf{l} \cdot \mathbf{h})^5)

基于物理的fresnel需要specular颜色(这里简化成只有亮度了)、light方向和halfway方向,必须在lighting pass计算。最常见的近似是用view和normal来代替light和halfway,这样就可以在shading pass才计算fresnel,而且对于所有角度的光源产生的fresnel系数都相同。实际上,这个近似只有在高光的那一个点的地方是相同的,越往边缘去会越暗。但因为fresnel本身比较弱,这个差异可以被直接忽略。因为通道实在不够,在KlayGE 4.0中,我也不得不采用这个近似的、不基于物理的fresnel,得到新的G-Buffer布局如下:

New G-Buffer V2

specular被挪到了RT1的A通道,RT0的RGB通道就能都用来存放normal了。那么,在24-bit normal下渲染结果又如何呢?

Normal 24-bit

可以看到,效果比只用16-bit好了许多,但离32-bit的情况还是很有差距的。至少一眼就能看出来梯度的现象。在SIGGRAPH 2010上,Crytek有个讲座叫CryENGINE 3: reaching the speed of light。里面提到了出现这个现象的根本原因在于:normal是被normalize过的!24-bit一共能表达256x256x256 = 16777216个不同的值,但如果仅限于normalizied的,就剩下了大概289880个,仅占了1.73 %。它有效的位数只有17-bit,所以梯度的格子仅比16-bit的时候密了一倍。Crytek的best fit for normals方法能表达16482364个值,也就是98.2 %,提升了几乎两个数量级。用best fit调整过的normal平滑的多了:

Normal 24-bit with Best Fit

已经看不出和32-bit normal的区别了。关于best fit for normals的具体方法,可以参考Crytek的ppt。这里提供了一个我的程序预计算出来的纹理,用来查询最佳长度。

normals_fitting.7z

和Crytek的方法不同的是,我省掉了它所说的y/x变换,所以从normal计算纹理坐标的时候也得去掉vTexCoord.y /= vTexCoord.x一行。

透明物体

游戏中透明物体是不可缺少的,对于延迟渲染来说,透明物体一直是痛苦的。常见的做法是在延迟渲染的场景之上用forward shading来单独渲染透明物体,但那样就意味着必须单独实现一整套forward shading的流水线。这对于维护和扩展都是很不利的,对性能也很有影响。

KlayGE 4.0里,我用的方法被称为Deep G-Buffer。其基本过程是,把前文篇所描述的延迟渲染流水线复制三份,不透明的物体、透明物体的背面、透明物体的正面分别有自己独立的G-Buffer、lighting pass、shading pass和special shading pass。最后会生成三张shading的结果,再把它们按照alpha混合起来就可以了。

首先建立的是不透明物体的G-Buffer,跟原先一样:

Opaque objects's G-Buffer

细致的朋友可以发现,由于用了best fit for normals,G-Buffer里的normal看上去很有趣。

然后用把cull设置为front,只画透明物体的背面,存在第二个G-Buffer中。这里还需要用类似depth peeling的方法clip掉比不透明物体更远的pixel。因为不透明物体挡住了绝大部分pixel,透明物体的背面只剩下很少一部分:

Transparent object back face's G-Buffer

同样,我们可以在第三个G-Buffer存透明物体的正面:

Transparent object front face's G-Buffer

经过lighting pass、shading pass和special shading pass,就得到了不透明物体的shading:

Opaque object's shading

透明物体背面的shading,几乎没有被照亮的:

Transparent object back face's shading

以及透明物体正面的shading:

Transparent object front face's shading

注意透明物体都会在special shading pass给出像素alpha值。接下来只要把它们混合起来,就可以得到我们想要的结果:

After blending

再来一张侧面图,可以看到由于光照方式一样,透明物体和不透明物体的光照能连续平滑地过渡。

Side view

这一节抛砖引玉地提出了在Deferred框架下渲染透明物体的一个方法,它能简单有效地解决问题。缺点是三倍的内存和带宽消耗。如果depth peeling的层数增加,内存和带宽的消耗还会增加。这里其实也可以借鉴其他order independent transparency的方法,来取代depth peeling分离G-Buffer。

实时全动态GI

Main article: 全局光照

Post process

KlayGE 4.0的延迟渲染中,post process主要有HDR、AA和color grading。下面将分别讲述它们的改进。

HDR

KlayGE 3.12用了filmic tonemapping之后,HDR部分就几乎没有别的改变。这里唯一的变化是最终输出的float4,把亮度存在A通道上。这是为了后面FXAA的需要。

AA

在Deferred框架中,无法使用硬件AA曾经是个恼人的问题。随着这些年各种基于post process的AA方法大量出现,Deferred下AA的问题基本被解决了。

团队成员陈顺斌和郭鹏曾为KlayGE 3.12提供了FXAA。在新版本中,FXAA也升级到了最新的3.11版。从FXAA 3开始,就要求输入纹理是LDR的RGBL格式(L为亮度),所以计算AA的地点也就从HDR之前改到了HDR之后。虽然FXAA 3.11可以用G通道代替L,但效果肯定会受影响。既然让HDR post process输出RGBL轻而易举,我就没有把L改成G。FXAA极快,目前的实现在GTX480上可以达到0.1ms的惊人速度。几乎做到了无性能损失的高质量AA。

Color grading

Color grading是这个版本新增的。以前游戏一般不太重视color grading的作用,但在电影业,color grading是流水线非常重要的一步(可以和skinning相提并论的)。这里我实现的color grading是用16x16x16的3D texture作为查找表,用原RGB作为地址去查询,查询出的结果即为调色后的颜色(来自GPU Gems 2: Chapter 24. Using Lookup Tables to Accelerate Color Transformations)。除了runtime的post process之外,还需要一个离线工具,用来生成那个3D texture。这里我用的方法类似CE3,先生成一个摊平的256×16的2D texture:

Color grading flatten

在photoshop里打开一张游戏截图,调整RGB曲线至需要的色调,然后把那个RGB曲线应用到之前生成的2D texture,最终打包成3D texture就得到了我们所需要的查找表。以后可能会根据需要做一个在线调整color grading的工具。

总结

这一章节把KlayGE 4.0中延迟渲染的改进逐一介绍了一下,希望能对也在做类似事情的朋友有所帮助。在总结里我也身边展望一下未来,看看在KlayGE 4.1中,延迟渲染部分还会可能出现什么改进。

  • 更高的速度。Multiresolution的方法在GI中获得了成功,也许也可以扩展到direct lighting和SSVO中,用于加速整个延迟渲染
  • 改进HDR中的bloom filter。学习3DMark11,用FFT的方式在一个pass内完成bloom、lens flare等特效。
  • 支持移动平台。精简的Deferred Rendering流水线将会以至到移动平台上。
  • 更多例子用延迟渲染实现。目前只有3个例子用到了deferred框架,其他还是forward的。以后会有越来越多的例子转到deferred中。