写这篇东西之前,我翻了翻以前的项目笔记。Lambert漫反射模型这六个字,大概是每个做渲染的人最早接触的术语之一,但真正把它琢磨透的人不多。很多人以为这就是个公式,背下来扔进shader里完事。实际上,它决定了你整个光照系统最底层的观感,也是你后续理解PBR、理解能量守恒、甚至理解实时渲染各种“歪门邪道”的起点。这篇文章我打算完完整整拆一遍——从它为什么叫“漫反射”、公式里每一项到底是什么物理意义,到直接在GLSL里落地、再到用它时候的经典翻车现场。
无论你是刚入坑图形学的新人、自己写软渲染器的爱好者,还是已经在引擎里调材质的半熟手,这篇文章都值得花十分钟看完。我能确保的是,读完你不会再觉得Lambert只是一行代码。
1. 核心思路拆解:为什么整个渲染圈都在用Lambert当“地基”
1.1 一个平面“均匀”反光背后的物理直觉
先别急着看公式。想象一块磨砂材质的墙面,或者一张哑光打印纸。在普通室内灯光下,你从正面看它、从侧面看它,亮度差别其实很小,对吧?这就是漫反射最核心的直觉——光打到粗糙表面上,被四面八方均匀地散射出去,观察者从任何角度看到的亮度都一样。
这种表面在图形学里叫“理想漫反射表面”(理想散射体简称),它有一个很重要的属性:完全不保留入射光的方向信息。平行光打过来,表面上每个点都把能量均等地弹向半球空间的各个方向。这个特性在辐射度量学里对应一个结论——出射辐射亮度(Radiance,单位方向上的功率)是一个与方向无关的常量。
很多人会混淆“亮度一样”和“我们看起来一样”这两件事。如果观察者从不同角度看同一个漫反射平面,确实觉得它一样亮,这是因为辐射亮度不随观察方向变化。但注意,并不是每个表面点接收到的能量一样——这就要引出下一部分,入射角的作用。Lambert模型最容易被新手忽略的地方,恰恰在这个地方。
1.2 余弦定律:为什么垂直照射比斜射更亮
Lambert模型的数学基础是Lambert余弦定律。这个定律说的是:一个理想漫反射表面反射出的辐射强度(Radiant Intensity),与入射光方向和表面法线夹角的余弦值成正比。公式长这样:
[ I_d = k_d \cdot I_L \cdot \cos\theta ]
为了照顾不熟希腊字母的读者,拆开解释一下:
- ( I_d ):漫反射出射强度;
- ( k_d ):漫反射系数(也就是材质的基础颜色/反照率,介于0到1之间);
- ( I_L ):入射光强度;
- ( \theta ):入射方向与表面法线的夹角。
在标准光照里,( \cos\theta ) 等价于归一化法线向量 ( N ) 和归一化光照方向向量 ( L ) 的点积,也就是 ( N \cdot L )。
这个规律的生活经验也很容易验证:夏天中午太阳在头顶,光线近似垂直地面,地面晒得滚烫;傍晚太阳斜射,同样的地面温度就温和很多。这是因为同样的光束面积,垂直投射时能量集中,斜射时能量被摊到了更大面积上。对应到电脑上,当一个平面正对光源时 ( N \cdot L = 1 ),它最亮;转到侧面时点积趋近于0,它便暗下去。抛弃这个直觉,你的shader很难做出正确的立体感。
1.3 公式里为什么没有视角方向
很多初学者问过我同一个问题:“为什么漫反射不管摄像机在哪?Blinn-Phong里高光明明和视线方向有关。”
原因在漫反射本身的物理特性里——理想漫反射表面把入射能量均匀散射到整个半球空间,那么不论你在哪个方向观察,你收集到的能量密度(辐射亮度)是恒定的。这就像一间屋子里点了根蜡烛,四面八方的人都看到同一支蜡烛的亮度,只要距离不变,角度不影响亮度。
这个“视角无关”特性在实际渲染中非常重要。它意味着Lambert项的计算不依赖视图矩阵,不需要每帧把法线变换到观察空间,所以你可以在世界空间直接算。这也是为什么移动端低画质模型往往只用Lambert:少算、少判断、效果却不会太差。而后面要讲的Blinn-Phong或GGX,都加了视角相关的计算,性能开销自然更高。
2. 细节深挖与变种对比:Lambert没你想象的那么“原始”
2.1 Lambert vs Blinn-Phong vs GGX,到底差在哪
既然开篇提到Lambert是地基,那就必须建立起和后来模型的对照关系,这样你才能真正理解它被替换掉的原因。我整理了一张速查表,试着说明各光照模型的核心部分和应用场景:
| 模型 | 观察视角依赖 | 能量守恒 | 特性 | 适用场景 |
|---|---|---|---|---|
| Lambert漫反射 | 否 | 基础半经验模型,经典方式不满足严格意义守恒 | 表面完全不反光,均匀散射 | 移动端低配、烘焙、软渲染器、光照基底 |
| Blinn-Phong | 是(高光项) | 不保证,高光可能过曝 | 带高光、半球余弦近似 | 老式实时渲染、风格化材质 |
| GGX(微表面) | 是 | 严格满足,有法线分布函数和几何遮蔽项 | 金属高光、掠射拖尾 | PBR工作流、通用实时/离线渲染 |
| Oren–Nayar | 依赖粗糙度弱化视角 | 不严格 | 粗糙表面漫反射更柔、带逆反射效应 | 粗糙粘土、月球表面、布料补光 |
我不打算在这里展开PBR全流程,但有一点要厘清:在PBR的漫反射项里,Lambert一般仍然作为基础项被保留,它负责处理光线在微表面内部多次弹射后“只进不出、均匀随机”的那部分能量。而镜面高光(Specular)则交给微表面模型。所以PBR的能量守恒做法是“漫反射项 + 高光项”分离,两者不是取代关系。
2.2 能量守恒视角:为什么有人给Lambert乘上 1/π
我在网上看到很多教程代码里有这么一行:
float diffuse = albedo / PI * max(0.0, dot(N, L));新手常困惑:为什么凭空冒出一个 ( 1/\pi ) ?这就要涉及辐射度量学和能量守恒。在物理正确的渲染中,反射率定义是出射总辐射通量与入射总辐射通量的比值。理想漫反射表面的BRDF(双向反射分布函数)是常数 ( f_r = \rho/\pi ),这里的 ( \rho ) 就是反照率(albedo),( \pi ) 来自于对半球空间上余弦分布的所有出射方向的积分。
换句话说:为了让表面反射的总能量不超过接收的总能量,必须把漫反射系数除以 ( \pi )。如果直接写成 ( k_d \cdot (N \cdot L) ) 而前面不放 ( 1/\pi ),那么真实物理世界里反照率为1的表面就会被“放大” ( \pi ) 倍,光打上去能量直接爆炸——这也是很多纯Lambert渲染的材质看起来过亮的原因。
但在传统实时渲染管线的“Photoshop式”光照里,人们为了让渲染结果和美术调色直觉一致,往往故意省略 ( 1/\pi ),因为美术习惯直接把材质颜色当“最终输出颜色”,而不是当成“物理反照率”。两种流派没有绝对对错,关键在于你的管线预期是什么。如果做离线渲染或PBR,强烈建议保留( 1/\pi ),否则能量无中生有;如果做风格化场景,那省略它、再加高对比度,反而是常见技法。
2.3 Half Lambert:来自《Half-Life 2》的经典改良
Lambert模型直接用在角色身上,阴影过渡有时候会显得生硬,尤其低光区域容易“死黑”。这就有一个经典变体:Half Lambert,由Valve在《半条命2》中推广。它的核心思路是把 ( N \cdot L ) 从[-1, 1]映射到[0, 1]区间,再进行半量偏移和平方处理:
float halfLambert = dot(N, L) * 0.5 + 0.5; float diffuse = albedo * halfLambert * halfLambert;为什么前面要乘0.5再加0.5?因为原来的 ( N \cdot L ) 在背光面是负值,直接拿来当颜色会变黑,把它压到[0,1]区间内,至少保证所有方向都有亮度。然后再平方一次,会让暗部过渡更柔,背光面不至于一下就死黑。这个处理完全是为了美术风格服务的,物理正确性让位给了“画面好看”。如果你做的是卡通渲染或风格化角色,Half Lambert几乎是必选项。
2.4 法线贴图下的Lambert要注意什么
加入法线贴图之后,Lambert的漫反射项照常计算,但注意这里的 ( N ) 不再是从几何体插值来的几何法线,而是经过法线贴图扰动后的法线。常见错误是:在切线空间计算点积,却拿世界空间的光照方向去点乘,结果整个模型的亮度像被揉皱的纸片一样,到处泛白。
正确的做法是先通过TBN矩阵(切线、副法线、法线矩阵)把法线贴图采样的值从切线空间变换到世界空间,再与光照方向求点积。这个坑我踩过好几次,每次都在漫反射项上花好几个小时排查,最后发现就是漏了一次空间变换。记牢:所有光照向量必须在同一个坐标空间内运算,否则你会在调试画面上看到一堆解释不通的亮斑和污渍。
3. 实操过程:从零手写一个带Lambert光照的渲染器
3.1 环境准备:你要准备什么工具
为了让人人都能跟着玩,我这里建议用C++ + 一个简单的窗口库搭一个软渲染器,或者干脆用GLSL在ShaderToy类的环境里直接跑。我分享的代码用GLSL风格写,方便你直接塞进任意着色器里看效果。
如果你更倾向完整工程,推荐:Visual Studio / CLion + GLFW + glad + glm 的组合。网上模板项目很多,这不影响我们对光照的理解。关键是拿出一个能跑通、能看到结果的窗口环境。
另外,如果你手边没有现成的3D模型,我建议先用一个球体或立方体,因为它们的法线方向一目了然,便于你验证光照方向对不对。我最开始写软渲染器时,直接把斯坦福兔子模型怼进去,结果法线方向反了也不知道,复查了很久才发现是模型加载问题,这是后话。
3.2 顶点着色器端:法线变换的细节
先给一段顶点着色器代码,重点在于把法线正确变换到世界空间:
// vertex shader #version 330 core layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; out vec3 WorldPos; out vec3 Normal; uniform mat4 model; uniform mat4 view; uniform mat4 projection; void main() { WorldPos = vec3(model * vec4(aPos, 1.0)); Normal = mat3(transpose(inverse(model))) * aNormal; gl_Position = projection * view * vec4(WorldPos, 1.0); }注意这行:
Normal = mat3(transpose(inverse(model))) * aNormal;这是标准做法,作用是应对非等比缩放的模型变换。如果直接用model矩阵去变换法线,缩放不一致会导致法线方向漂移,光照结果会扭曲。但求逆矩阵开销大,如果确认模型都是等比缩放或没有缩放,可以直接:
Normal = mat3(model) * aNormal;很多引擎在动态物体上也会预计算法线变换矩阵,避免每帧做逆矩阵运算。对新手而言,记住这个细节能省掉不少排查时间。
3.3 片元着色器端:最简单的Lambert实现
接着看片元阶段。这里是Lighting的河南,我建议一层层往里边加代码。
先写一个最基础、不带任何高光的版本:
// fragment shader #version 330 core in vec3 WorldPos; in vec3 Normal; out vec4 FragColor; uniform vec3 lightDir; // 方向光方向(由光源指向被照点,归一化) uniform vec3 lightColor; uniform vec3 albedo; // 材质基础色 void main() { vec3 N = normalize(Normal); vec3 L = normalize(-lightDir); // 如果传入的是“从光源出发”的方向,则需要取反 // Lambert漫反射核心 float diff = max(dot(N, L), 0.0); vec3 diffuse = albedo * lightColor * diff; FragColor = vec4(diffuse, 1.0); }注意我这里lightDir注释写的是“方向光方向(由光源指向被照点)”,那么-lightDir就是从被照点指向光源的向量,点积结果才是我希望的入射方向。不同引擎、不同教程对于“lightDir”的约定不一样,这是最容易踩的坑之一。建议在自己的代码里统一注释清楚:到底是“光源->表面”还是“表面->光源”。
如果你愿意加上那 ( 1/\pi ),就变成:
vec3 diffuse = albedo * lightColor * diff / 3.14159265;3.4 添加环境光和多个光源后的完整流程
场景里只有一个直射光的话,背光面会完全变成黑色,看起来比较“硬”。为了验证,我加一个简单的环境光项:
vec3 ambient = albedo * 0.05; // 极简环境项,防止纯黑 vec3 color = ambient + diffuse;这在离线渲染里叫“常数环境项”,只提供一个最低亮度;在实时引擎里一般用环境贴图采样替代,思路是一样的。
更多光源时,我一般把光照结果累加。假设场景里有两个平行光和一个点光源,那么每个光源都计算一次归一化方向并求Lambert项,最后把所有diffuse累加。需要注意:每个光源点积完都必须max(dot(N, L), 0.0),因为背向光源的面不应该被该光源照亮(不能减光)。这在你调试“越算越黑”的时候非常关键,我就是漏了这句代码,导致物体背光面出现了极难看的黑斑。
3.5 参数选择的经验值:albedo、lightColor、光强怎么给
我不太喜欢只晒代码,参数调完之后的效果才关键。
- albedo(反照率):取值范围[0,1]。纯白0.9,纯黑0.02。真实场景里鲜有超过0.95的材质,如果你在贴图里把“花白色布料”填成了RGB(1,1,1),那它就是一个理想纯白——这在实际肉眼看起来反而会有点过曝。
- lightColor / lightIntensity:平行光强度一般取1.0~3.0(看引擎对HDR的处理)。点光源和聚光灯由于有距离衰减,中心光强可以调到5~10。如果你用的是传统Lambert且没乘( 1/\pi ),光强大于1.5就很容易白成一片。
- 伽马校正:这个我多说一句。很多表面在纯Lambert下出现“死白”,不一定是能量问题,而是没有做伽马矫正。渲染流程输出线性光,显示器却按sRGB曲线输出,结果中间调偏亮。不加伽马校正的话,不管调什么光照模型,画面都会显得塑料感强。
4. 常见问题与排查:从黑脸到能量爆炸的避坑指南
4.1 为什么我的模型背光面一片漆黑
这个问题排行新手坑第一位,几乎每个用Lambert做角色渲染的人都会遇到。
排查顺序如下:
- 确认光照方向向量归一化了吗?如果两个向量长度不为1,点积结果会偏大或偏小,导致光照过强或过弱。
- 确认法线是否经过归一化?插值得到的法线长度并不是1,必须要normalize。如果忘了,漫反射项会出现奇怪的条纹。
- 确认你用了
max(dot(N, L), 0.0)而不是直接dot(N, L)。后者在背光面会得到负值,叠加到颜色上就相当于减光,或者最终被clamp到0,结果就是一团黑。 - 确认法线方向没反。看模型是不是“内发光”的样子,如果是,多半是顶点法线或索引顺序反了,翻转法线即可。
我自己写过一次小场景,放在移动端上,背光面黑得跟碳一样,查了半个下午,结果只是忘记把法线纹理里的采样值从[0,1]重映射到[-1,1]。这种问题不排查一遍,根本不会意识到自己漏了这么基础的步骤。我给你们的经验是:一旦出现黑面/暗面,最先怀疑的不是光照方向,而是法线的“方向约定”。
4.2 为什么光照过曝,白得没有层次
前面提到过,在经典Lambert里,如果光强设为1.0,diffuse结果就是albedo乘以( N\cdot L ),最大值就是albedo。正常情况下不会超过1(不乘( 1/\pi )也不会)。但如果你用了HDR管线,中间可能会有ToneMapping环节,忘掉ToneMapping会导致所有高光区域全部输出成纯白;或者你乘了( 1/\pi )后没有调整光强,结果整体偏暗,然后一着急猛拉光强,又导致暗部和亮部一起过曝。
解决思路:多光源场景下手动限制每个光源贡献最大值,再加温和的色调映射曲线。例如:
color = color / (color + vec3(1.0));这是最简单的Reinhard ToneMapping,可以在保持颜色不过曝的同时降低层次损失。实际项目里还可以用ACES、Filmic曲线。我个人的习惯是:调光照时先关掉ToneMapping,只调光源和材质参数,确认基本关系后再加上ToneMapping微调最终观感。
4.3 为什么加了光照后模型出现“棱角”或“三角面感”
通常是因为法线精度不够。软渲染器或低精度模型上,顶点法线经过插值后,在某些地方会突变。尤其是低模像盒子一样表面很平,平面内相邻片元的法线几乎一致,光照变化在棱角处会突然断开。
Lambert模型本身是连续的,问题通常出在shader里法线的量化上,或者在法线贴图压缩时压缩得太狠。你可以尝试使用更高精度的法线贴图格式,或者对顶点法线做平滑处理。此外,移动端低精度浮点(mediump)可能造成颜色带,我建议在片元着色器里声明precision highp float,并观察一下是否还有带状条纹。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决动作 |
|---|---|---|
| 背光面纯黑 | 光照方向约定反了或缺少环境项 | 检查L方向,添加ambient项 |
| 整体过亮/过曝 | 没有ToneMapping,或光强太大 | 加色调映射,降低光强 |
| 表面出现条带 | 浮点精度不足或伽马空间混乱 | 声明highp,做伽马校正 |
| 模型上出现不规则亮斑 | 法线空间不一致 | 统一坐标空间,检查法线贴图解码 |
| 光照方向看起来完全相反 | 点积顺序错误或忘记取反L | 打印N/L值,确认方向 |
| 越往边缘越暗 | 顶点插值法线未归一化 | normalize Normal |
这个表是排查效率利器。我建议把它存下来,等下次遇到光照问题时,按行扫一遍,基本能解决九成问题。
4.5 实操心得:先做减法再做加法
新手阶段失手多加高光、多反射的渲染太多太多,我一度也这样。后来我总结了一套自己的调试习惯:第1版shader只写Lambert漫反射,光源参数只用一组平行光,贴图只用纯色,环境光直接关掉。确认整个模型明暗过渡没问题之后,再逐个往后加环境光、高光、法线贴图、多光源和ToneMapping。每当新加一个环节出问题,立刻就把那个环节单独摘出来测试。这种“最小复现”方法能大幅缩短你排查问题的时间。
比如我做一个材质的Diffuse项时,会先故意让法线贴图处于关闭状态,直接看几何体光照是否正常。如果几何体光照正常、加贴图后出问题,那毫无疑问是法线贴图采样或TBN矩阵的问题;反之,问题就在光照基础里。这么一分离,你就不容易陷入“调了半天也不知道是谁的锅”的泥潭。
5. 拓展:Lambert在离线渲染和PBR管线里的角色
5.1 路径追踪器里的Lambert集成
在光线追踪和路径追踪器中,Lambert表面一般以BRDF采样形式出现。每次光线击中表面,要从以法线为中心的半球上按余弦分布采样一个出射方向,然后把入射辐射亮度乘以漫反射BRDF和余弦项,累积到像素值里。
这里有一个十分常见但也容易被新手写错的点:对余弦分布的采样到底怎么算概率密度函数。假设我采样方向 ( \omega_i ) 的PDF公式是 ( \cos\theta / \pi ),那么BRDF ( \rho / \pi ) 除以PDF后正好等于 ( \rho )——这说明在纯漫反射场景中,追踪到下一次事件时的权重不变,能量守恒非常干净。如果不乘这个PDF概率项,蒙特卡洛积分的结果会偏亮或偏暗,具体取决于你采样的方向分布。我在自己的软渲染器里就踩过“漫反射采样权重漏算 ( \pi )”的坑,结果就是辐照度整体偏大,球体亮到刺眼;修改PDF后,整幅画面立刻恢复正常。
所以做路径追踪的朋友,一定要把“BRDF、PDF、余弦项”三者关系理清。只需要记住一句话:物理正确的漫反射,BRDF恒为反照率除以( \pi ),而采样PDF则是余弦除以( \pi );两者一比,权重恰好是反照率。这就是Lambert在离线渲染里如此干净利落的原因。
5.2 PBR工作流中Lambert并没有消失
越来越多的人上来就切换成PBR流程,觉得Lambert“过时”了。实际上,PBR里的漫反射项很多引擎仍在用Lambert,只是外面包裹了菲涅尔修正和能量守恒的归一化。比如Unreal引擎的漫反射项就是基于Lambert的,但用镜面反射项动态调整漫反射权重:
[ f_d = \frac{1 - F(\theta_l)}{\pi} \cdot albedo ]
这里 ( F(\theta_l) ) 是菲涅尔项,用来描述随着入射角增大,表面反射增强,进而漫反射能量相应减少。虽然多了这个权重修正,但核心依然是Lambert的“反照率除以( \pi )”结构。所以不要觉得Lambert是过时货,它是PBR大厦里最底层的砖块之一。
5.3 风格化渲染里Lambert的再创作
如果说PBR是物理路线,那风格化渲染就是美术路线。除了Half Lambert,我还见过日本动画渲染里常用的“两级漫反射亮度区间映射”:把 ( N\cdot L ) 的值映射成明、暗两个色带,中间用斜坡平滑过渡。本质上还是先算Lambert项,再做阈值化处理。
这种做法的好处是:关键明暗关系依然由Lambert提供,保证了光照方向的一致性;而阈值化的步骤又能把画面推向卡通感,创造出“赛璐璐式”的色块效果。说白了,Lambert提供的是光照的“结构”,风格化提供的是光照的“质感”。两者并不冲突。
6. 写在最后:我的一点个人经验
最近这几年,我发现自己每次从零搭一个渲染Demo,第一步依然是先确认Lambert漫反射正常,再往上去加其他效果。因为它足够简单、足够直观、也足够可靠。很多人觉得它太基础不屑于深究,但我恰恰认为,基础模型里的每一个取舍都反映了渲染系统最底层的物理和美术权衡。
如果你问我学习Lambert模型最值得带走的一句话,我会说:多去理解它为什么对被照点取的是余弦、为什么与观察角度无关、为什么要在能量守恒时除以( \pi ),比背一遍公式有用得多。当你把这些想明白了,Blinn-Phong里为什么要有半程向量、GGX里的法线分布函数为什么那样积分——这些看似更难的问题,实际上都能顺着同一套逻辑推出来。图形学从来不是靠背公式走远的,是靠理解“为什么”走远的。
最后再分享一个小技巧:以后写shader时,在调色板上放一个纯灰色球体和一个纯白色球体,用它们来检查你的漫反射模型。灰色球体最能暴露能量是否守恒和偏色问题,白色球体则最能展示亮度范围。只要你把这两个球的Lambert项调对了,放到实际材质上往往也不会出大问题。这个习惯救过我很多次,希望你也能用上。