我第一次在软渲染器里画球的时候,屏幕上出现的东西像个被削过的石榴——每个三角面的颜色都不同,轮廓棱角分明,完全没有球的圆润感。当时我以为是 Blinn-Phong 公式写错了,折腾了几天才明白,光照模型算出来的只是"某个点该是什么颜色",而要让整个三角形网格看起来连续光滑,还得靠着色方法。这篇文章把这两摊事彻底分开:Blinn-Phong 反射模型怎么把环境光、漫反射、镜面反射凑成最终颜色;Phong Shading 怎么通过插值法线让平面看起来像曲面;以及你实际写着色器时一定会踩到的几个坑。
如果你正在学计算机图形学、写软渲染器,或者想在 OpenGL/GLSL 里实现基础光照但总觉得各种概念混成一团,这篇适合你。我会把公式尽量讲成直觉,也会给出一份可以直接抄作业的 GLSL 实现。你不需要提前会微积分,只需要知道向量点积和归一化就能跟上。
1. 从渲染方程说起:局部光照模型到底"省"掉了什么
1.1 渲染方程:光照的"完全体"长什么样
要理解 Blinn-Phong 为什么长这样,最好先看一眼它到底在近似什么。渲染方程把一点出射的光表达为自发光加上半球方向所有入射光经过 BRDF 散射后的积分。这个积分里的入射光不仅来自光源直接照射,还包括场景里其他物体反射过来的光,理论上是个无穷递归的过程。
想象你站在一个四面都是镜子的房间,每面镜子都映出房间里的其他东西,那些东西又继续映出别的东西——这就是全局光照的复杂度。实时渲染没法在逐像素级别把这个递归算完,所以才需要局部光照模型这种"偷懒但有效"的近似方案。Blinn-Phong 就是其中最经典的一个。
1.2 局部光照的近似逻辑与适用边界
局部光照做了什么取舍?它只关心一个点自己的法线方向、光源方向和观察方向,完全不考虑光线在物体之间的二次反弹。所以在局部光照下没有阴影,没有颜色溢出,墙面也不会把红色"涂"在旁边的白球上。环境光项就是对所有没算进来的间接光给一个固定常数的补偿。
这个边界值得想清楚:Blinn-Phong 不是"错误"的公式,而是一个"约等于"的公式。当场景主要表现直接照明下的物体质感,或者你只是想做技术验证,它完全够用。很多早期实时应用都是靠它在跑,直到后来才有阴影贴图和基于图像的光照来补充它缺失的部分。
2. Blinn-Phong的三件套:环境光、漫反射、镜面反射逐项拆解
2.1 环境光项:让背光面不至于死黑
Blinn-Phong 的第一项是环境光,公式是 I_ambient = k_a * I_a。其中 k_a 是环境光系数,I_a 是场景全局光强。它不依赖法线、不依赖光源方向,简单粗暴地给所有点加一个基础亮度,否则背光面会直接全黑,看起来像剪影一样扁平。
我通常把 k_a 设在 0.01 到 0.1 之间,具体看场景氛围。越接近 0,画面越暗越高对比度;越大越像有轻微的环境照明。很多新人在这一步容易把环境光调得很亮,结果整个模型灰蒙蒙的,立体感全没了。记住环境光是"兜底",不是主光源。
2.2 漫反射项:兰伯特定律与光源角度的关系
漫反射项是局部光照的核心,公式是 I_diffuse = k_d * (I / d²) * max(0, dot(N, L))。这里 N 是法线,L 是从表面点到光源方向的单位向量,d 是距离。dot(N, L) 表示光源方向与法线的夹角余弦,这正是兰伯特定律:单位面积接收到的光通量随入射角余弦变化。
表面正对光源时最亮,转到侧面逐渐变暗,超过 90 度后光源在表面背面,漫反射为 0。max(0, ·) 就是为了保证这种背光情况下不会出现负值颜色。距离衰减 I/d² 来自物理上的平方反比定律,但实时渲染里经常会发现它让远处物体过暗,很多人会改成线性衰减或者直接不用衰减。这不是"物理正确"就好,视觉优先才是重要的。
2.3 镜面反射项:半程向量的几何直觉
Blinn-Phong 的镜面反射项是 I_specular = k_s * I * max(0, dot(N, H))^shininess,其中 H = normalize(L + V)。L 是光源方向,V 是视线方向,H 就是它们的夹角平分方向的单位向量,叫半程向量。
为什么需要 H?这可以用粗糙表面微表面理论来理解:把表面看成由许多朝向随机的小镜子组成,只有那些微表面法线恰好对准 H 方向的小镜子,才能把光源的光反射进你的眼睛。dot(N, H) 衡量的是宏观法线和这个理想"镜面朝向"有多接近,再用 shininess 幂次把范围压窄,指数越大高光越小越锐利。
3. 同样是高光:Phong模型和Blinn-Phong模型的几何差异与取舍
3.1 反射向量R与半程向量H的差别
很多人分不清 Phong 模型和 Blinn-Phong 模型。Phong 原版是先计算光源方向关于法线的反射向量 R,再做 dot(R, V)。这里有一个潜在问题:当视线方向与反射方向的夹角大于 90 度时,dot(R, V) 为负,max(0, ·) 会让高光硬生生截断,边缘出现一道明显的"切线"。
Blinn-Phong 换成先构造 H = normalize(L + V),再做 dot(N, H)。因为 H 与 N 的夹角,通常比 R 与 V 的夹角小一半左右,所以高光过渡更柔和,不会出现突然消失的边界。从计算角度说,半程向量只需要一次加法和归一化,反射向量需要更复杂的运算,这也算一重优势。
3.2 高光形状的匹配:shininess指数换算经验
由于 dot(N, H) 在数值上通常比 dot(R, V) 大,所以就算使用相同的 shininess 指数,Blinn-Phong 的高光也会比 Phong 更宽更胖。如果想让两者视觉接近,经验上的做法是把 Blinn-Phong 的指数设为 Phong 指数的 4 倍左右。比如 Phong 用 32,Blinn-Phong 用 128。
这个 4 倍来自小角度近似推导,不是严格定理。不同光源角度、不同相机距离下,实际匹配值会有偏移。我的建议是把 4 倍当作起点,然后在场景里可视化对比,看着高光收缩速率差不多了再定。
3.3 为什么现代图形API默认Blinn-Phong
你翻现在的着色器教程,绝大多数默认代码都是 Blinn-Phong,原因很简单:计算量更小、数值更稳定、高光形状更符合微表面直觉。它不是一个激进的新方案,而是一个优化得恰到好处的经典。了解 Phong 的意义在于读老资料和调试老代码,很多教材的推导图和示例场景仍然基于 Phong,看得懂 Phong 才能理解 Blinn 的改进点是哪里。
4. 着色策略三选一:Flat、Gouraud、Phong Shading怎么用
4.1 Flat Shading:一个三角形一个颜色
Flat Shading 对每个三角形面取一个法线,整片三角形只计算一次光照,所有像素颜色相同。这是最朴素的着色方式,模型法线也没必要在顶点间平滑,直接用面法线就行。三角形数量少时,会有一种独特的低多边形风格感,很多人故意用这种风格做艺术化渲染。
但如果你想表现光滑曲面,Flat Shading 会立刻暴露多边形轮廓。球会变成钻石,圆柱会变成棱柱。渲染性能最好,一个三角形一次光照计算,但视觉质量太糙,通常只用于线框预览、风格化或极低端设备。
4.2 Gouraud Shading:顶点算完再插值
Gouraud Shading 对每个顶点计算一次光照颜色,然后三角形内部用重心坐标对颜色做插值。相比 Flat,相邻三角形的颜色能连续过渡,视觉上平滑很多,而且每个顶点只算一次光照,效率很高。早期实时渲染和手游早期设备上经常用它。
但它有两个明显问题。第一,高光容易被"漏掉":如果高光只出现在三角形中间,而三个顶点上都没有采到高光,插值后高光就完全消失了。第二,颜色插值在屏幕空间会产生马赫带,也就是感知上的边缘增强,明明颜色连续渐变,你总觉得有条线。要精细的高光,必须换 Phong Shading。
4.3 Phong Shading:插值法线再逐像素算
Phong Shading 和 Phong 光照模型虽然名字都带 Phong,但它们是两个完全不同维度的东西。Phong Shading 的流程是:顶点着色器只把法线传出去,三角形内部对法线做插值,然后在每个片元拿到插值后的法线,先归一化,再完整计算一次光照公式。
这样做的好处非常明显:三角形内部可以独立产生高光,模型看起来远比 Gouraud 细腻。代价是每个片元都要做完整的光照计算,包括归一化、点积、pow 等操作。在现代 GPU 上,这种逐像素计算早已是常态,所以 Phong Shading 搭配 Blinn-Phong 光照模型,就成了最普遍的组合。
4.4 三种着色方法的对比与选型
| 着色方法 | 计算位置 | 插值对象 | 高光细节 | 开销 | 典型场景 |
|---|---|---|---|---|---|
| Flat Shading | 每个三角形面 | 无 | 很差 | 低 | 低多边形风格、线框预览 |
| Gouraud Shading | 每个顶点 | 颜色 | 一般,容易丢高光 | 中 | 老实时设备、带宽受限 |
| Phong Shading | 每个片元 | 法线 | 好 | 高 | 现代实时渲染 |
选型建议很简单:桌面端直接 Phong Shading,不用犹豫。移动端需要看帧量和分辨率,如果项目对带宽敏感,可以先用 Gouraud 做降级方案。打算走风格化路线的话,Flat Shading 也会是个好选择,它反而能帮你做出干净利落的低多边形效果。
5. 用GLSL把Blinn-Phong + Phong Shading跑起来
5.1 为什么用这个组合
在 GLSL 里最容易实现且不容易出错的组合,就是 Blinn-Phong 光照模型加 Phong Shading 着色方法。光照模型决定"用什么公式算颜色",着色方法决定"在哪个阶段算颜色"。顶点着色器负责把位置和法线处理好,片元着色器拿到从顶点插值过来的数据后统一计算光照,逻辑非常清晰。
选这个组合的另一个原因是调试方便:你可以先把漫反射和环境光跑通,再追加高光,每一步都能单独验证。直接从 PBR 开始的初学者往往发现一堆参数找不到手感,反而在这个基础上逐步扩展更容易建立直觉。
5.2 顶点着色器:组织好几何数据
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; uniform mat4 model; uniform mat4 view; uniform mat4 projection; uniform mat3 normalMatrix; // 法线矩阵 out vec3 vWorldPos; out vec3 vNormal; void main() { vWorldPos = vec3(model * vec4(aPos, 1.0)); vNormal = normalMatrix * aNormal; gl_Position = projection * view * vec4(vWorldPos, 1.0); }这里最关键的是法线矩阵。很多人直接乘 model 矩阵,等模型做了非均匀缩放之后,高光立刻乱七八糟。关于法线矩阵的推导我放在后面踩坑部分详细讲。顶点着色器只输出世界空间位置和变换后的法线,剩下的光照交给片元。
5.3 片元着色器:光照公式落地
#version 330 core in vec3 vWorldPos; in vec3 vNormal; out vec4 FragColor; uniform vec3 lightPos; // 光源位置 uniform vec3 viewPos; // 相机位置 uniform vec3 lightColor; // 光源颜色 uniform vec3 objectColor;// 物体本身颜色 void main() { vec3 N = normalize(vNormal); vec3 L = normalize(lightPos - vWorldPos); vec3 V = normalize(viewPos - vWorldPos); vec3 H = normalize(L + V); // 环境光 float ambientStrength = 0.05; vec3 ambient = ambientStrength * lightColor; // 漫反射 float diff = max(dot(N, L), 0.0); vec3 diffuse = diff * lightColor; // 镜面反射 float specularStrength = 0.5; float shininess = 64.0; float spec = pow(max(dot(N, H), 0.0), shininess); vec3 specular = specularStrength * spec * lightColor; vec3 result = (ambient + diffuse + specular) * objectColor; FragColor = vec4(result, 1.0); }这段代码里去掉了距离衰减,只用了颜色乘法来模拟光色调。如果你想要点光源衰减,可以自己加一个衰减系数,比如float attenuation = 1.0 / (1.0 + 0.09 * d + 0.032 * d * d);,然后给 diffuse 和 specular 乘上它。这个公式不是唯一标准,只是很多项目里常用的参数。
5.4 怎么直观地观察两种高光的区别
写一个 debug 模式:同一个场景里,用一个 uniform 开关在 Phong 高光公式和 Blinn-Phong 高光公式之间切换。固定 shininess 为 32,你会发现 Blinn 的高光比 Phong 大一圈、更"扩散",而 Phong 的高光更"尖锐",边缘有时还会出现一道明显的截断。
再把 Blinn 的 shininess 改成 128 对比,你会发现它和 Phong(32) 的高光大小接近了。这样你就能直观理解前面说的 4 倍经验关系,不再是对着公式硬背。
6. 实战中的四个高频坑:法线矩阵、半程向量、指数调参、Gamma校色
6.1 坑一:非均匀缩放后高光"乱流"——法线矩阵
我第一次遇到这个问题时,模型沿 X 轴拉伸后,高光变成几道奇怪的条纹。我当时先怀疑法线数据,把法线输出成颜色看,RGB 值看起来正常;又怀疑着色器,公式也没写错。最后才发现是 model 矩阵包含了非均匀缩放,直接拿 model 变换法线导致法线不再垂直于表面。
解法是用法线矩阵。推导思路是这样:一个切向量 T 经过 M 变换后依然与表面平行,法线 N 必须满足 N'·T'=0。展开后得到 N' = (M⁻¹)ᵀ N,也就是模型矩阵的逆转置矩阵。在 OpenGL 里可以直接用glm::inverseTranspose(mat3(model))来生成。记住:非均匀缩放时这一步是必须的,不是可选项。
6.2 坑二:半程向量退化与点积钳制
半程向量 H = normalize(L + V),当光源和相机分别位于物体的两侧,L 和 V 恰好接近反向时,H 的模会趋近于 0,归一化时出现除零,可能产生 NaN,进而导致片元颜色变成诡异的黑点或花屏。解决办法是加个保护:float hLen = length(L + V); if (hLen > 1e-6) H = (L + V) / hLen;,或者在无法构造 H 时直接退化为 0 高光。
另一个细节是 pow 的底数不能为负。GPU 上对负数做浮点幂的结果是不确定的,所以必须先做max(dot(N, H), 0.0),再传入 pow。很多人直接写pow(dot(N,H), shininess),角度稍大一下就会出现不稳定的高光边缘。
6.3 坑三:高光指数、衰减项与画面观感的匹配
调参是个经验活。我自己常用的手感是:金属类用 80 到 150 的 shininess,高光锐利、颜色更接近光源色;塑料、皮肤这类非金属用 8 到 32,高光柔和铺开。衰减项如果直接用平方衰减,场景里有远有近时很容易让远处物体黑成一片。我会优先试线性衰减1 - clamp(d / dMax, 0, 1),如果画面观感不对再换别的。
调参顺序也有讲究。先固定光源为白色光,把三个系数逐个调好,再调指数,最后才引入有颜色的光源。不要一上来同时调五个参数,否则你永远不知道哪个值影响哪个效果。
6.4 坑四:Gamma校正导致的高光过曝
在默认 sRGB 颜色空间里直接做线性光照,出来的画面会偏暗、偏灰,高光区域还容易亮得刺眼。原因在于显示器本身有约 2.2 的伽马响应,而光照计算是针对线性光做的。解决方式有两种:一是加载贴图时声明为 sRGB 纹理,让硬件自动解码到线性空间;二是在片元着色器输出前做pow(color, vec3(1.0/2.2))做一次伽马校正。
我遇到过的症状是高光区域白成一片,颜色饱和度低,阴影部分又黑得没细节。当时以为是材质参数不对,调了半天发现是伽马问题。建议你在搭建基础光照管线时就把线性空间流程做好,不然后面切到 PBR 还得回来补课。
7. 局部光照的天花板:阴影、间接光照与能量不守恒
7.1 Blinn-Phong不负责的三件事
Blinn-Phong 不负责阴影。它只判断"这个点和光源之间有没有夹角",完全不检查中间是否有遮挡物。所以一个凹槽、一个被挡住的角落,只要法线朝光源,它就会被照亮。要得到阴影,必须另做 Shadow Mapping。
它也不负责间接光照。物体反射的光不会参与计算,墙面的颜色不会"染"到旁边的白球上。现代方案会加环境光遮蔽(AO)、光照贴图或者实时全局光来补充。能量守恒方面它同样是经验模型,多个光源叠加时总亮度可能超过入射能量,画面发白。
7.2 它对PBR的真正价值
PBR 里的高光项虽然换成了 GGX 等基于微表面理论的 BRDF,但它的内核依然是"法线分布与半程向量的匹配程度"。你在 Blinn-Phong 里积累的关于 H、指数、高光形状的直觉,直接迁移到 PBR 里依然成立。区别只是 PBR 用粗糙度参数来控制法线分布的宽度,而 Blinn-Phong 用 shininess 指数。
所以我建议不要跳过 Blinn-Phong 直接学 PBR。先把这个经典模型吃透,理解半程向量为什么是核心,你再看 PBR 的各种公式就能看出它在做什么近似、为什么要引入更复杂的分布函数。
7.3 下一步建议:从局部到基于物理着色
如果你现在的目标是把实时渲染做扎实,我建议的路线是:先给 Blinn-Phong 加上 Shadow Mapping 补上阴影;然后加 AO 或简单 IBL 补间接光;最后再把漫反射和镜面反射换成基于物理的 BRDF,接完整的 PBR 管线。每一步都是在补局部光照之前缺的三块短板,而不是推翻重来。
最后分享一个我实际的调研心得:很多人一上来就冲 PBR,结果贴图、粗糙度、金属度、法线强度一堆参数互相牵制,调了半天反而不如先用 Blinn-Phong 调一个干净的效果。我的做法是,做技术验证或小场景 Demo 时默认用 Blinn-Phong 加 Phong Shading,先把法线矩阵、Gamma 校正这些底层问题都调干净,再决定要不要上 PBR。另外,shininess 那个 4 倍换算关系,推荐你自己在场景里对比着调,不同光源角度下结论会有变化,别当死准则用。