本节摘要:Lambert 漫反射用一句点积回答"表面有多朝向光源",是所有光照模型的共同地基。本节建立点积的几何直觉,给出环境光加漫反射的完整着色器对,再补上点光源的距离衰减,让你的画面第一次拥有体积感。
读完本节,你应当能够:解释法线与光方向点积的物理含义;写出可编译的 Lambert 顶点与片元着色器对;区分平行光与点光源的代码差异;用环境光防止暗部死黑。
把物体照亮的原因可以用生活经验锚定:正对太阳的墙面亮,侧对太阳的墙面暗。Lambert 把这个经验精确化——表面接收的光能量与"表面法线和光线方向的夹角余弦"成正比。余弦在代码里就是向量点积:两个归一化向量的 dot 值天然是夹角余弦,垂直时为 0,同向时为 1。
于是核心代码只有一句:
float ndl = max(dot(N, L), 0.0); // N 法线、L 指向光源,都已归一化
max(..., 0.0) 不是防御式编程的装饰——背光面的点积是负数,直接乘上去会让颜色翻成负值,输出合并后出现诡异的反色。取零意味着"背光面不接收这盏灯的贡献",物理上也正确:光穿不过物体。
动手前把两个向量的方向约定钉死:N 从表面指向外,L 从表面指向光源(不是光的前进方向)。约定反了高光暗面会整体翻转,而且不报错——初学者在这一步浪费的时间,比后面所有数学加起来还多。

光有核心公式还不够,得把它装进完整管线。顶点侧负责把法线与位置送进世界空间(第 3 章的模板原样复用),片元侧完成着色:
// 顶点着色器:把光照需要的几何量送到世界空间 #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProj; out vec3 vNormal; out vec3 vWorldPos; void main() { vec4 world = uModel * vec4(aPos, 1.0); vWorldPos = world.xyz; vNormal = mat3(uModel) * aNormal; // 模型不含非均匀缩放时的简写 gl_Position = uProj * uView * world; }
// 片元着色器:环境光 + Lambert 漫反射 #version 330 core in vec3 vNormal; in vec3 vWorldPos; out vec4 FragColor; uniform vec3 uLightDir; // 指向光源的归一化方向(平行光) uniform vec3 uLightColor; // 光的颜色与强度 uniform vec3 uBaseColor; // 物体本色,可换成漫反射贴图采样 uniform vec3 uAmbient; // 环境光:替所有间接光兜底 void main() { vec3 N = normalize(vNormal); // 插值会破坏单位长度,必须重新归一 float ndl = max(dot(N, uLightDir), 0.0); vec3 color = uBaseColor * (uAmbient + uLightColor * ndl); FragColor = vec4(color, 1.0); }
两处细节值得停一停。normalize(vNormal) 不是多余的谨慎——法线经过插值后长度会偏离 1(三角形越大偏得越多),不归一化会在大面上看到亮度漂移。环境光项 uAmbient 的意义是替间接光兜底:现实里暗面也会被环境反弹照亮,纯 Lambert 的暗面是死黑,加一分环境光画面立刻"透气",这个参数就是调画面"发灰还是通透"的头号旋钮。
把 uBaseColor 换成漫反射贴图的采样值(第 5 章技术直接落位),物体就有了贴图细节与体积光照的结合。
平行光没有位置概念,所有片元共享一个方向;点光源有位置,每个片元要自己算方向与距离:
uniform vec3 uLightPos; vec3 toLight = uLightPos - vWorldPos; float dist = length(toLight); vec3 L = toLight / dist; // 即 normalize float atten = 1.0 / (1.0 + 0.09 * dist + 0.032 * dist * dist); // 经验衰减曲线 float ndl = max(dot(N, L), 0.0); vec3 color = uBaseColor * (uAmbient + uLightColor * ndl * atten);
衰减公式用平方倒数打底(物理事实:能量随距离平方摊薄),加两个系数控制近处强度与截止速度——纯物理式 1 除以 d 方 在近距离会爆炸,工程公式都是为了可控而做的软化。多光源就是把这段代码套进第 3 章讲过的 uniform 数组循环逐盏累加,注意光源数是片元着色器最重的常量开销之一,能合并的灯就合并。
💡 关键直觉:所有光照模型都在回答同一个问题——"这个方向进来的光,表面往观察方向反射出去多少"。Lambert 只答了入射一半,够产生体积感;下一节补上"往眼睛反射"的另一半,高光就出现了。
纯色环境光虽然能救黑,但上下一个颜色,物体从上到下均匀发灰——地面反射的光总比天顶强,这个事实被丢掉了。半球环境光用一行混合补上这层物理常识:
// 环境光随朝向变化:朝天采天色,朝下采地面色 vec3 hemisphere = mix(uGroundColor, uSkyColor, N.y * 0.5 + 0.5); vec3 ambient = hemisphere;
朝上的面吃到天蓝,朝下的面吃到土黄,侧面两者对半——画面立刻有了"处在真实环境里"的说服力,代价几乎为零。它是全局光照的乞丐版,却是写实场景性价比最高的一行代码。
另一个值得预习的概念是光照贴图:把静态场景的漫反射光预烘焙进纹理,运行时只采样不计算。窗边偏亮、墙角偏暗这些"环境光影"全部烤在图里,物体自带明暗变化。它与本章的实时光照并不互斥——烘焙管大面积底子,实时管动态物体,二者叠加是工程常态。
max(dot(N,L), 0) 一句;明暗有了,但画面还缺"精神"。下一节把高光加回来,Blinn-Phong 三成分合体。