这篇文章是对GEMS2里《Deferred Shading in S.T.A.L.K.E.R.》(中文译名《S.T.A.L.K.E.R.中的延期着色》,原作者Oles Shishkovtsov)的一个补充。它是在我们耗时两年时间、为游戏Tabula Rasa(Richard Garriott担纲的MMORPG)完成的渲染引擎的基础上形成的。GEMS2的这篇文章覆盖了实现一个Deferred Shading引擎的基本原理,而我们将重点放在了基于Deferred Shading引擎的工作中时可能遇到的更高层面的问题、技术和解决方案上。
1 Introduction
在计算机图形学的词典里,Shading表示“对受光物体的渲染”,这个渲染过程包括下面几步:
1, 计算几何多边形(也就是Mesh)。
2, 决定表面材质特性,例如法线、双向反射分布函数(bidirectional reflectance distribution function,BRDF)等等。
3, 计算入射光照。
4, 计算光照对表面的影响,并最终显示。
一般渲染引擎,渲染场景中的物体的时候,是将这四步一次执行完的。延迟着色则将前两步和后两步分开到渲染管道相互独立的两个部分来执行。
我们希望读者在阅读本文前,能先了解一下延迟着色的基本原理。以下的文章都不错,可以读读:Shishkovtsov 2005,Policarpo and Fonseca 2005,Hargreaves and Harris 2004。
在本文中:Forward Shading(前向渲染)是指4个步骤一齐处理的传统着色方法。Effect就是Direct3D的D3DX Effect,而Technique,Annotation和Pass,与它们在D3DX中的概念一样。
材质着色(Material Shader)是指用来渲染几何图元的Effect(也就是前两步),光着色则是指用来渲染可见光源的Effect。几何体(Body)用来指代那些需要渲染的物体。
在这里我们忽略了显卡相关的优化或实现,所有的解决方案都是普遍适应于SM2和SM3硬件的。我们希望能强调这个技术,而非实现。
2 Some Background
在Tabula Rasa中,我们一开始的渲染引擎是基于最初的DX9而完成的传统前向渲染技术的,使用了HLSL和D3DX Effect。我们的Effect使用了Pass里的Annotation来描述这个Pass所支持的光照。而在CPU这边,引擎可以算出来每个几何体被那些光源所影响——这个信息连同那些在Pass的Annotation里的信息一起,用于设置光源的参数、以及确定每个Pass该调用多少次。
这种前向着色有多种问题:
1, 计算每个几何体受那些光影响耗费了CPU的时间,更坏的是,这是个O(n*m)的操作。
2, Shader经常需要超过一次以上的Pass来渲染光照,渲染n个灯光,对于复杂的Shader,可能需要O(n)次运算。
3, 增加新的光照模型和新的光源类型,可能需要改变所有Effect的源文件。
4, Shader很快就将达到或者超出SM2的指令限制。
在MMO里,我们对游戏环境很少会有过于苛求的要求。我们无法控制同屏可见的玩家数量、无法控制同屏会有多少特效和光源。由于传统前向渲染缺乏对环境的控制,且对于光源的复杂度难于估量,因此我们选择了延期着色。这可以让我们的画面更接近于当今顶尖的游戏引擎,并且让光照所耗费的资源独立于场景的几何复杂度。
延期着色提供了下面的好处:
1, 光照所耗费的资源独立于场景复杂度,这样就不用再费尽心机去想着处理那些光源影响几何体了。
2, 不必要再为几何体的受光提供附加的Pass了,这样就节省了Draw Call和状态切换的数量。
3, 在增加新的光源类型和光照模型时,材质的Shader不需要做出任何改变。
4, 材质Shader不产生光照,这样就节省了计算额外的几何体的指令数。
延期着色需要显卡提供MRT的支持,且利用了不断增加的存储器的带宽——这也就意味着我们可能得对玩家所使用的硬件提出更高的要求。因此我们既实现了前向着色,也实现了延期着色。我们优化了前向着色管道,并在此基础上完成了延期着色管道。
有了一个完全基于前向着色的系统作为后盾,我们就可以以更高的硬件标准来完成延期渲染系统了。我们使用了SM2的显卡作为前向着色系统的最低配置,而延期着色系统,则需要支持SM3的显卡。这样就更易于开发一个延期渲染管道,因为我们不必要再顾虑指令数的限制,且能使用动态分支语句。
3前向着色支持
即便是工作在延期着色引擎下,对于半透明物体的渲染依旧需要前向渲染管道的支撑(详见本文第8节)。我们的引擎里保留了对整个前向着色管道的支持,这个管道用来处理半透明物体,以及用于在低端显卡上替代延期着色引擎。
本节讲述了我们是通过什么方法来同时支持前向和延期渲染的。
3.1 受限的特性
我们限制了前向渲染管道的特性,只让它实现延期渲染管道所有特性的一个很小的子集。有些特性因为技术上的原因无法支持,有些是因为工期太紧,但更多的,是为了开发起来方便而被我们丢弃掉了。
我们的前向渲染管道支持球状光源(hemispheric),方向光源和点光源,其中点光源是可选的,其他的所有类型光源都不支持(包括Spot Light,Box Light,它们只由延期着色管道来支持)。在延期渲染管道里构建的阴影和其他特性,在前向渲染管道中都不支持。
最后,前向渲染中的Shader是可以做逐顶点光照和逐像素光照的。在延期渲染管道中,所有的光都是逐像素的。
3.2 一个Effect,多个Technique
我们使用了在Effect中使用了不同的Technique来完成前向着色、延迟着色和Shadow Map,以及更多的东西。我们对每个Technique指定了Annotation来标明这个Technique使用了什么样的渲染方式。这就允许我们将所有的Shader代码放到一个统一的Effect文件里,来实现渲染引擎所需的所有Shader(见表19-1)。这包括前向着色中的静态和骨骼模型,延期着色使用到的“材质着色”(Material Shading)的静态和骨骼模型,以及Shadow Map。
把Effect所能用道德所有的Shader放到一个地方,我们就可以尽可能多地共享一些可以跨越不同渲染技术的代码。当然,我们不会去做一个超长的文件来储存这些代码,而是将这些Shader建立了一个由多个文件组成的Shader库,包含了各个Effect都能用到的共享的顶点和像素代码,以及常用的函数。这减少了Shader代码的复制,使得维护变得容易,减少了Bug,以及增强了各个Shader之间的稳定性(consistency)。
// These are defined in a common header, or definitions
// can be passed in to the effect compiler.
#define RM_FORWARD 1
#define RM_DEFERRED 2
#define TM_STATIC 1
#define TM_SKINNED 2
// Various techniques are defined, each using annotations to describe
// the render mode and the transform mode supported by the technique.
technique ExampleForwardStatic
<
int render_mode = RM_FORWARD;
int transform_mode = TM_STATIC;
>
{ . . . }
technique ExampleForwardSkinned
<
int render_mode = RM_FORWARD;
int transform_mode = TM_SKINNED;
>
{ . . . }
technique ExampleDeferredStatic
<
int render_mode = RM_DEFERRED;
int transform_mode = TM_STATIC;
>
{ . . . }
technique ExampleDeferredSkinned
<
int render_mode = RM_DEFERRED;
int transform_mode = TM_SKINNED;
>
{ . . . }
我们的前向渲染在对一个集合体使用多个光源时,很容易就需要增加额外的Pass了。增加Pass不仅会产生更多的Draw Call,也会造成更多的状态切换和更多重绘(原文是Overdraw,我感觉这里可能想表达重绘的意思)。我们发现在有很多光源的情况下,我们的前向渲染只绘制一小部分光源,就会比延期渲染慢。因此,为了更好的性能,我们严格限制了前向渲染管道里,对一个集合体受光的最大数量。
延期渲染管道每帧可以处理30个、40个、50个、甚至更多的动态光源,它们消耗的资源与几何体的数量,大小,以及受光程度毫无关系。然而,在前向渲染管道中,当有两个光源影响了一大坨几何体时,瞬间就卡了。由于两个渲染管道存在如此显著的性能差别,使用相同数量的光源几乎是不可能的。
我们为美术和策划提供了对光源优先级的编辑操作,提供了光源用于前向渲染、还是用于延期渲染、还是都用的开关。光源的优先级在两个管道中都有作用——当心能不足的时候,我们可以知道该关哪些光源;在延期渲染中,为了性能、质量设置,光源可能需要依据优先级关掉因它产生的阴影。
地图通常是按照延期渲染管道进行打光的。我们提供了一个很快的Pass来确认光源在前向渲染管道中是否是可接受的。一般地,在前向渲染管道下唯一的一个额外工作是增加Ambient Light的数量,来补偿相对于延期渲染管道少得多的灯光。
下面的这些技术在前向和延期渲染引擎中都有可能实现。在我们的延期渲染管道中,我们支持了所有这些技术。即便我们不用延期渲染,这些技术仍然可以使用(Even though deferred shading is not required, it made implementation much cleaner.)。在延期渲染中,我们将这些特性的实现与材质Shader分离开,这样我们就可以增加新的光照模型和光源类型,而不必要修改材质属性。这就正如我们可以添加新的、独立于光照模型和光源类型材质。
传统的球面光照(hemispheric lighting),正如DX文档里所说的那样,太普通了。这种光照模型使用了两个颜色,一般标记为Top和Bottom,然后基于表面法线对这两个颜色进行线性插值。标准的球面光照,根据表面法线方向朝正上方和正下方(这也就是为什么叫Top和Bottom),来对颜色进行插值。在Tabula Rasa中,我们支持了这种传统的球面光照,但我们也为方向光源提供了背部颜色(Back Color)。
在延期渲染中,美术可以很简单地增加多盏方向光源。我们发现他们经常使用一盏与另一盏光源恰巧相反的光源来模拟辐射度。他们很喜欢这种方法的结果,因此一个自然而然的优化就是:将这两个光源统一成一个特殊的方向光源——一个正面颜色和一个背部颜色。这给了他们相同的控制,但少了一半工作量。
对于之后的优化,背部颜色只是一个N·L的运算,或者一个简单的朗伯(Lambertian)光照模型。我们不必要为背部实现Specular,阴影,遮挡,以及更多高级光照技术。这些背部颜色只是对整个场景环境光和辐射度一个简单的近似。我们将正面颜色的N·L存了下来,将它的方向取反以用到背部颜色的计算上。
Globe Map是用来对光照添加颜色的,就像我们生活中的玻璃球(溜溜弹)那样。光线从光源发射出来,穿过玻璃球,然后被玻璃球赋予颜色和遮挡。对于点光源,我们使用一个Cube Map来完成这个功能,而对于聚光灯,我们使用2D纹理。这可以用于高效地模拟彩色玻璃的效果,或者通过一个模板来对光线进行遮挡。我们也为美术提供了旋转和让这些Globe Map动起来的效果。
可能的话,美术可以使用Globe Map来高效模拟Shadow Map,模拟彩色玻璃,迪斯科球(就是一般舞厅里那个旋转的,闪着暧昧和刺眼光芒的球球),以及更多。我们引擎里所有的光源都支持这些。请参考图19-1和19-3。
图19-1:基本的聚光灯
图19-2:简单的Globe Map
图19-3:融合了Globe Map的聚光灯。
4.3 Box Lights
在Tabula Rasa中,方向光是影响整个场景的全局光,且用于模拟太阳光和月亮光。我们发现,美术有时候想用方向光影响一小块区域,而不是整个个场景。
我们的解决方案是Box Light。这些光也是方向光,但他们只在一个长方体中起作用。在这个长方体中,我们可以支持类如聚光灯那样的衰减,这样,他们的强度就会随着距离边界越近而衰减得越厉害。Box Light也支持Shadow Map,Globe Map,背面颜色,以及所有其他被我们引擎支持的光源特性。
4.4 Shadow Maps
在Tabula Rasa中,没有预计算的光照。我们只用到了Shadow Map,而没有使用Stencil Shadow和Light Map。美术们可以让任何光源产生阴影(除了球面光照外)。对于Point Light,我们使用了Cube Map来产生Shadow Map,其它的情况下,我们都使用了2D纹理。
Tabula Rasa中的所有Shadow Map都使用了浮点纹理,且使用了抖动采样(Jitter Sampling)来进行柔化。美术可以控制抖动的幅度,以控制软阴影“软”的程度。这个方法允许我们用一个固定的方法,在所有的硬件上实现相同的效果,当然,对于Shadow Map,我们肯定是要使用硬件相关的纹理格式的。硬件相关的纹理格式可以提供诸如更好的精度,更好的硬件过滤。
Global Shadow Maps
很多论文讨论了全局阴影图,或者由一盏方向光的平截台体所产生的单独的阴影图。我们花了两个星期的时间来研究透视阴影图(Perspective shadow Maps)[Stamminger and Drettakis 2002]和梯形阴影图(trapezoidal shadow maps)[Martin and Tan 2004]。这两个方法最大的问题是最后的结果取决于光源方向和眼睛方向。只要摄像机一变化,阴影的质量就会发生改变,最坏的情况下,变成了标准的正交投影。
Tabula Rasa里面是有白天和夜晚的循环的,太阳和月亮持续的在天上划过。在黄昏和拂晓的时候,光源方向与水平面几乎平行,这就增加了摄像机方向与光源方向平行的几率。这是前述两种方法面对的最糟糕的情况。
由于摄像机和光源方向不断移动,阴影质量变得很难把控,我们最终(end up,这里不知道作者是想说最终,还是说不再)使用一张大的2048 X 2048的Shadow Map进行正交投影。这使得最后的结果很统一,而且与光源和摄像机的夹角无关。当然,肯定会有比我们这种方法好得多的方法,例如Cascaded Shadow Map。
我们使用了抖动采样来柔滑阴影边缘,我们对光源的位移进行了离散化,因此他总是指向Shadow Map中固定的位置,我们也对光源的方向离散化了,这样Shadow Map计算时的值不需要每帧都发生变化。最终的结果是,我们获得了一个稳定的阴影,无论摄像机如何移动。
请看表19-2。
表19-2 离散化光源位置以计算Shadow Map投影矩阵。
// Assumes a square shadow map and square shadow view volume.
// Compute how "wide" a pixel in the shadow map is in world space.
const float pixelSize = viewSize / shadowMapWidth;
// How much has our light position changed since last frame?
vector3 delta(lightPos - lastLightPos);
// Project the delta onto the basis vectors of the light
matrix.float xProj = dot(delta, lightRight);
float yProj = dot(delta, lightUp);
float zProj = dot(delta, lightDir);
// Quantize the projection to the nearest integral value.
// (How many "pixels" across and up has the light moved?)
const int numStepsX = static_cast(xProj / pixelSize);
const int numStepsY = static_cast(yProj / pixelSize);
// Go ahead and quantize "z" or the light direction.
// This value affects the depth components going into the shadowmap.
// This will stabilize the shadow depth values and minimize
// shadow bias aliasing.
const float zQuantization = 0.5f;
const int numStepsZ = static_cast(zProj / zQuantization);
// Compute the new light position that retains the same subpixel
// location as the last light
position.lightPos = lastLightPos + (pixelSize * numStepsX) * lgtRight +
(pixelSize * numStepsY) * lgtUp +
(zQuantization * numStepsZ) * lgtDir;
Local Shadow Maps
在我们的引擎中,所有光源都可能产生阴影,而整个地图有上百盏灯。引擎必须提供管理和使用Shadow Map的方法。所有的Shadow Map知道他们需要时才会被创建出来,并且,大多数Shadow Map是静态的,不需要每帧都重新创建。我们为美术提供了控制每个产影灯是使用静态Shadow Map还是动态的Shadow Map。静态的Shadow Map只生成一次,之后就一直使用,而动态的则每帧都会被刷新。
我们同样标定了几何体是静态的还是动态的,也就是运行时是否可动。我们可以根据这个标志来在计算中裁减掉部分几何体。当创建静态Shadow Map时,我们排除了动态几何体部分。这可以防止类如Avatar这样的动态物体产生的动态影“getting ‘baked‘ into a static shadow map”(这句话不是特别明白,可能想表达的意思就是,静态Shadow Map产生时仅考虑静态物体,而不考虑场景中当前的动态物体吧?这不废话么?!拍静态物体的Shadow Map当然不应该考虑动态物体了,要不干嘛弄这一套静态Shadow Map?!)。动态物体如同其它静态物体那样,使用静态Shadow Map来对自己打影。例如,沿着楼梯走的Avatar,将会被楼梯投到他身上的影子所影响。(个人感觉这里作者可能想表述的就是他们把静态物体和动态物体的产影分开了,互相独立,不过业界的应该都是这么做的吧?需要这么特别说明一下么?搞不明白!当然,也可能使我理解错了,欢迎大家批评指正!)
这里有很多种自动化和优化的方法。我们并不一开始就生成所有的静态Shadow Map,而是在需要用到的时候再去创建。这就意味着我们并不需要发布这些Shadow Map文件,且减少了Loading时和运行时从磁盘读取数据的数量。为了节省显存和节省纹理创建的开销,我们使用了Shadow Map池。关于这些本文后面会有更多地描述。
动态产影光源是最耗费的,他们需要常时重新生成他们的Shadow Map。如果动态产影光源不移动,或者移动得不那么剧烈,则就有一些方法可以提升一些性能了。最简单的是,除非有动态几何体在光源的影响范围内,否则就不要重新生成这些Shadow Map。另一个选择是将静态模型渲到各自独立的静态Shadow Map上,这样这些Shadow Map就只用创建一次了。每帧都需要将动态物体渲染到独立的动态Shadow Map上,在最后,只需要判断两个Shadow Map中最小的,或者最近的值就可以了。最后的结果就类似于整个场景的所有物体都产生了Shadow Map——其实我们生成的只有动态物体。
4.5 将来的扩展
由于基于延期着色的引擎已经将光照和几何渲染完全分开, 因此我们就可以很方便的修改或增加光照的特性了。事实上,前面说的Box Light,从会议上的提议到最后编辑器里的完整功能,我们只花了三天时间。
HDR,Bloom,以及其它特效,添加到延期渲染引擎里、与添加到传统渲染引擎的难度相当。延期渲染引擎的架构,使得他更易于扩展。一般的,在延期渲染引擎里增加一个特性,比在前向渲染引擎里增加一个特性显得简单,或者起码不会难太多。限制延期渲染引擎特性的最大问题是能添加到每个象素中的材质属性,可用的显存,以及显存带宽。
5 可读的Depth和Normal Buffer的优势
延期着色的一个前提是,需要创建储存深度和法线信息的纹理。这些信息将被用到光照计算中。然而,他们也可以超越光照的范畴,用于计算雾,深度Blur(应该是指DOF),体积粒子,以及消除半透明物体穿入不透明物体时的硬边。
5.1 高级水和折射
在Tabula Rasa的延期渲染中,我们的水面Shader充分考虑了水的深度信息(视空间下)。当水的被渲染时,我们将拿它的每个象素和我们延期着色中已有的深度进行比较。这就使我们的水面可以具备自动的海岸线,而且,水可以根据视空间的深度来改变颜色和半透明,同时,在水下的物体可以做折射,而水上的物体则不用。我们可以在一个Pass里面做完所有这些工作,而不像传统渲染引擎那样。(译者:但是,我个人认为,这种海岸线的效果真得不怎么样……!除了过度柔和的边缘之外,对于海浪之类的模拟较差。相比而言,还是Crysis、FarCry这类引擎的海面做得好啊)
我们的前向渲染引擎只支持基本的折射特性,它需要一条独立的Pass来初始化折射纹理的Alpha信息,以分辨那些在水面之上的部分,这些部分不能计算折射。[Sousa 2005]给出了这个算法。
在我们的延期渲染引擎中,我们可以采样到当前像素的视空间深度和被折射像素的视空间深度。通过比较这两个深度,我们可以知道究竟被折射像素是高于水面还是低于水面,低于水面的,发生折射,高于水面的,就不再处理了。见图19-4和19-5。
图19-4 前向渲染的水。
折射只在低于水面的地方产生,这里没有可访问的深度信息,只能用多个Pass来处理,不能用视空间深度。
图19-5 使用了前向渲染,但通过延期渲染的Depth Buffer来获取深度。
注意颜色和半透明随着视空间深度变化而变化,没有了水体的硬边,只需要一个Pass。
为了方便美术控制随深度变化的颜色和透明度,我们提供了一个Volume纹理,而非一个1维纹理。1维纹理只是一个从归一化的深度查询到透明度的速查表。而Volume纹理则允许美术模拟水深对半透明的非线性的变化。Volume纹理也用于影响水面的颜色。这可以使一个平板Volume纹理(也就是一张标准的2D纹理),也可以是有2或者4个W分量的Volume纹理。归一化的深度用于对W进行采样,UV则由美术来指定。水面的表面法线有两个相互独立的、UV动画的发现图构成。
5.2 分辨率无关的边缘检测
[Shishkovtsov 2005](GEMS2里的那篇文章)提出了一个边缘检测方法,用于在帧缓存上模拟反锯齿。这种方法需要一些与分辨率相关的魔数。我们也需要反锯齿,我们修改了一下这个方法,使之可以与分辨率无关。
我们对一个像素邻近的8个像素,进行深度梯度和法线角度的采样,这一点是与Gems2一致的。我们在这个点上判断深度上最大的和最小的变动,来确定边缘有多强。像素之间深度的梯度是与分辨率无关的。通过比较梯度变化率之间的关系,而不是梯度,就可以做到分辨率无关了。
我们的法线处理类似于GEMS2的方法。我们比较了中央像素和其周围、沿与我们检测梯度相同的边缘、的像素角度的余弦的变化(译者:我也没弄明白啥意思,具体就看代码吧……)。这里我们使用了我们自己的常数。无论如何,法线的变化率也是分辨率无关的,这就达到了我们的要求。
在这个算法中,我们没有做对“右上”或“前”边缘的选择的限制,因此很多边缘会有两个像素宽,不过,当使用了Filter来平滑这些边缘后,看起来也不错。
边缘检测的结果是生成了逐像素的边缘权重,这个值在0~1之间。这个权重反映了会有多少像素在它上面。在最后的渲染前,我们会把这个权重进行四个Bilinear采样。这四个采样是中心像素权重0,四周权重为1的采样。这样的结果就是目标像素的权重是它8个邻居权重的平均值。像素越是一个边缘像素,就会越多与它的邻居混合。请参考表19-3。
为了方便美术控制随深度变化的颜色和透明度,我们提供了一个Volume纹理,而非一个1维纹理。1维纹理只是一个从归一化的深度查询到透明度的速查表。而Volume纹理则允许美术模拟水深对半透明的非线性的变化。Volume纹理也用于影响水面的颜色。这可以使一个平板Volume纹理(也就是一张标准的2D纹理),也可以是有2或者4个W分量的Volume纹理。归一化的深度用于对W进行采样,UV则由美术来指定。水面的表面法线有两个相互独立的、UV动画的发现图构成。
5.2 分辨率无关的边缘检测
[Shishkovtsov 2005](GEMS2里的那篇文章)提出了一个边缘检测方法,用于在帧缓存上模拟反锯齿。这种方法需要一些与分辨率相关的魔数。我们也需要反锯齿,我们修改了一下这个方法,使之可以与分辨率无关。
我们对一个像素邻近的8个像素,进行深度梯度和法线角度的采样,这一点是与Gems2一致的。我们在这个点上判断深度上最大的和最小的变动,来确定边缘有多强。像素之间深度的梯度是与分辨率无关的。通过比较梯度变化率之间的关系,而不是梯度,就可以做到分辨率无关了。
我们的法线处理类似于GEMS2的方法。我们比较了中央像素和其周围、沿与我们检测梯度相同的边缘、的像素角度的余弦的变化(译者:我也没弄明白啥意思,具体就看代码吧……)。这里我们使用了我们自己的常数。无论如何,法线的变化率也是分辨率无关的,这就达到了我们的要求。
在这个算法中,我们没有做对“右上”或“前”边缘的选择的限制,因此很多边缘会有两个像素宽,不过,当使用了Filter来平滑这些边缘后,看起来也不错。
边缘检测的结果是生成了逐像素的边缘权重,这个值在0~1之间。这个权重反映了会有多少像素在它上面。在最后的渲染前,我们会把这个权重进行四个Bilinear采样。这四个采样是中心像素权重0,四周权重为1的采样。这样的结果就是目标像素的权重是它8个邻居权重的平均值。像素越是一个边缘像素,就会越多与它的邻居混合。请参考表19-3。
为了方便美术控制随深度变化的颜色和透明度,我们提供了一个Volume纹理,而非一个1维纹理。1维纹理只是一个从归一化的深度查询到透明度的速查表。而Volume纹理则允许美术模拟水深对半透明的非线性的变化。Volume纹理也用于影响水面的颜色。这可以使一个平板Volume纹理(也就是一张标准的2D纹理),也可以是有2或者4个W分量的Volume纹理。归一化的深度用于对W进行采样,UV则由美术来指定。水面的表面法线有两个相互独立的、UV动画的发现图构成。
5.2 分辨率无关的边缘检测
[Shishkovtsov 2005](GEMS2里的那篇文章)提出了一个边缘检测方法,用于在帧缓存上模拟反锯齿。这种方法需要一些与分辨率相关的魔数。我们也需要反锯齿,我们修改了一下这个方法,使之可以与分辨率无关。
我们对一个像素邻近的8个像素,进行深度梯度和法线角度的采样,这一点是与Gems2一致的。我们在这个点上判断深度上最大的和最小的变动,来确定边缘有多强。像素之间深度的梯度是与分辨率无关的。通过比较梯度变化率之间的关系,而不是梯度,就可以做到分辨率无关了。
我们的法线处理类似于GEMS2的方法。我们比较了中央像素和其周围、沿与我们检测梯度相同的边缘、的像素角度的余弦的变化(译者:我也没弄明白啥意思,具体就看代码吧……)。这里我们使用了我们自己的常数。无论如何,法线的变化率也是分辨率无关的,这就达到了我们的要求。
在这个算法中,我们没有做对“右上”或“前”边缘的选择的限制,因此很多边缘会有两个像素宽,不过,当使用了Filter来平滑这些边缘后,看起来也不错。
边缘检测的结果是生成了逐像素的边缘权重,这个值在0~1之间。这个权重反映了会有多少像素在它上面。在最后的渲染前,我们会把这个权重进行四个Bilinear采样。这四个采样是中心像素权重0,四周权重为1的采样。这样的结
////////////////////////////
// Neighbor offset table
////////////////////////////
const static float2 offsets[9] = { float2( 0.0, 0.0),//Center 0
float2(-1.0, -1.0), //Top Left 1
float2( 0.0, -1.0), //Top 2
float2( 1.0, -1.0), //Top Right 3
float2( 1.0, 0.0), //Right 4
float2( 1.0, 1.0), //Bottom Right 5
float2( 0.0, 1.0), //Bottom 6
float2(-1.0, 1.0), //Bottom Left 7
float2(-1.0, 0.0) //Left 8
};
float DL_GetEdgeWeight(in float2 screenPos)
{
float Depth[9];
float3 Normal[9];
//Retrieve normal and depth data for all neighbors.
for (int i=0; i<9; ++i)
{
float2 uv = screenPos + offsets[i] * PixelSize;
Depth[i] = DL_GetDepth(uv);
//Retrieves depth from MRTs
Normal[i]= DL_GetNormal(uv);
//Retrieves normal from MRTs
}
//Compute Deltas in Depth.
float4 Deltas1;
float4 Deltas2;
Deltas1.x = Depth[1];
Deltas1.y = Depth[2];
Deltas1.z = Depth[3];
Deltas1.w = Depth[4];
Deltas2.x = Depth[5];
Deltas2.y = Depth[6];
Deltas2.z = Depth[7];
Deltas2.w = Depth[8];
//Compute absolute gradients from center.
Deltas1 = abs(Deltas1 - Depth[0]);
Deltas2 = abs(Depth[0] - Deltas2);
//Find min and max gradient, ensuring min != 0
float4 maxDeltas = max(Deltas1, Deltas2);
float4 minDeltas = max(min(Deltas1, Deltas2), 0.00001);
// Compare change in gradients, flagging ones that change
// significantly.
// How severe the change must be to get flagged is a function of the
// minimum gradient. It is not resolution dependent. The constant
// number here would change based on how the depth values are stored
// and how sensitive the edge detection should be.
float4 depthResults = step(minDeltas * 25.0, maxDeltas);
//Compute change in the cosine of the angle between normals.
Deltas1.x = dot(Normal[1], Normal[0]);
Deltas1.y = dot(Normal[2], Normal[0]);
Deltas1.z = dot(Normal[3], Normal[0]);
Deltas1.w = dot(Normal[4], Normal[0]);
Deltas2.x = dot(Normal[5], Normal[0]);
Deltas2.y = dot(Normal[6], Normal[0]);
Deltas2.z = dot(Normal[7], Normal[0]);
Deltas2.w = dot(Normal[8], Normal[0]);
Deltas1 = abs(Deltas1 - Deltas2);
// Compare change in the cosine of the angles, flagging changes
// above some constant threshold. The cosine of the angle is not a
// linear function of the angle, so to have the flagging be
// independent of the angles involved, an arccos function would be
// required.
float4 normalResults = step(0.4, Deltas1);
normalResults = max(normalResults, depthResults);
return (normalResults.x + normalResults.y +
normalResults.z + normalResults.w) * 0.25;
}
果就是目标像素的权重是它8个邻居权重的平均值。像素越是一个边缘像素,就会越多与它的邻居混合。请参考表19-3。
表19-3:边缘检测的Shader代码。
Tabula Rasa的延期渲染瞄准的是DX9平台,SM3的硬件环境。这个阶段的配置有大量的用户群,然而同时,DX10和SM4可以减少许多限制。首要的一点是,SM3最多只支持4个Render Target,且不支持独立的Render Target位深度(也就是,4个RT必须具备同样的Bit数,如果你一个用的是R8G8B8A8,32bit,那么另一个你就不能用FP16,只能用FP32,因为FP16是16Bit的,而FP32是32Bit的)。这就限制了我们可以使用的、用于储存材质信息的数据通道数量。
一般的4个DX9 32Bit MRT纹理,除了深度缓冲(指DX本身的DepthStencil)之外,剩下还有13个数据通道来储存属性信息:3个4通道的RGBA纹理,和一个32Bit的高精度深度纹理。即便我们使用的是64Bit,而非32Bit,除了能提供更高的精度外,其实并不能增加数据通道的数量。
即便所有的数据通道都是按照顺序来储存信息的,但在SM3下,所有对数据的访问都通过浮点寄存器。这就意味着使用Bit Mask或者类似手段来做压缩或者将更多信息存储到一个通道里是不切实际的。到了SM4,才支持真正的整数运算。
必须指出的是,这些通道里存储的信息,直接决定了引擎能支持怎样的光源类型。我们只能尽可能避免存储某一个具体光源类型独特的数据。在通道受限的情况下,每个通道都必须最大程度地利用来存储那些最重要的数据。
这里有一些辅助压缩或者减少通道使用量的方法。存储视矩阵的法线时,可以存储在两个Channel里,而不是三个。在视矩阵里,法线的Z分量只可能具有统一的符号(正负号),因为所有可视的像素都面对摄像机。利用这个信息,同时,利用所有的法线都是单位向量,我们可以通过XY分量构建出Z分量来。另一个方法,是把材质属性存储到一个纹理速查表中,然后把必要的纹理坐标(也就是这个速查表的索引)存储到MRT的数据通道里。
这些材质的属性,就是维系材质和光源之间的胶水。它们是材质Shader的输出,同时是光照Shader的输入。
同时,他们也是材质和光照之间唯一的关联。这样,改变材质的属性数据(应该是指改变数据通道的组织,服了这为大哥了……到处写这种语焉不详模棱两可的话,官腔!……改变个数据至于要修改所有Shader吗?那你延期渲染的优势还能体现到哪里呢?!),同时必然需要改变所有的Shader,包括材质和光照。
我们并不直接把材质属性的数据通道或者数据格式暴露给光照Shader,而是通过一些函数来设置和获取这些信息(拜托,求你了,大哥,我们懂基本的封装,你不是写给小学生看的,OK?!快说重点!)。这样,数据的位置和格式就可以随意改变,而材质和光源则只需要重新编译,而不必修改。
我们也提供了一个在材质里专门初始化所有MRT数据的Shader。这可能增加了不必要的指令开销,但为我们未来扩展新的数据通道提供了便利,也不必要再去修改已经存在的材质Shader了。材质Shader只有在默认值需要发生改变时,才会去修改。请见表19-4:
表19-4:封装和隐藏MRT数据
/ Put all of the material attribute layout information in its own
// header file and include this header from material and light
// shaders. Provide accessor and mutator functions for each
// material attribute and use those functions exclusively for
// accessing the material attribute data in the MRTs.
// Deferred lighting material shader outputstruct
DL_PixelOutput
{
float4 mrt_0 : COLOR0;
float4 mrt_1 : COLOR1;
float4 mrt_2 : COLOR2;
float4 mrt_3 : COLOR3;
};
// Function to initialize material output to default values
void DL_Reset(out DL_PixelOutput frag)
{
// Set all material attributes to suitable default values
frag.mrt_0 = 0;
frag.mrt_1 = 0;
frag.mrt_2 = 0;
frag.mrt_3 = 0;
}
// Mutator/Accessor – Any data conversion/compression should be done
// here to keep it and the exact storage specifics abstracted and
// hidden from shaders
void DL_SetDiffuse(inout DL_PixelOutput frag, in float3 diffuse)
{
frag.mrt_0.rgb = diffuse;
}
float3 DL_GetDiffuse(in float2 coord)
{
return tex2D(MRT_Sampler_0, coord).rgb;
}
. . .
// Example material shader
DL_PixelOutput psProgram(INPUT input)
{
DL_PixelOutput output;
// Initialize output with default values
DL_Reset(output);
// Override default values with properties
// specific to this shader.
DL_SetDiffuse(output, input.diffuse);
DL_SetDepth(output, input.pos);
DL_SetNormal(output, input.normal);
return output;
}
6.2 精度
延期着色很容易由于丧失了数据的精度而引发问题。最明显的丢失,是由于材质数据被存储到了MRT数据通道里。在Tabula Rasa中,绝大多数数据通道是8Bit或者16Bit的,取决于我们使用了32Bit的Render Target还是64Bit的(一个Render Target有4通道这一点并没有改变)。硬件内部的寄存器与Render Target的内部格式精度并不一致,再读和写的时候均需要数据的转换。例如:我们的法线分量是通过硬件最高精度的运算得出的,但却要被存储到8Bit或者16Bit精度的通道里。在8Bit的情况下,高光看起来很不平滑,而且还会有破碎的情况出现。
在延期着色下,光照系统的性能直接取决于光源需要处理的像素的数量。我们用了下面的技术来减少光照需要计算的象素数量,以提升性能。
早期Z剔除(Early z-rejection),模板缓冲,以及动态分支,它们具备相同的特征:取决于数据的位置。这需要硬件体系结构的支持,不过现在绝大多数硬件都支持了。一般的,如果我们尽可能地使用了早期Z剔除,模板缓冲和动态分支,那么在屏幕上的一个局部区域内,所有的像素的行为都是均匀的。也就是说,他们都经过了Z剔除,模板,或者走入了同一个分支中。
我们使用了紧密包围着光源影响区域的Light Volume来计算光照。理论上说,如果我们对整个屏幕所有的像素全都用光照计算,那么最后的结果也是一样的,但是,性能就会变得很差(每个灯光对整个GBuffer进行一次全采样,OMG……)。Light Volume覆盖的屏幕空间的像素越少,光照Pixel Shader需要处理的数据就越少。我们适用锥体来描述聚光灯,球体来描述点光源,长方体来描述Box Light,而对于方向光这样的全局光照,我们适用了整个屏幕空间。
另一个延期渲染的论文都会描述的方法,是通过基于Light Volume和摄像机位置的深度测试和Cull Mode(顺时针逆时针那个),来减少计算量。这种调整最大程度的进行了早期Z剔除。这种方法需要CPU来判断用哪种深度测试和Cull Mode的组合可以最大程度的进行早期Z剔除。
在我们所有的情况下(我们的Light Volume不会被远面剔除掉),我们都使用“Greater”的深度测试和顺时针的绕法(也就是反着绕)。可以通过一些推测,来选出对自己最有效的深度测试和Cull Mode。然而我们遇到的瓶颈在其他地方,因此我们决定不再用这种方法,通过浪费CPU资源来优化性能。
(感觉本段的技术并无实用性,剔除肯定是必须做的,硬件Occlusion,其他的,各种方法其实都很简单,而且实用性较强。不过也有可能是我没理解了作者的意思。)
在延期渲染系统中,使用Stencil来屏蔽一些像素,是另一个常用的手段。基本上,就是用Stencil Buffer来指定哪些像素不必要进行光照。当渲染Light Volume的几何体时,可以通过简单的模板测试来取消对这些标定像素的处理。
我们试了一些这个技术的变种。我们发现这个方法所带来的性能提升,还不如增加了Draw Call导致的性能下降。我们试图使用一个“便宜的”Pass来标定所有的像素是否面向光源或者是否在光源之外。这个确实是减少了需要处理光源的像素的数量。在DX9一般的下,“便宜的”Pass增加的Draw Call抵消掉、甚至远超过了最后光源Pass时提升的性能。
我们利用了Stencil来标定那些之后延期渲染需要处理的场景中的不透明物体。这个方法把那些天空盒和其他正面不需要进行光照的物体排除掉了(主要是那些只有Emissive的物体)。这个方法不需要任何多余的Draw Call,因此会非常“便宜”。光照Pass之需要简单的把这些标定的像素丢弃掉就可以了。当天空盒占了整个屏幕绝大部分的时候,这种方法会带来相当程度的性能提升,而即便不是这样,这种方法起码也不会带来任何损失。
DX10减少了Draw Call的开销。对于那些瞄准了DX10平台的读者,制作一个“便宜的Pass”(第二段描述的方式)应该是个不错的尝试。然而在SM3下使用动态分支,比增加新的Pass要好一些。
SM3一个很关键的特性就是支持动态分支。动态分支不仅增加了GPU的可编程性,在合适的情况下,他也可以用来进行优化。
使用动态分支进行优化需要注意两个原则:
1, 制造一个或者两个动态分支,以确保能最大程度的跳过更多数量的代码和那些频率较高的代码。
2, 注意数据的位置。如果一个像素走了分支A,那么它邻近的像素最好也能尽可能走分支A。
光照中最好的时机是根据像素距离光源的远近和表面法线来进行分支。如果使用了法线图,则表面法线就会变得不再均匀,优化就会变得比较麻烦。
在使用延期着色的过程中并非都是一帆风顺的。由于显存带宽和数据通道的限制,延期着色也有它本身不可调和的问题。
延期着色最大的问题是在处理半透明物体的时候显得无能为力。不支持半透明不仅仅是硬件的限制问题,同时也是这个技术本身的硬伤:我们所有的工作均受限于“只能知道临近像素的材质信息”。在Tabula Rasa中,我们使用了大家都在用的方法:在延期着色渲染勒索由的不透明物体之后,使用前向着色来渲染所有的半透明物体。
要在延期着色中支持真正的半透明,则可能需要一些更多的帧缓冲来存储一个片断是否被遮挡的信息。这也是解决不排序半透明的一种方式。这种缓冲现在并不被我们的图形显卡支持。
然而,开启MRT时,只要Render Target允许,我们可以支持基于Alpha Test的Additive式的混合(也是一种Alpha混合)。(看了看后面,感觉这个意思可能是想说,由于延期着色中,Alpha是不会被存下来的,因此只能用来做Additive这样的混合,而不能做基于Alpha的混合。)当MRT的Alpha Test开启时,如果COLOR0(也就是第一张Render Target)的Alpha为零,当前片断的Test失败,则不会有任何Render Target被更新(也就是说,存储Alpha是无意义的,因为那些被裁减掉的像素根本就不会写到帧缓存里)。因此这里我们不能使用Alpha Test,而是应该使用clip指令来裁掉一个像素。因为Render Target 0并没有用来存储Diffuse,而是用于存储其它材质信息的。延期渲染管道渲染的东西都应该是完全不透明的,因此,我们不再使用这些通道来存储无意义的Alpha信息。
使用前向渲染来处理半透明几何体可以解决一些问题。我们使用了我们的前向渲染管道来处理水面和其他半透明几何体。水的Shader使用了在延期着色中生成的深度纹理。水的光照计算用的是传统的前向着色技术。这种方案也有一点问题——让半透明几何体和不透明几何体之间光照统一是比较困难的。而且,我们延期着色支持的很多光照特性,前向渲染管道是不支持的。这就使得两者的结合变得不太现实。
Tabula Rasa中,在两个方面,两种光照系统的不一致成为一个巨大的问题:头发和植被。头发和植被在半透明时看这会比较舒服。然而,当一个角色进入阴影的时候,他的头发没有变色,这点是不可接受的。同样的,当周围所有的东西都被投影的时候,仅有草没有被投影,也是不可接受的。
我们最终决定使用Alpha Test而不是半透明。这样,头发和植被就可以利用延期渲染来处理了。光源的效果在头发和植被上也比较统一。为了减少植被边缘的粗糙,我们尝试使用过一些小技巧。例如进行屏幕空间的半透明排序,或者使用半透明从前向着色过渡到延期着色。但没有一个方案是真正可用的。我们现在的做法是通过让植被变大变小来处理淡入和淡出。
由于硬件带宽的增加,延期着色才成为可能。延期着色需要写入到4个Render Target中,而不是1个,也就是写入量是原来的4倍。在光照Pass里,我们也需要从这所有的缓冲中读取信息,读入量也超过了过去。带宽和填充率,是延期着色最大的性能影响因素。
最大的减少带宽开销的因素是屏幕分辨率。带宽与渲染像素的数量直接相关,1280x1024只有1024x768的66%速度。延期渲染的引擎性能严重受限于分辨率的大小。
进行独立的位深度存取,在舍弃一定精度的前提下,应该可以减少带宽的损耗量。但是这种方法对我们并没有用,因为现在的硬件并不支持这种特性。我们的做法是尽可能减少材质数据的存储量、尽可能减少这些缓冲的使用率。
当渲染光照的时候,我们也使用了MRT。我们使用了两个Render Target,并进行Additive的混合。这些Render Target分别属于Diffuse和Specular的积累缓冲区。乍一眼看,这好像对于节省带宽而言是多余的,因为我们将信息写到了两个Render Target上。然而,这个选择确实可以提高效率。
将Diffuse和Specular加到一起的一办法可能如下面这样:
Fraglit =Fragunlit xLightdiffuse +Lightspecular·
这个公式是可以分为Diffuse和Specular两部分的。将这两部分分别放到两个Render Target的话,在光照Shader里,我们不用再去取出Unlit的片段(Frag Unlit)。Shader只是产生Light Diffuse和Light Specular项,它们除了表面与光源的关系外,不用承担任何其他的计算量。
如果我们不把Diffuse和Specular分开,那么Light Shader则必须计算出最终的片段颜色。这个计算必须获取Unlit的片段颜色(纹理本身的颜色),以及其他可能影响最终颜色的材质属性(例如自发光)。把这些最终颜色放到光照Shader中,就意味着我们将真正丢掉Diffuse和Specular分量。也就是说,我们无法从Shader的结果中分解出来光源的原始信息了。将Diffuse和Specular分量存储到Render Target中,对于进行HDR和其他需要影响光源的Post Process运算都很有利。
在所有的光照Shader都运行完毕后,我们进行最后一个全屏的Pass,来计算最终的片段颜色。这个最终的Post Process Pass里,我们计算雾、边缘检测和平滑、以及最终的片段颜色。这个方法确保了这些方法对每个象素仅计算一次,减少了绕路的数量,最大化了从MRT里读取信息时纹理Cache的命中率。从MRT中反解材质数据是耗费很高的运算,特别是当大量使用时,导致的纹理Cache的颠簸,会让这个情况变得更加糟糕。
使用这些光照的积累缓冲之后,我们可以很方便的在需要的时候关闭Specular光照,以避免带宽的浪费。这些光照的积累缓冲也可以在光照相关的Post Process中也很有用,例如增加对比度,计算HDR,以及其它类似的特效。
在Tabula Rasa中,即便在最普通的1024x768分辨率下,我们也要为延期渲染和反射这所有的Render Target花掉50MB的显存。这还不包括主缓冲,顶点和索引,以及纹理。而这些Render Target在1600x1200的分辨率下则需要100MB的显存。
我们使用了4个、屏幕大小的Render Target来存储几何体的材质数据。我们的光照Shader使用了两个、屏幕大小的Render Target。这些Render Target可以是32 Bit的或者64Bit的,取决于显卡和显示质量设置。然后,为了全局方向光,还有一个2048x2048的Shadow Map,以及为了其他光源产生的各种附加Shadow Map。
使用Render Target的一个可能的提议是:减少分辨率,只在最后渲染的时候把它们缩放上去。这有很多好处,但我们发现图像质量变差了,因此就没有继续接下去研究,不过这种方法有可能在一些特殊的应用中是可行的。
Render Target使用的显存只是一个问题。他们的生存周期和位置对整个性能有更为关键的影响。即使这些纹理在显存中,超出了我们的控制范畴,我们仍然可以做一些事情来挽回一些事情。
我们使我们主要的MRT早于其他任何纹理分配,这种分配可以帮助驱动,将他们放到最完整、连续的显存中。我们仍然受制于驱动的实现,但是我们起码可以帮助驱动去让它实现我们希望的结果。
我们使用了Shadow Map池,并允许光源共享这些Shadow Map。在引擎里,我们限制了Shadow Map的最大数量。基于光源的优先级,位置和所需的Shadow Map大小,为这些光源分配少量的Shadow Map。这些Shadow Map永远不会释放,只是不断地重用。这个减少了显存碎片,并且减少了因为创建和销毁资源而带来的性能损失。
基于这一点,我们也限制了每帧渲染(或重生)的Shadow Map的数量。如果有好几盏光同时需要生成他们的Shadow Map,引擎每帧只会创建一到两个,这就将花销平摊到了几帧中。
延期着色很容易由于丧失了数据的精度而引发问题。最明显的丢失,是由于材质数据被存储到了MRT数据通道里。在Tabula Rasa中,绝大多数数据通道是8Bit或者16Bit的,取决于我们使用了32Bit的Render Target还是64Bit的(一个Render Target有4通道这一点并没有改变)。硬件内部的寄存器与Render Target的内部格式精度并不一致,再读和写的时候均需要数据的转换。例如:我们的法线分量是通过硬件最高精度的运算得出的,但却要被存储到8Bit或者16Bit精度的通道里。在8Bit的情况下,高光看起来很不平滑,而且还会有破碎的情况出现。
在延期着色下,光照系统的性能直接取决于光源需要处理的像素的数量。我们用了下面的技术来减少光照需要计算的象素数量,以提升性能。
早期Z剔除(Early z-rejection),模板缓冲,以及动态分支,它们具备相同的特征:取决于数据的位置。这需要硬件体系结构的支持,不过现在绝大多数硬件都支持了。一般的,如果我们尽可能地使用了早期Z剔除,模板缓冲和动态分支,那么在屏幕上的一个局部区域内,所有的像素的行为都是均匀的。也就是说,他们都经过了Z剔除,模板,或者走入了同一个分支中。
我们使用了紧密包围着光源影响区域的Light Volume来计算光照。理论上说,如果我们对整个屏幕所有的像素全都用光照计算,那么最后的结果也是一样的,但是,性能就会变得很差(每个灯光对整个GBuffer进行一次全采样,OMG……)。Light Volume覆盖的屏幕空间的像素越少,光照Pixel Shader需要处理的数据就越少。我们适用锥体来描述聚光灯,球体来描述点光源,长方体来描述Box Light,而对于方向光这样的全局光照,我们适用了整个屏幕空间。
另一个延期渲染的论文都会描述的方法,是通过基于Light Volume和摄像机位置的深度测试和Cull Mode(顺时针逆时针那个),来减少计算量。这种调整最大程度的进行了早期Z剔除。这种方法需要CPU来判断用哪种深度测试和Cull Mode的组合可以最大程度的进行早期Z剔除。
在我们所有的情况下(我们的Light Volume不会被远面剔除掉),我们都使用“Greater”的深度测试和顺时针的绕法(也就是反着绕)。可以通过一些推测,来选出对自己最有效的深度测试和Cull Mode。然而我们遇到的瓶颈在其他地方,因此我们决定不再用这种方法,通过浪费CPU资源来优化性能。
(感觉本段的技术并无实用性,剔除肯定是必须做的,硬件Occlusion,其他的,各种方法其实都很简单,而且实用性较强。不过也有可能是我没理解了作者的意思。)
在延期渲染系统中,使用Stencil来屏蔽一些像素,是另一个常用的手段。基本上,就是用Stencil Buffer来指定哪些像素不必要进行光照。当渲染Light Volume的几何体时,可以通过简单的模板测试来取消对这些标定像素的处理。
我们试了一些这个技术的变种。我们发现这个方法所带来的性能提升,还不如增加了Draw Call导致的性能下降。我们试图使用一个“便宜的”Pass来标定所有的像素是否面向光源或者是否在光源之外。这个确实是减少了需要处理光源的像素的数量。在DX9一般的下,“便宜的”Pass增加的Draw Call抵消掉、甚至远超过了最后光源Pass时提升的性能。
我们利用了Stencil来标定那些之后延期渲染需要处理的场景中的不透明物体。这个方法把那些天空盒和其他正面不需要进行光照的物体排除掉了(主要是那些只有Emissive的物体)。这个方法不需要任何多余的Draw Call,因此会非常“便宜”。光照Pass之需要简单的把这些标定的像素丢弃掉就可以了。当天空盒占了整个屏幕绝大部分的时候,这种方法会带来相当程度的性能提升,而即便不是这样,这种方法起码也不会带来任何损失。
DX10减少了Draw Call的开销。对于那些瞄准了DX10平台的读者,制作一个“便宜的Pass”(第二段描述的方式)应该是个不错的尝试。然而在SM3下使用动态分支,比增加新的Pass要好一些。
SM3一个很关键的特性就是支持动态分支。动态分支不仅增加了GPU的可编程性,在合适的情况下,他也可以用来进行优化。
使用动态分支进行优化需要注意两个原则:
1, 制造一个或者两个动态分支,以确保能最大程度的跳过更多数量的代码和那些频率较高的代码。
2, 注意数据的位置。如果一个像素走了分支A,那么它邻近的像素最好也能尽可能走分支A。
光照中最好的时机是根据像素距离光源的远近和表面法线来进行分支。如果使用了法线图,则表面法线就会变得不再均匀,优化就会变得比较麻烦。
在使用延期着色的过程中并非都是一帆风顺的。由于显存带宽和数据通道的限制,延期着色也有它本身不可调和的问题。
延期着色最大的问题是在处理半透明物体的时候显得无能为力。不支持半透明不仅仅是硬件的限制问题,同时也是这个技术本身的硬伤:我们所有的工作均受限于“只能知道临近像素的材质信息”。在Tabula Rasa中,我们使用了大家都在用的方法:在延期着色渲染勒索由的不透明物体之后,使用前向着色来渲染所有的半透明物体。
要在延期着色中支持真正的半透明,则可能需要一些更多的帧缓冲来存储一个片断是否被遮挡的信息。这也是解决不排序半透明的一种方式。这种缓冲现在并不被我们的图形显卡支持。
然而,开启MRT时,只要Render Target允许,我们可以支持基于Alpha Test的Additive式的混合(也是一种Alpha混合)。(看了看后面,感觉这个意思可能是想说,由于延期着色中,Alpha是不会被存下来的,因此只能用来做Additive这样的混合,而不能做基于Alpha的混合。)当MRT的Alpha Test开启时,如果COLOR0(也就是第一张Render Target)的Alpha为零,当前片断的Test失败,则不会有任何Render Target被更新(也就是说,存储Alpha是无意义的,因为那些被裁减掉的像素根本就不会写到帧缓存里)。因此这里我们不能使用Alpha Test,而是应该使用clip指令来裁掉一个像素。因为Render Target 0并没有用来存储Diffuse,而是用于存储其它材质信息的。延期渲染管道渲染的东西都应该是完全不透明的,因此,我们不再使用这些通道来存储无意义的Alpha信息。
使用前向渲染来处理半透明几何体可以解决一些问题。我们使用了我们的前向渲染管道来处理水面和其他半透明几何体。水的Shader使用了在延期着色中生成的深度纹理。水的光照计算用的是传统的前向着色技术。这种方案也有一点问题——让半透明几何体和不透明几何体之间光照统一是比较困难的。而且,我们延期着色支持的很多光照特性,前向渲染管道是不支持的。这就使得两者的结合变得不太现实。
Tabula Rasa中,在两个方面,两种光照系统的不一致成为一个巨大的问题:头发和植被。头发和植被在半透明时看这会比较舒服。然而,当一个角色进入阴影的时候,他的头发没有变色,这点是不可接受的。同样的,当周围所有的东西都被投影的时候,仅有草没有被投影,也是不可接受的。
我们最终决定使用Alpha Test而不是半透明。这样,头发和植被就可以利用延期渲染来处理了。光源的效果在头发和植被上也比较统一。为了减少植被边缘的粗糙,我们尝试使用过一些小技巧。例如进行屏幕空间的半透明排序,或者使用半透明从前向着色过渡到延期着色。但没有一个方案是真正可用的。我们现在的做法是通过让植被变大变小来处理淡入和淡出。
由于硬件带宽的增加,延期着色才成为可能。延期着色需要写入到4个Render Target中,而不是1个,也就是写入量是原来的4倍。在光照Pass里,我们也需要从这所有的缓冲中读取信息,读入量也超过了过去。带宽和填充率,是延期着色最大的性能影响因素。
最大的减少带宽开销的因素是屏幕分辨率。带宽与渲染像素的数量直接相关,1280x1024只有1024x768的66%速度。延期渲染的引擎性能严重受限于分辨率的大小。
进行独立的位深度存取,在舍弃一定精度的前提下,应该可以减少带宽的损耗量。但是这种方法对我们并没有用,因为现在的硬件并不支持这种特性。我们的做法是尽可能减少材质数据的存储量、尽可能减少这些缓冲的使用率。
当渲染光照的时候,我们也使用了MRT。我们使用了两个Render Target,并进行Additive的混合。这些Render Target分别属于Diffuse和Specular的积累缓冲区。乍一眼看,这好像对于节省带宽而言是多余的,因为我们将信息写到了两个Render Target上。然而,这个选择确实可以提高效率。
将Diffuse和Specular加到一起的一办法可能如下面这样:
Fraglit =Fragunlit xLightdiffuse +Lightspecular·
这个公式是可以分为Diffuse和Specular两部分的。将这两部分分别放到两个Render Target的话,在光照Shader里,我们不用再去取出Unlit的片段(Frag Unlit)。Shader只是产生Light Diffuse和Light Specular项,它们除了表面与光源的关系外,不用承担任何其他的计算量。
如果我们不把Diffuse和Specular分开,那么Light Shader则必须计算出最终的片段颜色。这个计算必须获取Unlit的片段颜色(纹理本身的颜色),以及其他可能影响最终颜色的材质属性(例如自发光)。把这些最终颜色放到光照Shader中,就意味着我们将真正丢掉Diffuse和Specular分量。也就是说,我们无法从Shader的结果中分解出来光源的原始信息了。将Diffuse和Specular分量存储到Render Target中,对于进行HDR和其他需要影响光源的Post Process运算都很有利。
在所有的光照Shader都运行完毕后,我们进行最后一个全屏的Pass,来计算最终的片段颜色。这个最终的Post Process Pass里,我们计算雾、边缘检测和平滑、以及最终的片段颜色。这个方法确保了这些方法对每个象素仅计算一次,减少了绕路的数量,最大化了从MRT里读取信息时纹理Cache的命中率。从MRT中反解材质数据是耗费很高的运算,特别是当大量使用时,导致的纹理Cache的颠簸,会让这个情况变得更加糟糕。
使用这些光照的积累缓冲之后,我们可以很方便的在需要的时候关闭Specular光照,以避免带宽的浪费。这些光照的积累缓冲也可以在光照相关的Post Process中也很有用,例如增加对比度,计算HDR,以及其它类似的特效。
在Tabula Rasa中,即便在最普通的1024x768分辨率下,我们也要为延期渲染和反射这所有的Render Target花掉50MB的显存。这还不包括主缓冲,顶点和索引,以及纹理。而这些Render Target在1600x1200的分辨率下则需要100MB的显存。
我们使用了4个、屏幕大小的Render Target来存储几何体的材质数据。我们的光照Shader使用了两个、屏幕大小的Render Target。这些Render Target可以是32 Bit的或者64Bit的,取决于显卡和显示质量设置。然后,为了全局方向光,还有一个2048x2048的Shadow Map,以及为了其他光源产生的各种附加Shadow Map。
使用Render Target的一个可能的提议是:减少分辨率,只在最后渲染的时候把它们缩放上去。这有很多好处,但我们发现图像质量变差了,因此就没有继续接下去研究,不过这种方法有可能在一些特殊的应用中是可行的。
Render Target使用的显存只是一个问题。他们的生存周期和位置对整个性能有更为关键的影响。即使这些纹理在显存中,超出了我们的控制范畴,我们仍然可以做一些事情来挽回一些事情。
我们使我们主要的MRT早于其他任何纹理分配,这种分配可以帮助驱动,将他们放到最完整、连续的显存中。我们仍然受制于驱动的实现,但是我们起码可以帮助驱动去让它实现我们希望的结果。
我们使用了Shadow Map池,并允许光源共享这些Shadow Map。在引擎里,我们限制了Shadow Map的最大数量。基于光源的优先级,位置和所需的Shadow Map大小,为这些光源分配少量的Shadow Map。这些Shadow Map永远不会释放,只是不断地重用。这个减少了显存碎片,并且减少了因为创建和销毁资源而带来的性能损失。
基于这一点,我们也限制了每帧渲染(或重生)的Shadow Map的数量。如果有好几盏光同时需要生成他们的Shadow Map,引擎每帧只会创建一到两个,这就将花销平摊到了几帧中。
在Tabula Rasa中,使用延期渲染,使我们达到了预定的目标。我们找到了一条高性能、可度量的方法来实现延期渲染。在一些早期的SM3显卡——如NV6800Ultra——在基本的设置和中端分辨率上可以达到30帧。而在最新的DX10显卡,诸如NV8800和ATI2900上,可以在全效果下跑的很好。
Figure 19-6. An Outdoor Scene with a Global Shadow Map
在Tabula Rasa中,使用延期渲染,使我们达到了预定的目标。我们找到了一条高性能、可度量的方法来实现延期渲染。在一些早期的SM3显卡——如NV6800Ultra——在基本的设置和中端分辨率上可以达到30帧。而在最新的DX10显卡,诸如NV8800和ATI2900上,可以在全效果下跑的很好。
Figure 19-6. An Outdoor Scene with a Global Shadow Map
Figure 19-7. An Indoor Scene with Numerous Static Shadow-Casting LightsShown are box, spot, and point lights.
Figure 19-8. Fragment Colors and Normals Left: Unlit diffuse fragment color. Right: Normals
延期着色正在从理论走向现实。很多时候,很多新的技术需要耗费高昂代价,过于抽象,或者无法真正应用于商业。而延期着色则被证明是真实感游戏设计领域一个通用的、强大的、可控的技术。
延期着色还需要克服的主要障碍包括:
较高的显存带宽占用
无硬件反锯齿的支持
对Alpha Blend支持较差
我们发现当前驻留的显卡已经可以在稍低的分辨率下解决贷款问题了,而在当今最高端的机器上,可以在开启全部特性的前提下,适应更高的分辨率。在DX10即便显卡上,ATI和NVIDIA都增强了MRT的性能。DX10和SM4都提供了GPU支持的整数处理,以及从深度缓冲中读取数据。所有这些都可以减少显存带宽。当提供了新的硬件和特性时,性能自然就会提升。
在合适的Filter作用下,精确的边缘检测可以减少几何体边缘的锯齿。虽然这些方法并不像硬件全场景反锯齿那样精确,但是仍然可以以假乱真。
延期着色最显著的问题是对半透明的支持。我们自觉牺牲了一些半透明方面的图形质量,然而,我们觉得延期着色所带来的优点远远超过了这些问题。
延期着色主要的好处包括:
光照的开销与场景复杂度无关。
Shader可以访问深度和其他像素信息。
每个象素对每个光源仅运行一次。也就是说,那些被遮挡的像素是不会被光照计算到的。
材质和光照的Shader完全分开。
每天都有新的技术和新的硬件出来,由于他们的存在,延期渲染的地位也可能会有浮沉。未来是很难预料的,但我们很高兴当时做出了在当今的显卡上使用延期着色的决定。