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个角的射线。那么有:
其中,n为相机近剪裁面的距离,\(\theta\)为相机的fov:
截图可以看出,n为0.3,\(\theta\)为\(\dfrac{\pi}{3}\)。aspect的信息可以从GBuffer或者Depth Texture的分辨率得到:
得到aspect为\(\dfrac{1150}{531}\)。代入上面的公式计算出:
与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
为:
而实际上经过MVP变换到clip坐标系的坐标我们是知道的,即SV_POSITION里的值,那么矩阵M为:
问题其实就转换成解线性方程组了,可以解得矩阵M为:
当然,其实有了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转换到世界坐标系下的坐标应该是:
把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分量并非是相机指向cube投影到远剪裁面点的射线。fragment shader中会做进一步处理:
i.ray = i.ray * (_ProjectionParams.z / i.ray.z);
我们知道,Unity的view坐标系,可见物体的z坐标,一定是负值。那么通过除以ray.z的操作,可以让z坐标的值反转:
这样求出的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_BoxMin
和unity_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?
[4] Deferred Rendering(一) : 基础篇
[5] Deferred Shading rendering path
[6] 阴影渐变衰减UnityComputeShadowFadeDistance与UnityComputeShadowFade
[7] Unity中的多光源