高级光照、Blinn-Phong 与镜面截断边界
高级光照、Blinn-Phong 与镜面截断边界:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释 Phong 镜面光在「掠射光 + 低反光度」下为什么会断裂出一道硬边——问题出在哪一步、哪个向量
- 能比较 Phong 与 Blinn-Phong 的适用边界:知道 Blinn 用半程向量 H 把镜面项从
R·V换成N·H,以及换了之后反光度指数通常要调大 2~4 倍才得到相近大小的高光 - 能回答:同一个低反光度的平面被一束很斜的光照着,Phong 的高光边缘会出什么毛病,Blinn 为什么没有?
为什么高光边上会有一道扎眼的硬边
上一篇你用冯氏光照给立方体点上了高光——一小块会随视角挪动的亮斑。多数时候它挺好看。可一旦你把光源压得很低、几乎贴着墙面斜射过来,再让墙不太光滑(高光散成一大片那种),怪事就来了:那片本该柔和散开的亮斑,边上会突然冒出一道生硬的直边,像被一把剪刀「咔」地剪断,而不是自然地淡出去。
这道硬边不是你代码写错了,而是冯氏算高光的那套办法,在这种「斜光 + 钝面」的极端情况下算不下去了——它中途遇到一个迈不过去的坎,只好把高光强行掐成 0,于是留下一道断口。
这一章要解决的就是这件事:怎么让高光在任何角度的光下都平滑地淡出、不再断裂。办法是换一个更聪明的角度去量「这块表面有多反光」。没有它,你的反光物体在掠射光下永远会露出这道扎眼的硬边;有了它,高光从此圆润到底。
病根:Phong 的反射向量会「越界」
先看清冯氏(Phong)镜面是怎么断的。上一篇讲过,它的算法是:把光线关于法线反射出一个方向 R,再看你的观察方向 V 有多贴近 R——用 R·V(两个单位向量点乘 = 夹角余弦)来量,越贴近越亮,最后 pow(R·V, 反光度) 收一下锐度。
问题就藏在「R·V 再 max(…, 0)」这一步。当光源压得很低、你又从某些角度看过去,反射方向 R 和观察方向 V 的夹角会超过 90°。一旦超过 90°,R·V 就变成负数,被 max(…, 0) 一刀切成 0——高光强度在这个角度啪一下掉到 0,再往后全是 0。这就是↡冯氏(Phong)镜面光在「掠射光 + 低反光度」下出现的硬边/突然截断现象。Phong 用反射向量 R 与观察方向 V 的点乘 R·V 算高光,当 R 与 V 夹角超过 90°,R·V 变负被 max(…,0) 截成 0,高光强度在那个角度骤然归零,留下一道生硬的直边而非平滑淡出。反光度越低(高光越大)、光越掠射,这道断边越明显。——本该平滑淡出的高光,被这条 0 边界硬生生截出一道直边。
为什么低反光度时特别明显?反光度低 → 高光本来就铺得很大、淡出得很缓,它会一路铺到 R·V 接近 0 的边缘地带;恰好这片边缘地带最容易越过 90° 那道坎,于是断口正好落在高光还挺亮的地方,格外扎眼。反光度高时高光缩成一小撮、远离边界,就看不太出来。这道断裂下面 Demo 里你能亲手调出来。
解法:换个量法——半程向量 H
Blinn 的修法不在于「修反射向量」,而是根本不去求反射向量 R 了。他换了一个更稳的量法:与其问「反射方向 R 和你的视线 V 有多贴」,不如问「光线方向和视线方向的'正中间',和法线有多贴」。
这个「正中间」就是 ↡光线方向 L 和视线方向 V 的「中分线」方向,等于两者相加再归一化:H = normalize(L + V)。它永远落在 L 和 V 之间。几何意义:当 H 正好和表面法线 N 重合时,说明你的视线恰好处在镜面反射最强的方位上。Blinn-Phong 用 N 与 H 的贴合度(N·H)代替 Phong 的 R·V 来算高光。(halfway vector,记作 H):把指向光源的方向 L 和指向眼睛的方向 V 加起来、再归一化,H = normalize(L + V),得到的就是正好夹在 L 和 V 当中的那个方向。下面这张图把「Phong 求 R」和「Blinn 求 H」并排画清楚:
R·V)。右:Blinn 不求反射,而是把 光线方向 L 和视线方向 V 相加再归一化,得到正好落在两者正中间的半程向量 H = normalize(L + V),改看 N 和 H 有多贴近(N·H)。H 总在 L、V 之间,N 与 H 的夹角永不越界,这正是 Blinn 高光更平滑的根。H 的妙处在于它的几何含义:当 H 正好和法线 N 重合时,你的视线恰恰处在镜面反射最强的方位上——这一刻高光最亮。于是 Blinn 干脆用 N·H(法线和半程向量的贴合度)来量高光强弱,整套 ↡冯氏镜面项的改进版(Blinn-Phong)。不求反射向量 R,而是先算半程向量 H = normalize(L+V),再用 N·H 代替 Phong 的 R·V 算高光:spec = pow(max(N·H, 0), 反光度)。在受光面 N·L 大于 0、光不在表面下方的常见条件下,N·H 不会触发 Phong 那种视线与反射向量的截断;高光可平滑连续。它仍是经验模型,不自动满足能量守恒。就是把上一篇那行 pow(max(R·V, 0), n) 换成 pow(max(N·H, 0), n),别的全不动。
为什么 Blinn 不断裂:N 与 H 的夹角永不越界
关键来了:H 永远落在 L 和 V 之间(它就是两者的中分线)。只要表面被这盏灯照得到(漫反射那关 N·L > 0),并且光没有落到表面下方,N 和 H 的夹角就不会跨过 90° 那道坎。换句话说,常见受光条件下 N·H 不会触发 Phong 那种「R·V 变负再被 max(…, 0) 一刀切掉」的边界;高光会随着你偏离最佳方位而平滑地、连续地变暗。
下面这个对比把两者的高光强度沿表面铺开画出来,你一眼就看出区别:左边 Phong 走到中途突然竖直跌断,右边 Blinn 全程是圆润的曲线。这正是后面交互 Demo 里你能亲手擦出来的差异。
代价:反光度指数得调大 2~4 倍
天下没有白换的算法。N·H 和 R·V 虽然都在量「你多正对反光方位」,但它们对角度的敏感度不同:在同样偏离最佳方位的角度上,N·H 衰减得比 R·V 慢。结果就是:同一个反光度指数,Blinn 算出来的高光比 Phong 更大、更糊。
所以换到 Blinn 后,想得到和原来 Phong 一样大小的高光斑,↡镜面高光的「锐度」指数(GLSL 里 pow 的指数,上一篇叫反光度 shininess)。Blinn-Phong 用 N·H、Phong 用 R·V,二者对角度的敏感度不同:相同指数下 Blinn 的高光更大更糊。所以从 Phong 迁到 Blinn 时,要把这个指数调大到原来的约 2~4 倍,才能得到同样大小的高光斑。忘了调大是从 Phong 换 Blinn 最常见的「失真」原因。得调大到原来的大约 2~4 倍(经验值,不是精确换算)。比如 Phong 用 32,换 Blinn 大概要用 64~128。这一步特别容易忘——很多人换完 Blinn 觉得「高光怎么变得又大又肉」,就是没把指数调大。下面 Demo 里你可以一边切 Phong/Blinn、一边拖反光度,亲眼校准这个倍数。
适用范围:修正截断,不等于物理渲染
Blinn-Phong 是经典、有效的经验镜面模型,也是早期 OpenGL 固定功能管线使用的镜面模型。它让低反光度的视觉结果比 Phong 更合理,却不自动保证 ↡一个物理约束:表面反射出去的总能量不应超过照进来的能量。经典 Phong 和 Blinn-Phong 都是经验模型,单靠半程向量并不会自动满足它;需要能量归一化、菲涅耳项和微表面分布等更完整的模型。后面的 PBR 篇会处理这些约束。(反射的光不超过照进来的光)。真实感离线或实时项目通常应使用后续 PBR 的微表面模型;本章的正确结论只是:在必须二选一的经典模型里,Blinn-Phong 避开了 Phong 的镜面截断,并更适合低指数和掠射高光。
对比一下:Phong 断裂 vs Blinn 平滑
猜一猜:下面把同一束掠射光照在同一个低反光度表面上,沿着表面把每一处算出的高光强度画成一条「强度带」。左边是 Phong(用
R·V)、右边是 Blinn(用N·H)。把滑块从左拖到右——你觉得左边那条强度带会在中途突然竖直跌断,还是和右边一样圆润地滑到 0?先拖一拖,再看下面的解释。
左边 Phong 的强度带从峰值往右下滑,走到中间某处时 R·V 越过了 0、被 max(…, 0) 截断,强度啪一下竖直掉到 0,留下一道刺眼的硬边;右边 Blinn 用 N·H,半程向量 H 夹在 L、V 之间、夹角永不越界,强度是一条一路圆润滑到 0 的曲线,没有任何断口。两侧是同一束光、同一个反光度,只差「用哪个点乘量高光」:
看出来了吧——区别全在那道竖直硬边上。Phong 不是不想让高光淡出,而是 R·V 撞上了 90° 那道坎、被强行截成 0,淡出被「咔」地打断;Blinn 换了量法绕开了这道坎,于是高光自然地淡到底。一切就这一处差异,但在掠射光下它就是那道扎眼硬边的有无。
单步判断:先找截断,再校准指数
猜一猜:同样的低反光度材质,为什么只把
R·V换成N·H后,高光会变宽?先按三步判断截断条件,再用原教程的 8 对 32 做一次指数校准。
① Phong:R·V 小于 0 时发生截断
先确认高光边是不是 Phong 的截断:低反光度让高光范围变大,若某处
dot(R, V) 小于 0,max(..., 0) 会直接归零。
动手:把光调掠射,亲眼看高光断裂与修复
猜一猜:下面是一个被点光源照亮的球。把光源角度拖到很掠射(光几乎贴着球面斜射)、把反光度调低(高光铺得很大),然后在 Phong / Blinn 之间来回切——Phong 那侧高光边缘会出现什么?Blinn 切过去后那道边会怎样?先动手拖,再读下面。
这个球是片段着色器里逐像素解析算出来的(对每个像素和球求交、算法线、算光照)。三个控件:光照模型在 Phong(R·V)和 Blinn(N·H)之间切;反光度调高光锐度(调低高光变大,断裂更明显);光源角度把点光源从正上方一路压到贴着球面掠射。把光压掠射、反光度调低、切到 Phong,盯着高光朝向背光那侧的边缘——你会看到 Phong 那道硬边;切回 Blinn,硬边立刻被抹平:
实时演示加载中…
切到 Phong、把光压掠射、反光度压到很低(比如 816),高光朝背光那侧的边缘会出现一道偏硬的截断;同样参数切到 Blinn,那道边立刻变平滑。再试这条:先在 Phong 下记住高光斑大小,切到 Blinn 你会发现高光「胖」了一圈——这时把反光度往上调 24 倍(如 16→48),高光斑就缩回差不多大小。这就是上一节那条「换 Blinn 要调大指数」的实感。
代码对照:镜面那几行的 diff
改动小到惊人——从 Phong 到 Blinn,只动镜面那 2~3 行,漫反射、环境光、总装全不变。先看原来的 Phong 镜面(上一篇那段):求反射向量 R,再 pow(R·V, n)。
// Phong 镜面:先把光反射成 R,再看 R 与观察方向 V 多贴近
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm); // R = 光关于 N 的反射
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
vec3 specular = specularStrength * spec * lightColor;// Phong 镜面:先把光反射成 R,再看 R 与观察方向 V 多贴近
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm); // R = 光关于 N 的反射
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
vec3 specular = specularStrength * spec * lightColor;换成 Blinn-Phong:不再求 reflectDir,改求半程向量 halfwayDir = normalize(lightDir + viewDir),把 dot(viewDir, reflectDir) 换成 dot(norm, halfwayDir),并且把指数 32.0 调大(这里示意调到 64.0,约 2 倍)。变的就这三处:
// Blinn-Phong 镜面:求半程向量 H,再看 N 与 H 多贴近
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir); // H = normalize(L + V)
float spec = pow(max(dot(norm, halfwayDir), 0.0), 64.0); // 指数调大约 2~4 倍
vec3 specular = specularStrength * spec * lightColor;// Blinn-Phong 镜面:求半程向量 H,再看 N 与 H 多贴近
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir); // H = normalize(L + V)
float spec = pow(max(dot(norm, halfwayDir), 0.0), 64.0); // 指数调大约 2~4 倍
vec3 specular = specularStrength * spec * lightColor;三处差异并排看:① reflect(-lightDir, norm) → normalize(lightDir + viewDir)(求 R 改成求 H);② dot(viewDir, reflectDir) → dot(norm, halfwayDir)(R·V 改成 N·H);③ 指数 32.0 → 64.0(调大约 2~4 倍)。lightDir、viewDir、norm 的算法和上一篇完全一样,依旧都要 normalize。
注意半程向量这里用的是 lightDir + viewDir(两个都是从表面指向外的方向:指向光源、指向眼睛),相加再归一化。别误传成 -lightDir——那是 Phong 里喂给 reflect 的「光打过来」的方向,两处用的方向相反,别搞混。
容易踩的坑
小结
- Phong 镜面用
R·V,掠射光 + 低反光度下R·V越过 90° 被max(…,0)截成 0,高光断裂出硬边 - Blinn-Phong 改用半程向量
H = normalize(L + V),看N·H——在正常受光条件下避开 Phong 的视线-反射向量截断,高光平滑连续 - 代码上只改镜面那几行:求 R 改成求 H、
R·V改成N·H,其余全不动 - 换 Blinn 后
N·H衰减更慢,反光度指数要调大约 2~4 倍才得到同样大小的高光(最常被忘) - 何时用哪个:经典模型里,低指数或掠射光场景优先 Blinn-Phong;高反光度、小高光可继续用 Phong。二者都不是能量守恒的 PBR 模型,生产中的真实感渲染应转向后续微表面 PBR
练习
问题 1(改 Demo 代码型) 在上面那个球的 Demo 里,把它调成这样的目标效果:一束很掠射的光照着一个低反光度的球,高光边缘有明显硬边——也就是把 Phong 的断裂调到最扎眼。说出你把三个控件各调到什么方向,并解释为什么这组参数最容易看出断裂。
问题 2(场景选型题 · D 型) 三个场景,分别该用 Phong 还是 Blinn-Phong,给一句理由:① 一块潮湿的地砖被夕阳几乎水平地掠射,镜头扫过会看到长长的反光带;② 一颗抛光金属小珠,高光是极小一点、光源在正上方;③ 一个追求真实感、做了 gamma 校正的离线渲染场景。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Phong 镜面断裂
冯氏(Phong)镜面光在「光很掠射 + 反光度很低」时出现的硬边 / 突然截断。Phong 用反射向量 R 和视线 V 的点乘
R·V算高光,当 R 与 V 的夹角超过 90°,R·V变负、被max(…,0)截成 0,高光强度在那个角度啪一下归零,留下一道生硬直边而不是平滑淡出。反光度越低、光越掠射越明显。详见本章「病根」一节。- 半程向量
光线方向 L 和视线方向 V 的「中分线」方向,等于两者相加再归一化:
H = normalize(L + V)。它永远落在 L 和 V 之间。几何含义:当 H 正好和表面法线 N 重合时,你的视线恰好处在镜面反射最强的方位上、高光最亮。Blinn-Phong 用它代替反射向量来算高光。详见本章「解法」一节。- Blinn-Phong 镜面
冯氏镜面项的改进版。不求反射向量 R,而是先算半程向量
H = normalize(L+V),再用N·H代替 Phong 的R·V算高光:spec = pow(max(N·H, 0), 反光度)。因为 H 总在 L、V 之间、N 与 H 的夹角永不越界,N·H几乎不会被截负,高光得以平滑连续,没有 Phong 那种硬边断裂。详见本章「解法」一节。- 反光度指数
镜面高光的「锐度」指数(GLSL 里
pow的指数,上一篇叫反光度 shininess)。Blinn 用N·H、Phong 用R·V,二者对角度敏感度不同:相同指数下 Blinn 高光更大更糊。所以从 Phong 迁到 Blinn 时要把这个指数调大到原来的约 2~4 倍,才得到同样大小的高光斑。忘了调大是换 Blinn 后高光「变肉」的最常见原因。详见本章「代价」一节。- 能量守恒
一个物理约束:表面反射出去的总能量不该超过照进来的能量(不能凭空变亮)。经典 Phong 和 Blinn-Phong 都是经验模型,单靠半程向量并不会自动满足它;能量归一化、菲涅耳项和微表面分布等约束由后续 PBR 模型处理。详见本章「适用范围」一节。
版本、来源与运行边界
本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。
正式概念与状态责任
- blinn-phong:在“高级光照、Blinn-Phong 与镜面截断边界”中由fragment shader 的 specular 分支与归一化方向向量负责解释其输入、受控状态和可观察结果;运行时以输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线定位它的第一处变化。
- halfway vector:在“高级光照、Blinn-Phong 与镜面截断边界”中由fragment shader 的 specular 分支与归一化方向向量负责解释其输入、受控状态和可观察结果;运行时以输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线定位它的第一处变化。
- gamma:在“高级光照、Blinn-Phong 与镜面截断边界”中由fragment shader 的 specular 分支与归一化方向向量负责解释其输入、受控状态和可观察结果;运行时以输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“切换模型,计算 dot(V,R) 或 dot(N,H) 后取幂并累加高光”发生后,fragment shader 的 specular 分支与归一化方向向量应怎样改变normal/light/view、reflectDir/halfwayDir、指数和镜面贡献;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
高级光照、Blinn-Phong 与镜面截断边界:状态合同
对比 Phong 反射向量与 Blinn halfway vector,观察视角掠过反射面时的高光边界
验证场景
官方教程正式概念
logl-29 · 基线帧
blinn-phong:固定 context、资源内容与输入事件,执行“切换模型,计算 dot(V,R) 或 dot(N,H) 后取幂并累加高光”
冻结输入:blinn-phong
fragment shader 的 specular 分支与归一化方向向量记录normal/light/view、reflectDir/halfwayDir、指数和镜面贡献
结果:得到可重复的初始 GL 状态与资源身份
观测:输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线中的初始快照
预期:fragment shader 的 specular 分支与归一化方向向量得到可复查结果,并持续满足“所有方向归一且指数为非负;模型切换只改变镜面几何项”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“切换模型,计算 dot(V,R) 或 dot(N,H) 后取幂并累加高光”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“所有方向归一且指数为非负;模型切换只改变镜面几何项”。
CPU command · GL state · GPU result
高级光照、Blinn-Phong 与镜面截断边界:五段轨迹
当前观测:输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线中的初始快照
不变量:所有方向归一且指数为非负;模型切换只改变镜面几何项
实验三:单故障与同输入恢复
注入“未归一化 L+V 就当 halfway vector,距离变化也改变高光强度”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线一起恢复才算修复。
Single fault · first divergence · replay
高级光照、Blinn-Phong 与镜面截断边界:反例与恢复
故障:未归一化 L+V 就当 halfway vector,距离变化也改变高光强度
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“未归一化 L+V 就当 halfway vector,距离变化也改变高光强度”
所有方向归一且指数为非负;模型切换只改变镜面几何项
输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线
最小可重放检查
unit: logl-29
owner: fragment shader 的 specular 分支与归一化方向向量
state_or_resource: normal/light/view、reflectDir/halfwayDir、指数和镜面贡献
command: 切换模型,计算 dot(V,R) 或 dot(N,H) 后取幂并累加高光
pass_invariant: 所有方向归一且指数为非负;模型切换只改变镜面几何项
single_fault: 未归一化 L+V 就当 halfway vector,距离变化也改变高光强度
required_evidence: 输入向量长度、点积、指数、两模型镜面值与掠射角像素曲线复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。