08. 阴影:影子怎么来的
08. 阴影:影子怎么来的
本篇位置:模块二 · 第 8 篇 | 前置:光照 | 预计阅读:30 分钟
你已经在用它了
上一篇那盏灯,用在这里(libs/hwui/pipeline/skia/ReorderBarrierDrawables.cpp,删去厂商定制):
void EndReorderBarrierDrawable::drawShadow(SkCanvas* canvas, const RenderNode* casterNode,
const ShadowInfo& shadowInfo) {
const RenderProperties& casterProperties = casterNode->properties();
const SkPath* casterPath = shadowInfo.casterPath;
const Vector3 lightPos = LightingInfo::getLightCenter();
SkPoint3 skiaLightPos = SkPoint3::Make(lightPos.x, lightPos.y, lightPos.z);
SkPoint3 zParams = SkPoint3::Make(0, 0, casterProperties.getZ());
SkShadowUtils::DrawShadow(
canvas, *casterPath, zParams, skiaLightPos, LightingInfo::getLightRadius(),
shadowInfo.ambientColor, shadowInfo.spotColor,
shadowInfo.casterAlpha < 1.0f ? SkShadowFlags::kTransparentOccluder_ShadowFlag : 0);
}每个有 elevation 的 View 都会走这里,一次调用画出它的影子。
三个问题:
- 传进去的只有一条轮廓路径(
casterPath)、一个高度(zParams)和光源位置。没有任何深度图、也没有第二次渲染——影子是怎么算出来的? - 为什么要传
getLightRadius()?一个投影计算要光源半径做什么? - 游戏里的阴影和这个是一回事吗?
学完你会什么
- 说清 hwui 这套几何投影法的前提是什么,为什么三维场景用不了。
- 说清阴影贴图为什么必须渲染两趟,第一趟到底存了什么。
- 说清 shadow acne 从哪来、bias 怎么修、修过头又会怎样。
- 说清阴影为什么总是不够清晰,以及 cascade 在解决什么。
- 说清软阴影的物理来源,以及 PCF 是怎么近似它的。
1. hwui 走的是"几何投影"这条路
问题 1 的答案:它根本不需要渲染第二趟,因为它知道遮挡物的确切形状。
casterPath 是 View 的轮廓——一个矩形、一个圆角矩形,或者任意路径。有了这条轮廓、View 的高度 Z、以及光源位置,影子的形状可以直接用几何算出来:把轮廓上每个点从光源出发投影到地面上,连起来就是影子的边界。
这套办法的前提非常强:
| 前提 | hwui 满足吗 | 三维场景满足吗 |
|---|---|---|
| 遮挡物的轮廓是已知的解析形状 | 是,就是 View 的 path | 否,是几十万个三角形 |
| 只有一个接收面,且是平的 | 是,就是它下面那层 UI | 否,任意起伏的地形、其他物体 |
| 遮挡物之间不互相投影 | 基本是 | 否,角色投影到墙上、树影投到车上 |
第二条最致命。几何投影法算出来的是"影子的轮廓落在哪",它假设接收面是一个平面。如果影子要落在一段楼梯上、一个球面上、或者另一个物体上,投影出来的形状就完全不对了。
所以问题 3 的答案是:不是一回事,而且不是"简化版"的关系——是两套完全不同的算法。
2. 阴影贴图:把问题变成一次深度比较
三维场景的通用解法叫阴影贴图(shadow mapping),它换了个思路:
不去算影子的形状,而是逐点问"这个点能不能被光源看见"。
一个点在阴影里,等价于说:从光源看过去,这个点被别的东西挡住了。
而"被挡住了吗"这个问题,你在第 5 篇已经解决过了——那就是深度测试。只不过这次要从光源的位置去做。
图左(第一趟):把相机放到光源位置,渲染一遍场景,但只记深度、不要颜色——每个方向上"最近的东西有多远"。这张图就叫阴影贴图。图右(第二趟):正常从相机渲染,每个着色点算出自己到光源的距离,和阴影贴图里记的值比一比。
图里两个点:
| 点 | 到光源的实际距离 | 阴影贴图里记的值 | 判定 |
|---|---|---|---|
| A | 6.12 | 6.12 | 一样 → 它就是最近的那个 → 被照亮 |
| B | 8.21 | 4.40 | 比记录的远 → 前面有东西挡着 → 在影子里 |
整个算法就这么一句话:
为什么必须渲染两趟?因为第一趟要从光源的视角看世界。这不是"多余的一趟",而是这个方法的定义——你必须真的站到光源那里去看一次,才知道从那里看得见什么。
有几个光源,就要渲几趟。这是阴影最主要的开销来源,也是游戏里"实时阴影的光源数量"总是被严格限制的原因。
3. Shadow acne:表面把自己遮住了
阴影贴图有一堆恶名昭著的毛病,第一个是自遮挡。
现象是:本该被照亮的平面上出现一条条黑色的条纹,尤其在光线斜射的地方。它有个很形象的名字——shadow acne(阴影痘痘)。
图左:黑线是真实表面(一个斜面),蓝色阶梯是阴影贴图记录的值。阴影贴图分辨率有限,一个纹素只能存一个深度,于是记录值是阶梯状的。斜面有一半落在阶梯之上——按判定规则,这些地方"比记录的远",被判成了阴影,比例高达 50%。图右:给记录值统一加一点 bias,阶梯整体抬高,斜面完全落在下面,误判降到 0%。
根因是分辨率:阴影贴图的一个纹素覆盖了世界里的一小片区域,这片区域内所有点共用同一个深度值。而真实表面在这片区域内是连续变化的,必然有一半高于那个值、一半低于。
解法叫 depth bias:比较时给记录值加一个小量,相当于把整个阴影贴图"往远处推一点"。
但 bias 加过头会引出另一个毛病:影子和物体的接触点脱开,物体看起来像浮在空中——这叫 peter-panning(彼得潘效应,人影分离)。
于是 bias 的取值变成了一个两头受气的调参活:太小有痘痘,太大影子飘。工程上常用的改进:
- 斜率相关的 bias(slope-scaled depth bias)——表面越倾斜,需要的 bias 越大,那就按倾斜程度自动放大。硬件直接支持。
- 只渲染背面(front-face culling)——第一趟渲染阴影贴图时只画背面,让记录的深度天然比正面远一点。对封闭物体很有效。
4. 分辨率:阴影为什么总是糊的
第二个大毛病是阴影贴图的分辨率永远不够。
阴影贴图要覆盖光源能照到的整个范围。对太阳光这种平行光,那可能是整张地图几百米见方,而贴图只有 2048×2048——一个纹素对应现实里好几十厘米。于是近处的阴影边缘会呈现明显的锯齿状块块。
更糟的是这个误差在画面上分布极不均匀:相机附近的东西占屏幕像素多、需要的阴影精度高,而阴影贴图是按光源视角均匀分配的,不知道你在看哪。
主流解法叫 CSM(Cascaded Shadow Maps,级联阴影贴图):按距离把视锥切成几段,每段用一张自己的阴影贴图。近处那段范围小、精度高,远处那段范围大、精度低。因为近处占的屏幕像素多,这样分配才划算。
代价是每一级都要渲一趟——四级 CSM 意味着场景要多渲四遍。这就是阴影在游戏里始终是大开销的原因,也是移动端常常退回到"烘焙好的静态阴影 + 角色脚下一个圆形贴片"的原因。
5. 软阴影:回答 getLightRadius
现在回答问题 2。
理想点光源投出来的影子边缘是刀切一样的硬边——一个点要么能看见光源,要么看不见,没有中间状态。
但现实中所有光源都有大小:灯管、窗户、整片天空。于是遮挡物边缘附近的点能看到光源的一部分——挡住一半就是半亮,形成一圈明暗过渡带。
术语上,全黑的核心叫本影(umbra),过渡带叫半影(penumbra)。
图:同一个遮挡物、同样的高度,只改光源半径。半径 0(理想点光源)时半影宽度是 0.00,边缘是硬的;半径 0.8 时半影总宽 4.27;半径 2.0 时达到 10.67,影子明显变虚。
这就是 getLightRadius() 的作用:它告诉 Skia 这盏灯有多大,Skia 据此决定 View 的影子边缘该有多虚。你在不同 Android 版本上看到的阴影风格差异,很大程度就是这个参数在变。
还有一条更重要的规律:半影宽度还取决于遮挡物离接收面多远。手指贴在桌面上,影子边缘很锐;把手抬高,影子迅速变虚变淡。这是所有真实阴影的共同特征,也是"影子的虚实能传达高度"这件事的物理基础——Material Design 用阴影表达层级,靠的正是这个直觉。
游戏里怎么做软阴影?最常用的是 PCF(Percentage-Closer Filtering):不是只采一次阴影贴图,而是在周围采好几个点,数一数有几个判定为阴影,用比例当作"这个点有多暗"。
采 9 个点、5 个判阴影,这个点就是 的阴影。它得到的是一条平滑过渡的边缘,而不是非黑即白。
注意 PCF 本质上是"把阴影边缘模糊一下",它不是在物理地模拟半影——半影宽度应该随遮挡距离变化,而固定核的 PCF 到处一样宽。更进一步的 PCSS(Percentage-Closer Soft Shadows)会先估算遮挡物距离再决定模糊半径,代价更高。
⚠️ 别搞混
你熟的 elevation 阴影和游戏里的阴影贴图,不是简化和完整的关系,是两套不同的算法:
| hwui 的 elevation 阴影 | 阴影贴图 | |
|---|---|---|
| 基本思路 | 几何投影——把轮廓投到平面上 | 深度比较——从光源看得见吗 |
| 需要几趟渲染 | 一趟(就是正常那趟) | 至少两趟,每个光源一趟 |
| 需要额外显存吗 | 不需要 | 需要,一张或多张深度图 |
| 遮挡物形状 | 必须是已知的解析路径 | 任意三角形都行 |
| 接收面 | 必须是平面 | 任意形状 |
| 物体能互相投影吗 | 不能 | 能 |
| 有 acne / peter-panning 吗 | 没有(不涉及深度比较) | 有,需要调 bias |
| 软阴影怎么来 | Skia 按 lightRadius 直接算 | PCF / PCSS 等近似 |
一句话:一个是"算出影子的形状",一个是"逐点判断能不能被光源看见"。前者精确但适用面极窄,后者通用但满是需要调参的近似。
回到源码
问题 1:没有深度图,影子怎么算的?
用几何投影。casterPath 给出了遮挡物的精确轮廓,zParams 给出高度,光源位置已知——三者足以直接算出轮廓投影到下方平面上的形状。不需要第二趟渲染,也不需要任何深度缓冲。
代价是它绑死了三条前提:遮挡物轮廓已知、接收面是平面、物体之间不互相投影。UI 场景恰好全都满足。
问题 2:getLightRadius() 影响什么?
影子边缘有多虚。
点光源(半径 0)投出的是硬边影子,因为每个点要么看得见光源要么看不见。真实光源有大小,边缘附近的点只能看到光源的一部分,形成半影。图里半径从 0 到 2.0,半影宽度从 0.00 涨到 10.67。
Material Design 用影子的虚实表达层级高度,靠的正是这个物理规律:物体离接收面越远,影子越虚。
问题 3:和游戏里的阴影是一回事吗?
不是,是两套完全不同的算法,而且不是简化关系。
hwui 算的是"影子的形状"(几何投影,一趟搞定,精确但只适用于平面接收);游戏用的是"这个点能被光源看见吗"(阴影贴图,至少两趟,通用但需要和 acne、分辨率、软阴影一路缠斗)。
下次看到任何阴影实现,先问一句:它是在算形状,还是在做深度比较?这一问就能定位它属于哪一类,以及会有哪些典型毛病。
自测
1. 阴影贴图为什么必须渲染两趟?能不能优化掉一趟?
不能,两趟是这个方法的定义。
第一趟必须站到光源的位置去渲染一遍,才能知道从那里看出去每个方向上最近的东西有多远。这不是冗余计算,是获取"光源视角的可见性"这个信息的唯一途径。
推论:有几个投影阴影的光源,就要渲几趟。这是实时阴影最主要的开销,也是游戏里动态阴影光源数量总被严格限制的原因。
2. shadow acne 的根因是什么?为什么斜面上特别严重?
根因是阴影贴图的分辨率有限——一个纹素覆盖世界里的一小片区域,这片区域内所有点共用同一个深度值,而真实表面在这片区域内是连续变化的。
于是表面必然有一部分高于那个记录值,被判定成"比记录的远" → 误判为阴影。图里那个斜面误判比例高达 50%,表现为一条条黑色条纹。
斜面更严重是因为倾斜度越大,一个纹素覆盖范围内的真实深度变化越大,误差也越大。所以硬件提供了"斜率相关 bias",按倾斜程度自动放大补偿量。
3. bias 调大能消痘痘,为什么不能一直调大?
会出现 peter-panning——影子和物体的接触处脱开,物体看起来像浮在空中。
因为 bias 的本质是"把整个阴影贴图往远处推一点"。推得越多,物体底部附近本该有影子的区域越会被判成"没被挡住",接触阴影就消失了。
所以 bias 是个两头受气的参数:太小有痘痘,太大影子飘。工程上用斜率相关 bias、或者第一趟只渲背面来缓解。
4. CSM(级联阴影贴图)在解决什么问题?代价是什么?
解决阴影贴图精度分配不均的问题。
一张阴影贴图要覆盖光源照到的整个范围(对太阳光可能是几百米),分辨率却有限,导致一个纹素对应现实里几十厘米,近处阴影边缘出现明显块块。而且误差分布不合理——相机附近的东西占屏幕像素最多、最需要精度,阴影贴图却是按光源视角均匀分配的。
CSM 按距离把视锥切成几段,每段一张贴图:近处范围小精度高,远处范围大精度低。
代价是每一级都要渲一趟——四级 CSM 就是场景多渲四遍。这也是移动端常退回"烘焙静态阴影 + 角色脚下贴片"的原因。
5. 为什么手贴着桌面时影子边缘很锐,抬高之后就变虚了?
因为半影宽度取决于光源大小和遮挡物到接收面的距离。
真实光源有大小,遮挡物边缘附近的点只能看到光源的一部分,形成半明半暗的过渡带(半影)。遮挡物离接收面越远,同样的光源张角投出来的这条过渡带越宽。
贴着桌面时距离接近 0,半影宽度也接近 0,边缘锐利;抬高之后半影迅速变宽,影子变虚变淡。
Material Design 就是靠这个物理直觉用阴影表达层级高度的,hwui 那个 lightRadius 参数控制的正是这套效果的强度。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| 几何投影法 | 把已知轮廓投到平面上 | hwui 用;一趟搞定,但接收面必须是平面 |
| 阴影贴图 | 从光源渲一趟深度,第二趟逐点比 | 通用解法;核心就是一次深度比较 |
| 判定规则 | 到光源距离 > 记录值 → 在阴影里 | 和第 5 篇的深度测试是同一件事 |
| 两趟不能省 | 必须真的站到光源去看一次 | 每个阴影光源一趟,这是主要开销 |
| shadow acne | 表面自己把自己遮住 | 根因是阴影贴图分辨率有限 |
| depth bias | 把记录值推远一点 | 太小有痘痘,太大 peter-panning |
| CSM | 按距离分段,各用一张贴图 | 解决精度分配不均;代价是多渲几趟 |
| 本影 / 半影 | 全黑核心 / 明暗过渡带 | 半影来自光源有大小 |
| 半影宽度 | 随光源大小和遮挡距离增长 | 影子的虚实能传达高度 |
| PCF | 采多个点数比例 | 是模糊边缘,不是物理模拟半影 |
延伸阅读
- LearnOpenGL-CN《阴影映射》:https://learnopengl-cn.github.io/05 Advanced Lighting/03 Shadows/01 Shadow Mapping/
- NVIDIA GPU Gems 3《Cascaded Shadow Maps》:https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-10-parallel-split-shadow-maps-programmable-gpus
- Fernando《Percentage-Closer Soft Shadows》(PCSS 原始论文):https://developer.download.nvidia.com/shaderlibrary/docs/shadow_PCSS.pdf
- AOSP 源码:
frameworks/base/libs/hwui/pipeline/skia/ReorderBarrierDrawables.cpp