ShadowMap是Unity目前使用的实时阴影技术,还包含了Screen Shadow Map。非常惭愧,做自研引擎的时候我并没有自己去实现这套特性,那么现在在看Unity,刚好就阅读以下Unity是如何实现ShadowMap的。下面会有部分内容转载自冯乐乐,目前她的书《Unity Shader入门精要》已经被我们组列为入组必读书籍之一,值得读三遍以上(我真的读了三遍)。

阴影是如何实现的

传统的ShadowMap

ShadowMap的技术理解起来非常简单,首先把摄像机的位置放在光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。

在Unity的前向渲染路径中,如果场景中最重要的平行光开启了阴影,Unity就会为该光源计算它的ShadowMap,这张ShadowMap本质上也就是一张深度图,记录了从该光源的位置出发、能看到的场景中距离它最近的表面位置(深度信息)。

Unity中实现了一个额外的Pass来专门更新光源的ShadowMap,这个Pass就是LightMode标签被设置为ShadowCaster的Pass。这个Pass的RenderTarget不是帧缓存,而是ShadowMap(一张深度纹理图)。

然后在正常渲染的Pass中把顶点位置变换到光源空间下,以得到它在光源空间中的三维位置信息。然后根据xy分量对ShadowMap进行采样,得到ShadowMap中该位置的深度信息。把这个两个深度信息做对比,判断该点是否应该在阴影中。

Screenspace ShadowMap

延迟渲染中的光照计算绝大部分都是在屏幕空间里进行的,同样也包括阴影。这种屏幕空间的阴影实现主要有这么几个步骤:

  • 首先得到从当前摄像机处观察到的深度纹理。在延迟渲染里这张深度图本来就有,如果是前向渲染的话就需要把场景整个渲染一遍,把深度渲染到深度图中。
  • 然后再从光源出发得到从该光源处观察到的深度纹理,也被称为这个光源的ShadowMap。
  • 然后在屏幕空间做一次阴影收集计算(Shadows Collector),这次计算会得到一张屏幕空间阴影纹理,也就是说这张图里面需要有阴影的部分已经显示在图上了。这个过程概括来说就是把每一个像素根据它在摄像机深度纹理中的深度值得到世界空间坐标,再把它的坐标从世界空间转换到光源空间中,和光源的ShadowMap里面的深度值对比,如果大于ShadowMap中的深度距离,那么就说明光源无法照到,在阴影内。
  • 最后,在正常渲染物体为它计算阴影的时候,只需要按照当前处理的fragment在屏幕空间中的位置对步骤3得到的屏幕空间阴影图采样就可以了。

Unity5使用了Screenspace ShadowMap,会先通过LightMode为ShadowCaster的pass得到可投影阴影的光源的ShadowMap以及相机的深度纹理。然后根据这两张纹理得到屏幕空间的阴影图。阴影图包含了屏幕空间中所有有阴影的区域。如果希望一个物件接收阴影,那么在PS的最后根据其屏幕坐标对阴影图采样即可。

ScreenSpace ShadowMap原本是延迟渲染中产生阴影的办法,并非所有的平台都支持(目前Mobile平台默认不使用,虽然部分手机已经支持了),因为需要显卡支持MRT。

理论上ScreenSpace ShadowMap更快一些,毕竟不用把所有物件都转换到光源空间去判断是否在阴影中,只需要把深度阴影先计算出来,然后把深度阴影图转到光源空间去和ShadowMap对比即可。而且理论上有了深度阴影,也可以在渲染的时候预防overdraw。不过目前还没有看到具体数据信息,所以只能上理论上是这样的。

LightMode为ShadowCaster的pass

既然这个Pass如此重要,既可以用来计算ShadowMap,还可以计算相机的深度纹理,那么来看一下它的代码。(ScreenShadowMap的计算是通过另外一个Shader,在Unity5.5.x中被称为Hidden/Internal-ScreenSpaceShadows)

	// Pass to render object as a shadow caster
	Pass 
	{
		Name "ShadowCaster"
		Tags { "LightMode" = "ShadowCaster" }
		
		ZWrite On ZTest LEqual Cull Off

		CGPROGRAM
		#pragma vertex vert
		#pragma fragment frag
		#pragma target 2.0
		#pragma multi_compile_shadowcaster
		#include "UnityCG.cginc"

		struct v2f { 
			V2F_SHADOW_CASTER;
		};

		v2f vert( appdata_base v )
		{
			v2f o;
			TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
			return o;
		}

		float4 frag( v2f i ) : SV_Target
		{
			SHADOW_CASTER_FRAGMENT(i)
		}
		ENDCG
	}

代码很短,内容都在三个宏定义中。

	#ifdef SHADOWS_CUBE
		// Rendering into point light (cubemap) shadows
		#define V2F_SHADOW_CASTER_NOPOS float3 vec : TEXCOORD0;
		#define TRANSFER_SHADOW_CASTER_NOPOS(o,opos) o.vec = mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz; opos = UnityObjectToClipPos(v.vertex);
		#define SHADOW_CASTER_FRAGMENT(i) return UnityEncodeCubeShadowDepth ((length(i.vec) + unity_LightShadowBias.x) * _LightPositionRange.w);
	#else
		// Rendering into directional or spot light shadows
		#define V2F_SHADOW_CASTER_NOPOS
		#define TRANSFER_SHADOW_CASTER_NOPOS(o,opos) \
		opos = UnityClipSpaceShadowCasterPos(v.vertex.xyz, v.normal); \
		opos = UnityApplyLinearShadowBias(opos);
		#define SHADOW_CASTER_FRAGMENT(i) return 0;
	#endif
	#define V2F_SHADOW_CASTER V2F_SHADOW_CASTER_NOPOS float4 pos : SV_POSITION
	#define TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) TRANSFER_SHADOW_CASTER_NOPOS(o,o.pos)

当光源为方向光或者聚光灯的时候,在VS中只是做了一定的偏移(这些偏移大多数是用于防止self-shadowing)之后,将模型空间的坐标转为了相机空间的坐标作为VS的输出。PS直接返回0。这样就可以得到相机空间的深度图。我想ShadowMap肯定就是Unity在内部把相机位置移动到了光源位置,然后得到的深度图。这样,ShadowMap和相机的深度图都得到了。

光源为点光源的时候,vs多了一个输出,模型与光源的距离。在深度图中记录了这个值偏移后的信息。

需要注意的是,是否会渲染到生成深度纹理不仅仅和LightMode为ShadowCaster的Pass相关,也和渲染队列有关。正如Unity文档中所说:Note that only “opaque” objects (that which have their materials and shaders setup to use render queue <= 2500) are rendered into the depth texture.

Hidden/Internal-ScreenSpaceShadows挺长的就不放了,主要思路就是从摄像机的深度纹理里采样得到该fragment的深度值,然后利用矩阵变换计算得到该点对应的世界空间的世界坐标(利用CameraToWorld矩阵),然后再变换到光源空间下的坐标(利用WorldToShadow矩阵),最后拿这个坐标对光源的ShadowMap采样计算阴影。

在生成ShadowMap的时候,有时候为了效果和性能也会使用Shadow Cascades,也就是为什么用Frame Debug看到会绘制好多遍,相应的在生成ScreenShadowMap的时候就会用生成的多层Shadow Cascades去生成阴影图。(具体分层机制和场景的FarPlane值以及物体离摄像机有关,当FarPlane值较大,离摄像机都很近的时候,会有些层绘制不出来)

物体接收阴影

接收阴影就比较简单了,因为不管是使用ShadowMap还是ScreenspaceShadowmap,接收阴影的时候,只需要根据坐标转换得到纹理坐标,然后对纹理采样即可得到所需要的阴影信息。

如果使用ShadowMap,那么只接收不产生阴影的物件就不需要LightMode为ShadowCaster的pass,因为只需要在接收阴影的时候,将物件坐标转换,再根据ShadowMap计算阴影即可。

但是如果使用ScreenSpace ShadowMap的话,只接收不产生阴影的物件也需要LightMode为ShadowCaster的pass,因为如果没有这个Pass,在计算相机深度图的时候就没有这个物件的信息,如果这个物件的Z值对深度图产生了影响,那么得到的就是错误的深度图,进而得到的ScreenSpace ShadowMap也是错误的,因为得到的ScreenSpace ShadowMap是根据这个物件后面的物件的Z值,映射到ShadowMap上对比得到的,而万一后面的物件在阴影中,而当前这个物件的Z值比较靠前不在阴影中,那么得到的Screen ShadowMap的值却是在阴影中,那么就错误了。

然而因为光的种类很多,所以简单的理论实现起来需要考虑的情况还是比较多的。

	#define unityShadowCoord4 float4
	#define unityShadowCoord3 float3
	
	#if defined (SHADOWS_SCREEN)
		#define SHADOW_COORDS(idx1) unityShadowCoord4 _ShadowCoord : TEXCOORD##idx1;

		#if defined(UNITY_NO_SCREENSPACE_SHADOWS)

			UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);
			#define TRANSFER_SHADOW(a) a._ShadowCoord = mul( unity_WorldToShadow[0], mul( unity_ObjectToWorld, v.vertex ) );

			inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord)
			{
				#if defined(SHADOWS_NATIVE)

					fixed shadow = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, shadowCoord.xyz);
					shadow = _LightShadowData.r + shadow * (1-_LightShadowData.r);
					return shadow;

				#else

					unityShadowCoord dist = SAMPLE_DEPTH_TEXTURE(_ShadowMapTexture, shadowCoord.xy);

					// tegra is confused if we use _LightShadowData.x directly
					// with "ambiguous overloaded function reference max(mediump float, float)"
					unityShadowCoord lightShadowDataX = _LightShadowData.x;
					unityShadowCoord threshold = shadowCoord.z;
					return max(dist > threshold, lightShadowDataX);

				#endif
			}

		#else // UNITY_NO_SCREENSPACE_SHADOWS

			sampler2D _ShadowMapTexture;
			#define TRANSFER_SHADOW(a) a._ShadowCoord = ComputeScreenPos(a.pos);

			inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord)
			{
				fixed shadow = tex2Dproj( _ShadowMapTexture, UNITY_PROJ_COORD(shadowCoord) ).r;
				return shadow;
			}

		#endif

		#define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)

	#endif


	// ---- Spot light shadows
	#if defined (SHADOWS_DEPTH) && defined (SPOT)
		#define SHADOW_COORDS(idx1) unityShadowCoord4 _ShadowCoord : TEXCOORD##idx1;
		#define TRANSFER_SHADOW(a) a._ShadowCoord = mul (unity_WorldToShadow[0], mul(unity_ObjectToWorld,v.vertex));
		#define SHADOW_ATTENUATION(a) UnitySampleShadowmap(a._ShadowCoord)
	#endif


	// ---- Point light shadows
	#if defined (SHADOWS_CUBE)
		#define SHADOW_COORDS(idx1) unityShadowCoord3 _ShadowCoord : TEXCOORD##idx1;
		#define TRANSFER_SHADOW(a) a._ShadowCoord = mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz;
		#define SHADOW_ATTENUATION(a) UnitySampleShadowmap(a._ShadowCoord)
	#endif

	// ---- Shadows off
	#if !defined (SHADOWS_SCREEN) && !defined (SHADOWS_DEPTH) && !defined (SHADOWS_CUBE)
		#define SHADOW_COORDS(idx1)
		#define TRANSFER_SHADOW(a)
		#define SHADOW_ATTENUATION(a) 1.0
	#endif

SHADOW_COORDS是用于声明VS向PS传递的值。

TRANSFER_SHADOW是用于计算纹理坐标,当使用ScreenShadowMap的时候,纹理坐标为屏幕空间的坐标;当使用ShadowMap或者光源为聚光灯的时候,纹理坐标为阴影空间(光源空间)的坐标;当点光源的时候,纹理坐标为与光源之间的距离。

SHADOW_ATTENUATION就是根据纹理坐标进行采样。

虽然并非全部原创,但还是希望转载请注明出处:电子设备中的画家|王烁 于 2017 年 8 月 5 日发表,原文链接(http://geekfaner.com/unity/blog3_ShadowMap.html)