接上文:UnityShader17.1:ESM 阴影技术(上)
三、舞曲:一个大型 URP 项目中应用 ESM 的例子
3.1 ESM shadowmap 烘焙
因为平行光位置不会实时改变,因此可以对每个场景中的平行光离线烘焙对应的 shadowmap
3.1.1 光源空间正交矩阵生成
使用 Unity 自带的 Matrix4x4.Ortho 接口就 OK,然后就是
- 正交投影范围要能覆盖整个场景,并且不要太大,高度也是,这些可以值通过手动配置,也可以根据场景和光照配置来自动生成
- 其次由于光线不是垂直于地面照射的,但前面一步计算的正交投影 xy 平面是平行于场景地面的,并没有沿光线方向旋转,因此还需要进行一步斜切操作:即 x 和 y 都需要沿光照方向偏移一段距离,这段距离为
,其中
为当前点的垂直深度,
为每增加单位深度 x 和 y 轴的偏移量
- Matrix4x4.Ortho 生成的正交矩阵深度范围为 [-1, 1],而我们想要的范围是 [0, 1] 以便后面计算,因此还需要进行一个无视平台的转换
private static Matrix4x4 GetGPUProjMatrix(Matrix4x4 p)
{
p[2, 0] = p[2, 0] * (-0.5f) + p[3, 0] * 0.5f;
p[2, 1] = p[2, 1] * (-0.5f) + p[3, 1] * 0.5f;
p[2, 2] = p[2, 2] * (-0.5f) + p[3, 2] * 0.5f;
p[2, 3] = p[2, 3] * (-0.5f) + p[3, 3] * 0.5f;
return p;
}
public static void GetShadowMatrix(Vector2 size, Vector2 worldHeight, Vector2 worldOffset, Vector2 lightDirection, out Matrix4x4 m, out Matrix4x4 p)
{
m = Matrix4x4.TRS(new Vector3(-size.x * 0.5f + worldOffset.x, -size.y * 0.5f + worldOffset.y, 0.0f), Quaternion.Euler(-90, 0, 0), Vector3.one);
p = Matrix4x4.Ortho(-size.x * 0.5f, size.x * 0.5f, -size.y * 0.5f, size.y * 0.5f, worldHeight.x, worldHeight.y);
float z = Mathf.Sqrt(1 - lightDirection.x * lightDirection.x - lightDirection.y * lightDirection.y);
p[0, 2] = -(lightDirection.x / z) / size.x * 2.0f;
p[1, 2] = -(lightDirection.y / z) / size.y * 2.0f;
// ESM 的阴影是在DX11下烘焙的 这里将ESM_Matrix转换成了GL的矩阵格式
// GLES3.0 的绘制模式下没有做翻转,但是实际需要使用翻转后的矩阵
// GL.GetGPUProjectionMatrix 接口只会转DX到GL 遇到GL时直接不做处理
// 为了平台数据一致 直接统一转换
// p = GL.GetGPUProjectionMatrix(p, false);
p = GetGPUProjMatrix(p);
}
拿《仙境传说:爱如初见》当前最大的场景喵之国举例子:已知喵之国场景大小为 550x550:对应的高度范围为 [30, 100],无偏移
那么假设平行光方向刚好垂直于 xz 平面,即对于 lightDir 为 (0, 0),那么使用 Matrix4x4.Ortho 生成的矩阵如下:
然后考虑光源方向为 (-0.8, -0.3),即其光源单位向量在 x 的投影长度为 0.8,z 的投影长度为 0.3,那么其光源在 y 轴(即深度)方向投影长度就为
最后考虑到当前世界位置 (x, y, z),其每增加单位高度 ,那么它在 x 和 y 方向的单位偏移量就应该为
,纹理本身贴图长宽对应着场景 550m 的距离,Matrix4x4.Ortho 生成的 zNear 和 zFar 是 [-1, 1],因此最后其偏移量就为
如上推理最终带有斜切的投影矩阵如下:
此投影矩阵遵循 OpenGL 约定,即裁剪空间近平面处于 z = -1,而远平面处于 z = 1
还没有结束,这里还有一个坑:由于场景的高度范围为 [30, 100],它的地面高度并不为零,这就导致了哪怕是地面都会有 的偏移,因此在计算阴影中心时,需要将这部分偏移抵消掉:
foreach (Light light in GameObject.FindObjectsOfType<Light>())
{
if (light.isActiveAndEnabled && light.lightmapBakeType == LightmapBakeType.Mixed && light.shadows != LightShadows.None)
{
lightDirection = new Vector2(light.gameObject.transform.forward.x,
light.gameObject.transform.forward.z);
break;
}
}
if (!defaultLightmapData.Enable_ECSM_Dedicated)
{
worldSize = defaultLightmapData.worldSize2;
worldOffset = defaultLightmapData.worldOffset2;
ESM_C = defaultLightmapData.ESM_C2;
float zOffset = worldHeight.x;
//如果以玩家为中心
if (defaultLightmapData.Enable_PlayerOffset)
{
MPlayer player = MEntityMgr.singleton?.PlayerEntity;
if (player != null)
{
worldOffset = new Vector2(worldSize.x / 2 - player.Position.x,
worldSize.y / 2 - player.Position.z);
zOffset = player.Position.y;
}
}
float z = Mathf.Sqrt(1 - lightDirection.x * lightDirection.x - lightDirection.y * lightDirection.y);
worldOffset.x -= (lightDirection.x / z) * zOffset;
worldOffset.y -= (lightDirection.y / z) * zOffset;
}
这一部分会导致 V 矩阵的变化,其实本质上就是多一段往 (x, y) 方向的位移
上面的三个步骤决定了正交矩阵的最终形式,而对于配置文件可以每个场景给一个,其中除了正交矩阵参数的设置还有其它各项烘培的设置,包括后面最重要的 C 值:
3.1.2 依次绘制物体,写入深度
这块没有什么特别,只要注意剔除掉不绘制阴影的物体,以及部分 Alpha-Test 的物体就 OK:
注意此为离线烘焙方案,不是实时阴影的绘制方案,对于 ESM 实时阴影,URP 请直接使用 RenderFeature,并通过 FilterLayer 的方式绘制物体,代码略
foreach (var renderer in GameObject.FindObjectsOfType<Renderer>())
{
if (renderer.enabled && renderer.gameObject.activeInHierarchy && renderer.shadowCastingMode != ShadowCastingMode.Off)
{
//SetMat……
cmd.DrawRenderer(renderer, mat, 0, 0);
}
}
Graphics.ExecuteCommandBuffer(cmd);
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex.xyz);
o.texcoord = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
return o;
}
float4 frag (v2f i) : SV_Target
{
float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord);
clip(color.a - _Cutoff);
float depth = i.vertex.z / i.vertex.w;
return depth;
}
3.1.3 模糊与降采样
由于没有实时绘制要求,因此我们可以采用一个技巧:就是绘制 shadowmap 时给一个非常高的分辨率:8192 * 8192,然后最后保存 Texture 到硬盘的前一步进行降采样:
int maxTextureSize = Mathf.Min(SystemInfo.maxTextureSize, 8192);
Vector2Int renderTargetSize = new Vector2Int(maxTextureSize, maxTextureSize);
RenderTexture rt = RenderTexture.GetTemporary(renderTargetSize.x, renderTargetSize.y, 24, RenderTextureFormat.ARGBFloat);
CommandBuffer cmd = new CommandBuffer();
cmd.SetRenderTarget(rt.colorBuffer, rt.depthBuffer);
//绘制 shadowmap……
//blur 操作……
int downSample = s.FindProperty("downSample").intValue;
Vector2Int texSize = new Vector2Int(GetTexSize(worldSize.x * 8), GetTexSize(worldSize.y * 8));
int additionalDownSampleTimes = 0;
for (; maxTextureSize > texSize.x; maxTextureSize >>= 1, ++additionalDownSampleTimes);
//DownSample
RenderTexture fromRT = rt2;
RenderTexture toRT = null;
for (int i = 0; i < downSample + additionalDownSampleTimes; i++)
{
toRT = RenderTexture.GetTemporary(fromRT.width / 2, fromRT.height / 2, 0, RenderTextureFormat.ARGBFloat);
Graphics.Blit(fromRT, toRT, mat, 2);
RenderTexture.ReleaseTemporary(fromRT);
fromRT = toRT;
}
降采样时根据场景的大小来决定最终的降采样次数,如果是实时阴影出于性能考虑,可以不进行降采样或者仅降采样一次,一般而言最终分辨率 512-1024 就已非常足够
这一步最好在 Blur 卷积之后操作,以获得更佳的效果,而对于 Blur,采取的则是 UE Static ShadowMap ESM 方案:即对深度信息进行 Filtering :,其中
为 Gaussian Filter 对应的卷积图像
float normpdf(float x, float sigma)
{
return 0.39894 * exp(-0.5 * x * x / (sigma * sigma)) / sigma;
}
for (int j = 0; j <= kSize; ++j)
{
kernel[kSize + j] = kernel[kSize - j] = normpdf(float(j), sigma);
}
按照前文 2.2 改良版 ESM 的思路将其转到对数空间(logarithmic space):
后 Filter 的结果会是一个不会溢出的量:
float z0 = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, fragCoord.xy);
float w0 = kernel[kSize] * kernel[kSize] / (Z * Z);
float c = _ESM_C;
float sum = w0;
//read out the texels
for (int i = -kSize; i <= kSize; ++i)
{
for (int j = -kSize; j <= kSize; ++j)
{
if (i != 0 || j != 0)
{
float z = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, fragCoord.xy + float2(i, j) * _MainTex_TexelSize.xy);
float w = kernel[kSize + j] * kernel[kSize + i] / (Z * Z);
sum += w * exp(-c * (z - z0));
}
}
}
sum = z0 - log(sum) / c;
最麻烦的一步搞定
3.1.4 shadowmap 解编码与压缩
解编码很好理解:就是将浮点数拆散存储到多个通道当中,可以自己写,也可以参考 Unity 自带的方法 EncodeFloatRGBA 或 EncodeFloatRG:
inline float4 EncodeDepth(float v)
{
#ifdef HALF_DEPTH
float2 kEncodeMul = float2(1.0, 255.0);
float kEncodeBit = 1.0 / 255.0;
float2 enc = kEncodeMul * v;
enc = frac(enc);
enc.x -= enc.y * kEncodeBit;
return float4(enc.x, enc.y, 0, 1);
#else
return float4(v, 0, 0, 1);
#endif
}
需要注意的是,Encode 时需要确保深度 z 的范围为 [0, 1),否则可能会出现 C 值过高时严重漏光的问题:
HALF_DEPTH 关键字决定是否 16 位存储浮点数,可以对比一下效果,当然为了测试,静态物体接收阴影也是用的 ESM 而非 shadowmask:区别不是很大,所以最后还是用的 R8
然后就是配置支持是否针对各手机平台进行纹理压缩:
TextureImporter ti = (TextureImporter)TextureImporter.GetAtPath(path);
ti.mipmapEnabled = false;
var apf = ti.GetPlatformTextureSettings("Android");
var ipf = ti.GetPlatformTextureSettings("iPhone");
var wpf = ti.GetPlatformTextureSettings("Standalone");
apf.overridden = true;
ipf.overridden = true;
wpf.overridden = true;
if (isHighPrecision)
{
apf.format = IsCompression ? TextureImporterFormat.EAC_RG : TextureImporterFormat.RGB24;
ipf.format = IsCompression ? TextureImporterFormat.EAC_RG : TextureImporterFormat.RGB24;
wpf.format = IsCompression ? TextureImporterFormat.BC5 : TextureImporterFormat.RGB24;
}
else
{
apf.format = IsCompression ? TextureImporterFormat.EAC_R : TextureImporterFormat.R8;
ipf.format = IsCompression ? TextureImporterFormat.EAC_R : TextureImporterFormat.R8;
wpf.format = IsCompression ? TextureImporterFormat.BC4 : TextureImporterFormat.R8;
}
ti.SetPlatformTextureSettings(apf);
ti.SetPlatformTextureSettings(ipf);
ti.SetPlatformTextureSettings(wpf);
ti.SaveAndReimport();
搞定!最后生成的图是这样的:
3.2 漏光改良 VSM + ESM 的结合方案:EVSM
很可惜,改良版 ESM 其实并不能很好的解决漏光的问题,当 c 值过低时,得到一个质量相对不错的软阴影是没问题,但是对于横向比较薄又比较小的物体,其贴近地面的部分就几乎看不到影子:
产生这个问题的本质原因就是当前 pixel 距光源距离 d 与 shadowmap 深度距离 z 过近了从而在计算 时得到了一个虽然 <1 但远大于 0 的值(例如 0.9,阴影强度就只有实际 0.1 倍)
雪上加霜的是:你还并不能很简单的分辨当前 pixel 到底是如上这种贴近物体漏光的位置,还是阴影的边界,毕竟投影的边缘经过 Blur 后也可能出现 shadow 强度 = 0.1 的情况
那么这里就需要有种算法,把前者给判出来,并修正其阴影大小
而这正是 VSM 所能做的事情,VSM 通过方差来判断当前 pixel 是否在阴影边缘,并通过概率来获取软阴影效果,因此我们可以在计算 ESM 的同时计算 z 值的期望与方差,并在采样时算一下单边切比雪夫,就可以拿到当前 pixel 处于边界的概率
用人话讲,我们可以同时采用混合方案即 ESM + VSM 来解决 ESM 的漏光问题:主要思路有二:
- 同时独立计算 ESM VSM,并在最后求得 min(VSM, ESM) 得结果,这样做的好处就是:只有当且仅当 ESM 和 VSM 都漏光的 pixel,才会出现漏光现象,坏处你懂得
-
计算 ESM shadowmap 时同时计算方差,并在最后采样时,计算方差并通过标准正态分布查表法,来获得当前 pixel 位于阴影边界的概率,如果这个概率很低,但是计算得到的 ESM 指数结果却接近1,就可以认定当前 pixel 为漏光处,修正其阴影结果

方案①问题在于存在 ESM 和 VSM 都漏光的地方没法解决,不过可以换一个思路:其实并不是两种阴影都想要,只是想通过 VSM 去修正 ESM 漏光的部分,换句话说,VSM 只起到辅助修正的作用
依照着这个思路,当前项目若要完全舍弃 Shadowmask,对于大地图最终采取的方案是:
- 远景采用低分辨率单通道 ESM(512X, R8),C 值取 100-200,因为离视野较远,只需要有个“黑块”就行
- 近景采用高分辨率 ESM(1024X),分配置考虑
-
低中配不做 VSM,为了避免 ESM 漏光,采用对数 ESM 方案,设置 C 值为 150-220,必要时可以将最终阴影值重插值到一个范围(虽然我不推荐这么搞,但是效果也不算差,网上的主流思路)
result = min(result, saturate((lit - VSMBlurRange.x) / (VSMBlurRange.y - VSMBlurRange.x)));
-
低配使用单通道存储深度信息(R8),中配采用双通道(R8G8)
-
高配同时计算 VSM(512X, RGFloat),但是 VSM Shadowmap 并不绘制当前范围的所有物体,仅绘制用于修正 ESM 漏光部分的物件,例如场景中的小物件,以及部分树干等等,并且尽量避免绘制同一光源方向上有两个边缘交错的,会产生大方差的硬物件,最终计算 min(ESM, VSM),在这种情况下为了得到高质量的软阴影,ESM C 值不要给高,大概取 40-80 即可
-
搞定,考虑大世界无限大地图的话,ESM Shadowmap 可以按照场景切割生成多份,并且在玩家移动视角位置时流式加载对应位置的 ESM Shadowmap 即可
如果不完全舍弃 Shadowmask,就只生成一套 ESM 足够
EVSM 还有一个很好的实战情景,即家园玩法:一定区域内大多数建筑都是由玩家建造的,无法离线烘焙 shadowmask,但是建造完成后又不会去轻易改变位置,此时不考虑实时阴影的话 EVSM 就为唯一选择,可以仅在玩家完成建造完成、加载家园时动态烘焙一次 shadowmap,并实时采样
3.3 ESM 与 SHADOWMASK
ESM 阴影开关由全局的 Keyword 控制:不过考虑同一个 shader 中 keywords (变体)数量不能太多,因此对于场景中的物体,如果开启了 Unity 内置的 SHADOWS_SHADOWMASK,则默认开启 ESM_SHADOWMASK:
#pragma multi_compile __ SHADOWS_SHADOWMASK
#ifdef SHADOWS_SHADOWMASK
#define ESM_SHADOWMASK
#endif
同理,如果物体接受烘焙阴影,则采样 shadowmask,否则采样 ESM shadowmap:
#if !defined(LIGHTMAP_ON)
if defined(ESM_SHADOWMASK)
#define GET_SCENE_SHADOW(vi,isCreature) GetESMShadow(vi.worldPos.xyz, isCreature)
#else
//……
#endif
#else
#define GET_SCENE_SHADOW(vi,isCreature) 1
#endif
#if defined(LIGHTMAP_ON)
//有LIGHTMAP的不用ESM_SHADOWMASK
#if defined(SHADOWS_SHADOWMASK)
atten = min(atten, SampleShadowMask(i.ambientOrLightmapUV.xy).r);
#endif
#endif
而对于动态物体(例如怪物和角色),其自阴影需要实时计算
#pragma multi_compile __ ESM_SHADOWMASK
//-------------------------------------------------
ApplyShadow(col, GET_SCENE_SHADOW(i,true));
3.4 移动平台兼容
无非注意两点,一个是 Reserve-Z,在写入深度时,顶点着色器计算 o.vertex = TransformObjectToHClip(v.vertex.xyz) 并且输出 vertex 是标记 SV_POSITION 的,由于案例在构造矩阵时统一了 GL 和 DX 平台,因此最终写入深度 z 时,需要移除 Unity 帮你做的 Reserve-Z 操作:
float4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.texcoord);
//clip(color.a);
clip(color.a - _Cutoff);
float depth = 1 - i.vertex.z;
#if UNITY_REVERSED_Z
depth = 1 - depth;
#endif
其次就是部分低配机并不支持 RGFloat 这样高精度纹理的采样,对于这些机器,可以不启用高精度的 VSM,或者将 RGFloat 的值拆到一个 RGBAHalf 中去
inline float4 EncodeVSMDepth(float4 col)
{
#ifdef HALF_DEPTH
col.x = min(col.x, 0.9999999);
float2 kEncodeMul = float2(1.0, 255.0);
float kEncodeBit = 1.0 / 255.0;
float2 enc = kEncodeMul * col.x;
enc = frac(enc);
enc.x -= enc.y * kEncodeBit;
return float4(enc.x, col.y, enc.y, 0);
#else
return float4(col.xy, 0, 0);
#endif
}
3.5 动态物体自阴影
关于 C 值的选择,还是拿静态物体进行测试:
不过由于实际只有动态物体才需要用到 ESM,因此精度要求其实会更低,配置中有两套 C 值也是因此:我们希望人物在阴影区中过度的更平滑一点,不过这个做法不完全正确,因为 shadowmap 烘焙时也用到了 C,相对于两套 shadowmap 的思路,我们更希望能得到一个美术可接受的结果,而不是逻辑上完全正确:
实际计算时,由于 ESM 和 PCF 的思想是不冲突的,如果性能允许最后依然可以多次采样求个平均,而对于阴影模糊范围,可以理解为我们允许把最终的函数结果 由 [0, 1] 映射到一个更小的区间内
inline half GetESMShadow(half3 worldPos,bool isCreature)
{
bool useCreatureDedicatedValue = (isCreature && _ESMShadowParams_Creature.y != 0 );
float ESM_C = useCreatureDedicatedValue ? _ESMShadowParams_Creature.x:_ESMShadowParams.x;
float ESMBias = _ESMShadowParams.y;
float2 ESMBlurRange = useCreatureDedicatedValue ? _ESMShadowParams_Creature.zw:_ESMShadowParams.zw;
float4 shadowUV = mul(_ESMShadowMatrix, float4(worldPos.xyz, 1));
float2 texUV = shadowUV * 0.5 + 0.5;
float2 texUV0 = (floor(texUV * _ESMShadowMap_TexelSize.zw - 0.5) + 0.5) * _ESMShadowMap_TexelSize.xy;
float2 texUV1 = texUV0 + _ESMShadowMap_TexelSize.xy * float2(1, 0);
float2 texUV2 = texUV0 + _ESMShadowMap_TexelSize.xy * float2(0, 1);
float2 texUV3 = texUV0 + _ESMShadowMap_TexelSize.xy * float2(1, 1);
float2 w = (texUV - texUV0) / _ESMShadowMap_TexelSize.xy;
float depth0 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV0, 0)));
float depth1 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV1, 0)));
float depth2 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV2, 0)));
float depth3 = exp(-ESM_C * DecodeDepth(SAMPLE_TEXTURE2D_LOD(_ESMShadowMap, sampler_point_clamp, texUV3, 0)));
float depth = lerp(lerp(depth0, depth1, w.x), lerp(depth2, depth3, w.x), w.y);
float result = saturate(exp((1 - shadowUV.z / shadowUV.w) * 0.5 * ESM_C) * depth);
return saturate((result - ESMBlurRange.x) / (ESMBlurRange.y - ESMBlurRange.x));
}
对于最终的方案决策:考虑到大多数静态物体(例如地形等)阴影采样的 shadowmask 而非 ESM,效果如下,其中草的自阴影来自于 ESM shadowmap 采样:
四、终曲:shadowmap 的其它应用场景
4.1 URP 实时阴影
这一块可以直接参考源码 MainLightShadowCasterPass.cs 这个 pass,思路就是运行时动态生成 shadowmap,投影矩阵由接口 cullResults.ComputeDirectionalShadowMatricesAndCullingPrimitives 生成,进一步查看源码可以看出该矩阵对应正交投影空间大小主要由级联阴影设置、场景物件包围盒以及 URP 各项配置决定,由于第二点的原因,这个正交投影覆盖的区域往往不会小,这就导致阴影精度很低,对于大场景阴影质量略差
开启 CSM 可以缓解这个问题,会有一定性能要求
4.2 高质量单角色投影
对于部分展示角色的场景(例如登陆界面,衣橱界面等),可以特设投影矩阵:由于这种情况下往往只需要场景中央的人物/主角产生阴影,因此可以让光源投影范围大小刚刚好覆盖人物包围盒,这样就能保证阴影的最终品质,这有一个简单的参考
当然还可以再简单一些,就是直接再画一遍角色作为阴影,整体压扁再做个斜切,这样连 shadowmap 都不用,前提是要保证你的投影目标是一个绝对的平面
4.3 参考文章
方差阴影(variance shadow map)的两种实现 - 知乎
Unreal Engine UE4 静态阴影实现 Static ShadowMap ESM,改进ESM(log space 下做模糊) - 知乎