PA6 Shadows

Shader 与渲染算法 - 环境互动 - 阴影

beautiful shadow

1 前置的知识 Pre-Knowledge

Unity 中如何支持软阴影效果?

光线被遮挡产生了阴影(blocked light),而在 “全暗” 与 “全亮” 之间实际存在一个过渡区域,被称为 “penumbra”,半影。penumbra 在灯光较大,并且距离表面较远时候更为明显。

Unity 中虽然不直接支持物理精确的半影计算,但提供了软阴影(Soft Shadows)的效果。Unity 通过 shadow filtering(阴影过滤技术,如百分比渐近过滤,Percentage-Closer Filtering,简称 PCF),来模拟阴影边缘的柔化,以降低计算量并提高性能。

如何开启阴影?

首先在路径 "Edit - Project Settings - Quality - Shadows" 中确认如下设置:high quality level(支持硬阴影和软阴影),high resolution,a stable fit projection,a distance of 150,and four cascades;

然后在光源组件中打开阴影投射选项。resolution 设置应设置为依赖 quality settings。

1.1 阴影制作:shadow map

Unity 采用一种最为通用的技术支持阴影,即将 shadow 制作并存储为阴影贴图,称作 shadow mapping。

具体来说,shadow map 结合了两张采样信息:

  1. 从光源视角生成阴影贴图(Shadow Map)

    1. light - point:在光源视角渲染场景,记录光源到场景中最接近表面的深度信息,从而生成深度图作为阴影贴图;

    2. Cascaded 技术:如果使用 directional light,可以在此阶段应用 Cascaded Shadow Mapping(级联阴影映射),基于摄像机视锥体的多个距离层级生成独立的深度图,以优化阴影质量;

  2. 从摄像机视角渲染并进行阴影判定

    1. camera - point:在摄像机视角渲染时,将场景中每个像素的深度信息与阴影贴图中的深度信息进行比较:当前像素深度 > 阴影贴图中的深度,说明该像素被其他物体遮挡,处于阴影中;否则,该像素直接受到光照;

    2. 光照强度调整:根据深度比较的结果,调整像素的光照强度(实现为 attenuation 值的调整)。常见做法是将光源颜色乘以阴影因子:1 表示完全受光照,0 表示完全在阴影中,介于 0 和 1 之间的值用于软阴影的过渡区域。

shadow logic
如何理解 light - point 深度采样过程中的 Cascaded 技术?

对于 directional light,由于其照射范围广泛,Unity 采用 Cascaded Shadow Maps(级联阴影映射)技术,其具体过程可如下概括:

  1. 基于摄像机视锥体分级:首先,Unity 会根据摄像机视锥体的不同距离,将它划分为多个 Cascaded(级联),通常是近、中、远几个层级;

  2. 光源视角生成 shadow map:然后,Unity 从光源的视角出发,为每个摄像机视锥层级生成独立的 shadow map(阴影贴图);

  3. 摄像机视角应用 Cascaded:当摄像机视角渲染场景时,Unity 根据片段距离摄像机的远近,选取对应层级的 shadow map,以提供清晰度合适的阴影效果。

具体的执行次数在 "Edit - Project Settings - Quality - Shadows" 进行设置。比如如果选择了两个 cascaded,那么 Unity 对于每一个光源只会渲染两次。

为什么在 debugger 的时候会看到奇怪的情况?

在 frame debugger 里,会看到一些 shadow 比投射它们的物体更先渲染出来。

不要慌,最后就会好起来的。

1.2 奇怪的问题:shadow map 的毛病

1.2.1 Aliasing 锯齿问题

由于阴影贴图的有限分辨率和纹理采样的离散性,阴影边缘可能会出现锯齿状的伪影,特别是在阴影贴图分辨率较低或物体靠近摄像机时。

解决方案

  1. Cascaded Shadow Maps,级联阴影映射。上文有述;

  2. Shadow Projection,阴影投影模式。

    1. Stable Fit(稳定拟合):提供稳定的阴影,不会因摄像机的移动或旋转而产生明显变化。适用于需要阴影稳定性的场景,但可能导致近距离阴影分辨率较低;

    2. Close Fit(近距离拟合):提高摄像机附近阴影的分辨率。可能会在摄像机移动或旋转时产生阴影边缘的 “游动” 现象(Shadow Edge Swimming);

    3. Unity 默认使用 Stable Fit,以平衡阴影质量和稳定性。

1.2.2 Shadow Acne 阴影粉刺

Shadow Acne 指这样一种情况:由于深度比较的有限精度(片段的深度可能比存储的深度稍长),可能会在受光照的表面上出现不正确的自阴影,形成类似“粉刺”的斑点。也被称作 “self shadowing area”。

就会变成:

shadow acne (a bit exaggerated)

解决方案

  1. 深度偏移(Depth Bias)

    1. 在生成阴影贴图时,对深度值施加一个小的偏移量,防止自阴影;

    2. 偏移量过大会导致阴影与物体分离,出现 “Peter Panning”(彼得潘效应,阴影漂浮);

  2. 法线偏移(Normal Bias)

    1. 根据表面的法线方向,对阴影接收面进行偏移;

    2. 有效减少阴影粉刺,同时最小化 Peter Panning;

    3. 偏移量需要谨慎设置,以避免阴影出现缺失(类似小空洞)或不贴合的问题。

1.2.3 Anti-Aliasing 抗锯齿

MSAA(多重采样抗锯齿)无法解决阴影映射中的锯齿问题。原因是阴影映射的锯齿源于纹理采样,而非几何边缘。

解决方案

使用其他基于图像的后处理抗锯齿技术。比如 FXAA。因为它们是整个屏幕被渲染后才被应用的。

2 投射阴影 Casting Shadows

在这一步中进行光照的投影。具体来说,通过增加一个 ShadowCaster Pass,从光源的视角生成 shadow map。在这个 Pass 中,Shader 将场景中的顶点和片元转换到光源的坐标空间,记录从光源出发到场景中各个物体表面的距离。

2.1 粗糙的想法 Rough Idea

  1. 增加一个新的 pass,将 light mode 设置为 ShadowCaster;

  2. vertex shader:将物体顶点转换到裁剪屏幕空间,同时完成 depth bias 的矫正:

    1. 使用 UnityApplyLinearShadowBias 函数以支持 depth bias;

    2. 使用 UnityClipSpaceShadowCasterPos 函数以支持 normal bias;

  3. fragment shader:直接返回 0。GPU 会乖巧的记录深度值。

2.2 CODING

CastingShadow.shader
Shader "Code/PA6_Shadow/CastingShadow" {
	Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
		_BumpScale ("Bump Scale", Float) = 1
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
		_DetailTex ("Detail Texture", 2D) = "gray" {}
		[NoScaleOffset] _DetailNormalMap ("Detail Normals", 2D) = "bump" {}
		_DetailBumpScale ("Detail Bump Scale", Float) = 1
	}

	CGINCLUDE
	#define BINORMAL_PER_FRAGMENT
	ENDCG

	SubShader {
		Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile _ VERTEXLIGHT_ON
			#pragma vertex Vert
			#pragma fragment Frag
			#define FORWARD_BASE_PASS
			#include "CastingShadow_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile_fwdadd
			#pragma vertex Vert
			#pragma fragment Frag
			#include "CastingShadow_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ShadowCaster"
			}
			
			CGPROGRAM
			#pragma target 3.0
			#pragma vertex ShadowVert
			#pragma fragment ShadowFrag
			#include "CastingShadow_Shadow.cginc"
			ENDCG
		}
	}
}

3 接收阴影 Receiving Shadows

前一章节通过 Casting Shadows 生成了 shadow map,现在需要借助采样坐标,对 shadow map 进行采样,最终渲染输出阴影。

3.1 粗糙的想法 Rough Idea

  1. pass:base pass & additional pass。

    1. 首先考虑主光源所产生的阴影。在 Main 文件的 base pass 中增加包含 SHADOWS_SCREEN 关键字的 shader 变体;

    2. 将 additional pass 中的 multiple compile 语句修改为 #pragma multi_compile_fwdadd_fullshadows,从而实现对多种光源的支持;

  2. vertex shader:基于物体坐标对阴影坐标赋值,并进行裁剪空间到屏幕空间的调整;

  3. CreateLight:Unity 将阴影理解为光的衰减作用。因此根据阴影贴图的采样结果(使用 tex2D 函数),调整 attenuation 的值。

两条多编译指令会产生多少 shader 变体?

四种。每个指令包含两种状态(有关键字和无关键字),因此两条指令的组合生成四种可能的变体:

  1. 无关键字(NULL)

  2. 仅包含 SHADOWS_SCREEN

  3. 仅包含 VERTEXLIGHT_ON

  4. 同时包含 SHADOWS_SCREENVERTEXLIGHT_ON

这套机制虽然复杂,但它确保 Shader 能够适应不同光照效果的需求,并根据设备支持情况生成最优解。

为什么会产生 _ShadowCoord 的报错问题?

关于 _ShadowCoord 的报错问题,这是因为在开启 SHADOWS_SCREEN 时,UNITY_LIGHT_ATTENUATION 宏的行为会有所不同。这个宏在处理带阴影的光照时,需要引用 shadow map 的坐标信息,而 _ShadowCoord 是 Unity 定义的内置插值器(interpolator),用于传递 shadow map 相关的数据。在没有定义 SHADOWS_SCREEN 时,这个插值器是不会存在的,所以会出现缺少 _ShadowCoord 的报错。

为了快速解决问题,教程建议将 attenuation 设置为 1。这样可以临时跳过阴影数据的采样,确保代码可以运行,方便继续进行其他阴影的调试和开发。

UnityLight CreateLight (Interpolators i) {
	UnityLight light;

	#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
		light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
	#else
		light.dir = _WorldSpaceLightPos0.xyz;
	#endif
	
	#if defined(SHADOW_SCREEN)
		float attenuation = 1;
	#else
		UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);
	#endif

	light.color = _LightColor0.rgb * attenuation;
	light.ndotl = DotClamped(i.normal, light.dir);
	return light;
}

3.2 地道的用法 Native Way

  1. 使用 AutoLight 中预定义的三个有用的 macros:

    1. struct Interpolators:使用 SHADOW_COORD。它定义 shadow 的插值器;

    2. vertex shader:使用 TRANSFER_SHADOW。它给出 vertex shader 阶段的阴影坐标;

    3. CreateLight:在 UNITY_LIGHT_ATTENUATION 中使用 SHADOW_ATTENUATION。即使用插值作为它的第二个参数。

  2. 为帮助这些 macros 识别取值,修改数据名

    1. vertex:vertex position(instead of position)

    2. pos:interpolator position(instead of position)

3.2 CODING

ReceivingShadow.shader
Shader "Code/PA6_Shadow/ReceivingShadow" {
	Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
		_BumpScale ("Bump Scale", Float) = 1
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
		_DetailTex ("Detail Texture", 2D) = "gray" {}
		[NoScaleOffset] _DetailNormalMap ("Detail Normals", 2D) = "bump" {}
		_DetailBumpScale ("Detail Bump Scale", Float) = 1
	}

	CGINCLUDE
	#define BINORMAL_PER_FRAGMENT
	ENDCG

	SubShader {
		Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile _ SHADOWS_SCREEN
			#pragma multi_compile _ VERTEXLIGHT_ON
			#pragma vertex Vert
			#pragma fragment Frag
			#define FORWARD_BASE_PASS
			#include "ReceivingShadow_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile_fwdadd_fullshadows // 多种光源阴影
			#pragma vertex Vert
			#pragma fragment Frag
			#include "ReceivingShadow_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ShadowCaster"
			}
			
			CGPROGRAM
			#pragma target 3.0
			#pragma vertex ShadowVert
			#pragma fragment ShadowFrag
			#include "ReceivingShadow_Shadows.cginc"
			ENDCG
		}
	}
}
如何理解 Vert 函数里对 i.shadowCoordinates 的计算?

这是对基于 i.position 赋值的调整:

#if defined(SHADOWS_SCREEN)
    i.shadowCoordinates = i.position;
#endif

这时会看到阴影被挤压在屏幕中心的一小块区域上。这是因为来自 vertex shader 的 i.position 是片段的齐次裁剪空间坐标(clip space),其坐标在 (-1, 1) 范围;而我们需要的是范围为 (0, 1) 的屏幕空间坐标。

为解决这个问题,需要对 clip space 的 XY 坐标先进行 offset 变换(加上第四个齐次坐标 i.position.w),再减半(× 0.5)。ZW 坐标依然保留。

#if defined(SHADOWS_SCREEN)
    i.shadowCoordinates.xy = (i.position.xy + i.position.w) * 0.5;
    i.shadowCoordinates.zw = i.position.zw;
#endif

此外,此时的阴影可能是倒置的,那说明使用的图形 API 是 Direct3D,其中 Y 坐标 0 - 1 取值顺序为从上到下。为同步这个问题,在 vertex shader 中翻转 Y 坐标:

#if defined(SHADOWS_SCREEN)
    i.shadowCoordinates.xy = (float2(i.position.x, -i.position.y) + i.position.w) * 0.5;
    i.shadowCoordinates.zw = i.position.zw;
#endif
如何理解 CreateLight 函数里 i.shadowCoordinates 的计算?

在 Vert 函数里对 i.shadowCoordinates 进行坐标映射后,阴影结果依然是扭曲的。因为此时坐标是齐次坐标(homogeneous coordinates)。需要将其转换为屏幕空间坐标,就需要将其 XY 分量除以 W。

但是这个除法并不应该在 vertex shader 中进行。因为应该先插值,再做除法。于是就将这个除以 W 分量的齐次坐标调整移到 CreateLight 函数中:

#if defined(SHADOWS_SCREEN)
    float attenuation = tex2D(_ShadowMapTexture, i.shadowCoordinates.xy / i.shadowCoordinates.w);
#else
    UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif
插值顺序如何影响除法结果?

假如有两个不同位置的 XW 坐标对,即 (X, W):(0, 1) 和 (1, 4)。在透视投影中,我们需要将每个片段的 X 坐标除以 W,来从齐次坐标转换到常规的屏幕空间坐标。因此这里的目标是得到 (X / W)。

对于起点和终点,“先插值后除法” 和 “先除法后插值” 的结果都分别为 0 和 1/4。但对于其他位置,其值如图:

红色线:表示 “先除法后插值” 的效果。这样做会在起点和终点之间插值 X/W 值,而不会考虑透视校正。例如,若在 (0, 1) 和 (1, 4) 之间直接插值 X/W,中间点会是 (0 + 1/4) / 2 = 1/8,保持线性;

蓝色线:表示 “先插值后除法” 的效果。这种方法在透视投影下更符合真实情况。在这种情况下,插值 XW 坐标得到中间点 (0.5, 2.5),然后再进行 0.5 / 2.5 计算得到 1/5。这个方法在透视投影下生成非线性插值效果,适合模拟物体远近变化的自然感。

另:在两个点之间取中间位置(即 50% 的插值位置),使用线性插值公式:

(X,W)=(1t)(X1,W1)+t(X2,W2)(X,W)=(1−t)⋅(X1​,W1​)+t⋅(X2​,W2​)

t=0.5t=0.5 时,得到:

X=(10.5)0+0.51=0.5X=(1−0.5)⋅0+0.5⋅1=0.5

W=(10.5)1+0.54=2.5W=(1−0.5)⋅1+0.5⋅4=2.5

4 Spotlight Shadows

4.1 Difference:生成贴图

Spotlight shadow 的 depth pass 具有不同的作用。

directional light 和 spotlight 都使用 depth pass。但与之不同的是,因为 spotlight 的光束有其实际位置,而且本身具备透视,因此 directional light 使用 depth pass 支持屏幕空间的 cascades(级联投影 ),而 spotlight 则是在其光束范围内生成深度贴图,进行更简单的遮挡判断。

4.2 Difference:采样贴图

Spotlight shadow 的 filtering 的实现阶段在采样贴图中(而不是生成贴图时)。

  1. directional shadow:生成贴图阶段包含 filtering。制作 directional shadow 仅需对 screen-space shadow map 采样即可。Unity 在创建阴影贴图时就会实现 shadow filtering(用于柔化阴影边缘,实现软阴影)。

  2. spotlight shadow:生成贴图阶段不包含 filtering。由于 spotlight 并不使用 screen-space shadow,因此制作柔和阴影就需要在 fragment shader 中实现 shadow filtering。

spotlight shadow 如何进行采样?

SHADOW_ATTENUATION 宏使用 UnitySampleShadowmap 函数对 shadow map 进行采样。

使用硬阴影时,该函数会对阴影贴图采样一次。使用软阴影时,该函数会对阴影贴图采样四次,然后求平均值。其结果不如 screen-space shadow 的效果好,但速度会快得多。

但是我们还是什么都不用做。Unity 的 macro 隐藏了所有以上实现细节。

UnitySampleShadowmap 定义在 AutoLight - UnityShadowLibrary 中)

5 Point Light Shadows

5.1 Difference:map 格式

在场景中添加 point light。当通过 frame debugger 检查 shadow map 时,会发现 Unity 为每一个光源生成了四张 map。因为 point light 是在各个方向照射的,因此它的 shadow map 必须是 cube map 的形式。

cub map 通过摄影机的六个方向渲染场景而创建,每个方向创建 cube 的一个面。So shadows for point lights are expensive!

而且由于 Unity 不支持对 shadow cube map 的 filtering,所以 point light shadow 的边缘会更硬。

又贵,还糙。

直接添加 point light 会发生什么?

一片漆黑。

为什么添加 point light 提示编译错误?

提示 “UnityDecodeCubeShadowDepth is undefined” 编译错误,是因为 UnityShadowLibrary 依赖 UnityCG,所以需要首先包含 UnityCG

修改为:shader 文件中,将 UnityPBSLighting 文件包含在 AutoLight 文件之前。

5.2 粗糙的想法 Rough Idea

因为没有足够的平台支持,所以 Unity 并不使用 cube map,因此在 Shadow 文件中无法输出深度值来支持 point light,而是输出距离(fragment's distance)。

因为使用了几乎完全不同的方法,所以在 Shadow 文件中为 point light 创建一个单独的函数。

  1. pass:在 ShadowCaster 中加入 SHADOW_CUBE 关键字(用于 directional & spotlight shadows)。支持该功能可直接添加多编译指令 multi_compile_shadowcaster;

  2. shadow vertex program:根据片段与光源位置计算 i.lightVec,输出距离(而非深度)值;

  3. shadow fragment program:基于 i.lightVec 计算深度值 depth,将其作为 UnityEncodeCubeShadowDepth 的参数输出。

5.3 CODING

PointLightShadow.shader
Shader "Code/PA6_Shadow/PointLightShadow" {
	Properties {
		_Tint ("Tint", Color) = (1, 1, 1, 1)
		_MainTex ("Albedo", 2D) = "white" {}
		[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}
		_BumpScale ("Bump Scale", Float) = 1
		[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
		_Smoothness ("Smoothness", Range(0, 1)) = 0.1
		_DetailTex ("Detail Texture", 2D) = "gray" {}
		[NoScaleOffset] _DetailNormalMap ("Detail Normals", 2D) = "bump" {}
		_DetailBumpScale ("Detail Bump Scale", Float) = 1
	}

	CGINCLUDE
	#define BINORMAL_PER_FRAGMENT
	ENDCG

	SubShader {
		Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile _ SHADOWS_SCREEN
			#pragma multi_compile _ VERTEXLIGHT_ON
			#pragma vertex Vert
			#pragma fragment Frag
			#define FORWARD_BASE_PASS
			#include "PointLightShadow_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile_fwdadd_fullshadows // 多种光源阴影
			#pragma vertex Vert
			#pragma fragment Frag
			#include "PointLightShadow_Light.cginc"
			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ShadowCaster"
			}
			
			CGPROGRAM
			#pragma target 3.0
			#pragma multi_compile_shadowcaster
			#pragma vertex ShadowVert
			#pragma fragment ShadowFrag
			#include "PointLightShadow_Shadow.cginc"
			ENDCG
		}
	}
}
如何理解 Shadow 文件中 ShadowVert 的 i.lightVec 语句?
  1. mul(unity_ObjectToWorld, v.position).xyz 将顶点的位置从物体空间转换为世界空间,得到当前片段的世界坐标;

  2. _LightPositionRange 是一个 Unity 自动传递的四维向量,其中 xyz 表示点光源的位置(世界空间中),w 表示点光源的范围;

两位置相减,得到片段位置和点光源位置之间的向量。

如何理解 Shadow 文件中的 ShadowFrag 函数?

该函数是 fragment shader 生成 depth 值的过程:

  1. 首先使用 light vector 的 length(长度)为其赋值,并且向其添加 bias;

  2. 为映射到 0 - 1 范围,将其除以 light 的 range(范围)。变量 _LightPositionRange.w 包含了其范围的倒数,因此将 depth 乘以该变量;

  3. 最后使用 UnityEncodeCubeShadowDepth 函数将其解码并返回。

对于 UnityEncodeCubeShadowDepth 函数,因为 Unity 倾向使用 floating-point cub map,因此可以使用这个 cube map 时,此函数不会执行任何操作。如果无法实现,Unity 会对数值进行编码,使其存储在 8 位 RGBA 纹理的四个通道中。


APPENDIX

Anki

Reference

Last updated