前面已经写过在 Unity 和 OpenGL 上实现最简单阴影的文章了:
虽然现在有很多更先进的计算阴影的方案,但是不得不说 ShadowMapping 还是非常实用的,极大多数主流游戏目前也都还是基于 ShadowMapping 的各种变种来做阴影,特别是实时阴影
基本原理也非常简单:即以光源为相机,朝向光源方向渲染一个仅深度的 shadowmap,随后在正常的渲染流程中,将需要着色的片段变换到光源空间中,再将其深度与 shadowmap 中的深度值进行比较,以确定当前片段是否有被遮挡
Unity 的屏幕空间阴影技术原理也差不多,就是多进行了一步根据摄像机的深度图重建世界空间坐标的过程,这样就可以将阴影的计算放到后面,以避免去计算那些已经被遮挡的物体表面的阴影
但是考虑到 shadowmap 的精度问题,用上述方法得到的阴影必然是一个硬阴影,而且必然会有很严重的锯齿感,后面衍生的很多阴影算法:例如 PCF、ESM、VSM、CSM 都是为了改进和优化这个问题
为了后面更好的建模,定义
那么可以得到
其中 即光照对该位置的贡献比例,0 就意味着完全在阴影当中
想要实现软阴影,解决锯齿问题,那就必然需要进行模糊,也就是对结果进行滤波,PCF 正是这么做的:即利用多重采样和插值函数,并将插值的结果作为 的值
这个方法非常好理解,也很朴素:既然采样一个点计算阴影不够,我就把周围的点都给你采样一遍,然后对结果求个平均,不过这有个很严重的问题:多重采样非常影响性能,单次采样的开销取决于 GPU 是否支持 Pre-Fetch Texture 和这个采样是否是 Simple Texturing(不依赖其他采样结果的采样),但无论单次采样效率如何,多重采样在算法层面效率就低下,其总体复杂度是 ,其中 k 是采样次数,n 是片段数量,如果想要一个不错的软阴影效果,k 不会小
PCF 本质上就是一个对结果进行卷积的过程,写成通式就是:
考虑到我们或许可以进行预滤波(pre-filtering),也就是 ,后者用人话讲就是只需要在 shadow-prepass 阶段对 shadowmap 进行滤波,而无需在光照计算时阶段进行重复的采样和平均就可以得到最终的软阴影效果,这岂不是完美,但很可惜,不行!因为 是一个阶跃函数,并不满足上面的方程
那么我们在 上面做文章,让他稍微变换一下,满足相对正确效果的同时,又不再是一个阶跃函数,不就可以了嘛,没错,VSM 以及 ESM 正是这个思想!
在 ESM 中,,其中 c 为一个可以指定的常量,这个函数形式有以下几个特点:
这样,我们就得到了 ESM 的一个大致流程:
只要是和 shadowmap 相关的技术都需要注意这个问题,本质上是因为 shadowmap 分辨率不够,其纹素并不能和场景中的坐标一一对应,特别是离光源远的位置,更会出现多个 共享一个 ,从而导致得出错误的 的情况
ESM 相关的论文中也是有提到的:
其中红点为相机采样点,而蓝色为生成 shadowmap 时的光照采样点,可以看出在采样点 x 时,得到了一个 远大于 的结果(其中 仅为 shadowmap 深度) ,这个结果无论如何都是不对的,此时计算 得到的值,会远大于1,而事实上它位于被遮挡的边界,得出的结果应该在 0.5 附近才是正确的
对于上述的情况,我们可以把它揪出来,如果发现一个离谱的 超过了一个阈值 ,那我们就姑且可以确定它出现了上述的情况,此时我们对这个点单独去做 PCF 其实是可以接受的,当然这种情况往往只会出现在多重阴影的边缘,除此之外对 Shadowmap 做预滤波也可以有有效缓解,因此实际运用 ESM 时倒是可以直接忽略这个问题(还有其它更高级的解决方案,不过由于性能及其复杂程度,可以不用太过深究,如果有兴趣可以直接参考论文)
ESM 倒不会像普通 shadowmap 那样出现大规模的阴影粉刺(Shadow acne),因为对于极小的 误差,指数衰减没有那么明显,故不需要考虑 Depth Bias
对于 ESM:
②的漏光可以说是一个 BUG,但是它在某些情况下有可以作为特性被利用:一个经典的例子就是云层阴影,毕竟云的特性就是不完全遮光
如何解决 c 值过小时的漏光问题呢?很好办 c 值取大一点就好了嘛,那如何解决 c 值过大后 float 存储精度要求高的问题呢?很好办 c 值取小一点就好了嘛,那就另谋出路,看看能不能不存储指数结果,而是其它?
还真有:
这个改良版的 ESM 大致思路就是:既然我 shadowmap 存储 会出现值过大的情况,那么索性就不存这个指数了,直接存 ,但也因此我 blur 的部分就要重新考量:
考虑到卷积部分,其中 来自于高斯过滤中的 kernel,可以得到
也就是说,在进行过滤的时候,还是要对指数进行过滤(加权平均),只不过是结果转到 log
原文用的是一个更麻烦的等价写法,不用 而是转写为 ,这可以让指数计算时值尽量小,看上去可以提高精度,但是不采取这个方案也没有太大关系,精度最后测试下来都差不多
既然需要对指数结果进行过滤,而你 shadowmap 存储的是 并非指数结果,因此对于这张贴图不能无脑用硬件双线性插值,而是要先点采样手动插值,转指数后再双线性插值,然后拿这个结果套用回 ESM
总结下改良后的流程就是:
搞定,其实本质就是换了个公式,以避免 shadowmap 中存储的值过大,在这种情况下你的 C 值就可以取 150、200 甚至更高
因为平行光位置不会实时改变,因此可以对每个场景中的平行光离线烘焙对应的 shadowmap
使用 Unity 自带的 Matrix4x4.Ortho 接口就 OK,然后就是
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);
}
其中上面的三个步骤决定了正交矩阵的最终形式,而对于配置文件可以每个场景给一个,其中除了正交矩阵参数的设置还有其它各项烘培的设置,包括后面最重要的 C 值:
这块没有什么特别,只要注意剔除掉不绘制阴影的物体,以及部分 Alpha-Test 的物体就 OK:
foreach (var renderer in GameObject.FindObjectsOfType())
{
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;
}
由于没有实时绘制要求,因此我们可以采用一个技巧:就是绘制 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;
}
降采样时根据场景的大小来决定最终的降采样次数(决定 shadowmap 最终大小,一般场景最终大小都被限制到了 512 或 1024) ,当然支持配置额外的将采样次数,以对 shadowmap 进行进一步压缩以节省内存
在降采样之前,做好 blur 操作,前面提到过由于存储的不是指数结果,因此不好直接进行双线性过滤,不过没关系,暴力点采样求平均也是没问题的:
RenderTexture rt2 = RenderTexture.GetTemporary(renderTargetSize.x, renderTargetSize.y, 0, RenderTextureFormat.ARGBFloat);
Graphics.Blit(rt, rt2, mat, 3);
RenderTexture.ReleaseTemporary(rt);
这里的 shader 省略:就是最简单的 Kernel 矩阵模糊,公式参考前面一章改良 ESM
解编码很好理解:就是将浮点数拆散存储到多个通道当中,可以自己写,也可以参考 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
}
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();
搞定!最后生成的图是这样的:
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));
关于 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 采样:
这一块可以直接参考源码 MainLightShadowCasterPass.cs 这个 pass,思路就是运行时动态生成 shadowmap,投影矩阵由接口 cullResults.ComputeDirectionalShadowMatricesAndCullingPrimitives 生成,进一步查看源码可以看出该矩阵对应正交投影空间大小主要由级联阴影设置、场景物件包围盒以及 URP 各项配置决定,由于第二点的原因,这个正交投影覆盖的区域往往不会小,这就导致阴影精度很低,对于大场景阴影质量略差
开启 CSM 可以缓解这个问题,会有一定性能要求
对于部分展示角色的场景(例如登陆界面,衣橱界面等),可以特设投影矩阵:由于这种情况下往往只需要场景中央的人物/主角产生阴影,因此可以让光源投影范围大小刚刚好覆盖人物包围盒,这样就能保证阴影的最终品质,这有一个简单的参考
当然还可以再简单一些,就是直接再画一遍角色作为阴影,整体压扁再做个斜切,这样连 shadowmap 都不用,前提是要保证你的投影目标是一个绝对的平面
未完待续