研究延迟渲染
填充G-buffer(这里的g代表geometry,几何体的意思)
支持HDR和LDR
支持延迟反射
本节是渲染的第13节内容,前一小节介绍的是半透明物体阴影,本节将学习延迟渲染。
本节使用unity版本为:5.5.0f3
1 另一个渲染路径
到目前为止我们使用的都是unity的向前渲染路径,但它这并不是unity唯一提供的渲染方式。unity还提供了:延迟渲染、遗留的顶点光照渲染、遗留的延迟渲染。本节介绍最新的延迟渲染方式,其他两种不涉及。
我们为什么还需要延迟渲染方式呢? 为了回答这个问题,我们必须知道向前渲染和延迟渲染方式的不同点在哪里。
1.1 切换渲染路径
到底使用的是哪个渲染路径是通过Edit / Project Settings / Graphics设置,如下所示:
这里有三个等级:Low、Medium、High,分别是低中高三种GPU的设置。
为了改变渲染路径必须把Use Defaults改为不勾选,然后选择Rendering Path。
我们在Eidtor模式下,可以选择不同级别的GPU,在Editor / Graphics Emulation
我们可以取消勾选Use Defaults,然后自定义一些参数。
1.2 比较draw call
这里使用第7节的是阴影场景,来比较两个渲染路径下的draw call。此场景Ambient Intensity 设置为0。因为我们的shader暂时还不支持延迟渲染,将所有的材质球使用默认的材质球。
这个场景包含了一些物体以及两个平行光,我们把阴影关掉和不关掉对比下:
当使用向前渲染的时候,查看frame debug如何渲染场景的。
场景里包含了66个物体,所有的都可见。如果开启动态批处理,批处理会少于66个。但这是在仅仅有一盏平行光的情况下。如果有另外一个平行光,动态批处理将不起作用,因为所有的物体都会被画两次。批处理的个数共有134个(clear处理1个+66个几何体66*2+天空盒1)。
当阴影被开启的时候,将会产生更多的批处理以产生级联阴影映射图。回忆平行光阴影产生的方式。首先,要有深度缓冲,只需要有48个批处理,这个因为有动态批合并的原因。接着,级联阴影的创建。第一盏灯需要111个批处理,第二个灯需要121个批处理。阴影映射贴图被渲染到屏幕坐标缓冲。然后就是物体的绘制,攻击418个批处理。
比较下面的情况:
关闭阴影,然后摄像机的模式在forward和deferred两种模式下切换。
第一个模式forward:
第二个模式deferred:
这里两个灯对于所有的物体,只需要两个drawcall,一个灯一个drawcall,很节省。总的drawcall个数也将近减少了一半。
1.3 划分工作
延迟渲染在渲染多于一盏灯的时候效率要比向前渲染看起来要高一些。因为向前渲染需要对于每个物体的每盏灯需要一个额外的通道,但是延迟渲染则不需要。当然两个渲染方式都需要光照贴图,但是延迟渲染不需要额外的计算深度贴图,这个贴图是平行光阴影所需要的。延迟渲染是怎么做到的呢?
为了渲染物体,shader必须利用网格数据,并且把它转换到正确的空间,然后经过插值计算,计算表面属性,然后计算灯光。向前渲染对于每个像素灯都要重复做这些事情。附加通道比基础通道要耗时小,因为深度缓冲已经计算过,而且额外通道不需要计算间接光。但是它依然要重复基础通道相同的工作。
下图示两种通道要干的事情:
由于几何信息对于每个通道都是相同的,所以为什么不把他们缓存起来?在基础通道渲染的时候就把据缓冲起来,然后附加通道直接重用,避免了重复的工作。我们必须逐像素的保存数据,所以我们需要一个和分辨率匹配的缓冲,这和深度和帧缓存一样。
现在,所有几何体的数据都被缓存起来了,唯一漏掉的数据就是灯光。这也就意味着不用渲染所有的几何体了,只需要渲染灯光。除此之外,基础只要用来填充缓冲。所有的直接光都可以延迟到计算灯光的时候渲染,这也就是deferred shader。
1.4 多盏灯
如果你只有一盏灯,延迟渲染不能体现出效率优势。但是如果有多盏灯,效率会明显提升。每一个额外的灯,只要它没有释放阴影,就只需要一点点的工作量。
同样的,几何体和灯光是独立渲染的,一个物体受多少盏灯影响是没有限制的。所有的灯都是像素灯,影响它周围的物体,在Pixel Light Count设置的数字也不用关心了。
1.5 渲染灯
灯光他们是怎么渲染的呢?因为平行光影响所有的物体,他会被渲染成和屏幕大小相同的四边形。
Unity默认使用Internal-DeferredShading来渲这个四边形。它的像素着色器依赖UnityDeferredLibrary文件里的函数来获取所有的几何体数据来配置灯光。然后再计算灯光,就好像向前渲染一样。
聚光灯也类似,但是它不是覆盖全屏的。它渲染的是一个椎体,只有可见的区域才会被渲染,如果是不可见的,那么没有shader会被渲染。
如果一个像素在椎体内,将执行光照计算。在椎体外面的物体不需要渲染,为了不渲染这些不需要的像素,金椎体首先使用Internal-StencilWrite shader来渲染,将数据写入到模板缓冲,以 作为一个遮罩供后面的渲染使用。
点光源使用类似的方法,唯一不同的是,它的形状不是金字塔而是都变形球体。
1.6 灯光范围
如果你打开frame debugger,你会注意到延迟渲染的灯光阶段的颜色看起来很诡异。看起来像是反转过来的,在最后的阶段颜色才是正确的。
unity默认是使用LDR模式渲染颜色的。这种方式,颜色是存储在ARGB32贴图中。unity通过算范把这个颜色值进行编码以得到高动态范围的颜色,这个是编码,那么自然在final pass阶段需要解码才能得到正确的颜色。
而如果使用的是HDR模式,那么unity会把颜色以ARGBHalf格式存储。这种情况,编码将不再需要,也就不需要解码工作,是否使用HDR,可以在摄像机参数中配置。
1.7 几何缓冲
缓冲数据的弊端是要在某个地方进行存储。延迟渲染路径使用多个贴图来存储数据。这写贴图就是通常提到的几何缓冲,也叫g-buffer。
延迟渲染需要4个g-buffer。他们的大小对于LDR是160位,而对于HDR是192位。这个比单个32位的帧缓存要大的多。当前台式GPU可以支持到。而手机、平板的GPU在高分辨率下可能会遇到些问题。
你可以从Scene视口中查看些g-buff的信息。
你还可以查看frame debugger中的渲染阶段对应的贴图:
1.8 混合渲染模式
我们的shader目前并不支持延迟渲染路径。那么如果场景中的有些物体使用我们的shader,而且是延迟渲染模式下会如何呢?
从渲染的结果来,延迟先做,然后是向前渲染阶段。在延迟阶段,向前物体不会被渲染。唯一例外的是平行光阴影阶段,在那个阶段,向前物体需要深度通道。这个直接在g-buffer之后被填充。不好的地方是,向前物体,在环境缓冲中的颜色为黑色。
这个对透明物体也是这样的,他们需要一个独立的向前渲染阶段。
2 填充g-buffer
现在我们知道延迟渲染是如何工作的了,接下来就是在My First Lighting中写代码让他具备延迟渲染的功能。我们需要增加一个通道其LightMode设置为Deferred。通道之间的顺序没有关系,这里把此通道放在add通道和shadow通道之间。
Pass {
Tags {
"LightMode" = "Deferred"
}
}
当unity检测到我们shader中包含有延迟通道,它会把凡是使用了我们自己shader的不透明物体、剔除物体都包含在延迟阶段。透明物体依然在透明处理阶段。
由于目前这个通道还是空的,所有的物体都会被渲染为白色。我们要添加shader属性以及相应的代码。延迟通道的代码和base 通道的代码类似,所以拷贝过来即可,然后要做稍许的改动:
首先是关键字改变:FORWARD_BASE_PASS 替换为 DEFERRED_PASS
然后是延迟通道不需要:_RENDERING_FADE 和 _RENDERING_TRANSPARENT
最后,延迟阶段只有当gpu支持多写入目标情况才能使用,所以在不支持mrt的时候,直接排除延迟渲染通道。
Pass {
Tags {
"LightMode" = "Deferred"
}
CGPROGRAM
#pragma target 3.0
#pragma exclude_renderers nomrt
#pragma shader_feature _ _RENDERING_CUTOUT
#pragma shader_feature _METALLIC_MAP
#pragma shader_feature _ _SMOOTHNESS_ALBEDO _SMOOTHNESS_METALLIC
#pragma shader_feature _NORMAL_MAP
#pragma shader_feature _OCCLUSION_MAP
#pragma shader_feature _EMISSION_MAP
#pragma shader_feature _DETAIL_MASK
#pragma shader_feature _DETAIL_ALBEDO_MAP
#pragma shader_feature _DETAIL_NORMAL_MAP
#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
#define DEFERRED_PASS
#include "My Lighting.cginc"
ENDCG
}
现在延迟通道和bass通道功能一样,它是把最终的渲染结果写入到了g-buffer,这是不正确的。我们需要写入的几何数据,但是不包含直接光的计算。
2.1 四个输出
在My Lighting中,我们要在MyFragmentProgram程序段支持两种输出。一种是延迟通道需要填充四个缓冲。我们是通过输出四个对下来实现四个缓冲区的写入。另外一种只需要把写入一个渲染的最终结果即可,所以我们要定义一个输出结构体:
struct FragmentOutput {
#if defined(DEFERRED_PASS)
float4 gBuffer0 : SV_Target0;
float4 gBuffer1 : SV_Target1;
float4 gBuffer2 : SV_Target2;
float4 gBuffer3 : SV_Target3;
#else
float4 color : SV_Target;
#endif
};
这里说明下大小写的问题,对于语义是不区分大小写的,我们可以写成SV_TARGET,unity是可以理解的。但也不只是说所有的语义都可以忽略大小写,比如对于顶点数据的语义必须是大写的。
修改MyFragmentProgram,使其返回这个结构体。对于延迟通道,我们需要输出四个值,而对于普通的情况,我们只需要输出一个颜色即可。
FragmentOutput MyFragmentProgram (Interpolators i) {
…
FragmentOutput output;
#if defined(DEFERRED_PASS)
……
#else
output.color = color;
#endif
return output;
}
2.2 缓冲0
第一个g-buffer存储的是漫反射以及表面透明度。它是ARGB32格式的贴图,漫反射存储在RGB通道,排除画在A通道。可以单独提取一个函数:
#if defined(DEFERRED_PASS)
output.gBuffer0.rgb = albedo;
output.gBuffer0.a = GetOcclusion(i);
#else
你可使用frame debugger查看g-buffer的填充效果。比如我们要查看RT0的渲染效果:
2.3 缓冲1
第二个g-buffer存储的是镜面反射的RGB通道,光滑度存储在A通道。它同样也是ARGB32格式贴图。我们单独提出计算镜面方法:
output.gBuffer0.rgb = albedo;
output.gBuffer0.a = GetOcclusion(i);
output.gBuffer1.rgb = specularTint;
output.gBuffer1.a = GetSmoothness(i);
2.4 缓冲2
第三个缓冲存储的是世界法线向量,它存储在RGB通道,考虑精度依然采用32位存储。
output.gBuffer1.rgb = specularTint;
output.gBuffer1.a = GetSmoothness(i);
output.gBuffer2 = float4(i.normal * 0.5 + 0.5, 1);
2.5 缓冲3
第四个缓冲是用来计算场景中灯光。它的格式依赖于摄像机使用的是LDR还是HDR,如果使用的是LDR,它的就以ARGB2101010 格式存储。如果是HDR,则使用的是ARGBHalf格式存储,每个通道占16位,共计64位。所以使用HDR的是其缓冲是其他缓冲的两倍。只有RGB被使用到,A通道没有使用全部置为1。
第一个灯光被加到缓存的是自发光。我们没有单独一个自发光的灯,我们直接存储的是已经计算出来的颜色值。
output.gBuffer2 = float4(i.normal * 0.5 + 0.5, 1);
output.gBuffer3 = color; //直接赋上一个颜色。
但是这个颜色值已经包含了平行光的计算,所以我们使用一个较暗的灯代替它,直接赋值为黑色即可。
UnityLight CreateLight (Interpolators i) {
UnityLight light;
#if defined(DEFERRED_PASS)
light.dir = float3(0, 1, 0);
light.color = 0; //直接赋值为黑色
#else
#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
light.dir = _WorldSpaceLightPos0.xyz;
#endif
UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);
light.color = _LightColor0.rgb * attenuation;
#endif
//light.ndotl = DotClamped(i.normal, light.dir);
return light;
}
我们还要去掉DotClamped去掉,因为这个函数unity已经认为是过时的了。
我们去掉了直接光的效果,但是要包括自发光。所以要在自发光计算函数中加入宏:
float3 GetEmission (Interpolators i) {
#if defined(FORWARD_BASE_PASS) || defined(DEFERRED_PASS)
…
#else
return 0;
#endif
}
2.6 环境光
效果看起来不错,但是还不够完整,我们丢掉了环境光。
环境光和自发光一样,没有单独的通道。所以也必须自己手动的叠加上去,我们在计算间接光的时候加上去。所以在计算间接光的时候也要支持延迟通道。
UnityIndirect CreateIndirectLight (Interpolators i, float3 viewDir) {
…
#if defined(FORWARD_BASE_PASS) || defined(DEFERRED_PASS)
…
#endif
return indirectLight;
}
2.7 HDR和LDR
3 延迟反射
第8节教程介绍如何使用反射探针给表面增加反射。但是那种只是针对向前渲染才有效。当使用延迟渲染通道的时候,需要使用不同的方式。我将使用Reflections Scene来比较两者的不同。这个场景依然把Ambient的强度设置为0。打开这个场景之后,确保镜面反射的球体和地板使用的是我们的shader,并且其金属度和光滑度都设置为1。
此场景有三个反射探针。一个是在里面,一个是外面,另外一个是在两者之间,他们互不重叠。