1. 光照学习到底在学什么:从“能跑”到“跑得对”的认知升级
很多人学图形学,走到光照这一章就开始犯迷糊。前面画三角形、贴纹理、搞坐标变换,好歹有个直观的视觉反馈——模型动了、纹理贴上了,成就感来得很快。但一到光照,代码量突然翻倍,各种向量运算、点乘叉乘、反射折射,对着教程抄完一遍,画面确实亮了,可关掉教程自己写,脑子里一片空白。
这个现象太普遍了。我见过不少朋友,Phong模型和Blinn-Phong模型的代码能默写,但你问他“为什么高光指数越大高光越小”,他得愣半天。这就是典型的“跑通了但没学懂”。光照这一章的核心价值,不在于让你多会写几行着色器代码,而在于帮你建立一套从物理直觉到数学表达再到GPU执行的完整思维链路。
先把这个章节的知识版图理清楚。光照学习通常包含这么几块内容:基础光照模型(Phong和Blinn-Phong)、材质系统、光照贴图(漫反射贴图、镜面反射贴图、法线贴图)、投光物(平行光、点光源、聚光灯)以及多光源综合场景。这些内容不是孤立的,它们之间有一条清晰的递进关系——从最简单的“一个点光源照亮一个物体”,到“多个不同类型光源共同作用于一个带复杂材质的模型”。
为什么这个递进顺序不能乱?因为每一块都在为下一块铺路。你不理解Phong模型里的环境光、漫反射、镜面反射三个分量,就没法理解材质系统里为什么要用三个不同的贴图去分别控制它们。你不理解点光源的距离衰减公式,就没法理解聚光灯的平滑边缘是怎么算出来的。整个光照章节就像搭积木,底层不稳,上面越高越危险。
这篇文章适合谁看?如果你正在学OpenGL光照章节,跟着教程敲完了代码但感觉脑子里还是一团浆糊,那这篇总结就是写给你的。如果你已经学完光照想复习一遍,把零散的知识点串成体系,那也合适。甚至你用的是其他图形API,只要涉及基础光照模型,底层的数学原理和设计思路都是通用的。
我个人的体会是,光照这一章值得反复嚼三遍。第一遍跟着教程走,把代码跑通,看到画面亮起来。第二遍关掉教程,自己从零推导一遍每个公式,搞清楚每个向量代表什么、为什么这么算。第三遍跳出单个模型,去想“如果我要设计一个渲染引擎的光照系统,我会怎么组织这些代码”。三遍下来,你才算真正把这块知识吃透了。
2. 光照模型的核心原理拆解:为什么是这三个分量
2.1 环境光、漫反射、镜面反射的物理直觉
Phong光照模型把光照拆成三个分量:环境光、漫反射、镜面反射。这个拆法不是拍脑袋想出来的,它对应着现实中光与物体交互的三种基本方式。
环境光模拟的是“间接光照”。现实中,光线打到物体上不会凭空消失,它会在场景里弹来弹去,最终从各个方向微弱地照到物体表面。完整的全局光照计算这个弹射过程代价极高,所以Phong模型用一个常数来近似——假设场景里始终有一个均匀的、来自四面八方的微弱光照。这就是为什么环境光分量只有一个简单的乘法:环境光强度乘以物体颜色。
漫反射模拟的是“粗糙表面的散射”。光打到粗糙表面上,会向各个方向均匀散射,所以无论你从哪个角度看,这个表面的亮度都差不多。漫反射的关键在于光线方向与表面法线的夹角——光线垂直照射时最亮,斜着照时能量被摊薄,亮度下降。这就是Lambert余弦定律,用点乘来实现:光线方向向量和法线方向向量的点乘,结果就是夹角的余弦值。
镜面反射模拟的是“光滑表面的高光”。光打到光滑表面上会集中向一个方向反射,所以只有在特定角度才能看到高光。镜面反射的计算需要三个向量:光线方向、法线方向、观察方向。Phong模型的做法是先算出光线的反射向量,然后看反射向量和观察方向的夹角——夹角越小,高光越亮。Blinn-Phong模型做了个优化,不直接算反射向量,而是取光线方向和观察方向的半程向量,看半程向量和法线的夹角。这个优化不仅计算量更小,而且在很多情况下视觉效果更自然。
注意:环境光、漫反射、镜面反射这三个分量是相加关系,不是相乘。最终颜色 = 环境光贡献 + 漫反射贡献 + 镜面反射贡献。很多初学者会写成乘法,结果画面要么全黑要么全白。
2.2 Phong与Blinn-Phong的选型对比
Phong和Blinn-Phong的核心区别就在镜面反射的计算方式上。Phong用反射向量和观察方向的点乘,Blinn-Phong用半程向量和法线的点乘。这个差异带来的影响,值得单独拿出来说。
先看计算量。Phong模型需要计算反射向量,公式是reflect(-lightDir, normal),在着色器里就是一次reflect调用。Blinn-Phong需要计算半程向量,公式是normalize(lightDir + viewDir),一次加法加一次归一化。两者计算量差距不大,但Blinn-Phong少了一次reflect,在某些硬件上确实更快。
再看视觉效果。Phong模型在低精度高光指数下,高光边缘会出现明显的锯齿或断裂,因为反射向量和观察方向的夹角在边缘处变化剧烈。Blinn-Phong的半程向量变化更平滑,高光边缘更柔和。这也是为什么现代渲染管线里Blinn-Phong更常见。
但Blinn-Phong也不是没有缺点。当观察方向与光线方向夹角很大时,半程向量可能和法线的点乘结果小于零,导致高光突然消失。Phong模型在同样情况下表现更稳定。所以选哪个,取决于你的具体场景——如果追求性能和视觉平滑度,Blinn-Phong是首选;如果场景里观察角度变化极端,Phong可能更可靠。
| 对比维度 | Phong | Blinn-Phong |
|---|---|---|
| 镜面反射计算 | 反射向量与观察方向点乘 | 半程向量与法线点乘 |
| 计算量 | 略大 | 略小 |
| 高光边缘 | 低指数下可能有断裂 | 更平滑 |
| 极端角度表现 | 更稳定 | 可能高光消失 |
| 现代应用 | 较少 | 更常见 |
2.3 光照计算的坐标系选择
光照计算在哪个坐标系里做,这个问题看起来不起眼,但选错了会带来一堆麻烦。常见的选择有三种:世界坐标系、观察坐标系、切线坐标系。
世界坐标系是最直观的。光源位置、物体位置、观察位置都在世界空间里,算起来思路清晰。但问题是,如果物体动了,光照计算就得跟着变,而且法线变换到世界空间需要用法线矩阵,不能直接用模型矩阵。
观察坐标系的好处是观察者始终在原点,观察方向始终是(0,0,1),省去了传递观察位置的麻烦。但光源位置需要变换到观察空间,而且法线也要跟着变。
切线坐标系主要用于法线贴图。法线贴图里存储的法线是相对于表面切线空间的,所以光照计算要么把其他向量变换到切线空间,要么把法线从切线空间变换到世界空间。前者更常见,因为法线贴图采样后直接就能用。
我个人的建议是:基础光照用世界坐标系,思路最清晰,调试最方便。法线贴图用切线坐标系,避免额外的矩阵变换。多光源场景可以考虑观察坐标系,减少需要传递的uniform变量。
3. 材质与光照贴图:让物体“看起来像那么回事”
3.1 材质系统的设计哲学
材质系统要解决的问题是:同样一个光源,为什么木头看起来是木头,金属看起来是金属?答案在于不同材质对光的反应不同。木头粗糙,漫反射强、镜面反射弱;金属光滑,漫反射弱、镜面反射强。
在Phong模型里,材质用几个参数来描述:环境光颜色、漫反射颜色、镜面反射颜色、高光指数。这四个参数决定了物体在光照下的表现。环境光颜色通常和漫反射颜色一样,镜面反射颜色通常设为白色或灰色,高光指数控制高光的集中程度。
但只用参数来定义材质有个问题:整个物体只能用一种材质。现实中一个物体不同部位材质可能不同——比如一个木箱,铁皮包角和木头箱体的反光特性完全不同。这就是光照贴图要解决的问题。
3.2 漫反射贴图与镜面反射贴图
漫反射贴图存储的是每个像素的漫反射颜色。采样漫反射贴图,用采样结果替换原来统一的漫反射颜色,就能让物体表面呈现丰富的颜色变化。这一步相对简单,因为漫反射贴图就是普通的颜色纹理。
镜面反射贴图存储的是每个像素的镜面反射强度。通常是一张灰度图,白色区域表示高反光(如金属部件),黑色区域表示不反光(如木头、布料)。采样镜面反射贴图,用采样结果乘以镜面反射颜色,就能控制不同区域的高光强度。
这里有个容易踩的坑:镜面反射贴图的采样坐标通常和漫反射贴图一样,但如果你用了不同的纹理单元,记得在着色器里正确绑定和传递。另外,镜面反射贴图不需要sRGB校正,因为它存储的是强度值而不是颜色值。
实操心得:做镜面反射贴图时,不要追求“精确”。很多时候用一张简单的灰度图,甚至用漫反射贴图的亮度通道来近似,效果就足够了。过度追求贴图精度反而会让画面显得不自然。
3.3 法线贴图的原理与切线空间
法线贴图是光照章节里最让人头疼的部分。它的核心思想是:不增加模型面数,通过修改每个像素的法线方向来模拟表面凹凸细节。
法线贴图里存储的不是颜色,而是法线向量。因为法线向量的每个分量范围是[-1,1],而纹理存储范围是[0,1],所以需要做一个映射:存储值 = (法线值 + 1) / 2,采样时再反向映射回来:法线值 = 存储值 * 2 - 1。
关键问题来了:法线贴图里的法线是相对于哪个坐标系的?答案是切线空间。切线空间的原点在表面某一点,Z轴沿法线方向,X轴沿切线方向,Y轴沿副切线方向。这样存储的好处是,同一张法线贴图可以用在不同朝向的表面上,因为法线是相对于表面本身的。
使用法线贴图时,需要计算切线空间到世界空间的变换矩阵(TBN矩阵)。这个矩阵由切线、副切线、法线三个基向量组成。切线通常从模型顶点数据里获取,副切线通过法线和切线的叉乘得到。TBN矩阵的构建是法线贴图能否正确工作的关键,如果切线数据有问题,光照方向就会完全错乱。
| 常见问题 | 表现 | 排查方向 |
|---|---|---|
| 法线贴图反了 | 凹凸感相反 | 检查绿色通道是否需要翻转 |
| 光照方向错乱 | 高光位置不对 | 检查TBN矩阵是否正确 |
| 切线数据缺失 | 编译报错或黑屏 | 检查顶点属性是否包含切线 |
| 贴图接缝明显 | 边缘不连续 | 检查纹理环绕模式 |
4. 投光物的实现细节:从平行光到聚光灯
4.1 平行光:最简单也最容易忽略细节
平行光模拟的是太阳光——光源在无限远处,光线方向处处相同。实现上最简单,只需要一个方向向量,不需要位置和衰减计算。
但平行光有个容易忽略的细节:方向向量的方向。在光照计算里,我们通常用“从片段指向光源”的方向,而平行光定义的是“光线传播方向”。这两个方向是相反的。如果你定义平行光方向是(0,-1,0)表示光从上往下照,那么在着色器里计算时要用(0,1,0)作为光线方向向量。这个符号搞反了,画面就会全黑或者光照方向完全错误。
另一个细节是平行光不需要距离衰减。因为光源在无限远,距离变化对光照强度的影响可以忽略。如果你给平行光加了衰减计算,反而会让画面出现不自然的明暗变化。
4.2 点光源:距离衰减的三种模型
点光源模拟的是灯泡——光源在空间中一个具体位置,光线向四面八方均匀发射。点光源的核心是距离衰减:离光源越远,光照越弱。
衰减公式有三种常见模型:线性衰减、二次衰减、以及两者的混合。线性衰减是1.0 / (1.0 + linear * distance),二次衰减是1.0 / (1.0 + quadratic * distance * distance),混合模型把两者结合起来。
为什么要有三种?因为不同场景需要不同的衰减曲线。线性衰减在近距离变化平缓,远距离衰减慢;二次衰减在近距离变化剧烈,远距离衰减快。实际使用中,通常用混合模型,通过调节linear和quadratic参数来控制衰减曲线的形状。
参数怎么选?有个经验公式可以参考:假设你希望光源在距离d处衰减到某个强度,可以反推参数。但更实用的方法是试。先设linear=0.09,quadratic=0.032(这是教程里常用的值,对应50米左右的衰减范围),然后根据场景尺度调整。场景小就增大参数让衰减更快,场景大就减小参数。
注意:点光源的衰减计算是在片段着色器里逐像素进行的,所以距离越远的片段计算量越大。如果场景里有大量点光源,考虑用延迟渲染或者限制光源影响范围来优化性能。
4.3 聚光灯:平滑边缘的数学原理
聚光灯模拟的是手电筒——光线从一个点发出,但只朝一个锥形方向照射。聚光灯的实现需要三个参数:光源位置、照射方向、切光角。
切光角决定了光锥的大小。在着色器里,计算片段到光源的方向与聚光灯照射方向的夹角,如果夹角小于切光角,片段被照亮;否则不被照亮。但这样直接判断会产生硬边缘,看起来很不自然。
平滑边缘的做法是引入两个角度:内锥角和外锥角。在内锥角以内,光照强度为1;在外锥角以外,光照强度为0;两者之间做平滑插值。插值公式是clamp((cosAngle - cosOuter) / (cosInner - cosOuter), 0.0, 1.0)。这个公式的本质是把夹角余弦值映射到[0,1]区间,然后用这个值作为光照强度的乘数。
为什么用余弦值而不是角度值?因为点乘直接得到的就是余弦值,省去了反三角函数计算。而且余弦值在0到90度范围内是单调递减的,映射关系正确。
| 参数 | 作用 | 典型值 |
|---|---|---|
| 切光角 | 光锥总范围 | 12.5度 |
| 内锥角 | 全亮区域边界 | 切光角减2.5度 |
| 外锥角 | 全暗区域边界 | 切光角 |
| 平滑过渡 | 内外锥角之间 | 线性插值 |
4.4 多光源场景的组织方式
实际场景里通常有多个光源:一个平行光模拟太阳,几个点光源模拟灯泡,可能还有聚光灯做特殊效果。多光源的组织方式直接影响代码的可维护性和性能。
最直接的做法是在着色器里为每个光源写一套计算代码。但这样光源数量一变,着色器就要重新编译。更好的做法是用统一数组来管理光源。定义一个结构体数组,每个元素包含光源类型、位置、方向、颜色、衰减参数等。然后在着色器里循环遍历数组,根据光源类型分别计算。
但这里有个坑:着色器里的循环次数必须是常量。GLSL不支持动态循环次数,所以数组大小必须编译时确定。常见的做法是定义最大光源数量,比如16个,实际使用时只填充前N个,循环时判断索引是否小于实际光源数。
另一个优化思路是只计算影响范围内的光源。对于点光源和聚光灯,如果片段距离光源太远,衰减后贡献几乎为零,可以跳过计算。在CPU端做光源剔除,只把影响当前物体的光源传给着色器,能显著减少片段着色器的计算量。
5. 实操中的常见问题与排查技巧
5.1 光照计算中的向量归一化陷阱
向量归一化是光照计算里最基础也最容易出错的操作。法线要归一化,光线方向要归一化,观察方向要归一化,半程向量也要归一化。少归一化一个,光照结果就可能完全错误。
为什么归一化这么重要?因为点乘的结果受向量长度影响。两个单位向量的点乘结果就是夹角的余弦值,范围在[-1,1]之间。如果向量长度不是1,点乘结果就会被放大或缩小,导致光照强度计算错误。
常见的归一化遗漏场景:从顶点着色器传递到片段着色器的法线,经过插值后长度会变化,必须在片段着色器里重新归一化。从uniform传入的光源方向,如果没在CPU端归一化,也要在着色器里归一化。半程向量计算出来后必须归一化,否则点乘结果没有意义。
实操心得:在片段着色器开头,把所有参与光照计算的向量统一归一化一遍。虽然多几次计算,但能避免绝大多数光照异常问题。调试阶段宁可多算,不要少算。
5.2 法线矩阵与模型变换的坑
法线从模型空间变换到世界空间,不能直接用模型矩阵。因为模型矩阵可能包含非均匀缩放,这会导致法线方向扭曲。正确的做法是用法线矩阵,即模型矩阵左上角3x3部分的逆矩阵的转置。
为什么?法线是垂直于表面的向量,如果模型被非均匀缩放,法线方向会偏离垂直方向。用逆矩阵的转置可以修正这个偏差。如果模型只有旋转和平移,没有缩放,那么法线矩阵就等于模型矩阵的左上角3x3部分,可以直接用。
这个坑在什么时候会踩到?当你给物体加了非均匀缩放(比如把一个立方体压扁),光照就会变得很奇怪——高光位置偏移,明暗分界不对。这时候检查法线矩阵,十有八九是直接用了模型矩阵。
5.3 光照贴图颜色空间问题
光照贴图涉及颜色空间转换,这个问题很隐蔽但影响很大。漫反射贴图存储的是颜色信息,应该按sRGB空间处理;镜面反射贴图存储的是强度信息,应该按线性空间处理。
如果漫反射贴图没有做sRGB校正,颜色会偏暗,因为纹理采样得到的是sRGB值,直接当线性值用会丢失亮度。如果镜面反射贴图做了sRGB校正,高光强度会偏大,因为强度值被错误地提亮了。
OpenGL里处理这个问题的方式是:加载漫反射贴图时设置内部格式为GL_SRGB或GL_SRGB_ALPHA,让硬件自动做sRGB到线性的转换。镜面反射贴图用普通的GL_RGB或GL_RGBA,不做转换。
| 贴图类型 | 内部格式 | 颜色空间 | 原因 |
|---|---|---|---|
| 漫反射贴图 | GL_SRGB | sRGB转线性 | 存储颜色信息 |
| 镜面反射贴图 | GL_RGB | 线性 | 存储强度信息 |
| 法线贴图 | GL_RGB | 线性 | 存储向量信息 |
| 高光贴图 | GL_RGB | 线性 | 存储强度信息 |
5.4 性能优化:什么时候该省,什么时候不能省
光照计算是片段着色器里的大头,优化空间很大。但优化要有取舍,不能为了性能牺牲正确性。
可以省的地方:距离衰减计算可以用近似公式代替精确公式;高光指数可以用查表代替实时计算;多光源场景可以做光源剔除,只计算影响范围内的光源。
不能省的地方:法线归一化不能省,否则光照方向错误;TBN矩阵计算不能省,否则法线贴图失效;sRGB校正不能省,否则颜色不对。
一个实用的优化技巧是预计算。把能在CPU端算好的东西提前算好,比如光源的衰减参数、TBN矩阵的基向量,通过uniform传给着色器,减少片段着色器的计算量。
另一个技巧是降低精度。在移动端或者性能敏感的场景,可以用mediump代替highp,用近似函数代替精确函数。但要注意,法线计算对精度敏感,降精度可能导致光照瑕疵。
6. 从光照章节延伸出去的学习路径
光照章节学完,下一步通常有几个方向:阴影、高级光照模型、基于物理的渲染。这三个方向都和光照章节有直接关联。
阴影是光照的自然延伸——有了光照,自然要考虑遮挡。阴影贴图的原理是从光源视角渲染一张深度图,然后在正常渲染时比较深度值判断是否在阴影中。光照章节里学的光源类型、投影矩阵、深度测试,都是阴影的基础。
高级光照模型比如Cook-Torrance、GGX,是在Phong模型基础上的改进。它们更精确地模拟了微表面的光学行为,但核心思路仍然是环境光、漫反射、镜面反射的分解,只是每个分量的计算更复杂。
基于物理的渲染(PBR)是光照模型的终极形态。它用物理参数(粗糙度、金属度)代替经验参数(高光指数、镜面反射颜色),让材质表现更真实、更一致。但PBR的基础仍然是光照计算,只是把Phong模型里的经验公式换成了物理公式。
我个人的学习路径是这样的:先把Phong和Blinn-Phong吃透,能自己推导每个公式;然后学法线贴图和材质系统,理解如何用贴图控制光照参数;接着学多光源和投光物,掌握不同光源类型的实现;最后再进入阴影和PBR。这个顺序不能乱,因为后面的内容都依赖前面的基础。
如果你现在还在光照章节挣扎,别急。我当初学的时候,Phong模型写了五遍才真正理解。第一遍照着教程抄,第二遍自己默写,第三遍改参数看效果,第四遍用不同坐标系实现,第五遍优化性能。每一遍都有新的收获。光照这块知识,值得你花时间反复打磨。