在这里插入图片描述

前言

Shadow Mapping是在即时渲染中最为广泛使用的一种阴影技术,在如今很多高级的阴影技术例如Percentage Closer Soft Shadow中都能见到它的影子。掌握这项技术将为学习之后的高级阴影技术打下坚实的基础。

算法原理

Shadow Mapping的核心思想:让我们先考虑一个理想情况,光线没有折射和反射,那么光沿着直线传播,遇到物体会被阻挡。当我们沿着光源来观察场景时,我们会看到所有的直接光照,反之,所有没看见的地方都没有被直接光照照射。
所以,我们在光源位置记录一张深度图(即Shadow Map),这张图记录了所有光线能够到达的位置,然后再渲染每个片元时,和深度图的深度做对比,即可知道这个片元是否能被光线照射到。
原理

实现框架

落实到具体实现阶段,Shadow Mapping的主要步骤如下:

  1. 在光源位置渲染场景,获取Shadow Map;
  2. 计算世界转灯光坐标系的矩阵记M,和Shadow Map一起送入着色器;
  3. 相机正常渲染场景,在片元着色阶段,使用矩阵M计算片元在灯光坐标系下的深度,记Dp
  4. 采样该片元灯光坐标系下Shadow Map记录的深度,记Ds
  5. 如果Dp>Ds,片元的深度就比Shadow Map记录的深,光线被遮挡。

具体实现

在这里我们先考虑最简单的方向光的灯光模型,首先我们先实现第一步,在光源位置渲染场景,获得Shadow Map。为了在光源位置渲染场景,首先我们要在光源位置创建相机:

GameObject go = new GameObject("Directional Light Camera");
//相机的朝向与光源方向一致
go.transform.rotation = transform.rotation;

Camera cam = go.AddComponent<Camera>();
cam.backgroundColor = Color.white;
cam.clearFlags = CameraClearFlags.Color;
//平行光没有透视关系,使用正交相机模拟平行接受光线
cam.orthographic = true;
cam.enabled = false;

//创建深度贴图,注意这里使用的是RenderTextureFormat.Depth类型,直接使用Unity定义的深度贴图格式
RenderTexture shadowTexture = new RenderTexture(shadowResolution, shadowResolution, 24, RenderTextureFormat.Depth);
shadowTexture.filterMode = FilterMode.Point;
//指定灯光相机的渲染目标
cam.targetTexture = shadowTexture;

光源的相机需要手动计算视锥体,将必要的模型包含在其中。在Common Techniques to Improve Shadow Depth Maps这篇文章中提到,视锥体有两种基本的主要计算思路:以观察相机视锥体为主和以场景为主。这里选择以观察相机的视锥来计算视锥体。
在这里插入图片描述

	计算相机的包围盒
	Vector3[] nearPt = new Vector3[4];
	Vector3[] farPt = new Vector3[4];
	//获取近裁剪面、远裁剪面的顶点坐标
	//该函数获取到的是相机局部坐标系的顶点坐标
	Camera.main.CalculateFrustumCorners(new Rect(0, 0, 1, 1), Camera.main.nearClipPlane, Camera.MonoOrStereoscopicEye.Mono, nearPt);
	Camera.main.CalculateFrustumCorners(new Rect(0, 0, 1, 1), Camera.main.farClipPlane, Camera.MonoOrStereoscopicEye.Mono, farPt);
	//计算从相机局部坐标系转到灯光坐标系的矩阵
	Matrix4x4 cameraToLight = transform.worldToLocalMatrix * Camera.main.transform.localToWorldMatrix;
	for (int i = 0; i < 4; ++i) {
		//将顶点坐标转移到灯光坐标系
		nearPt[i] = cameraToLight.MultiplyPoint(nearPt[i]);
		farPt[i] = cameraToLight.MultiplyPoint(farPt[i]);
	}
	
	在灯光坐标系下计算最大外包AABB盒
	float[] xs = { nearPt[0].x, nearPt[1].x, nearPt[2].x, nearPt[3].x, farPt[0].x, farPt[1].x, farPt[2].x, farPt[3].x };
	float[] ys = { nearPt[0].y, nearPt[1].y, nearPt[2].y, nearPt[3].y, farPt[0].y, farPt[1].y, farPt[2].y, farPt[3].y };
	float[] zs = { nearPt[0].z, nearPt[1].z, nearPt[2].z, nearPt[3].z, farPt[0].z, farPt[1].z, farPt[2].z, farPt[3].z };
	
	Vector3 minPt = new Vector3(Mathf.Min(xs), Mathf.Min(ys), Mathf.Min(zs));
	Vector3 maxPt = new Vector3(Mathf.Max(xs), Mathf.Max(ys), Mathf.Max(zs));
	
	//更新旋转
	cam.transform.rotation = transform.rotation;
	//相机的位置在近平面的中心
	cam.transform.position = transform.TransformPoint(new Vector3((minPt.x + maxPt.x) * 0.5f, (minPt.y + maxPt.y) * 0.5f, minPt.z));
	cam.nearClipPlane = 0;
	cam.farClipPlane = maxPt.z - minPt.z;
	//宽高比=宽度/高度
	cam.aspect = (maxPt.x - minPt.x) / (maxPt.y - minPt.y);
	cam.orthographicSize = (maxPt.y - minPt.y) * 0.5f;

此时,灯光相机的视锥体已经可以包裹住整个相机的视锥体:
在这里插入图片描述
我们创建一个Shader,用于在灯光坐标系下渲染场景的纹理(获取深度Shader可以参考Unity手册:使用深度纹理),然后在脚本中主动调用相机的渲染来渲染Shadow Map:

lightCam.RenderWithShader(Shader.Find("LearningShadow/SMCaster"), "");

Camera.RenderWithShader可以让相机用指定的Shader来渲染场景中的网格,对场景进行渲染后得到的结果如下:
在这里插入图片描述
因为在创建贴图时使用了RenderTextureFormat.Depth格式,在PC上的实现是使用了单通道的贴图实现的,所以预览图将其作为R通道渲染,变成了红色到黑色的图像,Unity对Depth格式的描述如下:

深度格式用于将高精度“深度”值渲染到渲染纹理中。实际使用哪种格式取决于平台。在OpenGL上,它是本机“深度组件”格式(通常为24或16位),在Direct3D9上,它是32位浮点数(“ R32F”)格式。在编写使用或渲染到深度纹理的着色器时,必须注意确保它们在OpenGL和Direct3D上均可使用。

获取到Shadow Map之后,我们需要计算一个由世界坐标转灯光坐标的矩阵,用于在计算阴影时将片元的世界坐标转到灯光坐标:

//设置Shadow Map
Shader.SetGlobalTexture("_gShadowTexture", shadowTexture);

//计算世界转灯光矩阵
Matrix4x4 worldToLight = GL.GetGPUProjectionMatrix(lightCam.projectionMatrix, false) * lightCam.worldToCameraMatrix;
//设置世界转灯光矩阵
Shader.SetGlobalMatrix("_gWorldToLight", worldToLight);

设置完所有需要的参数后,创建一个用于计算阴影的Shader,首先计算出片元的灯光坐标系:

...
struct v2f{
	...
	float4 shadowPos : TEXCOORD0;
}

float4x4 _gWorldToLight;
v2f vert(appdata_base v){
	v2f o;
	...
	//计算顶点的世界坐标
	float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
	//计算顶点的灯光坐标系坐标
	o.shadowPos = mul(_gWorldToLight, worldPos);
	...
}

在片元着色器里,我们可以使用以下代码计算出灯光坐标系下,片元的深度:

//计算灯光坐标系下的片元深度
float lightCoordDepth = i.shadowPos.z / i.shadowPos.w;
#if defined(SHADER_TARGET_GLSL)
	lightCoordDepth = lightCoordDepth * 0.5 + 0.5;
#elif defined(UNITY_REVERSED_Z)
	lightCoordDepth = 1 - lightCoordDepth;
#endif

通过透视除法,将灯光坐标系下的片元位置转换到NDC坐标系,此时要注意如果是在OPENGL下,NDC在深度的坐标范围是[-1, 1],另外在一些设备上,深度是[1, 0],这些都不是我们想要的。因此使用宏分开处理,确保最终的深度范围是[0, 1]。

有了灯光坐标系下的NDC坐标,我们也能使用NDC坐标去采样Shadow Map了,这样我们就可以获取到这个片元在灯光坐标系下,同一个位置能被灯光照射到的深度:

//构建灯光坐标系下的NDC坐标,作为UV进行深度采样
float2 uv = i.shadowPos.xy / i.shadowPos.w;
uv = uv * 0.5 + 0.5;
float depth = SAMPLE_DEPTH_TEXTURE(_gShadowTexture, uv);
#if defined(UNITY_REVERSED_Z)
	depth = 1 - depth;
#endif

采样Shadow Map和灯光坐标系下的深度如下图:
在这里插入图片描述
在采样深度中已经能大致看到阴影的形状。由于这两个值都还是在灯光坐标系下,因此深度是可以进行比较的,我们判定,当片元的深度小于采样的深度时,片元在阴影中:

float shadow = (depth < lightCoordDepth) ? 0 : 1;

在这里插入图片描述

问题

Shadow Acne

可以看到,渲染的结果非常糟糕。主要原因在于,我们进行深度比较的时候,相机的深度是由相机空间转换到灯光空间,再和原本就是在灯光空间采集的深度图进行数值的比较。而所有采集图像都是有分辨率的,可以理解为每个片元代表了一小片表面的属性。如果把一小块面积放得很大,就如下图所示:
在这里插入图片描述
可以看出来,不仅每个片元覆盖的表面范围不一样,而且因为渲染的方向不同(灯光的渲染方向是灯光的方向),每个表面的朝向也不同。这就像对模拟信号进行采样,两个采样方法(回到我们的场景中,就是渲染角度、分辨率的不同)都不同,却用两个采样方法对同一个模拟信号采样后(渲染同一个场景),将结果进行比较计算(比较深度),于是结果就会出现像是摩尔纹一样的图案。如果将上图中相机的深度在灯光空间与shadow map的深度进行比对,结果也显而易见:
在这里插入图片描述
解决这个问题的思路也很简单,在对比前把shadow map的深度整体位移一下,只要我位移的深度比采样带来的误差大,就可以盖掉误差带来的影响:

//偏移采样深度,避开shadow acne
float shadow = (depth < lightCoordDepth - _gShadowBias) ? 0 : 1;

偏移量是个经验值,太小无法解决acne问题,太大则出现阴影浮空的问题。这里使用试验出来的经验值:0.003。另外,我们可以设置采集深度用的贴图采样模式来改善采样的数值:

shadowTexture.filterMode = FilterMode.Trilinear;

渲染场景,我们的场景看上去干净多了:
在这里插入图片描述
更进一步,我们观察一下这种采样干扰是否和表面法线、光照方向有关:
在这里插入图片描述

可以观察到,随着曲面和光照方向的夹角增大,采样的像素深度需要的偏移量也在增大。我们可以使用正切函数将夹角和偏移量进行关联:
B i a s = t a n ( α ) × M a x B i a s Bias=tan(\alpha)\times MaxBias Bias=tan(α)×MaxBias
其中α为表面法线和表面到灯光向量的夹角。转换为Shader:

struct v2f{
	...
	float3 worldPos : TEXCOORD1;
	float3 worldNormal : TEXCOORD2;
};

v2f vert (appdata_base v){
	...
	//计算顶点的世界坐标
	float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
	...
	
	o.worldPos = worldPos;
	o.worldNormal = UnityObjectToWorldNormal(v.normal);
	return o;
}

fixed4 frag (v2f i) : SV_Target {
	...
	//指向灯光方向
	float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
	//bias = tan(cos(n·l)),其中n为法向,l指向灯光
	//我们使用acos(n·l)来获取夹角,然后在求正交值
	float bias = _gShadowBias * tan(acos(saturate(dot(worldLightDir, i.worldNormal))));
	bias = clamp(bias, 0, 0.01);
	
	//偏移采样深度,避开shadow acne
	float shadow = (depth < lightCoordDepth - bias) ? 0 : 1;
	...
}

最后结果如下,可以看出来,在圆的边缘位置,锯齿形状的采样问题得以改善许多。
在这里插入图片描述

Peter Panning

另一个常见的问题是由偏移值造成的,因为无论我们怎么调节一个合适的偏移值,偏移值最终确实是造成了深度的位移,这会使本该有阴影的地方失去阴影:
在这里插入图片描述
对于这种问题,Shadow Mapping方法似乎没有特别有效的解决方案。我们能做的事情之一,是尽量挑选合适的Bias:
在这里插入图片描述
还有一种做法是在记录深度时,记录网格背面的深度:

Shader "LearningShadow/SMCaster"{
    SubShader{
    	Cull Front
    	...
   	}
}

这样出来的结果,阴影的acne问题就会出现在背面:
在这里插入图片描述
这种出现在背面的问题其实是可以无视的,因为在一般的渲染下,都会使用N·L来判断片元是否能接收到光线,背面实际上是不接收光线的。如果希望修复这个问题,可以参考前面的shadow acne部分,反过来思考,这点就留给读者大展身手了。
我们可以通过实现一个简单的Lambert光照模型,并且加上Shadow Maping技术来看一个综合的效果:

fixed4 frag (v2f i) : SV_Target{
	...
	float3 worldN = normalize(i.worldNormal);
	float3 worldLight = normalize(UnityWorldSpaceLightDir(i.worldPos));
	float lambert = saturate(dot(worldN, worldLight));
	
	float3 col = UNITY_LIGHTMODEL_AMBIENT.xyz + shadow * lambert * _LightColor0.rgb;
	return fixed4(col, 1);
}

在这里插入图片描述
当然,渲染背面也不是万能的,有时候,我们就是需要单面的模型来优化场景的面数,所以永远没有最好的技术,只有最适合实际情况的技术。

到这一步,这个场景的阴影总算能够勉强入眼了,下一篇,我们尝试进一步优化阴影的质量。下面贴上完整的核心代码:

SM_DirectionalLight.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SM_DirectionalLight : MonoBehaviour {
    public int shadowResolution = 1024;
    public float shadowBias = 0.005f;
    private RenderTexture shadowTexture;
    private Camera lightCam;
    private void Start() {
        //创建深度贴图,注意这里使用的是RenderTextureFormat.Depth类型,直接使用Unity定义的深度贴图格式
        shadowTexture = new RenderTexture(shadowResolution, shadowResolution, 24, RenderTextureFormat.Depth);
        shadowTexture.filterMode = FilterMode.Trilinear;
        lightCam = createLightCamera();
        //指定灯光相机的渲染目标
        lightCam.targetTexture = shadowTexture;
    }

    private void Update() {
        updateCamera(lightCam);
        //渲染Shadow Map
        lightCam.RenderWithShader(Shader.Find("LearningShadow/SMCaster"), "");
        //设置Shadow Map
        Shader.SetGlobalTexture("_gShadowTexture", shadowTexture);

        //计算世界转灯光矩阵
        Matrix4x4 worldToLight = GL.GetGPUProjectionMatrix(lightCam.projectionMatrix, false) * lightCam.worldToCameraMatrix;
        //设置世界转灯光矩阵
        Shader.SetGlobalMatrix("_gWorldToLight", worldToLight);
        Shader.SetGlobalFloat("_gShadowBias", shadowBias);
    }

    private void updateCamera(Camera cam) {
        计算相机的包围盒
        Vector3[] nearPt = new Vector3[4];
        Vector3[] farPt = new Vector3[4];
        //获取近裁剪面、远裁剪面的顶点坐标
        //该函数获取到的是相机局部坐标系的顶点坐标
        Camera.main.CalculateFrustumCorners(new Rect(0, 0, 1, 1), Camera.main.nearClipPlane, Camera.MonoOrStereoscopicEye.Mono, nearPt);
        Camera.main.CalculateFrustumCorners(new Rect(0, 0, 1, 1), Camera.main.farClipPlane, Camera.MonoOrStereoscopicEye.Mono, farPt);
        //计算从相机局部坐标系转到灯光坐标系的矩阵
        Matrix4x4 cameraToLight = transform.worldToLocalMatrix * Camera.main.transform.localToWorldMatrix;
        for (int i = 0; i < 4; ++i) {
            //将顶点坐标转移到灯光坐标系
            nearPt[i] = cameraToLight.MultiplyPoint(nearPt[i]);
            farPt[i] = cameraToLight.MultiplyPoint(farPt[i]);

            //Debug.DrawLine(transform.TransformPoint(nearPt[i]), transform.TransformPoint(farPt[i]), Color.red);
        }

        找到所有点的最大外包AABB盒
        float[] xs = { nearPt[0].x, nearPt[1].x, nearPt[2].x, nearPt[3].x, farPt[0].x, farPt[1].x, farPt[2].x, farPt[3].x };
        float[] ys = { nearPt[0].y, nearPt[1].y, nearPt[2].y, nearPt[3].y, farPt[0].y, farPt[1].y, farPt[2].y, farPt[3].y };
        float[] zs = { nearPt[0].z, nearPt[1].z, nearPt[2].z, nearPt[3].z, farPt[0].z, farPt[1].z, farPt[2].z, farPt[3].z };
        
        Vector3 minPt = new Vector3(Mathf.Min(xs), Mathf.Min(ys), Mathf.Min(zs));
        Vector3 maxPt = new Vector3(Mathf.Max(xs), Mathf.Max(ys), Mathf.Max(zs));

        //更新旋转
        cam.transform.rotation = transform.rotation;
        //相机的位置在近屏面的中心
        cam.transform.position = transform.TransformPoint(new Vector3((minPt.x + maxPt.x) * 0.5f, (minPt.y + maxPt.y) * 0.5f, minPt.z));
        cam.nearClipPlane = 0;// minPt.z;
        cam.farClipPlane = maxPt.z - minPt.z;
        cam.aspect = (maxPt.x - minPt.x) / (maxPt.y - minPt.y);
        cam.orthographicSize = (maxPt.y - minPt.y) * 0.5f;
    }

    private Camera createLightCamera() {
        GameObject go = new GameObject("Directional Light Camera");
        //相机的朝向与光源方向一致
        go.transform.rotation = transform.rotation;
        //go.hideFlags = HideFlags.DontSave;

        Camera cam = go.AddComponent<Camera>();
        cam.backgroundColor = Color.white;
        cam.clearFlags = CameraClearFlags.Color;
        //平行光没有透视关系,使用正交相机模拟平行接受光线
        cam.orthographic = true;
        cam.enabled = false;
        return cam;
    }
}

SMCaster.shader

Shader "LearningShadow/SMCaster"{
    SubShader{
		Fog{Mode Off}
		Lighting Off
		ColorMask 0
		//Cull Front

        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            v2f vert (appdata_base v){
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
				UNITY_TRANSFER_DEPTH(o.depth);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target{
				UNITY_OUTPUT_DEPTH(i.depth); 
            }
            ENDCG
        }
    }
}

SMShadow.shader

Shader "LearningShadow/SMShadow"{
    SubShader{
        Tags { "RenderType"="Opaque" }

        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
			#include "Lighting.cginc"

            struct v2f{
                float4 pos : SV_POSITION;
				float4 shadowPos : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				float3 worldNormal : TEXCOORD2;
            };

			float4x4 _gWorldToLight;

            v2f vert (appdata_base v){
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
				//计算顶点的世界坐标
				float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
				//计算顶点的灯光坐标系坐标
				o.shadowPos = mul(_gWorldToLight, worldPos);

				o.worldPos = worldPos;
				o.worldNormal = UnityObjectToWorldNormal(v.normal);
                return o;
            }

			sampler2D _gShadowTexture;
			float _gShadowBias;

            fixed4 frag (v2f i) : SV_Target{
				//构建灯光坐标系下的NDC坐标,作为UV进行深度采样
				float2 uv = i.shadowPos.xy / i.shadowPos.w;
				uv = uv * 0.5 + 0.5;
				float depth = SAMPLE_DEPTH_TEXTURE(_gShadowTexture, uv);
				#if defined(UNITY_REVERSED_Z)
				depth = 1 - depth;
				#endif

				//计算灯光坐标系下的片元深度
				float lightCoordDepth = i.shadowPos.z / i.shadowPos.w;
			#if defined(SHADER_TARGET_GLSL)
				lightCoordDepth = lightCoordDepth * 0.5 + 0.5;
			#elif defined(UNITY_REVERSED_Z)
				lightCoordDepth = 1 - lightCoordDepth;
			#endif
				//指向灯光方向
				float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));	
				//bias = tan(cos(n·l)),其中n为法向,l指向灯光
				//我们使用acos(n·l)来获取夹角,然后在求正交值
				float bias = _gShadowBias * tan(acos(saturate(dot(worldLightDir, i.worldNormal))));
				bias = clamp(bias, 0, 0.01);

				//偏移采样深度,避开shadow acne
				float shadow = (depth < lightCoordDepth - bias) ? 0 : 1;

				float3 worldN = normalize(i.worldNormal);
				float3 worldLight = normalize(UnityWorldSpaceLightDir(i.worldPos));
				float lambert = saturate(dot(worldN, worldLight));

				float3 col = UNITY_LIGHTMODEL_AMBIENT.xyz + shadow * lambert * _LightColor0.rgb;
				return fixed4(col, 1);
            }
            ENDCG
        }
    }
}

后记

这篇文章这是我个人学习的笔记,用于整理和记录知识点,要不然总有种还没学完的感觉。写文章比想象中要费时费力的多,许多小细节和知识点都要回去反复推敲,制图时,也总产生“这个知识点我真的理解对了吗?”这样的疑问。同时,它也让我对这项技术有了更深刻的理解,许多之前认为理解了的细节,也被发现根本没有理解,确实地为我带来了进步。
但是,文章总会有所纰漏,知识还是有所欠缺。所以有任何疑问,错误的地方也请提出,多多包涵 ٩(๑❛ᴗ❛๑)۶ 。

参考文献

  1. opengl-tutorial.Tutorial 16 : Shadow mapping
  2. Common Techniques to Improve Shadow Depth Maps
  3. Unity实时阴影实现——Shadow Mapping
  4. 深度的应用
  5. Unity手册:使用深度纹理
  6. 预定义的着色器预处理宏
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐