前言:本节实现各种阴影,包括平行光阴影,聚光灯和点光源阴影,软阴影。
平行光阴影
1.实现原理
ShadowMap的实现主要分为两个pass
- 第一个pass:在光源位置创建一个深度摄像机,以光源位置作为视点,将每个像素点的深度值(z-depth)也就是距离光源最近的对象距离记录在 Z-buffer 中,生成深度图
- 第二个pass:从正常摄像机渲染场景,将每个fragment 到光源的距离和 Shadow Map 中保存的深度值进行比较,如果大于后者则说明被其他物体遮挡处于阴影之中

2.编写代码注意点
- 在顶点输出数据结构中添加
SHADOW_COORDS(index)(声明阴影纹理坐标),index为可用的空闲寄存器序号,如SHADOW_COORDS(4)相当于float4 _ShadowCoord : TEXCOORD4 - 在顶点着色器使用
TRANSFER_SHADOW(o)宏。它会根据当前的光照模式和平台特性,计算出正确的阴影采样坐标并赋值 - 在片元着色器这使用
SHADOW_ATTENUATION(i)或UNITY_LIGHT_ATTENUATION(atten, i, worldPos)宏。这些宏会使用之前计算的_ShadowCoord对阴影贴图进行采样,返回一个阴影衰减因子(0 表示完全在阴影中,1 表示完全受光) - 为了让
SHADOW_COORDS和相关宏正常工作,你通常需要:引入头文件(#include "AutoLight.cginc")、LightMode通常需要设置为"ForwardBase"或"ForwardAdd"、Pass 中添加#pragma multi_compile_fwdbase(对于主平行光)或#pragma multi_compile_fwdadd,以确保 Unity 生成处理阴影所需的变体、添加fallback "specular"(建议添加)。
3.代码实现
步骤1:生成深度图
Shader "MyCustom/CastShadow"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
};
struct v2f
{
float2 depth : TEXCOORD0;
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.depth = o.vertex.zw;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// 执行了透视除法。得到的 depth 值是归一化设备坐标 (NDC) 中的 Z 值
float depth = i.depth.x / i.depth.y;
// 平台兼容性处理:统一映射到 [0, 1]
#if defined (SHADER_TARGET_GLSL)
depth = depth * 0.5 + 0.5; // (-1, 1) -> (0, 1)
#elif defined (UNITY_REVERSED_Z)
depth = 1 - depth; // (1, 0) -> (0, 1)
#endif
// 编码为 RGBA 颜色
float4 col = EncodeFloatRGBA(depth);
return col;
}
ENDCG
}
}
}
步骤2:生成阴影
Shader "MyCustom/ShadowMap"
{
SubShader
{
Pass
{
NAME "DirectionalShadow"
Blend DstColor Zero
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma shader_feature _PCFSOFTSHADOW
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 shadowCoord : TEXCOORD0;
float4 worldPos : TEXCOORD1;
float3 worldNormal : TEXCOORD2;
float4 pos : SV_POSITION;
};
// 步骤1生成的图
sampler2D _MyCustomShadowMap;
float4 _MyCustomShadowMap_TexelSize;
// 灯光视图投影矩阵,将世界空间转换到灯光的视界空间
// 外部C#计算:GL.GetGPUProjectionMatrix(cam.projectionMatrix, false) * cam. worldToCameralatrix
// cam为平行光上的摄像机
float4x4 _MyCustomLightSpaceMatrix;
float _MyCustomShadowStrength;
float _pcfSoftShadow(float depth, float2 uv, float bias)
{
float shadow = 0;
// 3x3 网格采样
for (int x = -1; x <= 1; ++x)
{
for (int y = -1; y <= 1; ++y)
{
float2 _uv = uv + float2(x, y) * _MyCustomShadowMap_TexelSize.xy;
float4 col = tex2D(_MyCustomShadowMap, _uv);
float sampleDepth = DecodeFloatRGBA(col);
// 累加每个采样点的阴影结果
shadow += sampleDepth + bias < depth ? _MyCustomShadowStrength : 1;
}
}
// 取平均值,产生柔和过渡
return shadow / 9;
}
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
// 1. 转换到世界空间
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
// 2. 使用预计算的灯光视图投影矩阵,将世界坐标转换到灯光裁剪空间
o.shadowCoord = mul(_MyCustomLightSpaceMatrix, worldPos);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = worldPos;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// 透视除法,得到 NDC 坐标 (-1 到 1)
float2 uv = i.shadowCoord.xy / i.shadowCoord.w;
uv = uv * 0.5 + 0.5; // (-1, 1) -> (0, 1)
// 从世界空间拿到的深度值
float depth = i.shadowCoord.z / i.shadowCoord.w;
#if defined (SHADER_TARGET_GLSL)
depth = depth * 0.5 + 0.5;
#elif defined (UNITY_REVERSED_Z)
depth = 1 - depth;
#endif
// 计算 Bias(偏移量)
// 由于阴影贴图分辨率有限,表面自遮挡会导致错误的阴影条纹
// 根据法线与光线的夹角动态调整偏移。当表面与光线接近平行时(ndotl 接近 0),需要更大的 Bias 来避免误判
float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
float ndotl = dot(normalize(i.worldNormal), lightDir);
float bias = max(0.05 * (1.0 - ndotl), 0.005);
#ifdef _PCFSOFTSHADOW
// 模式一:软阴影(PCF Soft Shadow)
float shadow = _pcfSoftShadow(depth, uv, bias);
#else
// 模式二:硬阴影(Hard Shadow)
float4 col = tex2D(_MyCustomShadowMap, uv);
// 解码阴影贴图中存储的深度值
float sampleDepth = DecodeFloatRGBA(col);
// 如果 贴图深度 + Bias < 当前像素深度,说明当前像素在遮挡物后面,处于阴影中
float shadow = sampleDepth + bias < depth ? _MyCustomShadowStrength : 1;
#endif
// 避免采样到错误的边缘数据
if (uv.x > 1 || uv.y > 1 || uv.x < 0 || uv.y < 0)
shadow = 1;
return shadow;
}
ENDCG
}
}
}
Bias优化
1.问题成因
在实时渲染中,阴影会出现条纹。阴影条纹问题通常被称为阴影痤疮(Shadow Acne)或自阴影错误。这是基于阴影贴图(Shadow Mapping)技术的一个经典缺陷。阴影条纹表现为模型表面出现不规则的黑白相间条纹或斑点,仿佛物体自己在给自己投射阴影。其根本原因在于深度比较时的精度误差,具体由以下三个因素共同导致:
-
离散采样与分辨率限制:阴影贴图是一张有限分辨率的 2D 纹理(如 1024x1024 或 2048x2048)。当场景中的几何体非常复杂或距离光源较远时,阴影贴图中的单个像素(Texel)可能覆盖较大的世界空间区域。这意味着阴影贴图记录的深度值是离散的、近似化的,而非连续精确的。
-
浮点数精度误差:GPU 在进行深度计算时使用浮点数。当光线以极低的角度( grazing angle )照射表面时,表面在灯光视角下的深度变化非常剧烈。由于浮点数的精度限制,渲染当前像素时计算出的深度值(Receiver Depth)与阴影贴图中存储的深度值(Occluder Depth,即同一表面之前被记录的值)之间会产生微小的数值偏差。
-
深度比较逻辑缺陷:理论上,对于一个平面,
currentDepth应该等于shadowMapDepth。但由于上述的插值误差和精度问题,有时会出现currentDepth略大于shadowMapDepth的情况。这导致本应受光的表面像素被误判为"位于遮挡物之后",从而错误地渲染为阴影。这种误判在表面上随机分布,形成了条纹状的视觉瑕疵。
2.引入 Bias(偏移量)的解决方案
为了解决 Shadow Acne,最通用且有效的方法是引入 Depth Bias(深度偏移)。其核心思想是:在生成阴影贴图或进行深度比较时,人为地将遮挡物的深度值"推远"一点,或者将接收者的深度判定阈值放宽,确保表面不会误判自己为遮挡物。这样,当再次比较同一表面的深度时,currentDepth 就不会轻易超过 shadowMapDepth + bias,从而消除自阴影。
3.普通Bias的弊端及引入斜率缩放偏移(Normal Bias)
对所有像素应用一个固定的深度偏移值,无法适应不同角度的表面。角度过大或过小依然会造成问题。Normal Bias根据表面法线与光线方向的夹角动态调整偏移量,能更精确地消除条纹。

GD = FG * tanθ
FG = (视锥体大小/shadow map大小)
cosθ = dot(n,l) / (\|n\| \*\|l\|) = dot(n,l) 当n,l为单位向量
tanθ = sqrt(1 - cosθ * cosθ)
补充:当θ过大时,计算出的GD过大,使用法线方向的GH代替(GH平行n,H为CD上的垂足), 则
GH = GD * sinθ (基于法线方向的bias)
聚光灯阴影
在shader实现上没有什么区别,在灯光空间矩阵计算略有不同。(注:cam为光源绑定的摄像机,Shader为传入的阴影shader)
- 平行光C#灯光空间转换:
// 将物体从世界坐标转到当前光源坐标
Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, false);
Shader.SetGlobalMatrix("_MyCustomLightSpaceMatrix", projectionMatrix * cam.worldToCameraMatrix);
- 聚光灯C#灯光空间转换:
// 矫正shadowmap上下颠倒
nMat = new Matrix4x4();
nMat.SetRow(0, new Vector4(1f, 0f, 0f, 0f));
nMat.SetRow(1, new Vector4(0f, -1f, 0f, 0f));
nMat.SetRow(2, new Vector4(0f, 0f, 1f, 0f));
nMat.SetRow(3, new Vector4(0f, 0f, 0f, 1f));
// 将物体从世界坐标转到当前光源坐标
Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, false);
Shader.SetGlobalMatrix("_MyCustomLightSpaceMatrix", projectionMatrix * nMat * cam.worldToCameraMatrix);
// 传入摄像机空间,在这个空间中计算normal bias
Shader.SetGlobalMatrix("_MyCustomWorldToCameraMatrix", cam.worldToCameraMatrix);
// 将视锥体大小和shadow map大小传入计算normal bias
Shader.SetGlobalFloat("_MyCustomFrustumSize", projectionMatrix.m11);
Shader.SetGlobalFloat("_MyCustomShadowMapSize", shadowMapSize);
点光源阴影
平行光、聚光灯都只用单张2D纹理存储阴影,而点光源是全向360°覆盖的光源,无法用单一2D贴图完整记录所有方向的深度,必须使用立方体贴图(Cube Map)。
立方体贴图由6张独立的2D面组成,分别对应点光源的±X、±Y、±Z六个方向,每个面都有自己的视锥体,共同包裹住整个点光源的球形照射范围。
深度值为物体在灯光空间位置到最远截面的距离
cs
// 记录当前摄像机
Quaternion currentQuat = cam.transform.rotation;
float currentFOV = cam.fieldOfView;
// 1: 在当前点光空间生成shadow cube
// 绘制场景
cam.transform.rotation = Quaternion.identity;
// +x
cam.transform.eulerAngles = new Vector3(0, 90, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.PositiveX);
// -z
cam.transform.eulerAngles = new Vector3(0, 0, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.NegativeZ);
// -x
cam.transform.eulerAngles = new Vector3(0, 270, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.NegativeX);
// +z
cam.transform.eulerAngles = new Vector3(0, 180, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.PositiveZ);
// +y
cam.transform.eulerAngles = new Vector3(-90, 0, 0);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.PositiveY);
// -y
cam.transform.eulerAngles = new Vector3(90, 0, 0);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.NegativeY);
// 保存当前的cubemap
shadowCube.Apply();
// 2: 将shadow cube计算需要的数据传递给custom shader
Shader.SetGlobalTexture("_MyCustomShadowCube", shadowCube);
// 绘制完毕之后,camera赋回初值
cam.transform.rotation = currentQuat;
cam.fieldOfView = currentFOV;
// 使用摄像机最远截面计算深度值
Shader.SetGlobalFloat("_MyCustomFarPlane", cam.farClipPlane);
锥体光源的Normal Bias

PCSS软阴影
1.原理及流程介绍
之前的PCF软阴影通过采样周围像素的阴影平均计算实现模糊软阴影,而PCSS软阴影是模拟真实世界中"接触阴影硬、远处阴影软"的特性。
W_Light: 光源面积尺寸
D_Blocker:遮挡物的深度
D_Receiver:被投影物的深度

Blocker Search:通过多重采样,计算出平均遮挡深度 D_Blocker
Penumbra Size:计算圆盘半径大小 W_Penumbra
Filtering:通过多重采样,计算出平均 Visibility(实际上就是调用PCF算法)
2.代码实现
cs
// --------步骤一辅助函数--------
// 1.寻找阻挡物
void _findBlocker(float depth, _ShadowCoord coord, float bias,float2 rotation, out float avgBlockerDepth, out float numBlockers)
{
float blockerSum = 0;
// (depth - _MyCustomNearPlane) / depth:这是一个归一化的深度因子。随着 depth(当前像素深度)增加,这个比值趋近于 1;当 depth 接近 _MyCustomNearPlane 时,比值趋近于 0。这意味着距离光源越远,搜索范围越大。
float sampleSize = (depth - _MyCustomNearPlane) / depth * _ShadowSoftness / _MyCustomShadowMapSize;
// 使用预定义的 64 个泊松分布偏移量。相比规则网格,泊松分布能更好地避免采样别名效应,使阴影边缘更自然。
for (int i = 0; i < 64; ++i)
{
#ifdef _MYCUSTOM_POINTLIGHT
// 点光源的坐标可能是 3D 方向向量或立方体贴图 UV,因此只旋转 XY 分量,保持 Z/W 分量不变
_ShadowCoord _coord = coord + float3(_rotate(PoissonOffsets[i] * sampleSize, rotation), 0);
#else
_ShadowCoord _coord = coord + _rotate(PoissonOffsets[i] * sampleSize, rotation);
#endif
float sampleDepth = 0;
if (_sampleDepth(depth, _coord, bias, sampleDepth))
{
// 累加所有遮挡物的深度值并返回
blockerSum += sampleDepth;
// 统计遮挡物的数量并返回
numBlockers += 1;
}
}
avgBlockerDepth = blockerSum / numBlockers;
}
// 返回是否是阻挡物并返回其深度值
bool _sampleDepth(float depth, _ShadowCoord coord, float bias, out float sampleDepth)
{
// 点光采集ShadowCube,其他光采集ShadowMap
#ifdef _MYCUSTOM_POINTLIGHT
float4 col = texCUBE(_MyCustomShadowCube, normalize(coord));
sampleDepth = DecodeFloatRGBA(col);
sampleDepth = pow(sampleDepth, 2.2);
#else
float4 col = tex2D(_MyCustomShadowMap, coord);
sampleDepth = DecodeFloatRGBA(col);
#endif
return sampleDepth + bias < depth;
}
// ----------------
// // --------步骤三辅助函数--------
float _pcfFilter(float depth, _ShadowCoord coord, float bias, float penumbra, float2 rotation)
{
float shadow = 0;
for (int i = 0; i < 64; ++i)
{
#ifdef _MYCUSTOM_POINTLIGHT
_ShadowCoord _coord = coord + float3(_rotate(PoissonOffsets[i] * penumbra, rotation), 0);
#else
_ShadowCoord _coord = coord + _rotate(PoissonOffsets[i] * penumbra, rotation);
#endif
shadow += _shadowValue(depth, _coord, bias);
}
return shadow / 64;
}
// 如果是阻挡物,返回阴影强度
float _shadowValue(float depth, _ShadowCoord coord, float bias)
{
float sampleDepth = 0;
float shadow = _sampleDepth(depth, coord, bias, sampleDepth) ? _MyCustomShadowStrength : 0;
return shadow;
}
// ----------------
float _pcssSoftShadow(float depth, _ShadowCoord coord, float bias, float2 texcoord)
{
// PCSS 需要进行大量采样(这里用了64次)。如果采样图案是固定的网格,会在阴影边缘产生明显的周期性条纹(Banding Artifacts)。
// 使用 蓝噪声纹理 (Blue Noise)。蓝噪声具有高频分布均匀且低频能量少的特性比纯随机噪声更平滑,比规则网格更自然。
// 为每个像素生成一个随机的旋转角度 rotation,在后续采样时旋转采样图案,将固定的条纹误差打散成高频噪声,视觉上更柔和。
float random = tex2D(_ShadowBlueNoise, texcoord * _ShadowBlueNoiseRepeat * _ScreenParams.xy).r;
random = random * 2 - 1; // (0, 1) -> (-1, 1)
float rotationAngle = random * 3.1415926;
float2 rotation = float2(cos(rotationAngle), sin(rotationAngle));
// Step 1. 求得遮挡物的平均深度
float avgBlockerDepth = 0;
float numBlockers = 0;
_findBlocker(depth, coord, bias, rotation, avgBlockerDepth, numBlockers);
if (numBlockers < 1)
{
// 该区域不在阴影内
return 0;
}
// Step 2. 求得半影大小
float penumbra = (depth - avgBlockerDepth) / avgBlockerDepth * _ShadowSoftness / _MyCustomShadowMapSize;
// Step 3. pcf filter计算阴影
float shadow = _pcfFilter(depth, coord, bias, penumbra, rotation);
return shadow;
}