Phong 光照、法线矩阵与逐片段着色
Phong 光照、法线矩阵与逐片段着色:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释冯氏光照的环境光、漫反射、镜面三项各自的输入、公式与视觉作用
- 能实现世界空间的
FragPos、法线矩阵、光源方向与观察方向数据链 - 能修改镜面强度和反光度,把同一物体调成哑光或小而锐利的高光
- 能比较 Gouraud 顶点光照与 Phong 逐片段光照的计算位置、插值对象和典型伪影
- 能判断法线未归一化、漏写
max、空间不一致或法线矩阵错误分别造成什么画面问题
为什么一束手电光需要拆成三项计算
上一章你知道了「看到的颜色 = 光色 × 物色」,可那时的「光色」是你一口价给的——整个物体被一束均匀的光泡着,平平的一片,毫无立体感。可现实里拿手电筒照一面墙,从来不是这样。
仔细看那束光打在墙上的样子:墙整体不是全黑(哪怕手电没正对的地方也朦朦地能看见,因为屋里到处都有点漫反射的余光);正对手电的那一圈最亮,越偏到旁边越暗;如果墙面有点光滑,正对你眼睛的角度上还会有一小块刺眼的反光点,你一动头,那个亮点就跟着挪。
这章要解决的就是:怎么把「一束光照在物体上」这件事算得有这三层味道——四面八方的底光、正对光源才亮的明暗、还有那一点会动的高光。没有它,物体永远是上一章那种死板的纯色块;有了它,立方体才像被真的灯照着。
冯氏光照模型:把光拆成三块来算
把上面那三层味道拼起来的经典办法,就是 ↡一种把物体受光拆成「环境光 + 漫反射 + 镜面」三块分别算、再加起来的经典光照模型。便宜、好懂、效果够用,是实时图形的入门基石。命名自提出者 Bui Tuong Phong。(Phong)。它不追求物理上百分百真实,而是用三块好算的近似拼出「够像」的效果:
- 环境光:四面八方都有的一点底光,保证物体不会全黑;
- 漫反射:正对光源的面亮、偏过去的面暗,这块给出物体的立体感;
- 镜面:光滑表面上那一小块刺眼的反光点,给出「光泽」。
三块各自算出一个值,最后加在一起,再乘上物体颜色,就是这个像素最终的颜色。接下来三节一块一块拆。它们都发生在片段着色器里——上一章你已知道,片段着色器负责给每个像素定颜色;本章就是把这个颜色从「光色 × 物色」升级成「(环境 + 漫反射 + 镜面) × 物色」。本章主 Demo 的四个控件,正好对应这三块里你最该亲手拨弄的旋钮。
环境光:永远在的那点底光
第一块最简单。↡冯氏光照里模拟「四面八方间接光」的一块:现实中光会被墙面、地面反复弹来弹去,使背光处也不全黑。用一个很小的常量系数乘光色来近似,让物体各处都有一点保底亮度。(ambient)模拟的是现实里那些到处乱弹的间接光——光打到墙上、地上反复反射,连没被直接照到的角落也朦朦地有点亮。
要精确算这种乱弹的光很贵,冯氏模型干脆偷懒:给物体各处都加一个很小的、恒定的亮度。算法就一句——拿一个小系数(比如 0.1)乘上光的颜色。它和方向、法线全无关,每个像素都加同样多,作用只有一个:托底,让背光面不至于死黑一片。本章 Demo 里的「环境强度」滑块改的就是这个系数——稍后你可以把它拉到 0,看背光面瞬间变得多黑。
漫反射:正对光源才亮
第二块是立体感的来源,也是本章数学的重头。↡冯氏光照里模拟「方向性受光」的一块:表面越正对光源越亮、越偏越暗。靠表面法线与「指向光源方向」的夹角算出(夹角越小越亮)。物体的立体明暗几乎全靠它。(diffuse)说的是:一个表面越正对光源就越亮,越偏就越暗。墙正对手电的那一圈最亮,斜着的地方暗,就是它。
怎么判断一个表面「有多正对光源」?靠两样东西的夹角:一是 ↡一个垂直于物体表面、指向外侧的方向向量(约定取长度为 1 的单位向量)。它代表「这块面朝哪儿」,是判断表面正不正对光源、算反射的关键依据。(normal,记作 N),代表这块面朝哪个方向;二是从这块表面指向光源的方向(记作 L)。N 和 L 的夹角越小,说明这面越正对光源、越亮;夹角到 90° 甚至更大(背着光),就该是黑的。下面这张图把这件事画清楚:
判断「夹角大小」在数学上有个现成又便宜的工具——点乘(上一篇的「变换」章里讲过:两个单位向量点乘 = 它们夹角的余弦 cosθ)。夹角 0°(正对)时 cosθ=1,90°(侧着)时 cosθ=0,再大就成负数。所以「有多正对光源」直接用 N·L 来量,正好 1 到 0。具体公式留到 §4,先记住直觉:漫反射亮度 = N 和 L 有多同向。在本章 Demo 里,「光源方位」滑块就是在绕着立方体挪动光源、改变各个面上的这个夹角——拖一拖就能看见明暗在立方体上滑动。
法线得是单位长度,还不能直接用模型矩阵变
漫反射全靠 N,这里有两个小白极易忽略、却必然翻车的点,先点破:
第一,点乘要等于「夹角余弦」,前提是两个向量长度都为 1。所以法线用之前必须做 ↡把一个向量按比例缩放到长度为 1(保持方向不变)的操作,GLSL 里是 normalize()。点乘要等于「夹角余弦」必须两边都是单位向量,所以法线、光线方向用前都要归一化。(normalize)——把它缩放到长度 1 但方向不变。漏了这步,点乘出来的就不是纯粹的余弦,明暗全乱。
第二,法线是个方向,不能像顶点位置那样直接乘模型矩阵。位置乘 model 没问题,但法线一旦遇到不等比缩放(比如把立方体拉长),直接用 model 变换会让它不再垂直于表面,漫反射就错了。正确做法是用一个专门的 ↡专门用来变换法线的矩阵,等于模型矩阵左上角 3×3 部分「求逆再转置」(mat3(transpose(inverse(model))))。直接用模型矩阵变换法线,遇到不等比缩放会让法线不再垂直于表面;法线矩阵修正这个问题。,它等于「模型矩阵左上 3×3 的逆的转置」mat3(transpose(inverse(model)))。为什么是这个式子,§4 给一句直觉,代码怎么写见 §6。
世界空间:让三方都站在同一张地图上
漫反射要算 N 和 L 的夹角,镜面还要算「光从这块表面反射后是否射向你的眼睛」。这些都涉及位置和方向的相互关系——光源在哪、表面在哪、眼睛在哪,必须三方都用同一套坐标描述,比较才有意义。
本章统一选在 ↡物体被模型矩阵摆放到场景里之后所在的统一坐标空间(还没经过观察/投影变换)。本章把光源位置、表面位置、观察者位置都换算到这个空间里来比较,光照计算才一致。里算光照(坐标系统章讲过的那个「把物体摆进场景后」的空间)。所以顶点着色器除了照常算屏幕位置,还要额外把这个顶点的世界空间位置算出来传给片段着色器(记作 FragPos),片段着色器才知道「这块表面具体在世界的哪个点」,进而算它到光源、到眼睛的方向。这一步 §6 第一段就会写到。
镜面:那一点会动的高光
第三块给「光泽」。↡冯氏光照里模拟「光滑表面反光点」的一块:光按法线镜像反射出一个方向,观察者越贴近这个反射方向,越能看到一小块刺眼高光。给出金属、塑料的光泽感。(specular)模拟的就是手电照在光滑墙面上那块刺眼的亮斑——它不像漫反射那样跟你的视角无关,而是你换个角度看,它就挪位置甚至消失。
原理是「反射」:一束光打到表面,会按法线镜像反射出一个方向 R(入射角 = 反射角)。如果你的观察方向正好贴着这个反射方向 R,就能接到强烈的反光;偏离 R 越远,反光越弱。下面这张图左栏画的就是这套反射几何:
还有个旋钮叫 ↡镜面高光的「锐度」参数,又叫高光指数(GLSL 里 pow 的指数)。值越大,高光斑越小越锐、越像抛光金属;值越小,斑越大越钝、越像哑光塑料。本章 Demo「高光指数」滑块调的就是它。(shininess,也叫高光指数):上图右栏画了它的作用——它控制高光斑的锐度。数值越大,高光越小越集中、越像抛光金属;越小则高光散成一大片、像哑光塑料。本章 Demo 的「镜面强度」滑块调高光的亮度、「高光指数」滑块调这个锐度——一会儿你就用这俩调出哑光和高反光两种质感。
把三块写成公式
前面都在讲直觉,这节把漫反射和镜面那两块落成精确的式子(环境光太简单,不必单列)。先约定本节符号:
- :归一化后的表面法线(单位向量,长度 1)
- :从表面指向光源的方向,归一化后
- :从表面**指向观察者(眼睛)**的方向,归一化后
- : 关于 的反射方向,归一化后
- :反光度(高光指数,shininess),一个正数
漫反射强度:
这个式子在说:漫反射的强度就是「法线和指向光源方向的点乘」,再用 max 把负数截成 0。为什么是点乘?因为两个单位向量的点乘正好等于它们夹角的余弦 cosθ:正对光源(θ=0)时为 1(最亮),侧着(θ=90°)时为 0,背着光(θ>90°)时变负。
为什么要套一层 ?因为夹角超过 90°(表面背对光源)时点乘是负数,而「负的亮度」没有意义——不处理的话,背光面会算出负值,叠加时反而把别处的颜色减暗,画面发脏。max(…, 0) 把这些负值统一压成 0,等于宣布「背光面不被这盏灯照亮」。
这里也能看清为什么必须先归一化:点乘的完整式子是 。只有当 ,它才正好等于 ;否则还混进了两个向量的长度,算出来的「亮度」就被向量长短污染了。这就是上一节强调「法线用前必须 normalize」的根。
镜面强度:
这个式子在说:先看反射方向 和观察方向 有多同向(点乘,越贴近反射方向值越大),同样用 max 截掉负值,再把这个 01 的值乘方 次。乘方这一步是关键:一个 01 的小数取越高的次方,会衰减得越快——所以 越大,只有 极其接近 1(你几乎正对反射方向)时才亮,高光斑就越小越锐; 小则衰减慢,高光散成一大片。这正是上一节那张图右栏「指数越大、斑越小越尖」的数学来历。
法线从模型空间变到世界空间时使用模型矩阵线性部分的逆转置:
这个式子在说:先用逆转置保持法线与变换后切平面的垂直关系,再归一化恢复单位长度。只有模型矩阵完全没有不等比缩放时,才可以在证明等价后简化;通用实现不要直接拿 mat3(model) 代替。
最后把三块合起来(环境光 是常量系数乘光色,漫反射、镜面各自乘上光色和强度系数后相加),整体再乘物体颜色,就是冯氏光照的全貌——具体落到代码见下一节。
这里的 表示 RGB 逐通道相乘。三项先形成到达表面的总光照,再由物体颜色控制各通道反射比例;这与上一章的颜色乘法完全衔接。
动手:拖光源、调出哑光和高反光
在进入完整 WebGL Demo 前,先按三项叠加顺序核对每一步应该给画面增加什么。若某一步没有出现对应变化,就能直接定位是 ambient、diffuse 还是 specular 数据链断了。
只开环境光:确认底色
暂时关闭漫反射与镜面,只保留 ambientStrength * lightColor。所有面应得到相同底光;若仍全黑,先检查 lightColor、objectColor 与最终输出。
下面就是本章主 Demo:一个带法线的立方体,被一个绕着它公转的点光源照亮(那个小亮块就是光源的位置)。片段着色器里跑的正是上面那套 环境 + 漫反射 + 镜面。四个控件一一对应本章的三块:光源方位(挪光源,看漫反射明暗在各面滑动)、环境强度(那点底光)、镜面强度(高光多亮)、高光指数(高光多锐)。
Demo 打开时就是完整的冯氏光照(三项全开)。想看清这三项是怎么一层层叠出来的,点画布下方的「从头分步看」:先只剩环境光(扁平底光)、再叠漫反射(明暗面出现)、最后加镜面(高光斑冒头)——逐步推进,每一步都是该阶段的实时渲染。
猜一猜:把环境强度拉到 0、再把光源方位转到立方体背面,朝你这面会变成什么样?接着把高光指数从小调到大,那个白色的高光亮点会怎么变——是变大变糊,还是缩小变尖?先动手拖一拖,再看下面的代码。
光照演示加载中…
三块和控件的对应关系:拉环境强度只改那一份恒定底光(拉到 0,背光面没了托底就发黑);拖光源方位改的是各面上法线 N 与指向光源 L 的夹角,于是明暗在立方体上滑动——这正是 §3「漫反射」那张图在立方体上的实况;镜面强度 + 高光指数则一个调那块白色高光的亮度、一个调它的大小锐度。想要哑光:把镜面强度调小、高光指数也调小(高光几乎没了);想要高反光:镜面强度调大、高光指数调大(缩成一小块刺眼亮斑)。
代码逐段拆解
把上面 Demo 背后的着色器摊开看。光照算在片段着色器里,但它需要顶点着色器先把两样东西备好递下来:世界空间位置 FragPos 和变换过的法线 Normal。C++/OpenGL 与 WebGL2/TS 互为镜像。
第一步:顶点着色器——把法线和世界坐标递下去
顶点着色器照旧算屏幕位置 gl_Position,再额外做两件本章新增的事:算出这个顶点的世界空间位置 FragPos(位置乘模型矩阵),以及用法线矩阵变换法线后递出去。
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal; // 顶点属性:法线
out vec3 FragPos; // 递出:世界空间位置
out vec3 Normal; // 递出:变换后的法线
uniform mat4 model, view, projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
FragPos = vec3(model * vec4(aPos, 1.0)); // 位置 → 世界空间
Normal = mat3(transpose(inverse(model))) * aNormal; // 法线矩阵变换法线
}#version 300 es
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal; // 顶点属性:法线
out vec3 FragPos; // 递出:世界空间位置
out vec3 Normal; // 递出:变换后的法线
uniform mat4 model, view, projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
FragPos = vec3(model * vec4(aPos, 1.0)); // 位置 → 世界空间
Normal = mat3(transpose(inverse(model))) * aNormal; // 法线矩阵变换法线
}FragPos = vec3(model * vec4(aPos, 1.0)) 把顶点位置乘上模型矩阵,得到它在世界空间的落点;只取前 3 个分量(vec3(...))丢掉齐次的 w。法线那行就是 §3 说的法线矩阵。两端 GLSL 几乎一字不差,差别只在版本声明,以及一条性能提醒:
第二步:片段着色器——先备料,再算环境光
片段着色器接住插值后的 FragPos、Normal,先把要用的方向向量备齐(这里就用到 §3 强调的 normalize),再算最简单的环境光——一个小系数乘光色。
#version 330 core
in vec3 FragPos;
in vec3 Normal;
out vec4 FragColor;
uniform vec3 lightPos, viewPos, lightColor, objectColor;
void main() {
vec3 norm = normalize(Normal); // 法线归一化(点乘前必做)
vec3 lightDir = normalize(lightPos - FragPos); // 指向光源方向 L
// 环境光:一个小常量系数 × 光色
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
// …漫反射、镜面接着往下写(见第三、四步)
}#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
in vec3 FragPos;
in vec3 Normal;
out vec4 FragColor;
uniform vec3 lightPos, viewPos, lightColor, objectColor;
void main() {
vec3 norm = normalize(Normal); // 法线归一化(点乘前必做)
vec3 lightDir = normalize(lightPos - FragPos); // 指向光源方向 L
// 环境光:一个小常量系数 × 光色
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
// …漫反射、镜面接着往下写(见第三、四步)
}lightDir = normalize(lightPos - FragPos) 用「光源位置减去这块表面的位置」得到从表面指向光源的向量 L,再归一化。norm 也归一化了——这俩马上要点乘,§4 说过不归一化点乘就不是纯余弦。两端唯一差异还是那条 precision:
第三步:漫反射——N·L 再 max 截负
备好 norm 和 lightDir,漫反射就是 §4 那条公式的直译:点乘求「有多正对光源」,max(…, 0.0) 截掉背光面的负值,乘上光色。
// 漫反射:N·L,越正对光源越大;max 截掉背光面的负值
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;// 漫反射:N·L,越正对光源越大;max 截掉背光面的负值
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;这两端一字不差——漫反射的 GLSL 是纯计算,没有任何 API 差异。dot(norm, lightDir) 就是 ,max(…, 0.0) 就是那层防负保护,这一句正是 §3 那张「正对最亮、背光归零」的图落到代码上。
第四步:镜面——reflect 求反射、pow 控锐度
镜面要多两个向量:从表面指向眼睛的观察方向 viewDir,和光的反射方向 reflectDir。GLSL 内建的 reflect(入射, 法线) 直接帮你算反射方向——注意入射要传 -lightDir(lightDir 是指向光源,取负才是「光打过来」的方向)。
// 镜面:观察方向 V、反射方向 R,pow 的指数 = 反光度(这里 32)
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
float specularStrength = 0.5;
vec3 specular = specularStrength * spec * lightColor;// 镜面:观察方向 V、反射方向 R,pow 的指数 = 反光度(这里 32)
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
float specularStrength = 0.5;
vec3 specular = specularStrength * spec * lightColor;这一段两端也完全一样。dot(viewDir, reflectDir) 就是 (看你多正对反射方向),pow(…, 32.0) 里的 32.0 就是反光度——把它调大,高光缩小变锐(Demo 里「高光指数」滑块),specularStrength 则对应「镜面强度」。viewPos 是观察者(相机)位置,由 CPU 传进来。
第五步:三块相加,乘物体颜色
最后把三块加起来,整体乘上物体颜色,写进 FragColor。这一行就是冯氏光照的「总装」,也呼应了上一章——括号里那坨就是「这块表面到底被多少、什么色的光照到」,乘 objectColor 正是上一章的「光色 × 物色」。
// 冯氏总装:(环境 + 漫反射 + 镜面) × 物体颜色
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);// 冯氏总装:(环境 + 漫反射 + 镜面) × 物体颜色
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);三块都是先乘 lightColor 算出「到达这块表面的光」,加起来再乘 objectColor——这正是上一章「反射所见 = 光色 × 物色」,只不过本章把那个抽象的「光色」拆成了环境 + 漫反射 + 镜面三块算出来。完整可运行源码将在章末提供(M5)。
Gouraud 与 Phong shading:公式相同,计算频率不同
上面的实现把 FragPos 和 Normal 从顶点着色器插值到片段着色器,再对每个片段计算光照,这叫 Phong shading。它不要和“冯氏反射模型”混为一件事:反射模型规定 ambient/diffuse/specular 怎么算;shading 方式决定这些公式在顶点还是片段阶段执行。
若把完整光照公式移到顶点着色器,只把算好的颜色插值到片段,这就是 Gouraud shading。它每个顶点只算一次,代价较低;但低面数模型的三角形内部只能线性插值几个顶点颜色,小而锐利的镜面高光可能刚好落在三角形内部、任何顶点都没采到,于是高光变形甚至完全消失。
本章采用逐片段 Phong shading,是因为立方体面数少而高光变化快,差异非常容易看见。移动端或顶点极密的场景可以评估 Gouraud,但必须接受质量与性能的交换,不能只因为代码能跑就认为两者等价。
容易踩的坑
小结
- 冯氏光照 = 环境光 + 漫反射 + 镜面三块分别算、相加,再乘物体颜色
- 环境光是恒定的一点底光,托底防全黑;漫反射靠
N·L(越正对光源越亮),是立体感的来源 - 法线用前必须 归一化,且要用法线矩阵变换(别直接乘 model),否则明暗全错
- 镜面靠反射方向 R 和观察方向 V 的贴合度,反光度 shininess 是
pow的指数,越大高光越小越锐 - 三块都在世界空间里算,所以顶点着色器要额外递出世界位置
FragPos
练习
问题 1(改 Demo 代码型) 在上面的 Demo 里,把这个立方体先调成哑光质感(几乎看不到高光、表面发钝),再调成高反光金属感(有一小块又小又亮的刺眼高光)。分别说出你动了哪两个控件、各往哪个方向调、为什么。
问题 2(推导 / 问答题) 漫反射写成 diff = dot(norm, lightDir)(漏了 max)。在物体背光的那一面会算出什么?为什么这会让画面发脏?正确该怎么写?
问题 3(独立实现题) 不看上面的代码,写出片段着色器里漫反射 + 镜面两块的关键几行(已知 Normal、FragPos、lightPos、viewPos 都有)。目标效果:正对光源的面亮、有一块锐利高光。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 冯氏光照模型
一种把物体受光拆成「环境光 + 漫反射 + 镜面」三块分别算、再加起来的经典光照模型。不追求物理完全真实,但便宜、好懂、效果够用,是实时图形的入门基石。名字来自提出者 Bui Tuong Phong。详见本章「冯氏光照模型」一节。
- 环境光
冯氏光照里模拟「四面八方间接光」的一块。现实中光会被墙面、地面反复弹来弹去,让背光处也不全黑;冯氏模型偷懒地用一个很小的常量系数乘光色来近似它,作用是给物体各处一点保底亮度,防止全黑。详见本章「环境光」一节。
- 漫反射
冯氏光照里模拟「方向性受光」的一块:表面越正对光源越亮、越偏越暗,是物体立体明暗的主要来源。靠表面法线 N 与「指向光源方向」L 的夹角算出来(夹角越小越亮,即 N·L 越大越亮)。详见本章「漫反射」一节。
- 法线
一个垂直于物体表面、指向外侧的方向向量(约定取长度为 1 的单位向量)。它代表「这块面朝哪儿」,是判断表面正不正对光源、算反射的关键依据。详见本章「漫反射」与「法线」相关小节。
- 法线归一化
把一个向量按比例缩放到长度为 1(方向不变)的操作,GLSL 里是
normalize()。点乘要等于「夹角余弦」,前提是两个向量都是单位长度,所以法线、光线方向等用前都要归一化,否则点乘结果会被向量长短污染。详见本章「法线得是单位长度」一节与 §4。- 法线矩阵
专门用来变换法线的矩阵,等于模型矩阵左上角 3×3 部分「求逆再转置」——
mat3(transpose(inverse(model)))。直接用模型矩阵变换法线,遇到不等比缩放(拉长/压扁)会让法线不再垂直于表面,导致明暗算错;法线矩阵修正这个问题。实战中在 CPU 侧预算好。详见本章「法线得是单位长度」一节。- 镜面光照
冯氏光照里模拟「光滑表面反光点」的一块。光按法线镜像反射出一个方向,观察者越贴近这个反射方向,越能看到一小块刺眼高光;换个视角看,高光会挪位甚至消失。它给出金属、塑料那种光泽感。详见本章「镜面」一节。
- 反光度
镜面高光的「锐度」参数,又叫高光指数(GLSL 里
pow的指数)。值越大,高光斑越小越锐、越像抛光金属;值越小,斑越大越钝、越像哑光塑料。本章 Demo「高光指数」滑块调的就是它。详见本章「镜面」一节。- 世界空间
物体被模型矩阵摆放进场景之后所在的统一坐标空间(还没经过观察 / 投影变换)。本章把光源位置、表面位置、观察者位置都换算到这个空间里再比较,光照计算才一致——所以顶点着色器要额外把世界空间位置
FragPos递给片段着色器。详见本章「世界空间」一节。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- ambient:在“Phong 光照、法线矩阵与逐片段着色”中由lighting program 的片段阶段与 normal matrix负责解释其输入、受控状态和可观察结果;运行时以法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素定位它的第一处变化。
- diffuse:在“Phong 光照、法线矩阵与逐片段着色”中由lighting program 的片段阶段与 normal matrix负责解释其输入、受控状态和可观察结果;运行时以法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素定位它的第一处变化。
- specular:在“Phong 光照、法线矩阵与逐片段着色”中由lighting program 的片段阶段与 normal matrix负责解释其输入、受控状态和可观察结果;运行时以法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素定位它的第一处变化。
- phong:在“Phong 光照、法线矩阵与逐片段着色”中由lighting program 的片段阶段与 normal matrix负责解释其输入、受控状态和可观察结果;运行时以法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“变换法线,计算 N·L 和反射向量,再累加环境/漫反射/镜面项”发生后,lighting program 的片段阶段与 normal matrix应怎样改变世界空间位置/法线、光向量、视线、三项贡献和最终线性颜色;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
Phong 光照、法线矩阵与逐片段着色:状态合同
把 ambient、diffuse、specular 与正确法线矩阵组合为逐片段 Phong 光照
验证场景
官方教程正式概念
logl-10 · 基线帧
ambient:固定 context、资源内容与输入事件,执行“变换法线,计算 N·L 和反射向量,再累加环境/漫反射/镜面项”
冻结输入:ambient
lighting program 的片段阶段与 normal matrix记录世界空间位置/法线、光向量、视线、三项贡献和最终线性颜色
结果:得到可重复的初始 GL 状态与资源身份
观测:法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素中的初始快照
预期:lighting program 的片段阶段与 normal matrix得到可复查结果,并持续满足“参与点积的向量位于同一空间且归一;非均匀缩放使用逆转置法线矩阵”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“变换法线,计算 N·L 和反射向量,再累加环境/漫反射/镜面项”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“参与点积的向量位于同一空间且归一;非均匀缩放使用逆转置法线矩阵”。
CPU command · GL state · GPU result
Phong 光照、法线矩阵与逐片段着色:五段轨迹
当前观测:法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素中的初始快照
不变量:参与点积的向量位于同一空间且归一;非均匀缩放使用逆转置法线矩阵
实验三:单故障与同输入恢复
注入“直接用带非均匀缩放的 model mat3 变换法线,亮面方向随缩放漂移”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素一起恢复才算修复。
Single fault · first divergence · replay
Phong 光照、法线矩阵与逐片段着色:反例与恢复
故障:直接用带非均匀缩放的 model mat3 变换法线,亮面方向随缩放漂移
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“直接用带非均匀缩放的 model mat3 变换法线,亮面方向随缩放漂移”
参与点积的向量位于同一空间且归一;非均匀缩放使用逆转置法线矩阵
法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素
最小可重放检查
unit: logl-10
owner: lighting program 的片段阶段与 normal matrix
state_or_resource: 世界空间位置/法线、光向量、视线、三项贡献和最终线性颜色
command: 变换法线,计算 N·L 和反射向量,再累加环境/漫反射/镜面项
pass_invariant: 参与点积的向量位于同一空间且归一;非均匀缩放使用逆转置法线矩阵
single_fault: 直接用带非均匀缩放的 model mat3 变换法线,亮面方向随缩放漂移
required_evidence: 法线长度、normal matrix、N·L、各光照分量、GLSL 输出与像素复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。