Unity图形学习笔记——阴影篇01:Shadow Mapping
前言Shadow Mapping是在即时渲染中最为广泛使用的一种阴影技术,在如今很多高级的阴影技术例如Percentage Closer Soft Shadow中都能见到它的影子。学习原理和造车轮是为了更好的控制车辆,而不是为了去造车轮。抱着这个理念,在此记录下笔者在Unity中学习和重建渲染技术的过程和发现。步骤Shadow Mapping主要有三大步骤:在光源位置渲染场景,获取光源的深度贴图;
前言
Shadow Mapping是在即时渲染中最为广泛使用的一种阴影技术,在如今很多高级的阴影技术例如Percentage Closer Soft Shadow中都能见到它的影子。掌握这项技术将为学习之后的高级阴影技术打下坚实的基础。
算法原理
Shadow Mapping的核心思想:让我们先考虑一个理想情况,光线没有折射和反射,那么光沿着直线传播,遇到物体会被阻挡。当我们沿着光源来观察场景时,我们会看到所有的直接光照,反之,所有没看见的地方都没有被直接光照照射。
所以,我们在光源位置记录一张深度图(即Shadow Map),这张图记录了所有光线能够到达的位置,然后再渲染每个片元时,和深度图的深度做对比,即可知道这个片元是否能被光线照射到。
实现框架
落实到具体实现阶段,Shadow Mapping的主要步骤如下:
- 在光源位置渲染场景,获取Shadow Map;
- 计算世界转灯光坐标系的矩阵记M,和Shadow Map一起送入着色器;
- 相机正常渲染场景,在片元着色阶段,使用矩阵M计算片元在灯光坐标系下的深度,记Dp;
- 采样该片元灯光坐标系下Shadow Map记录的深度,记Ds;
- 如果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
}
}
}
后记
这篇文章这是我个人学习的笔记,用于整理和记录知识点,要不然总有种还没学完的感觉。写文章比想象中要费时费力的多,许多小细节和知识点都要回去反复推敲,制图时,也总产生“这个知识点我真的理解对了吗?”这样的疑问。同时,它也让我对这项技术有了更深刻的理解,许多之前认为理解了的细节,也被发现根本没有理解,确实地为我带来了进步。
但是,文章总会有所纰漏,知识还是有所欠缺。所以有任何疑问,错误的地方也请提出,多多包涵 ٩(๑❛ᴗ❛๑)۶ 。
参考文献
更多推荐
所有评论(0)