ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Shadow Map实时阴影原理与三维GIS工程落地实践

Shadow Map实时阴影原理与三维GIS工程落地实践 实时阴影这件事几乎是每个做三维可视化的团队绕不过去的一道坎。不管是做游戏引擎、数字孪生还是三维 GIS 场景只要场景里出现了建筑、地形、树木、车辆这些物体用户第一眼就会问影子呢没有影子的三维场景看起来就像贴图拼出来的纸片空间感和真实感直接掉一个档次。而 Shadow Map阴影贴图作为实时阴影里最主流、工程落地最广的方案从 1978 年 Williams 提出到现在依然是游戏引擎和三维 GIS 渲染管线里的核心组件。我在过去几年里既在游戏引擎侧调过级联阴影的参数也在三维 GIS 场景里处理过几十平方公里地形加建筑的阴影问题。这两件事看起来都是开个阴影但实际踩的坑完全不一样。游戏引擎里阴影质量主要受限于帧率和显存而三维 GIS 里阴影的难点在于场景尺度巨大、相机视锥变化剧烈、地形起伏导致阴影贴图精度分配极其困难。这篇内容就把 Shadow Map 的原理、工程实现、以及在三维 GIS 场景里的落地经验从头到尾捋一遍尽量把为什么这么做讲清楚而不是只丢一堆参数。1. 从一张深度图说起Shadow Map 到底在算什么1.1 核心思想把光源当成一台相机Shadow Map 的思路其实非常朴素既然阴影的本质是某个点被别的物体挡住了光那我只要知道从光源看过去哪些地方最近有物体就行了。于是我们把光源当成一台相机朝场景方向渲染一遍只记录深度不记录颜色得到一张深度图这就是 Shadow Map。渲染主场景的时候对于每个像素对应的世界空间位置 P我把它变换到光源的裁剪空间得到它在光源视角下的深度值 d_light。然后拿 P 在光源空间下的 xy 坐标去采样 Shadow Map得到该方向上的最近深度 d_map。如果 d_light d_map bias说明 P 到光源之间还有别的物体挡着P 就在阴影里否则就在光照下。这个判断逻辑用伪代码写出来大概是这样// 光源空间变换 vec4 lightSpacePos lightSpaceMatrix * vec4(worldPos, 1.0); vec3 projCoord lightSpacePos.xyz / lightSpacePos.w; projCoord projCoord * 0.5 0.5; // 转到 [0,1] float closestDepth texture(shadowMap, projCoord.xy).r; float currentDepth projCoord.z; float shadow currentDepth - bias closestDepth ? 0.0 : 1.0;看起来简单但魔鬼全在细节里。bias 取多少、Shadow Map 分辨率多大、光源视锥怎么框、采样怎么过滤每一个都会直接影响最终画面。我见过太多项目Shadow Map 是开了但要么满屏阴影痤疮shadow acne要么物体边缘飘着一圈 Peter Panning要么远处阴影糊成一团。1.2 为什么是贴图而不是几何有人会问既然要判断遮挡为什么不直接做几何求交原因很直接实时渲染里几何求交的复杂度跟场景三角形数量成正比一个三维 GIS 场景动辄几千万甚至上亿三角形逐像素做射线求交根本跑不动。而 Shadow Map 把可见性判断预计算成了一张纹理主渲染 pass 里每个像素只需要一次纹理采样加一次比较代价是常数级的。这就是它能在实时管线里活到今天的原因。代价也很明显Shadow Map 是离散的分辨率有限所以必然有走样。所有的工程优化本质上都是在有限分辨率这个约束下把精度分配到最需要的地方去。理解了这一点后面所有的技术选择就都顺了。1.3 一张表看清 Shadow Map 的取舍维度Shadow Map 的表现工程含义时间复杂度与场景三角形数相关渲染 pass主 pass 为常数适合大场景但光源 pass 也要控三角形空间复杂度一张或多张深度纹理分辨率直接吃显存级联会成倍增加阴影质量受分辨率、bias、过滤方式影响近处清晰远处糊是常态支持光源类型平行光、聚光灯天然支持点光源需立方体贴图三维 GIS 里主要是太阳光平行光软阴影需要额外 PCF/PCSS 等硬阴影边缘很假软阴影是刚需2. 光源视锥怎么框正交投影与级联的取舍2.1 平行光为什么用正交投影三维 GIS 里的主光源基本就是太阳属于平行光。平行光的特点是光线方向一致没有近大远小的透视效果所以光源相机要用正交投影Orthographic而不是透视投影。这一点如果搞错阴影会整体错位而且越远越离谱。正交投影的视锥就是一个长方体由六个参数决定left、right、bottom、top、near、far。前四个决定横向范围near/far 决定深度范围。关键问题是这个长方体要框多大框太小视锥外的物体不产生阴影会出现阴影突然消失框太大同样的 Shadow Map 分辨率要覆盖更大面积每个像素对应的世界空间尺寸变大阴影精度直线下降。这就是 Shadow Map 最核心的矛盾。2.2 朴素做法整个场景一个视锥最直接的做法是把整个场景的包围盒投影到光源空间用一个正交视锥全包住。小场景没问题但三维 GIS 场景动辄几十公里一个 2048×2048 的 Shadow Map 覆盖 20 公里每个纹素对应差不多 10 米建筑阴影边缘会糊成马赛克完全没法看。我早期做过一个园区级别的项目场景大概 2 公里见方用单张 4096 的 Shadow Map近处建筑阴影勉强能看但地面上的树影基本就是一团黑块。后来加了级联才解决。2.3 级联阴影贴图CSM把精度花在刀刃上级联阴影贴图Cascaded Shadow MapsCSM的思路是把相机的视锥按距离切成几段每段单独渲染一张 Shadow Map近处用高精度远处用低精度。这样近处建筑阴影清晰远处虽然糊但人眼也不敏感。具体实现上通常切 3 到 4 级。切分方式有两种对数切分和均匀切分。对数切分让近处级联更密符合人眼对近处更敏感的特性均匀切分实现简单但近处精度不够。实际项目里常用的是两者混合加一个 lambda 参数控制偏向float lambda 0.75f; for (int i 1; i cascadeCount; i) { float p i / (float)cascadeCount; float logSplit near * pow(far / near, p); float uniSplit near (far - near) * p; splits[i] lambda * logSplit (1.0f - lambda) * uniSplit; }lambda 取 0.75 左右是比较常见的经验值偏向对数切分近处精度更好。2.4 级联切换时的接缝问题级联之间如果处理不好会出现明显的接缝近处级联和远处级联的阴影质量、偏移量不一致边界上会有一条突兀的线。解决办法有两个方向一是让相邻级联的视锥有重叠采样时做混合过渡二是统一各级联的 bias 和过滤参数减少差异。我一般会在级联边界附近做一个 5% 到 10% 的过渡带采样时对两个级联的结果做线性插值。这样虽然多一次采样但接缝基本看不出来。过渡带太窄会看到硬边太宽又会让过渡区域阴影发虚需要根据场景尺度调。2.5 三维 GIS 场景的特殊处理三维 GIS 和游戏场景最大的区别是相机经常在几公里高空俯瞰也可能贴地漫游。如果级联切分固定按相机视锥来高空俯瞰时近处级联覆盖的地面范围会非常大精度反而不够。我的做法是根据相机高度动态调整级联的 near 值和切分比例。相机高的时候把近处级联的覆盖范围拉大因为此时近处其实也是大范围相机贴地时恢复常规切分。这个逻辑不复杂但能明显改善高空视角下的阴影质量。3. 阴影痤疮与 Peter Panningbias 的平衡术3.1 阴影痤疮是怎么来的Shadow Map 最经典的 artifact 就是阴影痤疮shadow acne物体表面出现条纹状的黑色斑点。原因是深度比较时的精度误差——同一个表面在 Shadow Map 里记录的深度和主 pass 里算出来的深度理论上应该相等但因为浮点精度、光栅化差异实际会有微小偏差。当 d_light 略大于 d_map 时就被误判成阴影。这个问题在斜面上尤其严重因为斜面上相邻像素在光源空间的深度变化快误差更容易超过阈值。3.2 bias 的两种形式最直接的解法是加一个深度偏移depth bias让比较时给 d_light 减掉一个常数float bias 0.005; float shadow currentDepth - bias closestDepth ? 0.0 : 1.0;但常数 bias 有个问题斜面上需要的 bias 比正面大得多。于是有了斜率缩放 biasslope-scaled bias根据表面法线和光源方向的夹角动态调整float cosTheta dot(normal, lightDir); float bias max(0.05 * (1.0 - cosTheta), 0.005);夹角越大越斜bias 越大。这个公式在大多数场景里都能用但参数需要根据场景尺度调。三维 GIS 里地形起伏大斜率变化剧烈我一般会把最大 bias 调得比游戏场景更大一些。3.3 Peter Panningbias 过大的副作用bias 不是越大越好。bias 太大物体会浮起来阴影和物体底部脱开这就是 Peter Panning。在三维 GIS 里建筑底部和地面之间出现一条亮缝非常出戏。平衡点的找法先关掉所有过滤把 bias 从 0 开始慢慢加加到痤疮刚好消失为止然后再开过滤微调。这个过程没有捷径必须肉眼调。我一般会准备一个测试场景放几个不同角度的斜面、一个球、一个立方体专门用来调 bias。3.4 法线偏移比深度偏移更稳的方案深度偏移是在深度方向上做文章法线偏移normal offset则是把采样位置沿法线方向挪一点vec3 offsetPos worldPos normal * normalOffsetScale;这样做的好处是偏移量跟表面朝向无关斜面上也不会出现过度偏移。代价是需要额外传法线而且偏移量要跟纹素世界尺寸挂钩。在三维 GIS 里我倾向于法线偏移和斜率 bias 结合使用前者处理大部分情况后者兜底极端斜面。3.5 一个实用的调试流程调 bias 这件事我总结了一个固定流程基本能覆盖大部分场景先把 Shadow Map 分辨率调到最高排除分辨率干扰。关闭 PCF用最近邻采样让 artifact 暴露得最明显。从 0 开始加斜率 bias直到痤疮消失。检查 Peter Panning如果出现就减小 bias 或改用正常偏移。开启 PCF观察边缘必要时微调 bias。最后把分辨率降回目标值确认效果可接受。这个流程看起来笨但比盲目试参数快得多。4. 从硬边到软边PCF 与过滤的工程实现4.1 硬阴影为什么不能忍Shadow Map 直接采样出来的是硬阴影边缘像刀切一样。现实中的阴影边缘都有半影因为光源有面积。硬阴影在游戏里勉强能接受但在三维 GIS 这种偏写实的场景里建筑投下的硬边阴影会显得非常假。4.2 PCF用多次采样换柔和百分比渐近过滤Percentage Closer FilteringPCF是最常用的软阴影方案。思路很简单不采一个点而是采周围一圈点每个点做深度比较最后把结果平均float pcfShadow(sampler2D shadowMap, vec3 projCoord, float bias) { float shadow 0.0; vec2 texelSize 1.0 / textureSize(shadowMap, 0); for (int x -1; x 1; x) { for (int y -1; y 1; y) { float depth texture(shadowMap, projCoord.xy vec2(x, y) * texelSize).r; shadow projCoord.z - bias depth ? 0.0 : 1.0; } } return shadow / 9.0; }3×3 的 PCF 是最低配边缘会有点糊但能接受。要更柔和就上 5×5 甚至泊松盘采样代价是采样次数平方增长。三维 GIS 里我一般用 4×4 或者 8 个点的泊松盘兼顾质量和性能。4.3 硬件 PCF 与比较采样器现代图形 API 支持比较采样器comparison sampler硬件层面直接做深度比较配合线性过滤能免费得到 2×2 的 PCF。用法是把采样器类型设成 shadow sampler采样时传一个比较值float shadow textureProj(shadowSampler, lightSpacePos);这一行就顶了软件 PCF 的 4 次采样性能好很多。但硬件 PCF 的过滤范围固定想要更柔和还是得叠加软件采样。我的习惯是硬件 PCF 打底再叠 4 个点的软件采样效果和性能比较平衡。4.4 接触硬化让阴影边缘更真实真实阴影有个特性物体接触地面的地方阴影最实越远越虚。这叫接触硬化contact hardening。PCSSPercentage Closer Soft Shadows就是模拟这个效果的核心是根据遮挡物距离动态调整过滤核大小。PCSS 的代价是要先估计遮挡物距离需要额外采样性能开销不小。三维 GIS 里如果场景复杂我一般只在近处级联开 PCSS远处还是普通 PCF。这样近处建筑阴影有层次远处也不至于拖垮帧率。4.5 过滤方式对比方案采样次数软度性能适用场景最近邻1硬边最好调试、低配硬件 PCF1硬件 4 次轻微好通用打底3×3 PCF9中等中常规场景泊松盘 PCF8-16较软中写实场景PCSS16接触硬化差近处级联5. 三维 GIS 场景里的阴影落地难点5.1 场景尺度从米到公里三维 GIS 场景的尺度跨度极大。一个城市级场景建筑可能几十米高地形起伏几百米可视范围几十公里。Shadow Map 的纹素世界尺寸 视锥宽度 / 分辨率。2048 分辨率覆盖 10 公里每个纹素接近 5 米建筑阴影边缘完全没法看。解决办法只有两个方向一是级联把近处精度提上去二是限制阴影距离远处干脆不投阴影用环境光遮蔽AO或者烘焙阴影代替。实际项目里我一般把实时阴影限制在相机周围 1 到 2 公里再远就用低精度或者不投。5.2 地形起伏导致的精度分配难题平地上级联切分很好做但三维 GIS 里地形起伏大相机可能在山谷里也可能在山顶。如果按相机视锥切分山谷里近处级联可能全被山体挡住精度浪费了山顶上又可能近处级联覆盖范围不够。我的处理方式是结合地形高度做自适应。简单说就是根据相机位置的地形高度和视线方向动态调整级联的 near 和切分比例。这个逻辑需要读地形高度图实现起来有点绕但对山地场景的阴影质量提升很明显。5.3 大量静态物体的阴影优化三维 GIS 场景里建筑、地形、道路这些大部分是静态的。静态物体的 Shadow Map 可以缓存不用每帧重渲。做法是把静态物体的 Shadow Map 渲染到一张单独的纹理只在光源方向变化或者场景变化时更新。太阳方向在 GIS 里通常跟时间绑定如果时间流逝速度慢可以每隔几帧更新一次静态阴影动态物体车辆、行人单独渲染到另一张图最后合并。这样能省下大量渲染开销。5.4 阴影与 LOD 的配合三维 GIS 场景普遍用 LOD细节层次远处建筑用低模。Shadow Map 渲染时如果也用低模阴影边缘会更糊如果用高模又浪费性能。我的经验是Shadow Map 渲染用的 LOD 级别比主渲染高一级也就是用更精细的模型因为阴影对轮廓敏感低模的轮廓误差在阴影上会被放大。5.5 一个真实的调优案例之前做过一个山地城市的三维 GIS 项目场景大概 15 公里见方建筑几千栋地形起伏 300 米。初始方案是单张 4096 Shadow Map结果近处建筑阴影糊远处地形阴影直接消失。调整过程分三步第一步上 4 级 CSM近处级联覆盖 200 米远处覆盖到 3 公里第二步静态阴影缓存静态物体每 10 帧更新一次第三步近处级联开 PCSS远处用硬件 PCF。最终帧率从 25 提到 55阴影质量也上来了。这个案例说明Shadow Map 的优化不是单点问题是分辨率、级联、缓存、过滤的组合拳。6. 性能与质量的平衡参数怎么定6.1 分辨率不是越高越好Shadow Map 分辨率每翻一倍显存占用翻四倍渲染开销也翻倍。2048 到 4096 的提升肉眼在大多数场景里并不明显但性能差距很大。我的建议是近处级联用 2048 或 4096远处级联用 1024 甚至 512。级联之间分辨率不同但因为是不同距离视觉上反而更合理。6.2 级联数量的选择级联数量 3 到 4 是甜点区。2 级近处精度不够5 级以上收益递减而且切换接缝更难处理。三维 GIS 里如果场景特别大我会用 4 级但把最远一级的覆盖范围控制住再远就不投实时阴影了。6.3 阴影距离的取舍实时阴影距离设多远取决于场景和性能预算。城市级场景我一般设 1 到 2 公里园区级设 500 米到 1 公里。超出距离的物体不投阴影用 AO 或者烘焙阴影补。这个取舍很现实用户不会盯着 3 公里外的阴影看但会盯着近处的建筑。6.4 关键参数速查表参数推荐值说明近处级联分辨率2048-4096建筑阴影清晰度关键远处级联分辨率512-1024够用就行级联数量3-4甜点区阴影距离1-2 公里按场景调斜率 bias0.005-0.05按尺度调PCF 核3×3 到 4×4兼顾质量性能静态更新间隔5-10 帧时间流逝慢时6.5 移动端和 Web 端的特殊考虑如果三维 GIS 跑在移动端或者 Web 端性能预算更紧。移动端我一般只用 2 级 CSM分辨率 1024PCF 用硬件自带的。Web 端受限于 WebGL比较采样器支持有限软件 PCF 采样次数要控制。这些平台上的阴影能用就行别追求极致。7. 踩过的坑与实战心得7.1 光源视锥没跟着相机走早期做三维 GIS 时我把光源视锥固定在了场景中心结果相机移动到边缘时阴影全没了。后来改成视锥跟随相机但跟随方式有讲究不能简单跟着相机位置要跟着相机视锥在光源空间的投影范围。这个坑很隐蔽因为相机在中心时一切正常一移动就出问题。7.2 深度精度不够导致的闪烁Shadow Map 的深度精度如果不够相机移动时阴影边缘会闪烁。解决办法是用 32 位深度纹理或者把 near/far 范围收紧。三维 GIS 里场景大near/far 范围容易拉得很大深度精度被摊薄。我一般会把光源视锥的 near/far 尽量收紧到实际需要的范围。7.3 透明物体的阴影三维 GIS 里树木、植被常用透明贴图。Shadow Map 渲染时如果不处理 alpha树影就是一个方块。解决办法是在 Shadow Map pass 里也做 alpha 测试丢弃透明像素。但这样会带来新的走样问题需要配合 alpha 相关的 bias 调整。7.4 多光源的阴影管理三维 GIS 里除了太阳可能还有路灯、车灯。多光源阴影不能每个都开 Shadow Map性能扛不住。我的做法是只给主光源太阳开实时阴影其他光源用简单的投影贴图或者干脆不投。如果一定要多光源阴影就限制同时开启的数量按距离和重要性排序。7.5 阴影与后处理的配合阴影结果出来后往往还要跟 AO、雾效、色调映射配合。阴影和 AO 叠加时容易过暗需要控制强度。雾效会削弱远处阴影的对比度这其实是好事能让远处阴影不那么突兀。色调映射则要注意阴影区域的亮度不要被压得太死。7.6 调试工具的重要性Shadow Map 调试如果没有可视化工具基本靠猜。我一般会做几个调试视图Shadow Map 深度图、级联分界可视化、bias 可视化。有了这些调参效率能提升好几倍。特别是级联分界可视化能一眼看出级联切分是否合理。8. 阴影之外什么时候该考虑别的方案Shadow Map 不是万能的。如果场景是静态的光照也不变烘焙阴影lightmap质量更高、性能更好。如果场景特别大实时阴影覆盖不过来可以用屏幕空间阴影SSS或者距离场阴影DFSS补充。三维 GIS 里我通常的组合是近处实时 Shadow Map中距离烘焙或者 AO远处靠雾效和环境光糊过去。选择方案的核心判断标准是阴影对场景真实感的影响有多大性能预算有多少。如果用户主要看宏观地形阴影没那么重要如果用户要看建筑细节阴影就是刚需。搞清楚用户真正看什么比盲目堆技术更重要。最后分享一个我自己的习惯每次调阴影参数我都会录一段相机漫游的视频而不是只看静态截图。因为阴影的问题很多是动态的——闪烁、接缝、级联切换静态截图根本看不出来。录视频回放问题一目了然。这个习惯帮我省了很多来回调试的时间。
返回列表