Unity的Deferred Shading

什么是Deferred Shading

Unity自身除了支持前向渲染之外,还支持延迟渲染。Unity的rendering path可以通过Edit/Project Settings中的Graphics进行全局设置:

除此之外,我们还可以在Main Camera中进行覆盖设置:

需要注意的是,Unity的延迟渲染不支持MSAA。具体原因可以参考[2]。

延迟渲染主要是为了解决前向渲染在多光源场景下效率低的问题,这里的延迟指的是将光照部分延迟到后面再进行计算。在前向渲染中,为了计算每个pixel的最终颜色,多个光源要跑多次light pass,将每个光源计算的结果进行混合。每个light pass都会重复计算一遍pixel的几何信息,比如normal,diffuse,specular等,这实际上是没有必要的,只要计算一遍,缓存起来就可以了。除此之外,在不考虑early-z的情况下,深度测试是在fragment shader之后进行的,那么必定存在大量不可见的pixel,都跑了一遍复杂的light pass计算。延迟渲染的实现,就是预先多一个geometry pass,利用深度测试,将不可见的pixel剔除,同时使用MRT(nultiple render targets),将pixel的几何信息,分别存储到不同的G-Buffer中,这样在light pass的时候,直接采样G-Buffer就可以进行光照计算了。

说了这么多,不如来对比一下同一个场景下前向渲染和延迟渲染的draw call数量:

如图所示,这个场景包含两个平行光源。先看前向渲染:

总共有457个draw call,首先为了绘制平行光阴影的screen space shadow map,需要对场景跑一遍depth pass,然后对两个平行光源,依次绘制shadow map,进行阴影收集,最后对场景中受光照影响的物体,分别跑一遍forward base的light pass和forward add的light pass。

那么再看下延迟渲染:

可以发现此时只有329个draw call了,Unity首先对场景跑了一遍geometry pass,绘制G-Buffer,然后将该阶段的深度缓存拷贝到depth buffer中,再经过一个reflections相关的pass,绘制反射信息,就到了light pass阶段。light pass中的绘制shadow map的过程与前向渲染类似,先绘制再collect,只不过少了depth pass,这是因为我们在geometry pass之后,已经有了depth buffer了。 可以看到,真正负责绘制光源着色信息的只有2个draw call,一个光源各一个。

G-Buffer

现在,让我们以一个拥有1个平行光源,和3个反射探针的场景为例,来深入其中,一探究竟:

要想让我们自定义的shader支持延迟渲染,就必须要设置LightMode为Deferred,而且只有GPU支持MRT(multiple render targets)时延迟渲染才有效。另外它不能是transparent的,transparent的物体会被Unity强行走前向渲染的流程。

		Pass {
			Tags {
				"LightMode" = "Deferred"
			}

			CGPROGRAM

			#pragma target 3.0
			#pragma exclude_renderers nomrt

			...

			ENDCG
		}

那问题来了,如果没有这个LightMode的pass会怎么样?Unity将不会对这些物体执行geometry pass,还是会走正常的前向渲染的流程,并且还会在geometry pass之后,为这些物体跑一遍depth pass,如图所示:

Unity的延迟渲染需要4个G-buffer。因此geometry pass的fragment shader的输出需要定义如下:

struct FragmentOutput {
		float4 gBuffer0 : SV_Target0;
		float4 gBuffer1 : SV_Target1;
		float4 gBuffer2 : SV_Target2;
		float4 gBuffer3 : SV_Target3;
};

gBuffer0是ARGB32格式的texture,rgb通道存储的是diffuse信息,a通道存储的是occlusion信息;

gBuffer1是ARGB32格式的texture,rgb通道存储的是specular信息,a通道存储的是roughness信息;

gBuffer2是ARGB2101010格式的texture,rgb通道各占10位,a通道只占2位,它的rgb通道存储的是normal信息,a通道未被使用;

gBuffer3根据是否开启HDR,有不同的格式,在未开启HDR时,是ARGB2101010格式的texture,而在开启HDR时,是ARGBHalf格式的texture,即每个通道占16位。这个buffer就是用来存储场景中的各种光照信息。这里的光照信息主要是自发光,间接的环境光,而不包括场景中光源的直接光照,毕竟光源的光照计算是延迟到后面再去做的。另外还有一点要注意的是,在未开启HDR时,gBuffer3的信息要以对数的形式进行存储,意味着我们要在代码中进行判断并转换:

#pragma multi_compile _ UNITY_HDR_ON

FragmentOutput MyFragmentProgram (Interpolators i) {
    	...
    	FragmentOutput output;
		#if !defined(UNITY_HDR_ON)
			color.rgb = exp2(-color.rgb);
		#endif
		output.gBuffer0.rgb = albedo;
		output.gBuffer0.a = GetOcclusion(i);
		output.gBuffer1.rgb = specularTint;
		output.gBuffer1.a = GetSmoothness(i);
		output.gBuffer2 = float4(i.normal * 0.5 + 0.5, 1);
		output.gBuffer3 = color;
		return output;
}

有了Deferred的shader之后,我们再看下Frame Debug:

我们注意到,除了常规的blend设置和深度设置之外,geometry pass还开启了模板测试。由于Stencil Comp设置为Always,因此模板测试总是成功的,Stencil Pass设置的是Replace,意味着测试成功时,将把Stencil Ref写入到模板缓存中。写入时会通过Stencil WriteMask掩码操作,只写入mask通过的位。那么综上所述,geometry pass除了绘制了一份深度信息外,还记录了模板信息,所有在场景中的可见物体对应pixel的模板值均为192 & 207 = 192。用RenderDoc截帧,得到geometry pass绘制的4个G-Buffer如图所示:

depth buffer如图所示,这里分别展示了buffer此时记录的深度信息和模板信息:

首先我们发现texture是上下颠倒的,这是DirectX纹理坐标系的原因。其次,场景中的金属反射球,在gBuffer0中全黑,而gBuffer1中全白,这是因为反射球的材质将Metallic属性调到了1,故而只有specular而没有diffuse。gBuffer3除了skybox全黑的原因是因为场景中没有间接关照和自发光信息。模板信息是符合预期的,即只有出现可见物体的地方保存了模板信息,其值为192。这里得到的depth buffer,会通过RenderDeferred.CopyDepth拷贝一份到名为Deferred Depth的buffer中去,给后面reflections相关的pass使用,这些pass会以不同的方式去修改depth buffer,尤其是模板信息,因此需要保留一份原始的场景深度信息,也就是Deferred Depth这个buffer。

Deferred Reflections-Skybox

那么,我们现在来看下reflections相关的pass。我们知道,在前向渲染中,Unity使用反射探针来实现反射的效果,并且每个物体可以混合不同的反射探针。而在延迟渲染中,不同的反射探针是基于pixel进行混合的,从Frame Debug中可知Unity使用了一个名为DeferredReflections的shader来做这件事:

Unity会先用这个shader绘制一遍skybox的reflection信息,然后再根据反射探针的重要程度,依次绘制场景中反射探针的reflection信息。这个shader有两个pass,由Frame Debug可知当前用的是第1个pass,让我们先来看下代码,从vertex shader看起:

struct unity_v2f_deferred {
    float4 pos : SV_POSITION;
    float4 uv : TEXCOORD0;
    float3 ray : TEXCOORD1;
};

float _LightAsQuad;

unity_v2f_deferred vert_deferred (float4 vertex : POSITION, float3 normal : NORMAL)
{
    unity_v2f_deferred o;
    o.pos = UnityObjectToClipPos(vertex);
    o.uv = ComputeScreenPos(o.pos);
    o.ray = UnityObjectToViewPos(vertex) * float3(-1,-1,1);

    // normal contains a ray pointing from the camera to one of near plane's
    // corners in camera space when we are drawing a full screen quad.
    // Otherwise, when rendering 3D shapes, use the ray calculated here.
    o.ray = lerp(o.ray, normal, _LightAsQuad);

    return o;
}

由上图可知,在绘制skybox时,_LightAsQuad的值为1,那么上述代码中只需要关注输入的vertex和normal信息。用RenderDoc抓帧得到:

可以看出,经过透视变换后的SV_POSITION坐标(x,y)分布在(-1, 1)上,而z分量为1,这恰好是clip坐标系中近剪裁面的位置。也就是说,经过vertex shader输出的顶点,就是表示整个近剪裁面。

再看normal信息,它其实表示的是相机空间中从相机位置出发到达近剪裁面4个角的射线。那么有:

\[\textbf{r} = (\pm x,\pm y,z) = (\pm\dfrac{w}{2}, \pm\dfrac{h}{2}, n) \\ tan \dfrac{\theta}{2} = \dfrac{\dfrac{h}{2}}{n} \\ aspect = \dfrac{w}{h} \]

其中,n为相机近剪裁面的距离,\(\theta\)为相机的fov:

截图可以看出,n为0.3,\(\theta\)\(\dfrac{\pi}{3}\)。aspect的信息可以从GBuffer或者Depth Texture的分辨率得到:

得到aspect为\(\dfrac{1150}{531}\)。代入上面的公式计算出:

\[\textbf{r} = (\pm0.37511,\pm0.17321,0.3) \]

与RenderDoc中的信息完全吻合。fragment shader的代码如下:

half4 frag (unity_v2f_deferred i) : SV_Target
{
    // Stripped from UnityDeferredCalculateLightParams, refactor into function ?
    i.ray = i.ray * (_ProjectionParams.z / i.ray.z);
    float2 uv = i.uv.xy / i.uv.w;

    // read depth and reconstruct world position
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv);
    depth = Linear01Depth (depth);
    float4 viewPos = float4(i.ray * depth,1);
    float3 worldPos = mul (unity_CameraToWorld, viewPos).xyz;

    half4 gbuffer0 = tex2D (_CameraGBufferTexture0, uv);
    half4 gbuffer1 = tex2D (_CameraGBufferTexture1, uv);
    half4 gbuffer2 = tex2D (_CameraGBufferTexture2, uv);
    UnityStandardData data = UnityStandardDataFromGbuffer(gbuffer0, gbuffer1, gbuffer2);

    float3 eyeVec = normalize(worldPos - _WorldSpaceCameraPos);
    half oneMinusReflectivity = 1 - SpecularStrength(data.specularColor);

    half3 worldNormalRefl = reflect(eyeVec, data.normalWorld);

    // Unused member don't need to be initialized
    UnityGIInput d;
    d.worldPos = worldPos;
    d.worldViewDir = -eyeVec;
    d.probeHDR[0] = unity_SpecCube0_HDR;
    d.boxMin[0].w = 1; // 1 in .w allow to disable blending in UnityGI_IndirectSpecular call since it doesn't work in Deferred

    float blendDistance = unity_SpecCube1_ProbePosition.w; // will be set to blend distance for this probe
    #ifdef UNITY_SPECCUBE_BOX_PROJECTION
    d.probePosition[0]  = unity_SpecCube0_ProbePosition;
    d.boxMin[0].xyz     = unity_SpecCube0_BoxMin - float4(blendDistance,blendDistance,blendDistance,0);
    d.boxMax[0].xyz     = unity_SpecCube0_BoxMax + float4(blendDistance,blendDistance,blendDistance,0);
    #endif

    Unity_GlossyEnvironmentData g = UnityGlossyEnvironmentSetup(data.smoothness, d.worldViewDir, data.normalWorld, data.specularColor);

    half3 env0 = UnityGI_IndirectSpecular(d, data.occlusion, g);

    UnityLight light;
    light.color = half3(0, 0, 0);
    light.dir = half3(0, 1, 0);

    UnityIndirect ind;
    ind.diffuse = 0;
    ind.specular = env0;

    half3 rgb = UNITY_BRDF_PBS (0, data.specularColor, oneMinusReflectivity, data.smoothness, data.normalWorld, -eyeVec, light, ind).rgb;

    // Calculate falloff value, so reflections on the edges of the probe would gradually blend to previous reflection.
    // Also this ensures that pixels not located in the reflection probe AABB won't
    // accidentally pick up reflections from this probe.
    half3 distance = distanceFromAABB(worldPos, unity_SpecCube0_BoxMin.xyz, unity_SpecCube0_BoxMax.xyz);
    half falloff = saturate(1.0 - length(distance)/blendDistance);

    return half4(rgb, falloff);
}

_ProjectionParams是Unity保存投影相关的参数,z分量代表远剪裁面的距离。fragment shader首先取到当前pixel的场景深度,通过Linear01Depth将其转换到线性空间,函数Linear01Depth考虑了reverse-z的情况,对外输出结果保持统一,即0永远是离相机最近,1永远是离相机最远。得到线性深度之后,就可以计算出投影到当前pixel离相机最近的物体,位于相机空间的坐标。通过坐标系转换,进而能得到物体在世界空间中的坐标。我们就是根据该物体的信息(世界坐标,法线,视线向量,反射向量),来从skybox对应的cubemap采样,计算reflection信息。因为对于当前pixel而言,该物体是离相机最近的,意味着位于该物体之后的都会被遮挡,对reflection没有任何贡献。因此只需要计算离相近最近物体的reflection信息即可。

从Frame Debug可以发现,skybox对应的反射cube范围为无穷大,因此所有物体必定位于cube之中,不必考虑物体在cube之外的情况。函数只需要考虑剔除掉光照的diffuse信息,传递到函数UNITY_BRDF_PBS中,只计算specular信息返回。shader绘制的render target是一个名为Deferred Reflections的texture,这里也设置了模板测试参数,只有通过模板测试的pixel才能成功写入texture。这里的Stencil Ref为128,ReadMask为128,Stencil Comp为Equal,意味着只需要比较第8位的值,只有第8位为1时模板测试才通过。那么什么样的pixel,模板缓存第8位的值为1呢?答案就是前面geometry pass绘制到的pixel。geometry pass会把绘制的pixel的模板值设置为192,192 & 128 = 128 & 128,测试通过。这意味着,只有存在可见物体的pixel,才会绘制reflection信息。这也是合理的,因为如果当前pixel连物体信息都不存在,就更不可能存在reflection信息了。

Deferred Reflections-反射探针

接下来的draw call,Unity使用了一个名为StencilWrite的shader进行绘制,该shader代码平平无奇,看上去等于什么也没做:

Shader "Hidden/Internal-StencilWrite"
{
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            #include "UnityCG.cginc"
            struct a2v {
                float4 pos : POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };
            struct v2f {
                float4 vertex : SV_POSITION;
                UNITY_VERTEX_OUTPUT_STEREO
            };
            v2f vert (a2v v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.vertex = UnityObjectToClipPos(v.pos);
                return o;
            }
            fixed4 frag () : SV_Target { return fixed4(0,0,0,0); }
            ENDCG
        }
    }
    Fallback Off
}

但是该draw call设置的rasterizer state就有意思了:

首先ColorMask设置成了0,意味着fragment shader输出的颜色不会写入到Deferred Reflections这个buffer中。同时,Cull也设置成了Off,意味着物体的正面和背面都会渲染一遍。这里的Stencil Ref设置为128,Stencil Comp设置为Always,意味着模板测试总是通过的,但是这里还设置了Stencil ZFail为Invert,也就是深度测试失败时,需要将模板缓存中的值按位取反,写入到缓存中。注意这里的Stencil WriteMask设置为16,也就是按位取反的结果,只有第5位才会真正写入到缓存中。

那么,传入该shader的顶点信息又是什么样的呢?用RenderDoc截帧可知,传入shader的其实是一个cube,它的中心位于local坐标系的原点,大小为1:

但其实,我们更关心的是,这个cube变换到世界坐标系之后,它的坐标是怎样的。由Frame Debug中可看到unity_MatrixVP为:

\[\textbf{VP} = \begin{bmatrix} 0.68 & -0.0035 & 0.43 & 0.69 \\ 0.24 & -1.7 & -0.4 & 0.25 \\ 0.00015 & 0.000081 & -0.00024 & 0.3 \\ -0.52 & -0.27 & 0.81 & 10 \end{bmatrix} \]

而实际上经过MVP变换到clip坐标系的坐标我们是知道的,即SV_POSITION里的值,那么矩阵M为:

\[\textbf{VP} \cdot \textbf{M} \cdot v = v' \]

\[\textbf{M} \cdot v = \textbf{VP}^{-1} \cdot v' \]

问题其实就转换成解线性方程组了,可以解得矩阵M为:

\[\textbf{M} = \begin{bmatrix} 9.01 & 0 & 0 & 0 \\ 0 & 5.01 & 0 & 2.5 \\ 0 & 0 & 9.01 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \]

当然,其实有了RenderDoc,这一切计算都可以省掉。我们知道这两个矩阵在shader的vs阶段使用,那么只需定位vs阶段用到的const buffer即可:

可以发现,const buffer 1框中的部分恰好对应了Frame Debug中VP矩阵的转置形式。类似地,const buffer 0的部分对应了M矩阵的转置形式。有了这个从local坐标系转换到世界坐标系的矩阵,我们便能观察出它所代表的实际意义。对比其中的数值,可以发现该矩阵恰好对应了场景中的一个反射探针:

这个反射探针位于世界坐标系的(0,2,0)点,它的包围box是一个x=9.01,y=5.01,z=9.01的box,而且box的中心点在y方向上有2个单位的偏移量。翻译成数学语言,就是一个在local坐标系的包围box,经过矩阵M转换到世界坐标系下的坐标应该是:

\[p_w = \textbf{M} \cdot p_l = (9.01x, 5.01y+2.5,9.01z,1)^T \]

把local坐标系中box的中心(0,0,0)和顶点(+/-0.5,+/-0.5,+/-0.5)代入上式,得到的结果正是世界坐标系中box中心和顶点的坐标。那么经过这么漫长的过程,我们可以得出结论,这个StencilWrite的shader,输入的顶点信息就是反射探针的box信息。

再回到这个shader本身的作用上来,它对box的正面和背面进行绘制,如果场景深度小于box正面的深度,那么模板测试会ZFail两次,对当前的模板值连续invert两次,等于无事发生。如果场景深度大于box背面的深度,那么模板测试和深度测试都会通过,模板值保持不变,也等于无事发生。但是,如果场景深度大于box正面的深度,而且小于box背面的深度,那么模板测试只会ZFail一次,当前的模板值就会发生改变,由于~192 & 16 = 16,因此第5位会被写入1,也就是模板值会从192变成208。换言之,只有位于box内部的物体,对应pixel的模板值会被改写。那这个shader的作用就很明显了,它就是为了找到位于反射探针box范围内的物体,通过新的模板值将其标记,只有这些物体才会使用该反射探针的cubemap进行采样,绘制reflections信息。我们也可以使用RenderDoc查看当前的depth buffer的模板值,来验证我们的猜想:

场景中反射探针的box大小如图所示:

可以看出,box内部的模板值和外部是不同的。有了这一标记,Unity继续使用DeferredReflections这个shader进行绘制。让我们着重看一下,与前面skybox绘制相比,有哪些不同的地方。

首先,vertex shader使用的_LightAsQuad变成了0,那么传给fragement shader的ray分量完全取决于顶点的坐标:

    o.ray = UnityObjectToViewPos(vertex) * float3(-1,-1,1);

通过RenderDoc可以发现,这里传入的顶点就是前面stencil pass的反射探针的cube。那么这里的ray分量为:

\[ray = (-x_v, -y_v, z_v) \]

此时得到的ray分量并非是相机指向cube投影到远剪裁面点的射线。fragment shader中会做进一步处理:

    i.ray = i.ray * (_ProjectionParams.z / i.ray.z);

我们知道,Unity的view坐标系,可见物体的z坐标,一定是负值。那么通过除以ray.z的操作,可以让z坐标的值反转:

\[ray = (\dfrac{-x_v}{-|z_v|}, \dfrac{-y_v}{-|z_v|}, 1) \cdot f \]

\[ray = (\dfrac{x_v}{|z_v|}, \dfrac{y_v}{|z_v|}, 1) \cdot f \]

这样求出的ray分量,就可以代入到后面计算场景深度,转换到世界坐标系,求出被cube覆盖的区域中离相机最近的物体坐标。这里坐标系转换使用的是unity_CameraToWorld矩阵,这个矩阵接受的view空间的向量,要求z分量为正,而上面的运算刚好满足这一条件。

此外,与skybox不同的是,反射探针这里还考虑了blendDistance。blendDistance表示在cube之外的物体也有可能接受到该探针的reflections信息,blend的程度由blendDistance和物体离cube的距离共同决定。blendDistance在反射探针inspector中可以设置:

blendDistance会对box相关的属性产生影响。例如把上面box的blendDistance设置为1,从Frame Debug中观察到:

unity_SpecCube1_ProbePosition的w分量表示当前box的blendDistance。除此之外,用RenderDoc还能发现,box的几何信息也发生了改变:

SV_POSITION的坐标发生了变化,仿佛这个box变大了,实际也的确如此:

可以看出世界坐标系变换的矩阵发生了变化,使得box的尺寸x,y,z方向都增加了2×blendDistance。不过虽然几何上box的尺寸变大了,但是unity_SpecCube0_BoxMinunity_SpecCube0_BoxMax这两个变量依旧保存了box原先的尺寸。只有box的几何尺寸变大,才能覆盖包含blendDistance的投影区域,而只有保存原先尺寸,才能计算出物体到原始cube的距离,进而进行blend。

从Frame Debug可知,这里blend的模式设置为SrcAlpha OneMinusSrcAlpha,能够成功绘制也需要通过模板测试。这里Stencil Ref设置为144,ReadMask设置为16,模板测试通过的条件为Equal。144 & 16 = 16,那么只有当前模板值第5位为1的pixel才能通过测试。显然,只有位于反射探针box范围内的物体才会被绘制,并且这里的box范围包括原始box外blendDistance的区域。最后,不论模板测试成功或是失败,都会把第5位清零,也就是把模板值复原回stencil pass之前的模样。

除了这种方式之外,Unity还会使用另外一种策略绘制reflections信息。例如,我们将刚刚这个反射探针的cube尺寸调大(调整box size或者blend distance),这里将blend distance设置为2:

Frame Debug中发现stencil pass消失了,只剩下DeferredReflections这一绘制pass。不过它设置的rasterizer state发生了变化:

这里的深度测试从Less Equal变成了Greater,Cull Back也变成了Cull Front。换言之只有物体背面会被渲染,正面会被剔除。这样就能在fragment shader中获得所有在box背面前方的物体。也就是说,它虽然不能像前一种方法那么精确,只获取box内部的物体,但是至少可以剔除掉box背后的物体。

那这样,会不会不在box内部,在box前方的物体被错误渲染了呢?答案是不会的。别忘记我们还有一个blendDistance,代码会计算物体到原始box范围的距离,如果超出了blendDistance,那么pixel color的alpha分量会设置为0,对最终的结果无贡献。

至于Unity如何选取绘制的策略,这里并没有找到相关内容,猜测是如果box前方的物体数量较多,走blend alpha为0的开销相对较大,就会跑一遍stencil pass,来通过模板测试省掉不必要的绘制。

skybox和所有反射探针都绘制完后,Unity会再次使用DeferredReflections这个shader,把刚刚绘制的reflections信息输出到back buffer,只是这次使用的是shader的另一个pass:

// Adds reflection buffer to the lighting buffer
Pass
{
    ZWrite Off
    ZTest Always
    Blend [_SrcBlend] [_DstBlend]

    CGPROGRAM
        #pragma target 3.0
        #pragma vertex vert
        #pragma fragment frag
        #pragma multi_compile ___ UNITY_HDR_ON

        #include "UnityCG.cginc"

        sampler2D _CameraReflectionsTexture;

        struct v2f {
            float2 uv : TEXCOORD0;
            float4 pos : SV_POSITION;
        };

        v2f vert (float4 vertex : POSITION)
        {
            v2f o;
            o.pos = UnityObjectToClipPos(vertex);
            o.uv = ComputeScreenPos (o.pos).xy;
            return o;
        }

        half4 frag (v2f i) : SV_Target
        {
            half4 c = tex2D (_CameraReflectionsTexture, i.uv);
            #ifdef UNITY_HDR_ON
            return float4(c.rgb, 0.0f);
            #else
            return float4(exp2(-c.rgb), 0.0f);
            #endif

        }
    ENDCG
}

这个pass很简单,这里就不做分析了。

Deferred Shading Light Pass

在此之后,就正式进入绘制光源信息的pass。Unity首先跟前向渲染一样绘制shadowmap,如果是平行光源还会有一个collect shadows的pass,真正绘制光源信息是使用DeferredShading这一shader进行的:

通过查看源码可以发现,关键代码集中在函数CalculateLight中:

half4 CalculateLight (unity_v2f_deferred i)
{
    float3 wpos;
    float2 uv;
    float atten, fadeDist;
    UnityLight light;
    UNITY_INITIALIZE_OUTPUT(UnityLight, light);
    UnityDeferredCalculateLightParams (i, wpos, uv, light.dir, atten, fadeDist);

    light.color = _LightColor.rgb * atten;

    // unpack Gbuffer
    half4 gbuffer0 = tex2D (_CameraGBufferTexture0, uv);
    half4 gbuffer1 = tex2D (_CameraGBufferTexture1, uv);
    half4 gbuffer2 = tex2D (_CameraGBufferTexture2, uv);
    UnityStandardData data = UnityStandardDataFromGbuffer(gbuffer0, gbuffer1, gbuffer2);

    float3 eyeVec = normalize(wpos-_WorldSpaceCameraPos);
    half oneMinusReflectivity = 1 - SpecularStrength(data.specularColor.rgb);

    UnityIndirect ind;
    UNITY_INITIALIZE_OUTPUT(UnityIndirect, ind);
    ind.diffuse = 0;
    ind.specular = 0;

    half4 res = UNITY_BRDF_PBS (data.diffuseColor, data.specularColor, oneMinusReflectivity, data.smoothness, data.normalWorld, -eyeVec, light, ind);

    return res;
}

函数基本上也是一目了然,通过UnityDeferredCalculateLightParams计算出光源信息,综合G-Buffer中的场景几何信息,计算最终的颜色。来看看UnityDeferredCalculateLightParams输出了光源的哪些信息:

// --------------------------------------------------------
// Common lighting data calculation (direction, attenuation, ...)
void UnityDeferredCalculateLightParams (
    unity_v2f_deferred i,
    out float3 outWorldPos,
    out float2 outUV,
    out half3 outLightDir,
    out float outAtten,
    out float outFadeDist)
{
    i.ray = i.ray * (_ProjectionParams.z / i.ray.z);
    float2 uv = i.uv.xy / i.uv.w;

    // read depth and reconstruct world position
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv);
    depth = Linear01Depth (depth);
    float4 vpos = float4(i.ray * depth,1);
    float3 wpos = mul (unity_CameraToWorld, vpos).xyz;

    float fadeDist = UnityComputeShadowFadeDistance(wpos, vpos.z);

    // spot light case
    #if defined (SPOT)
        float3 tolight = _LightPos.xyz - wpos;
        half3 lightDir = normalize (tolight);

        float4 uvCookie = mul (unity_WorldToLight, float4(wpos,1));
        // negative bias because http://aras-p.info/blog/2010/01/07/screenspace-vs-mip-mapping/
        float atten = tex2Dbias (_LightTexture0, float4(uvCookie.xy / uvCookie.w, 0, -8)).w;
        atten *= uvCookie.w < 0;
        float att = dot(tolight, tolight) * _LightPos.w;
        atten *= tex2D (_LightTextureB0, att.rr).r;

        atten *= UnityDeferredComputeShadow (wpos, fadeDist, uv);

    // directional light case
    #elif defined (DIRECTIONAL) || defined (DIRECTIONAL_COOKIE)
        half3 lightDir = -_LightDir.xyz;
        float atten = 1.0;

        atten *= UnityDeferredComputeShadow (wpos, fadeDist, uv);

        #if defined (DIRECTIONAL_COOKIE)
        atten *= tex2Dbias (_LightTexture0, float4(mul(unity_WorldToLight, half4(wpos,1)).xy, 0, -8)).w;
        #endif //DIRECTIONAL_COOKIE

    // point light case
    #elif defined (POINT) || defined (POINT_COOKIE)
        float3 tolight = wpos - _LightPos.xyz;
        half3 lightDir = -normalize (tolight);

        float att = dot(tolight, tolight) * _LightPos.w;
        float atten = tex2D (_LightTextureB0, att.rr).r;

        atten *= UnityDeferredComputeShadow (tolight, fadeDist, uv);

        #if defined (POINT_COOKIE)
        atten *= texCUBEbias(_LightTexture0, float4(mul(unity_WorldToLight, half4(wpos,1)).xyz, -8)).w;
        #endif //POINT_COOKIE
    #else
        half3 lightDir = 0;
        float atten = 0;
    #endif

    outWorldPos = wpos;
    outUV = uv;
    outLightDir = lightDir;
    outAtten = atten;
    outFadeDist = fadeDist;
}

函数输出了光源覆盖区域的物体世界坐标,用来采样G-Buffer的uv坐标,光源方向,光照的衰减程度,到阴影衰减中心的距离。函数首先计算场景物体的世界坐标,使用UnityComputeShadowFadeDistance求出物体到阴影衰减中心的距离,该函数定义如下:

float UnityComputeShadowFadeDistance(float3 wpos, float z)
{
    float sphereDist = distance(wpos, unity_ShadowFadeCenterAndType.xyz);
    return lerp(z, sphereDist, unity_ShadowFadeCenterAndType.w);
}

通过Frame Debug发现,三种光源(平行光,点光,聚光)下unity_ShadowFadeCenterAndType均为(0,0,0,0),那么这里的fadeDistance就是vpos.z。接下来,函数根据光源类型的不同,分别计算它们的衰减信息。

对于聚光灯,和前向光照类似,会对_LightTexture0这张spot cookie纹理和_LightTextureB0这张衰减纹理进行采样,得到光照衰减信息(有关内容可以参考之前的文章《Unity中的多光源》[7])。然后使用UnityDeferredComputeShadow从shadowmap中采样阴影,再拿之前得到的阴影fadeDistance,通过UnityComputeShadowFade计算阴影衰减的程度:

half UnityComputeShadowFade(float fadeDist)
{
    return saturate(fadeDist * _LightShadowData.z + _LightShadowData.w);
}

在之前的文章《Unity中的shadows(三)receive shadows》[8]我们已经提到过:

_LightShadowData = new Vector4(
    1 - light.shadowStrength,                                                             // x = 1.0 - shadowStrength
    Mathf.Max(camera.farClipPlane / QualitySettings.shadowDistance, 1.0f),                // y = max(cameraFarClip / shadowDistance, 1.0) // but not used in current built-in shader codebase
    5.0f / Mathf.Min(camera.farClipPlane, QualitySettings.shadowDistance),                // z = shadow bias
    -1.0f * (2.0f + camera.fieldOfView / 180.0f * 2.0f)                                    // w = -1.0f * (2.0f + camera.fieldOfView / 180.0f * 2.0f) // fov is regarded as 0 when orthographic.
);

对于平行光源,默认的光照衰减为1,如果设置了cookie则还需要采样cookie纹理,然后再计算阴影衰减,得到最终结果。对于点光源,也是类似的,也就不展开说了。

最后,我们通过Frame Debug看一下这三种光源在CPU层面的绘制信息。首先来看聚光灯,发现Unity采用了类似reflections的绘制方式,先使用一个stencil pass来标记位于聚光灯区域内的物体,然后再去跑真正的light pass。这里,Unity使用了一个4个顶点的pyramid来模拟聚光灯:

而对于点光源,Unity采用了类似reflections的另一种绘制方式,它只有一个light pass,设置了Cull Front和ZTest Greater,使得点光源区域内和前方的物体都会参与到光照计算。这里Unity使用了一个42个顶点的icosphere来模拟点光源:

平行光是最简单的,因为它覆盖的区域就是整个场景,所以Unity采用了类似skybox的reflections绘制方式,用一个覆盖整个screen的quad来模拟平行光:

如果你觉得我的文章有帮助,欢迎关注我的微信公众号(大龄社畜的游戏开发之路

Reference

[1] Deferred Shading

[2] 延迟渲染为什么不支持MSAA?

[3] 对多重采样(MSAA)原理的一些疑问?

[4] Deferred Rendering(一) : 基础篇

[5] Deferred Shading rendering path

[6] 阴影渐变衰减UnityComputeShadowFadeDistance与UnityComputeShadowFade

[7] Unity中的多光源

[8] Unity中的shadows(三)receive shadows

posted @ 2021-11-01 19:16  异次元的归来  阅读(344)  评论(0编辑  收藏  举报