不知道你发现没有,自己写一个渲染引擎,几何变换、相机矩阵都跑通之后,最兴奋也最受打击的一步,往往是把第一盏灯放进场景。兴奋是因为终于能看到立体感了,受打击是因为满屏的塑料质感,亮的地方像刀切一样白,暗的地方死黑一片,转个视角高光还在乱跳。这不是参数没调够,而是光照模型(Lighting models)本身没有选对、没有吃透。
这篇文章以我搭过的一个简单引擎项目为主线,只讲局部光照里最常用、最能稳定出效果的一整套做法:为什么漫反射用Lambert,高光用Blinn-Phong而不是Phong,三种光源为什么写法完全不同,衰减公式为什么长成那个样子,材质参数又是怎么跟光照模型拧在一起的。如果你正卡在自己渲染引擎的“光照阶段”,或者在做课程里的软件渲染器作业,这篇应该能帮你把思路理顺。
1. 为什么一开灯画面就翻车:光照模型到底在解决什么问题
1.1 一束光照到桌子:方向、法线与视线的三角关系
先问个最基础的问题:我们为什么要写光照模型?因为屏幕上某个像素的颜色,本质上等于“这个像素对应的物体表面点,最终有多少光进入了虚拟相机”。光从光源出发,打到表面,被表面反射,其中一部分进入相机。这条路径上有三个向量非常关键:光源方向L、表面法线N、视线方向V。光照模型,就是用一条数学公式去近似“这三个方向关系下的反射能量”。
很多人第一步就栽在“为什么同一个表面,换个角度就变亮变暗”。拿一张纸放在台灯下,纸面正对台灯时最亮,倾斜一点就暗一些,再斜就暗更多,直到几乎平行时,反射进眼睛的光就很少了。这个现象叫朗伯余弦定律,它说的就是反射光强与cosθ成正比,θ是法线N和光源方向L的夹角。直觉理解是:同样一束光斜着打过来,会摊开在更大的表面积上,单位面积分到的能量变小,所以看起来就变暗了。
在代码里,cosθ是用单位向量的点乘dot(N, L)算出来的,结果范围在-1到1之间。1代表光源正对法线,0代表平行扫过,-1代表光从表面背面照过来。这个数值会被直接拿来缩放光源颜色,所以严格来说,漫反射的强度本质上就是一个“方向余弦响应”。理解这一层,后面所有公式都顺了。
1.2 局部光照与全局光照:简单引擎的站位选择
这里要澄清一个容易让新手焦虑的概念:全局光照。现实世界里,光在物体之间反复弹射,白墙反射的光会打亮桌子暗面,窗帘透进来的光会在房间角落铺上淡色,这些都属于间接光照。想在引擎里完整模拟它,需要做辐射度、路径追踪之类的重计算,那不是“简单引擎”的第一站。
我的建议很直接:第一版引擎老老实实做局部光照(Local Illumination),只考虑光源直接照射到表面的能量,把间接光用一个很小的环境光项近似掉。环境光项本身没有任何空间信息,就是往最终颜色里加一个常数偏移,比如:
ambient = ambientStrength * ambientColor这个常数用来模拟“场景里所有物体互相反光后留下的最低亮度”。只要别调太大,画面既不会死黑,也不会丧失立体感。局部光照的每条公式都可解析、每个结果都可预期,调试起来远比一上来就套全局光照方案舒服。等材质系统、多光源、相机管线都稳定了,再考虑加环境贴图或者做一次简单的间接光,完全不迟。
这个站位决定了你的工作量边界:简单引擎的目的不是物理正确,而是视觉可控。视觉可控的意思是,你调整任何一个参数,都能大致猜到画面会发生什么。局部光照模型天然满足这个需求,这也是它在实时渲染里活到今天的原因。
2. 数学底座:Lambert漫反射与Blinn-Phong高光的取舍逻辑
2.1 Lambert漫反射:一张纸被照亮的全部秘密
把上一节的cosθ写成完整公式,就是标准的Lambert漫反射:
diffuse = albedo * lightColor * max(dot(N, L), 0.0)等号右边四项:albedo是表面本身的颜色,红砖墙偏红、白纸偏白;lightColor是光源颜色;dot(N, L)就是cosθ;max(0, ...)的意思是,当光线从表面背面射过来时,不期望出现“负光照”,直接把贡献归零。
这个max看起来简单,却是引擎里一堆“怪现象”的来源。很多新手舍不得写max,或者写成了abs,结果物体背面被染上奇怪的颜色。一个小技巧:调试漫反射时,直接把输出写成diffuse,你能立刻看出哪些面是正面、哪些是背面,非常直观。
Lambert的局限也很明显:它没有任何依赖视线方向V的项,意味着不管从哪个角度观察,同一个点的明暗完全一样。现实里哑光表面确实接近这种效果,但塑料杯有高光,手机屏幕有强烈反射,金属表面更是明暗跳动剧烈。所以光靠Lambert,画面会像涂了一层薄薄的哑光漆,立体感有了,质感还没有。
2.2 Phong高光:反射向量与视线方向的夹角为何不好用
为了补上高光,传统Phong模型引入一个想法:镜面反射光会集中在“反射向量R”附近。先用反射公式算出R:
R = 2 * dot(N, L) * N - L这个公式的本质是把入射方向L关于法线N做一次镜面翻转。然后看反射向量R和视线方向V的夹角φ,dot(R, V)越大,说明视线越接近反射方向,看到的高光越强。最终写成:
specular = lightColor * specStrength * pow(max(dot(R, V), 0.0), shininess)shininess(反光度)控制高光的收缩程度:取值小,高光面积大、边界模糊;取值大,高光面积小,像抛光金属。pow在这里的作用是把光谱“挤”向反射方向,让高光有一个从亮到暗的锐利过渡。
Phong在纸面上很漂亮,实际写起来却有几个不舒服的地方。一是反射向量R多了几次运算;二是当视线方向和反射方向夹角超过90度时,dot(R, V)会跌成0,高光一下子被“砍”掉,边缘出现明显的不连续痕迹;三是它对法线精度要求很高,只要法线带一点噪声,高光就会在物体表面抖来抖去。效果倒不是不能用,但总觉得哪里被绊着。
2.3 Blinn-Phong:半程向量带来的稳定与效率
于是就有了Blinn-Phong。它不再单独计算反射向量,而是直接算半程向量H——光源方向和视线方向的中间方向:
H = normalize(L + V) specular = lightColor * specStrength * pow(max(dot(N, H), 0.0), shininess)从几何上看,H是“看向光源的方向”和“真正视线方向”之间的中间轴。当N和H重合时,说明视线恰好盯着反射方向,高光最强。这个变换省掉了反射向量计算,还消除了Phong在大夹角下高光突变的问题,所以后来几乎所有实时渲染引擎在非物理光照管线里都默认用Blinn-Phong。
我做简单引擎时也直接选Blinn-Phong。这里的取舍逻辑很简单:同样的效果,更少的着色器指令、更稳定的边缘、更符合“手调参数看效果”的工作流。你可以把Blinn-Phong理解成“给Phong找了个更顺手的工具”,它不追求物理真实,但实时渲染性价比实在太高。
下面这张表可以帮你快速对比三者定位:
| 模型 | 依赖关键向量 | 优势 | 主要问题 |
|---|---|---|---|
| Lambert | N、L | 最简、稳定、无视线依赖 | 没有高光,表面像哑光 |
| Phong | N、L、R、V | 高光方向直观 | 反射向量计算多,大角度有突变 |
| Blinn-Phong | N、L、V、H | 高光稳定,运算少 | 高光形状与Phong略有差异 |
3. 在渲染管线里落地:从逐顶点光照到逐片元光照的经典选择
3.1 Gouraud式逐顶点光照:便宜,但会留下马赫带
光照模型公式写出来不复杂,真正决定画面质量的是你把它放在渲染管线的哪个阶段执行。最早的实用方案是逐顶点光照:顶点着色器里算好光照颜色,让插值器把颜色在三角形内部平滑过渡。这个方法后来被称为Gouraud Shading,优点是明显:每个顶点只算一次,三角形内部只有一次线性插值,性能极好,非常适合软件渲染器。
它的缺点也很致命。光照模型往往是非线性函数,尤其是pow高光,它是“挤”出来的。你在顶点之间做线性插值,插值的却是已经被pow挤压过的数值,三角形内部颜色会偏离真实曲线,于是高光经常在三角形顶点上聚成一团光斑,三角形内部反而暗下去。更糟的是,相邻三角形各自不同,网格上会浮现一圈圈“马赫带”——大脑会自动加强明暗突变处的边缘感,让画面显得脏而破碎。
我第一版软件渲染器就踩过这个坑:几何正确、法线正确,但渲染出的球面上总有一道道明暗竖纹。查明原因后我把光照从顶点阶段挪到片元阶段,竖纹立刻消失。这是简单引擎里最值得认真体会的一次迁移,它比任何理论解释都直观。
3.2 逐片元光照的最小实现流程
所谓逐片元光照,就是在片元着色器阶段对每个屏幕像素执行完整的光照模型计算。这个流程写顺之后,后续加阴影、加多光源都只是往同一框架里塞东西。
最小实现可以拆成五步:
- 顶点着色器输出世界坐标、法线,以及纹理坐标等其他需要的属性。
- 片元着色器拿到插值后的世界坐标和法线,立刻归一化。
- 计算光源方向L、视线方向V、半程向量H。
- 分别计算漫反射项和高光项。
- 环境光、漫反射、高光相加,输出到颜色缓冲区。
对应到GLSL风格的着色器,核心代码大概是这个模样:
// 片元着色器局部示意 vec3 N = normalize(vNormal); vec3 L = normalize(uLightPos - vWorldPos); vec3 V = normalize(uCameraPos - vWorldPos); vec3 H = normalize(L + V); float ndotl = max(dot(N, L), 0.0); float ndoth = max(dot(N, H), 0.0); vec3 diffuseColor = uAlbedo * uLightColor * ndotl; vec3 specularColor = uLightColor * uSpecStrength * pow(ndoth, uShininess); vec3 finalColor = uAmbient + diffuseColor + specularColor;很多人在这个阶段忘记第一行给法线归一化。顶点法线经过光栅化插值后,长度通常不再是1,直接拿去点乘,结果会被整体放缩,画面就出现一种“忽明忽暗”的脏感。归一化看着只多一条语句,却是整个光照调试里优先级最高的习惯。
3.3 给着色器立几条朴素规矩:点乘、归一化与分支
写多了光照着色器,我给自己总结了几条很朴素的规矩,简单引擎阶段完全够用:
- 需要参与点乘的向量,统一先normalize。不要只在公式里写一次,要确保所有方向向量长度都是1。
- 能合并的向量计算不要拆开算。半程向量H是L和V同时参与的结果,一次normalize就够,别分别归一化L和V再相加,那样等于多花一组运算。
- 片元着色器里尽量避免动态分支。同一三角形内部的像素走不同分支,会让GPU并行效率变差。像if (ndotl > 0)这种,更推荐直接用max替掉,把分支翻译成数学运算。
- pow的底数永远夹到0以上。底数一旦为负,在不同硬件上结果可能完全不同,调试时最怕这种平台差异。
这四条不是教条,是从一次次画面异常里总结的。尤其是pow底数变负,几乎每个写光照模型的人都遇到过,但很少有人第一时间往底数上想。
4. 从单灯到多灯:光源类型差异与衰减策略
4.1 平行光、点光源、聚光灯:参数差异就是公式差异
引擎里最常见的三种光源类型,参数不一样,光照公式写法也不一样。很多人以为是三种算法,其实它们只是同一套光照模型换了输入参数:
- 平行光只有方向,没有位置和衰减。场景里所有表面的L方向都是同一个向量,太阳光就是典型。
- 点光源有位置,L方向从表面指向光源位置,随距离变化,并且要做距离衰减。
- 聚光灯在点光源基础上多一个主轴方向和锥角限制:偏离主轴越远,光照越弱,锥角之外完全不照亮。
对应到计算上的差异可以写得很精简:
// 方向向量与衰减示意 vec3 L = useDirectional ? normalize(uLightDir) : normalize(uLightPos - vWorldPos); float attenuation = useDirectional ? 1.0 : 1.0 / (1.0 + kLinear * d + kQuadratic * d * d);聚光灯的锥角衰减通常用主轴方向S和L的反向之间的点乘近似:dot(-L, S)越接近1,说明片元越在锥角中心,再配合内外两个阈值做平滑过渡,就能做出手电筒那种“中心亮、边缘柔”的效果。
4.2 物理衰减与视觉衰减:平方反比为什么不直接可用
从物理角度,点光源辐射到单位面积的能量按距离平方反比衰减,也就是1/d²。但直接在光照模型里用它,会出现很尴尬的画面:光源附近亮到爆,稍微走远几步就黑到看不见,动态范围拉太大,显示器根本装不下。所以实战里更常用改良形式:
attenuation = 1.0 / (1.0 + kLinear * d + kQuadratic * d * d)分母里的常数项避免距离为0时除零爆炸,线性项提供一条平滑骨架,二次项保留一部分物理特征。你调kLinear和kQuadratic的过程,本质上是在“物理正确”和“显示舒适”之间找平衡。我的经验是kLinear给0.1到0.2,kQuadratic给0.01到0.03,然后按场景里灯光的影响范围微调,别一上来就照搬教科书的纯平方反比。
还有衰减截断问题。理论上点光源影响整个场景,但实际引擎里不可能让一盏灯遍历所有物体。简单做法是给点光源一个最大影响半径,超出半径直接算衰减为0,或者做一个平滑渐变。这个“范围外不贡献”的假设,是多光源性能优化的起点。
4.3 简单引擎的多光源方案:固定上限比无限光源更实用
多光源怎么塞进渲染流程?最直白的方式是给着色器传一个光源数组,循环累加每个光源的贡献。但动态循环访问数组在一些图形API里并不高效,所以常见做法是设置一个相对稳定的光源上限,比如4到8个,对每个片元执行固定次数的累加。
vec3 total = vec3(0.0); for (int i = 0; i < MAX_LIGHTS; i++) { if (i >= uLightCount) break; total += accumulateLight(uLights[i], ...); }这种写法胜在实现简单,调整灯数量只需改一个uniform,整个场景渲染代码不用变。代价是就算场景里只有一盏灯,也会循环最大次数,浪费一点算力。简单引擎完全接受这种浪费,因为正确性和可维护性比这点性能更值钱。
等以后想优化,再考虑按光源影响半径裁剪物体、用模板标记受影响的区域,或者干脆做延迟渲染,把光照循环挪到屏幕空间。这些进阶手段有一个共同前提:你已经在某个固定上限的多光源框架里把光照逻辑调通了。
5. 材质参数与光照模型的连接:调出塑料、金属与旧墙面
5.1 三个旋钮的直觉:反照率、高光强度与反光度
光照模型本身是通用的,它不知道你画的是塑料还是一堵墙。材质信息通过参数喂进来,在Blinn-Phong里最核心的只有三个值:
- 反照率(Albedo / Diffuse Color):表面“本色”,决定漫反射项颜色。红墙反照率偏红,白纸接近纯白。
- 高光强度(Specular Strength):决定高光项整体强弱。塑料高光清晰但强度不高,镜面金属高光极强,甚至能压过漫反射。
- 反光度(Shininess):决定高光点大小和锐度。数值低,高光像磨砂扩散;数值高,高光像镜面聚焦。
这三个值组合起来的“性格”非常鲜明。塑料制品通常是中等反照率、中等高光强度、中等反光度;抛光金属是低反照率、高高光强度、高反光度;旧墙面就是高反照率、接近零的高光强度。把这些配方记住,不用看参考图也能调出大致质感。
需要说明的是,Blinn-Phong里这些参数不是物理量,只是“看起来像”的视觉参数。真实塑料的微表面结构会让高光随观察角度变化更复杂,但在简单模型里一个shininess就能模拟得七七八八。别被“不物理”的说法吓住,视觉上可控,就是简单引擎最大的合格标准。
5.2 参数调试的对照实验:从基准白球开始拆项
调材质参数,我最推荐的方式是“拆项对照”。先在场景中心放一个标准圆球,给它中性反照率(比如0.5灰度)、低高光强度、中等反光度,场景调成单点光源,背景尽量干净。然后每次只改一个参数,观察变化。
更系统的做法,是在调试阶段把最终颜色拆成三个可切换的输出:
// 调试用输出开关 finalColor = uDebugAmbient ? ambient : finalColor; finalColor = uDebugDiffuse ? diffuse : finalColor; finalColor = uDebugSpecular ? specular : finalColor; finalColor = uDebugComposite ? ambient + diffuse + specular : finalColor;只看漫反射项,能立刻发现法线方向对不对、光源方向是否反了;只看高光项,能迅速看出shininess把高光挤到多大;只看合成结果,才能评价三项配比是否舒服。我用这个办法排查过好几次“画面像塑料”的问题,最后都发现是某个单项太强,而不是整套模型出错。
一个小经验:环境光项别调太高。很多新手为了让暗部不黑死,把环境光直接调到画面亮度的两三成,结果整个画面灰蒙蒙,漫反射立体感全被盖掉。环境光只是“暗部最低亮度”的托底,一般调到漫反射最高亮的10%到20%就够,它不是主光源的替代品。
5.3 旧光照模型的边界:什么时候该考虑PBR
Blinn-Phong用起来顺手,但边界也要说清楚。最明显的痛点是能量不守恒:反射出去的光能可能超过入射光能,高光项和漫反射项各自独立,某些角度颜色亮得不正常。再加上它用一个标量shininess描述高光,没法区分金属和非金属的反射特性差异,粗糙表面的微表面变化更是表现不了。
当你发现这些局限真正影响画面质量时,就是换基于物理的光照模型(PBR)的时候。PBR的核心不是一两个公式替换,而是一整套工作流:金属度、粗糙度、法线贴图、能量守恒的BRDF。但无论它多“物理”,踩坑时的调试手段仍然绕不开“拆项对照”这套方法论,所以先把Blinn-Phong吃透再上PBR,路会顺很多。
6. 光照调试实录:发黑、过曝与接缝闪烁的完整排查链路
6.1 法线为什么会指向反方向:从模型数据到变换矩阵
光照画面最烦人的问题之一,就是“怎么调都发黑”。如果只有部分表面发黑,其他表面正常,第一个怀疑对象是法线。法线反了会让dot(N, L)变负数,经过max截断后输出0,这个面就彻底失去漫反射贡献,只剩环境光托底。
常见的法线错误来源有三个。一是模型导出时顶点法线没有统一朝向,网格里混着少量内翻的面;二是法线数据的符号在某个转换环节被写错;三是变换矩阵带非均匀缩放时,直接用模型变换矩阵乘以法线,导致法线方向被错误扭曲。第三种最隐蔽,因为画面看起来“只是有点怪”,但功能上又好像都对。
正确做法是利用法线变换矩阵,也就是模型变换矩阵的逆转置矩阵。因为非均匀缩放会同时作用在旋转和缩放上,普通矩阵会把法线拉变形,逆转置矩阵能保证变换后的法线仍然垂直于表面。简单引擎如果只做均匀缩放,这个问题不明显,但一旦你把物体拉伸成长方体、压扁成圆盘,就一定躲不过。
6.2 颜色空间:暗部发蓝、亮部过曝的“脏”来自哪里
另一种让人头疼的问题是:参数明明没动,整张图却“脏”。暗部蒙一层灰蓝,亮部动不动白到过曝,中间调透着一股浑浊感。这通常不是光照模型的问题,而是颜色空间链路没打通。
显示器和彩色图像普遍以sRGB颜色空间工作,它带大约2.2的伽马曲线。如果你的渲染管线把线性颜色值直接输出到帧缓冲,却没有做线性到sRGB的转换,看到的画面就会比数学推导结果偏暗、偏色。反过来,如果读取纹理时没把sRGB纹理转回线性空间再做光照混合,所有线性运算就都在非线性空间里进行,结果自然就“脏”了。
简单引擎的正确姿势是:参与光照计算的数值全部保持在线性空间,最终输出到帧缓冲前做一次sRGB编码。很多图形API可以靠帧缓冲格式自动完成,也可以在着色器末尾手动做。我建议哪怕引擎现在还只画纯色物体,也先把颜色空间链路打通,后面加纹理、雾效、环境光时,这个问题会越积越深,到时候再回头改就痛苦了。
6.3 浮点精度与接缝闪烁:大世界坐标下的灵异事件
第三个经典坑是接缝闪烁。两个共面的三角形明明应该连续,却在拼接处出现一亮一暗的条纹,仔细看还会随距离变化跳动。这类问题通常指向浮点精度。
原因在于,当顶点世界坐标离原点很远时,float的尾数精度不足以记录足够细的差值。两个顶点之间的短边可能被舍入成同一个值,插值出的法线或位置便出现意外的跳跃,反映到画面上就是接缝处的高光、颜色周期性闪烁。
排查链路很有规律:先看法线精度问题,把法线直接输出颜色观察;再看深度精度问题,调整远近裁剪面的比例;最后才想到顶点坐标本身的float精度。解决方案分几档:简单粗暴的做法是加大精度,或者把场景原点移到需要高精度的物体附近;更系统的做法是在渲染时以相机为原点,把顶点坐标先变换到相对相机的空间再参与插值,这样数值大小始终被控制在合理范围内。我自己在把场景切换到相对相机坐标之后,这类接缝闪烁一直没有再犯,算是用起来最省心的一种方案。