07. 光照:颜色怎么算出来
07. 光照:颜色怎么算出来
本篇位置:模块二 · 第 7 篇 | 前置:混合 | 预计阅读:30 分钟
你已经在用它了
hwui 里有一盏灯。它是全局的、静态的,整个 Android UI 共用一盏(libs/hwui/Lighting.h):
struct LightGeometry {
Vector3 center;
float radius;
};
struct LightInfo {
LightInfo(uint8_t ambientShadowAlpha, uint8_t spotShadowAlpha)
: ambientShadowAlpha(ambientShadowAlpha), spotShadowAlpha(spotShadowAlpha) {}
uint8_t ambientShadowAlpha;
uint8_t spotShadowAlpha;
};它有位置(center,一个三维坐标)、有大小(radius),还区分了两种光的强度。
三个问题:
- Android UI 是二维的,为什么需要一盏有三维位置的灯?它照亮了什么?
radius是干什么的?一个点光源要半径做什么?- 游戏里的光照公式动辄一长串,这里为什么只有两个
uint8_t?中间省掉了什么?
学完你会什么
- 写出并解释 Lambert 漫反射项,说清"正对光源最亮"背后的物理原因。
- 说清高光是什么、高光指数控制的到底是哪个量。
- 说清环境光在补什么洞,以及经典模型为什么会"能量不守恒"。
- 说清 PBR 换掉了什么、为什么换了之后美术不用反复调参。
- 说清光照计算必须在什么空间里做。
1. 先回答:这盏灯照亮的是阴影
问题 1 的答案有点出人意料:这盏灯不照亮任何表面,它只用来算阴影的形状。
Android 的 Material Design 里,每个 View 有一个 elevation(离屏幕的高度)。有了光源位置和 View 的高度,就能算出它的影子应该投在哪、偏多远、多大多虚——这正是下一篇的主题。
所以 LightInfo 里只有两个 uint8_t(环境光阴影和聚光阴影的浓度),没有任何漫反射、高光参数——因为它压根不做表面着色。问题 3 的答案就是:它不是省略了,是根本不需要。UI 元素的颜色是美术直接给的,不是算出来的。
但这盏灯里那两个概念——位置和半径——恰恰是理解真实光照的入口。这一篇讲的就是:如果真要用这盏灯去照亮一个表面,该怎么算。
2. 漫反射:为什么正对光源最亮
给定表面法线 和指向光源的方向 (都归一化),最基础的光照模型是这一行:
- 就是两者夹角的余弦(M1 第 1 篇讲的点积第三种用法);
- 是这个表面的反照率(albedo),也就是它本身的颜色;
- 外面那个 把负值截断——光源在表面背面时根本照不到,不 clamp 会算出"负的光"。
这叫 Lambert 漫反射(diffuse),描述的是光打到粗糙表面后向各个方向均匀散开的那部分。
为什么是余弦?不是凑出来的,是几何必然:
图:同一束宽度固定的平行光,打在不同倾角的表面上。正对时(0°)照亮长度 1.00;45° 时被摊开到 1.41;70° 时摊开到 2.92 倍。光的总能量没变,摊得越开,单位面积接收到的就越少——正好是 :1.00、0.71、0.34。
这就是 Lambert 余弦定律的物理含义:不是"角度大了光变弱了",而是同样多的光被摊在了更大的面积上。
一个常见的误解:Lambert 项里没有观察方向 。也就是说,漫反射的亮度和你从哪个角度看无关——粗糙表面把光均匀散向各个方向,你换个位置看,同一块地方还是那么亮。这是"漫反射"这个名字的全部含义。
下一节的高光正相反,它强烈依赖观察角度。
3. 高光:反光那一块
抛光的金属、湿的路面、眼球,除了均匀散开的漫反射,还有一块又亮又集中的反光——高光(specular)。
它的模型思路是:光从 打到表面按镜面反射弹开,观察方向 越接近那条理想反射方向,你看到的反光越强。
最常用的写法是 Blinn-Phong:先把光源方向和观察方向合成一条半程向量 ,再拿它和法线比:
是 和 的角平分线。当 和 重合时,说明 正好落在理想反射方向上,高光最亮。
关键是那个指数 shininess,它控制的是"反光有多集中":
图:极坐标下的 ,0° 是理想反射方向。指数从 4 涨到 256,叶瓣明显收窄变尖。
用数字说更清楚——强度掉到一半时对应的角度:
| shininess | 半亮角 | 观感 |
|---|---|---|
| 4 | 32.8° | 一大片朦胧的反光,哑光塑料 |
| 16 | 16.7° | 中等,抛光木头 |
| 64 | 8.4° | 一小块亮斑,光滑金属 |
| 256 | 4.2° | 一个尖锐的亮点,接近镜面 |
指数越大,高光越窄越亮,材质看起来越光滑。这个直觉下一节讲 PBR 时会被换一套参数重新表述。
4. 三项叠加,以及一个从根上的毛病
再补最后一块:环境光(ambient)。
它是个和角度完全无关的常数 ,粗暴地近似"这个表面就算没被任何光源直接照到,也不该是纯黑"。现实里这份亮度来自周围环境反弹回来的间接光,而 Lambert/Blinn-Phong 这类局部光照模型只看单个光源和单个表面,算不出这种全局效果——环境光就是补这个洞的权宜之计。
三项一加,就是这类模型的完整输出:
图:三项随夹角的变化。高光又窄又亮只在正对时出现,漫反射平缓地铺开,环境光在最底下托着不让阴影区死黑。
现在说这套模型从根上的毛病:它不保证能量守恒。
一个表面反射出去的光,不该超过它接收到的。但 、、 在经典模型里是三个互相独立的手调常数,谁也不管谁。图里用的 、、,正对光源时三项之和是 1.50——凭空多反射出了 50% 的光。
画面上的表现是不自然的过曝、发光感。而且更麻烦的是:这套参数在这个光照环境下调好看了,换一个场景就得重调——因为这些数字和任何物理量都没有对应关系,纯粹是美术凭经验试出来的"看起来对"。
5. PBR 换掉了什么
PBR(physically based rendering,基于物理的渲染)解决的就是上面那个问题。它换的不是公式复杂度,而是参数的含义。
核心思路叫微表面(microfacet):把表面看成无数远小于一个像素的小镜面,每个都是完美镜面反射。宏观上看到的漫反射和高光,全是海量小镜面朝向各异、统计叠加出来的结果。
于是参数换成了两个有物理含义的量:
| 参数 | 含义 | 对应经典模型的什么 |
|---|---|---|
| 粗糙度(roughness) | 小镜面朝向的散乱程度 | 就是 shininess 的物理版——粗糙度低 = 指数大 = 高光集中 |
| 金属度(metallic) | 是金属还是非金属 | 经典模型里没有对应物 |
金属度这个参数解释了一个以前只能靠美术经验的现象:
- 非金属(塑料、木头、皮肤):光会折射进材质内部再散射出来,形成有颜色的漫反射;高光本身接近无色(白色的反光)。
- 金属:几乎没有次表面散射,光要么被直接反射、要么被吸收。漫反射趋近于零,高光带材质本身的颜色——金子的反光是金黄的,不是白的。
再加上菲涅尔效应(任何材质在掠射角下反射率都会显著升高,这就是水面从正上方能看到水底、平视却全是反光的原因),一套 PBR 材质就齐了。
换成物理参数之后最大的好处是:同一份材质换到任何光照环境下都说得通、观感一致,不用为每个场景重调魔术数字。这才是"现代游戏几乎都用 PBR"的真正原因——不是因为它更好看,是因为它可预测、可复用。
具体的 BRDF 推导、GGX 分布函数、几何遮蔽项超出本篇范围。能对着一段 PBR shader 认出每个变量对应哪个物理量,就够用了。
6. 光照必须在线性空间里算
上一篇讲混合时提过 M1 第 8 篇那条铁律,光照这里再强调一次,因为它出错的方式更隐蔽。
三项相加是线性域里的加法。如果 直接用了一张没解码的 sRGB 贴图的像素值,那三项叠加、乘光强、所有的运算全部建立在错误的数值上。
症状是:画面整体偏暗、阴影区糊成一团、明暗过渡不自然。而且它不会报错,只是"看起来有点脏"——很多人会误以为是美术风格问题,跑去调参数补救,越调越乱。
工程上的正确做法:把贴图声明成 sRGB 格式,让硬件在采样时自动解码成线性值(免费,采样器里就做了),所有光照计算在线性域完成,最后写出时再编码回 sRGB。
⚠️ 别搞混
hwui 那盏灯和这一篇讲的光照,虽然都叫"光",做的事完全不同:
hwui 的 LightGeometry | 渲染里的光源 | |
|---|---|---|
| 用途 | 只算阴影的形状(下一篇) | 算表面的颜色 |
| 参数 | 位置、半径、两个阴影浓度 | 位置/方向、颜色、强度、衰减 |
| 数量 | 全局一盏,静态 | 场景里几个到几百个 |
| 表面参数 | 无——UI 颜色是美术给的 | albedo / roughness / metallic |
| 算不算 | 不算 | 这是核心 |
一句话:hwui 的灯是个"投影仪",只管影子往哪投;渲染里的光源是真的在给表面算亮度。
回到源码
问题 1:二维的 UI 为什么需要三维光源?
因为它要算阴影。Material Design 给每个 View 一个 elevation,有了光源位置和这个高度,就能算出影子投在哪、偏多远。这盏灯从不参与表面着色,UI 元素的颜色是美术直接给的。
问题 2:radius 是干什么的?
它决定影子边缘有多虚。
一个理想的点光源(半径为 0)投出来的影子边缘是刀切一样的硬边。现实中的光源都有大小——灯管、窗户、天空——于是遮挡物边缘附近的点能"看到"光源的一部分而不是全部,形成半明半暗的过渡带(半影)。
光源越大,半影越宽,影子越虚。radius 就是在描述这件事。下一篇会展开。
问题 3:为什么只有两个 uint8_t?
因为它不做表面着色,只做阴影,而阴影在这里只需要"多深"这一个参数。两个值分别对应两种阴影:环境光阴影(物体紧贴地面那一圈,接触阴影)和聚光阴影(光源方向投出去的那一片)。
不是简化,是这个场景根本不需要光照模型。
自测
1. 为什么 Lambert 漫反射项里没有观察方向 V?
因为漫反射就定义为"光打到粗糙表面后向各个方向均匀散开"。既然各方向均匀,从哪个角度看就都一样亮。
这是"漫反射"这个名字的全部含义,也是它和高光最本质的区别——高光强烈依赖 ,你走动时高光会跟着移动,漫反射不会。
2. 为什么正对光源最亮?用一句物理解释,别用公式。
同样多的光被摊在了更大的面积上。
光束的总能量不变,但表面倾斜时被照亮的面积变大——图里 70° 时照亮长度是正对时的 2.92 倍,于是单位面积接收到的能量降到 。
所以余弦不是凑出来的经验公式,是投影面积的几何必然。
3. shininess 从 16 调到 64,材质看起来会怎么变?
更光滑、更像金属。
高光的半亮角从 16.7° 收窄到 8.4°,反光区域面积大约缩到四分之一,同时显得更亮更集中。
直觉上:指数控制的是"这个表面把光反射得有多集中"。哑光材质把光散得很开(小指数),镜面把光全部集中到一个方向(大指数)。
4. 经典 Phong 模型的"能量不守恒"具体指什么?会怎样?
、、 是三个互相独立的手调常数,加起来可能超过 1——表面反射出去的光超过了它接收到的。
图里 ,凭空多了 50%。画面上表现为不自然的过曝、发光感。
更麻烦的是可移植性:这套数字和物理量没有对应关系,在一个光照环境下调好看了,换个场景就得重调。这正是 PBR 用 roughness/metallic 替代它们要解决的问题。
5. 为什么金子的高光是金黄色,塑料的高光是白色?
因为金属度不同。
非金属(塑料):入射光大部分折射进材质内部,被内部颗粒散射后再出来,带上了材质的颜色——这形成有颜色的漫反射。而表面直接反射的那部分几乎不改变颜色,所以高光是光源的颜色(通常是白)。
金属:几乎没有次表面散射,光要么被表面直接反射、要么被吸收。所以漫反射趋近于零,而反射光会被金属本身的电子结构选择性吸收,带上材质颜色——金子的高光就是金黄的。
这也是为什么 PBR 里 metallic 通常是 0 或 1 而不是中间值:材质要么是金属要么不是。
小结
| 概念 | 一句话 | 要点 |
|---|---|---|
| hwui 那盏灯 | 只算阴影形状,不做着色 | radius 决定影子有多虚 |
| Lambert 漫反射 | 余弦来自投影面积,与视角无关 | |
| clamp 到 0 | 拦住背面的"负光照" | 物理上不成立的结果 |
| 高光 Blinn-Phong | 半程向量 是 和 的角平分线 | |
| shininess | 控制反光有多集中 | 4→32.8°,256→4.2° |
| 环境光 | 常数项,补间接光的洞 | 局部光照模型算不出全局效果 |
| 能量不守恒 | 三个独立常数可能加超过 1 | 过曝、换场景要重调 |
| PBR | 换成 roughness / metallic | 物理参数,跨环境可复用 |
| 金属 vs 非金属 | 漫反射趋零、高光带色 | 解释了金子为什么反金光 |
| 线性空间 | 光照计算必须在线性域 | 贴图声明 sRGB,硬件自动解码 |
延伸阅读
- LearnOpenGL-CN《基础光照》:https://learnopengl-cn.github.io/02 Lighting/02 Basic Lighting/
- LearnOpenGL-CN《PBR 理论》:https://learnopengl-cn.github.io/07 PBR/01 Theory/
- 《Physically Based Rendering》在线版(PBR 的权威参考书):https://pbr-book.org/
- GAMES101 Lecture 7:Shading 1:https://games-cn.org/intro-graphics/
- AOSP 源码:
frameworks/base/libs/hwui/Lighting.h、frameworks/base/libs/hwui/LightingInfo.cpp