经典光照:环境光、漫反射和一点高光
Shading Equation & Classic Lighting Models
从渲染方程到一个求和式
完整的渲染方程要对一个半球上所有方向的入射光做积分,实时渲染算不起。《游戏引擎架构》(GEA) 用两条假设把它化简:
- 场景里只有理想化的点光源和方向光,没有面光源;
- 间接光照忽略不计,或者只做粗略的近似。
理想光源照到表面上的一点,只有一个入射方向 。所以积分变成了对光源的求和,再加上一个代表全部间接光的环境光项,就得到了着色方程:
翻译成大白话:看到的亮度 = 环境光 + 每个光源各自贡献一份。每一份由三样东西相乘:材质的反射特性 (BRDF)、光源的照度 ,以及法线和光线夹角的余弦(小于 0 时取 0)。RGB 之间的乘除都是逐分量的,正是第三课讲的「颜色相乘」。
这个方程给不出照片级的真实感,但能产生可信的画面。它是早期游戏的全部光照,也是理解 BRDF 怎么用的最好入门。
几个向量
所有的经典光照模型都围绕表面上一点的几个单位向量展开:
- 反射向量:,入射角等于反射角。GLSL 里写作
reflect(-l, n)。 - 半向量:,GLSL 里写作
normalize(l + v)。
三个分量
LearnOpenGL 把经典光照拆成三项,分别对应三种现象:
环境光
一个常数因子乘以光的颜色,模拟所有没被建模的间接光,免得背光面一片漆黑。
真实的间接光在场景里是变化的,但通常变得很平缓。所以更讲究的做法是把它存成一个随位置和方向缓慢变化的函数,比如一张环境贴图。低频的模型表达不了表面细节和自遮挡,通常还要再乘一个环境光遮蔽项。
漫反射:Lambert
理想的漫反射表面把光均匀地散射到各个方向。在着色器里,就是法线和光线方向点积,再截掉负数:
float ndl = max(dot(n, l), 0.0);
vec3 diffuse = albedo * lightColor * ndl;
夹角越大,同样的光摊在越大的面积上,看起来就越暗。
高光:Phong 与 Blinn-Phong
漫反射在各个方向上都一样亮,高光却只在某些方向上亮:眼睛越接近镜面反射的方向,看到的高光越强。
- Phong(1975):比较反射向量 和视线 ,
pow(max(dot(r, v), 0.0), α); - Blinn-Phong(1977):比较半向量 和法线 ,
pow(max(dot(n, h), 0.0), α)。
指数 叫光泽度:越大,高光越小、越集中。高光通常更接近光的颜色,而不是物体的漫反射色。顺带一提,「银色」其实不是一种颜色,而是一个处处镜面反射的白色物体。
下面是一个在片元着色器里「假装」出来的球:每个像素按它在圆盘上的位置算出法线。把鼠标移进画布,光源会跟着鼠标走:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
为什么要换成半向量
Blinn-Phong 不只是换了一种写法,它修了 Phong 的一个毛病。
Phong 比较的是视线和反射方向的夹角。光泽度很低(高光很大很散)时,这个夹角一超过 90°,点积就变成负数,被 max 截成 0,高光在那里被硬生生切断。漫反射在 90° 以外归零是合理的,镜面反射测的却是视线和反射方向的夹角,低指数时那里本该还有一点贡献。
半向量和法线的夹角大约只有视线和反射方向夹角的一半,只要光源不在表面下方,就不会超过 90°,也就没有这道硬切口。
直接改上面的表达式就能重画:x 是 0 到 1 的横坐标,可以用 sin、pow、smoothstep 等函数。
三个版本对照
GEA 列出了三种写法。它们都不是物理准确的,只是合理的近似:
| 模型 | 镜面项 | 说明 |
|---|---|---|
| 原始 Phong(1975) | 能量不守恒,也不满足 Helmholtz 互易 | |
| 修正 Phong | 去掉除以 ,乘上归一化因子 | |
| Blinn-Phong(1977) | 半向量在微表面理论的推导里也会自然出现,有物理依据 |
实际的引擎常常把归一化因子简化掉,并进镜面色里,得到最常见的写法:
有些教材只把余弦乘在漫反射上,不乘在高光上。GEA 的看法是:Blinn-Phong 本来就是早于现代辐射度量方法的一个 hack,余弦放不放进镜面项,都不是百分之百物理正确。
在 3D 里试试
下面换成一个真正的三维模型。可以切换光照模型、换形状,用滑块调光源的方位、仰角和各项强度:
在哪里算:逐顶点还是逐像素
同一个光照模型,放在管线的不同位置算,效果差别很大:
- 平面着色 (flat shading):每个三角形只算一次,通常只有一个光源、不算高光。最早的引擎这么做,现在主要用作刻意的风格。
- Gouraud 着色:只在顶点上算光照,三角形内部对颜色做线性插值。网格密时效果尚可,三角形一大,线性插值的毛病就很明显,高光尤其容易走样。
- Phong 着色:在片元着色器里逐像素计算。注意,这里的「Phong」说的是在哪算,和 Phong 光照模型是两回事。
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
在游戏里怎么用
取舍与常见的坑
小结与练习
- 着色方程 = 环境光 + 对每个光源求和:BRDF × 照度 × 余弦。
- Lambert 漫反射看 ;Phong 高光看 ,Blinn-Phong 看 。半向量避免了 Phong 在低光泽度时的硬切口。
- 同一个模型可以逐三角形、逐顶点或逐像素计算;逐像素最平滑,代价也最高。
练习:在「拖着光源照一个球」的代码里加第二个光源:一盏从右下方打过来的蓝色补光,强度只有主光的三分之一。把两盏灯的漫反射和高光分别加起来,看看球的立体感有什么变化。
GEA 把渲染方程在两条简化假设下化成的求和式称为着色方程:只有理想化的点光源和方向光,间接光只用一个环境光项粗略近似。配上 Lambert、Phong、Blinn-Phong 这类简单 BRDF,它给不出照片级真实感,但能产生可信的画面,是早期游戏的全部光照,也是理解 BRDF 用法的最好入门(GEA4 卷 II 12.4、12.5.1)。
从渲染方程到着色方程
两条假设(卷 II 12.4.1):
- 场景里只有理想化的点光源和方向光,没有面光源;
- 间接光照忽略不计,或只做粗略近似。
理想光源在每个表面点只有唯一的入射方向 ,所以不必对半球积分,BRDF 可以写成非微分形式 。积分变成对光源求和;没有面光源,自发光项也去掉;再加一个代表全部间接光的环境光项 :
下标 v 表示"视觉的"(光度学量),不要和视线向量 混淆。RGB 量之间的乘除都是逐分量的。
这个方程的代价是:不能物理正确地处理间接光,也不能处理面光源,因此单靠它产生不了软阴影(卷 II 12.4)。
环境光项
真实的间接光在场景中变化,但通常变化平缓。可以把它建模成位置和方向的低频函数,而不是常数,例如存进立方体贴图(环境贴图,见 环境贴图)。低频模型表达不了表面细节和自遮挡,所以通常还要乘一个环境光遮蔽项(卷 II 12.4.1.1,见 环境光遮蔽)。
Lambert 漫反射与反照率
- 理想漫反射表面把入射功率按 Lambert 余弦定律向各方向散射。GEA 强调余弦项不是漫反射材质本身的性质,而来自辐照度"按垂直于传播方向的面积计"的定义,所以 Lambert BRDF 就是一个常数 ,单位是 sr⁻¹,严格说不是"颜色","Lambert 余弦定律"这个名字有点误导(卷 II 12.4.3.1)。
- 反射率 ,对漫反射是常数 ,即反照率 (albedo)。代入常数 BRDF,,得 。
- 常见做法是把 并进漫反射色,记作 ,这样"BRDF 等于漫反射色"。但要记住它其实是 BRDF 而不是反射率,换回反射率要乘 π(卷 II 12.4.3.2):
镜面反射定律
入射角等于反射角,反射向量 ( 指向光源)。理想镜面的 BRDF 除 方向外处处为零。"银色"不是一种颜色,而是处处镜面反射的白色物体,即 。真实材质的 BRDF 通常同时有漫反射和展开的镜面分量,而且两者对波长的吸收不同,所以把反射率拆成 和 (卷 II 12.4.3.3)。
Phong、修正 Phong 与 Blinn-Phong
三者都不物理准确,只是合理近似(卷 II 12.4.3.4–12.4.3.5):
| 模型 | 镜面项 | 说明 |
|---|---|---|
| 原始 Phong (1975) | 越大高光越集中;不守恒能量,也不满足 Helmholtz 互易 | |
| 修正 Phong | 去掉除以 ,乘归一化因子,使 可当作反射率 | |
| Blinn-Phong (1977) | 用半向量 ;三者漫反射项都是 |
- 用反射向量的 Phong 有瑕疵,例如在掠射角看平面时高光不会合理地拉长。半向量不用算反射向量,而且在微表面推导中自然出现,有物理依据。归一化因子 的推导 GEA 留给参考文献 [2] 第 7.6 节,不在书中推导。
- 实际引擎常取 ,把归一化简化为 并进镜面色 ,得到最常见的写法:
- 两种写法的区别:有些教材把余弦项只乘在漫反射上,这其实对应未修正的 Phong 式 BRDF(原始 Phong 的 恰好抵消了镜面部分的余弦);修正版让余弦同时作用于两项。GEA 的看法是 Blinn-Phong 本来就是早于现代辐射度量方法的 hack,余弦放不放在镜面项里都不是 100% 物理正确。
在光栅化引擎里用着色方程
- 现代引擎在像素着色器里逐像素求值;早期引擎只在顶点上算光照、在三角形内插值颜色,即 Gouraud 着色,网格密时效果尚可,三角形大时线性插值的缺陷非常明显;更早的引擎每个三角形只算一次(通常只有一个光源、不算高光),即平面着色 (flat shading),现在主要作为刻意的风格(卷 II 12.5.1,频率选择见 着色频率与材质系统)。
- 这种用法还默认空间里是真空或洁净空气,透明物体和烟雾等参与介质用 alpha 混合和粒子卡片之类的技巧处理。
- 因为只对相机直接可见的点求值、又忽略参与介质,出射亮度 就等于虚拟传感器收到的亮度,可以直接写成像素颜色(卷 II 12.5.1.1)。
- 输入从哪来:漫反射率通常采样漫反射贴图(反照率贴图);镜面反射率可以是顶点属性插值或采样镜面贴图,高光指数一般作为材质参数;法线来自面法线、插值顶点法线或法线贴图;、 由位置差归一化得到(卷 II 12.5.1.2)。
示例 HLSL 着色器的结构
GEA 给了一个完整的 Blinn-Phong 像素着色器,光照部分的结构是(卷 II 12.5.1.3):
- 常量缓冲分为每相机、材质、光照三组;光照组包含环境光颜色、一个太阳方向光和固定上限个点光源(各有颜色、位置、内外半径和开关)。
- 环境光:
c_ambientColor × 材质漫反射色。 - 太阳:
halfDir = normalize(toCamera + toSun);漫反射saturate(dot(toSun, n));镜面saturate(pow(dot(n, halfDir), specPower))。 - 点光源:同上,再乘
smoothstep(outerRadius, innerRadius, distance)作为距离衰减(不是平方反比,而是在影响半径内平滑衰减到 0)。 - 像素着色器先重新归一化插值后的法线,再把光照结果乘漫反射纹理颜色和顶点颜色。
原书问题(12.5.1.3 示例代码):
saturate(pow(nDotH, c_specularPower))先pow后钳制。nDotH为负时 HLSL 的pow结果是 NaN(MS Learn "pow":x<0 时返回 NaN;pow通常实现为exp2(y*log2(x))),代码只是靠saturate对 NaN 的处理(D3D10 及以后的功能规范规定 NaN 饱和为 0)才碰巧得到 0,移植到其他着色语言或对结果另作运算时就会出错。应写成pow(saturate(nDotH), c_specularPower),或用max(nDotH, 0)。- 镜面项没有乘 ,也没有在 时置零,所以背光面上仍可能出现高光;这对应上文"两种写法"里只把余弦乘在漫反射上的版本,而不是书中式 (12.30)。
BlinnPhong(pIn.position, ...)传入的是SV_POSITION屏幕空间坐标而非世界坐标,见 可编程着色器与 GPU 管线 中的说明。- 正文把存镜面反射率的贴图叫"specular power map",而高光指数(specular power)在同一段又说是材质参数;存反射率的应叫镜面贴图 (specular map)。
光栅化引擎的其他光照技巧
- 焦散 (Caustics):水面、抛光金属等强烈反射或折射造成的明亮光斑,反射面移动时会闪烁游动。可以把一张带半随机亮斑的(可动画)纹理投射到受影响表面上(卷 II 12.5.8)。
- 自发光物体:用自发光纹理,颜色始终满强度、与环境光照无关,例如霓虹灯、车灯。复杂的发光物往往组合多种技术,GEA 以《路易吉洋楼》(Wii) 的手电筒为例:镜头用自发光纹理,配一个向场景投光的聚光灯、一个半透明锥体网格表示光柱、面向相机的透明卡片做镜头光晕(引擎支持 HDR 时也可以用泛光),再加投射纹理做手电照在表面上的焦散(卷 II 12.5.10,见 泛光与镜头光晕)。
取舍
- 优点:便宜、易懂、参数直观,适合风格化或性能受限的平台;与渲染方程的关系清楚,便于理解为什么现代 PBR 要换更复杂的 BRDF。
- 缺点:没有面光源和软阴影,间接光只有常数或低频近似,镜面模型不守恒能量(未归一化时)。现代照片级引擎改用微表面 BRDF 和基于物理的光照,见 基于物理的着色。
GEA 原书中的问题
- 12.5.1.2 正文说半向量"由法线和光照方向平均得到",与 12.4.3.5 的定义 以及书中 HLSL 代码
normalize(toCameraDir + toLightDir)都不一致;应为光照方向与视线方向。 - 12.4.3.1 说"Lambert 余弦定律"有点名不副实,因为常数 BRDF 里没有余弦项。但 Lambert 余弦定律说的是理想漫反射面在某方向的辐射强度正比于该方向与法线夹角的余弦 (GEA 自己同节也是这样表述的);把强度除以投影面积 正好得到与方向无关的辐射亮度,这与常数 BRDF 是同一件事的两种说法,并不矛盾(Wikipedia "Lambert's cosine law")。着色方程中的 是入射端的投影面积余弦,与 Lambert 定律的出射端余弦是两个不同的余弦。同节还把出射方向的余弦写成 ,按其记号应为 。
- 12.4.1 说"没有面光源,所以自发光项 消失":自发光项描述的是被着色表面自身的发光,与有没有面光源无关;着色方程去掉它是一种简化,自发光物体在 12.5.10 中又以自发光纹理的形式加回(见下文"自发光物体")。
- 12.4.3.5 称半向量"自然出现在基于微表面的次表面散射 BRDF 推导中";半向量来自微表面镜面反射模型,"次表面散射"一词疑为笔误。
LearnOpenGL 的视角:Phong 三分量与法线矩阵(Lighting / Basic Lighting)
来源:Basic Lighting;摘录 Lighting — Basic Lighting。公式层级与 GEA 着色方程对照见上文;此处记录 GLSL 落地要点。
环境 / 漫反射 / 镜面
- 环境光:常数因子 × 光色,模拟未建模的间接光,避免背光面全黑。
- 漫反射:单位法线与单位
lightDir(光位置 − 片元世界位置)点积,max(dot, 0)后再乘光色;夹角越大越暗。 - 镜面:
reflect(-lightDir, norm)与视线方向点积,再pow(..., shininess);shininess越大高光越紧。镜面更偏光色而非物体漫反射色。
书中在世界空间做光照:顶点着色器输出 FragPos = vec3(model * vec4(aPos,1)) 与法线;片元里用 viewPos uniform。也可改到观察空间(观察者在原点),需同步变换相关向量。
法线矩阵
法线是方向、不受平移;非均匀缩放会使「用模型矩阵直接变法线」不再垂直于表面。正确做法:对模型矩阵左上 3×3 取逆的转置(法线矩阵)。GLSL 示例:Normal = mat3(transpose(inverse(model))) * aNormal。逆矩阵在着色器里昂贵,产品中通常 CPU 算好当 uniform 传入。
Phong 着色 vs Gouraud 着色
同一 Phong 模型若在顶点着色器计算再插值颜色,叫 Gouraud 着色,顶点数少时更省,但高光与渐变易失真;在片元着色器计算叫 Phong 着色,更平滑。与 着色频率与材质系统 中逐顶点/逐像素讨论一致。
Blinn-Phong 预告
Advanced Lighting 章用半向量 normalize(lightDir + viewDir) 替代反射向量;高光指数通常需约 2–4 倍才能接近原 Phong 外观。详见该章视角(写入本页或进阶光照页时交叉链接)。
LearnOpenGL 的视角:Blinn-Phong 半向量(Advanced Lighting)
Phong 镜面在低 shininess(大粗糙高光)时,视线与反射方向夹角超过 90° 会使 dot 变负,高光被硬切掉——漫反射里 >90° 归零合理,镜面测的是视线–反射角,低指数时仍可能有贡献。Blinn-Phong 改用半向量 ,用 dot(normal, halfwayDir) 升幂;视线与「理想反射」对齐时半向量与法线对齐,且半向量–法线角通常不超过 90°(光源不在表面下方时)。
GLSL:halfwayDir = normalize(lightDir + viewDir)。半向量与法线夹角往往小于视线–反射夹角,要接近原 Phong 外观需把指数大约乘 2–4(教程对比:Phong 8 vs Blinn 32)。早期 OpenGL 固定管线即用 Blinn-Phong。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 12 章 12.4.1、12.4.3.1–12.4.3.5、12.5.1、12.5.8、12.5.10 节
- Lighting — Basic Lighting
- learnopengl.com/Lighting/Basic-Lighting
- Advanced Lighting — Blinn-Phong
- learnopengl.com/Advanced-Lighting/Advanced-Lighting