多光源函数化、数组上传与贡献累加
多光源函数化、数组上传与贡献累加:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释多光源为何等于每盏灯独立的 Phong 贡献之和
- 能实现 CalcDirLight、CalcPointLight、CalcSpotLight 三个纯贡献函数
- 能实现定长 pointLights 数组的 GLSL 循环与 CPU 逐元素逐字段上传
- 能修改平行光、点光源数量和聚光开关而不破坏 result 累加结构
- 能判断覆盖赋值、数组未灌满、贡献过曝或片段光源数过多造成的问题
为什么一个房间里的多盏灯必须独立计算再累加
前面几章你一直在跟一盏灯打交道——手电照墙、绕着立方体转的那个小亮点。可你现在所在的房间,多半不止一盏灯:头顶一盏吸顶灯把整屋照亮,桌上一盏台灯在桌面投下一小圈暖光,墙角也许还有一束射灯打在画上。
看看桌面被照亮的样子:它同时接到了吸顶灯的均匀光、台灯那圈集中的暖光——两份光叠在一起,台灯正下方那块就比别处更亮。一个表面的明暗,从来不是某一盏灯说了算,而是它接到的所有光加在一起的结果。
这章要解决的就是:怎么让一个物体同时被好几盏不同类型的灯照亮,而代码又不乱成一团?没有它,你的场景永远只能点一盏灯,平平淡淡;有了它,吸顶灯、台灯、射灯可以一起开,物体被照得有层次——这也正好给整个光照篇收个完整的尾。
多光源:最终色 = 每盏光的贡献相加
把好几盏灯同时照在一个物体上,叫 ↡一个场景里同时存在多盏光(不同类型也行),同一个表面会同时接到它们的光。本章的做法是:让每盏光各自算出「它对这块表面的颜色贡献」,再把所有贡献相加,就是这块表面的最终颜色。。它背后只有一句话的道理:一块表面的最终颜色,等于照到它的每一盏光各自贡献的颜色之和。
这其实不新——前几章你算一盏光时,得到的就是「这盏光对这块表面的贡献」(环境 + 漫反射 + 镜面那一坨)。多光源无非是把这件事对每盏光各做一遍,然后全加起来。难点不在数学(数学还是上几章那套 Phong),而在怎么组织代码:三类光各有各的算法,要是一股脑全塞进 main,几十行纠缠在一起,没法看也没法改。
如上图,本章的整个套路就是:每类光封成一个独立的小函数,各算各的贡献;main 里把这些贡献一份份加起来。下面先认清要应付哪三类光(都来自上一章「投光物」),再讲怎么函数化、怎么累加。
要应付的三类光(投光物回顾)
上一章把光按「光怎么打出来」分成了三类,本章要让它们同时上场。第一类是 ↡一种「投光物」:假设光源无限远,照过来的全是同方向的平行光线(比如太阳光)。它只有方向、没有位置,也不随距离衰减——场景里各处受到的方向都一样。(directional light):只有方向、没有位置,不随距离变弱,像太阳。第二类是 ↡一种「投光物」:有一个具体位置、向四面八方发光的灯泡,光照强度随距离衰减(越远越暗)。点光源是本章里要放好几个、用数组 + 循环处理的那一类。(point light):有位置、向四周发光、离得越远越暗(按距离衰减),像灯泡。第三类是 ↡一种「投光物」:从一个位置朝一个方向射出一个锥形光束,只照亮锥内、锥外不照(边缘还能做柔和过渡),像手电或舞台射灯。它同时有位置、方向、切光角和距离衰减。(spotlight):从一个位置朝一个方向射出锥形光束,只照锥内,像手电。
这三类的 Phong 主体(环境 + 漫反射 + 镜面)算法是一样的,差别只在「光从哪来」「要不要按距离衰减」「要不要限制在锥内」。所以它们天生适合各写成一个函数,把这些差异关在各自函数内部。
把每盏光的计算装进一个函数
本章真正的主角,是 ↡把「某一类光对一块表面的 Phong 计算」整段封装成一个 GLSL 函数(比如 CalcPointLight),函数返回一个 vec3——这盏光对这块片段的颜色贡献。好处是 main 里只需一行调用就拿到一盏光的贡献,代码清爽、可复用、可循环。:给每一类光写一个 GLSL 函数,函数算完那一类光的环境 + 漫反射 + 镜面,返回一个 vec3——这盏光对当前这块片段的颜色贡献。三个函数的名字按惯例叫 CalcDirLight、CalcPointLight、CalcSpotLight。
这么一封装,main 就清爽了:算平行光只要 CalcDirLight(...),拿回一份贡献;点光源有好几个,就用一个循环逐个调 CalcPointLight(...) 累加;最后再加上聚光。每盏光的内部细节(衰减怎么算、锥形怎么判断)都锁在各自函数里,main 只管「把各份贡献加起来」。这就是上图说的「函数化 + 累加」——本章没有新数学,整章的功夫全在这套代码组织上。具体怎么写,全在 §6。
动手:亲手加灯、看贡献叠加
先按 main 中的真实累加顺序逐步核对总颜色:
平行光初始化 result
先计算 CalcDirLight。它可以用 = 初始化 result;若场景没有平行光,则从 vec3(0.0) 开始,不能留下未初始化值。
前几章你只点过一盏光。这个 Demo 让你一次点好几盏:一个立方体,同时被一盏平行光 + 若干点光源 + 一盏聚光照亮,片段着色器对每一类光各算一份 环境 + 漫反射 + 镜面,再把所有贡献相加——正是本章 §3 那张图说的事。顶部一行可以选灯、开关、给点光源 +/- 增删;下方只显示你当前选中那一盏的参数(颜色、方位、强度,外加点光源的衰减档或聚光的切光角)。
打开就能看到默认布灯:冷色平行光打底,一盏暖橙、一盏冷青两个点光源从两侧照来,再加一盏暖白聚光——立方体不同的面被不同的灯主导,被多盏光同时照到的棱角处明显更亮,个别地方已经叠加到顶满发白。
猜一猜:把两盏点光源的方位都转到立方体同一个面、再把强度都拉高,那个面会怎样?几份漫反射 / 镜面叠加上去,会不会亮到顶满、糊成一片纯白(过曝)?反过来,把点光源一盏盏删掉、再关掉平行光和聚光,画面会变成什么样——会全黑吗?动手试试,看「相加」和「减到没有」各自的样子。
多光源演示加载中…
试着把两盏点光源转到同一面、强度拉满——那个面很快顶白过曝,这正是上面「猜一猜」的答案,也对应 §7 要提醒的一个坑。再反过来:把灯一盏盏删掉、平行光和聚光也关了,立方体不会死黑,而是落到一层很弱的环境光上变暗——因为每盏光的贡献里都含一份环境项,全没了还兜着最低限度的底光。每加一盏灯就多一份贡献、每减一盏就少一份,result = 贡献₁ + 贡献₂ + … 这条加法,你现在可以亲手加减验证。
代码逐段拆解
把上面那套「函数化 + 累加」摊开成真代码。沿用前两章的约定:物体材质用一个 Material 结构体(环境/漫反射/镜面三色 + 反光度),每一类光各用自己的结构体描述。光照仍算在片段着色器里。C++/OpenGL 与 WebGL2/TS 互为镜像。
第一步:声明三类光的结构体 + 点光源的 uniform 数组
每类光打成一个结构体。平行光只有方向,点光源多了位置和三个衰减系数,聚光在点光源基础上再加方向和两个切光角余弦。最关键的一行:点光源要放好几个,于是声明成一个 ↡GLSL 里一段连续、定长的同类型 uniform,比如 pointLights[4]。配合 #define 定个数、用下标 pointLights[i] 取第 i 个,就能在着色器里循环处理多盏同类光。长度必须是编译期常量,且 CPU 侧要逐个元素灌值。,长度用 #define 定个常量 NR_POINT_LIGHTS。
struct DirLight { // 平行光:只有方向
vec3 direction;
vec3 ambient, diffuse, specular;
};
struct PointLight { // 点光源:有位置 + 距离衰减系数
vec3 position;
float constant, linear, quadratic;
vec3 ambient, diffuse, specular;
};
struct SpotLight { /* 位置+方向+切光角+衰减,见第四步 */ };
uniform DirLight dirLight;
#define NR_POINT_LIGHTS 4 // 点光源个数(编译期常量)
uniform PointLight pointLights[NR_POINT_LIGHTS]; // ← uniform 数组
uniform SpotLight spotLight;struct DirLight { // 平行光:只有方向
vec3 direction;
vec3 ambient, diffuse, specular;
};
struct PointLight { // 点光源:有位置 + 距离衰减系数
vec3 position;
float constant, linear, quadratic;
vec3 ambient, diffuse, specular;
};
struct SpotLight { /* 位置+方向+切光角+衰减,见第四步 */ };
uniform DirLight dirLight;
#define NR_POINT_LIGHTS 4 // 点光源个数(编译期常量)
uniform PointLight pointLights[NR_POINT_LIGHTS]; // ← uniform 数组
uniform SpotLight spotLight;pointLights[NR_POINT_LIGHTS] 就是连续放 4 个 PointLight,待会儿用 pointLights[i] 取第 i 个、在循环里逐个算。结构体与 #define、数组声明这些 GLSL 语法两端完全一样,但「怎么把数组灌进去」两端有差别:
第二步:平行光函数 CalcDirLight
第一个光贡献函数。平行光最简单:方向是固定的(用 -light.direction 得到「指向光源」的方向),没有位置、不衰减。函数体就是前几章那套 Phong 三项,最后返回三项之和。
vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir) {
vec3 lightDir = normalize(-light.direction); // 平行光:方向恒定
float diff = max(dot(normal, lightDir), 0.0); // 漫反射 N·L
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 ambient = light.ambient * material.ambient;
vec3 diffuse = light.diffuse * diff * material.diffuse;
vec3 specular = light.specular * spec * material.specular;
return ambient + diffuse + specular; // ← 返回这盏光的贡献
}vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir) {
vec3 lightDir = normalize(-light.direction); // 平行光:方向恒定
float diff = max(dot(normal, lightDir), 0.0); // 漫反射 N·L
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 ambient = light.ambient * material.ambient;
vec3 diffuse = light.diffuse * diff * material.diffuse;
vec3 specular = light.specular * spec * material.specular;
return ambient + diffuse + specular; // ← 返回这盏光的贡献
}注意三项里的 diff、spec、material.*、light.* 全是「基础光照」「材质」两章的老朋友,没有任何新东西。新的只有形式:把它们包进一个函数、返回一个 vec3。这段是纯计算,两端一字不差。点光源、聚光的函数就是在这个骨架上各加一点料。
第三步:点光源函数 CalcPointLight(多了距离衰减)
点光源比平行光多两件事:lightDir 由「光源位置 − 片段位置」算出(它有具体位置),以及按距离做 ↡光照强度随「光源到表面的距离」变弱的处理(越远越暗)。点光源/聚光常用 1/(常数项 + 一次项·d + 二次项·d²) 这个衰减系数,乘到环境/漫反射/镜面三项上。它来自上一章「投光物」。——离得越远越暗。衰减系数乘到三项上,其余和平行光一样。
vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir) {
vec3 lightDir = normalize(light.position - fragPos); // 有位置:指向光源
float diff = max(dot(normal, lightDir), 0.0);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// 距离衰减:1 / (常数 + 一次·d + 二次·d²),来自上一章
float d = length(light.position - fragPos);
float att = 1.0 / (light.constant + light.linear * d + light.quadratic * d * d);
vec3 ambient = light.ambient * material.ambient * att;
vec3 diffuse = light.diffuse * diff * material.diffuse * att;
vec3 specular = light.specular * spec * material.specular * att;
return ambient + diffuse + specular;
}vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir) {
vec3 lightDir = normalize(light.position - fragPos); // 有位置:指向光源
float diff = max(dot(normal, lightDir), 0.0);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// 距离衰减:1 / (常数 + 一次·d + 二次·d²),来自上一章
float d = length(light.position - fragPos);
float att = 1.0 / (light.constant + light.linear * d + light.quadratic * d * d);
vec3 ambient = light.ambient * material.ambient * att;
vec3 diffuse = light.diffuse * diff * material.diffuse * att;
vec3 specular = light.specular * spec * material.specular * att;
return ambient + diffuse + specular;
}和 CalcDirLight 比,多的就是中间那两行衰减 att 和把它乘进三项。它比平行光多收一个 fragPos 参数(因为要算距离)。注意函数签名里把 normal、fragPos、viewDir 都当参数传进来——这样函数不依赖任何外部全局,main 里循环调它就很干净。这段同样是纯 GLSL,两端一字不差。
第四步:聚光函数 CalcSpotLight(再加锥形软边)
聚光在点光源基础上,再判断片段是否落在光锥内:用「片段方向与聚光朝向的夹角」和内外切光角余弦比较,做一个 0~1 的强度因子 intensity,乘到漫反射 / 镜面上(边缘柔和过渡)。环境光通常保留,让锥外也有点底光。
vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir) {
vec3 lightDir = normalize(light.position - fragPos);
float diff = max(dot(normal, lightDir), 0.0);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
float d = length(light.position - fragPos);
float att = 1.0 / (light.constant + light.linear * d + light.quadratic * d * d);
// 聚光锥:内外切光角之间做软边过渡(上一章讲过)
float theta = dot(lightDir, normalize(-light.direction));
float eps = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / eps, 0.0, 1.0);
vec3 ambient = light.ambient * material.ambient * att;
vec3 diffuse = light.diffuse * diff * material.diffuse * att * intensity;
vec3 specular = light.specular * spec * material.specular * att * intensity;
return ambient + diffuse + specular;
}vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir) {
vec3 lightDir = normalize(light.position - fragPos);
float diff = max(dot(normal, lightDir), 0.0);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
float d = length(light.position - fragPos);
float att = 1.0 / (light.constant + light.linear * d + light.quadratic * d * d);
// 聚光锥:内外切光角之间做软边过渡(上一章讲过)
float theta = dot(lightDir, normalize(-light.direction));
float eps = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / eps, 0.0, 1.0);
vec3 ambient = light.ambient * material.ambient * att;
vec3 diffuse = light.diffuse * diff * material.diffuse * att * intensity;
vec3 specular = light.specular * spec * material.specular * att * intensity;
return ambient + diffuse + specular;
}三个函数排在一起,结构高度一致——都是「算三项 → 乘衰减/锥形因子 → 返回之和」,差异只在各自多出来的那几行(衰减、锥形)。cutOff / outerCutOff 是内外切光角的余弦、intensity 的软边公式都来自上一章「投光物」,这里直接复用。原书把 CalcSpotLight 留作练习,本章给出完整版,方便你把光照篇收成一套完整的代码。
第五步:main 把三类贡献全部相加
终于到 main——也是全章的点睛。先把所有光共用的 norm、viewDir 备好(只算一次),然后:平行光直接调一次;点光源用 for 循环遍历数组,每次 result += CalcPointLight(...);最后再加上聚光。相加,就是多光源的全部秘密。
void main() {
vec3 norm = normalize(Normal);
vec3 viewDir = normalize(viewPos - FragPos);
// 1) 平行光:一份贡献
vec3 result = CalcDirLight(dirLight, norm, viewDir);
// 2) N 个点光源:循环逐个累加
for (int i = 0; i < NR_POINT_LIGHTS; i++)
result += CalcPointLight(pointLights[i], norm, FragPos, viewDir);
// 3) 聚光:再加一份
result += CalcSpotLight(spotLight, norm, FragPos, viewDir);
FragColor = vec4(result, 1.0); // 各份贡献之和 = 最终色
}void main() {
vec3 norm = normalize(Normal);
vec3 viewDir = normalize(viewPos - FragPos);
// 1) 平行光:一份贡献
vec3 result = CalcDirLight(dirLight, norm, viewDir);
// 2) N 个点光源:循环逐个累加
for (int i = 0; i < NR_POINT_LIGHTS; i++)
result += CalcPointLight(pointLights[i], norm, FragPos, viewDir);
// 3) 聚光:再加一份
result += CalcSpotLight(spotLight, norm, FragPos, viewDir);
FragColor = vec4(result, 1.0); // 各份贡献之和 = 最终色
}读这段 main,你会发现它几乎就是把 §3 那张图翻译成了代码:CalcDirLight 出贡献 1、循环里 CalcPointLight 出贡献 2…、CalcSpotLight 出贡献 3,result 把它们 += 累加,写进 FragColor 就是最终色。for 循环和 result += 这种 GLSL 写法两端完全一致。要加第二个聚光、第五个点光源?改一下数组长度或多调一行,main 的结构不变——这就是函数化的好处。
第六步:CPU 侧逐元素灌满 uniform 数组
着色器声明好了,CPU 侧得把每盏光的数都灌进去。平行光、聚光各灌一组;点光源是数组,必须带下标逐个灌满,一个都不能漏(漏的那个会是默认全 0,等于一盏黑灯,见 §7)。
// 平行光(一组)
shader.setVec3("dirLight.direction", -0.2f, -1.0f, -0.3f);
shader.setVec3("dirLight.diffuse", 0.4f, 0.4f, 0.4f);
// …dirLight 其余项略
// 点光源数组:从 0 到 NR_POINT_LIGHTS-1 逐个灌满
for (int i = 0; i < 4; i++) {
std::string p = "pointLights[" + std::to_string(i) + "].";
shader.setVec3 (p + "position", positions[i]);
shader.setFloat(p + "constant", 1.0f);
shader.setFloat(p + "linear", 0.09f);
shader.setFloat(p + "quadratic",0.032f);
shader.setVec3 (p + "diffuse", 0.8f, 0.8f, 0.8f);
// …该点光源其余项略
}const loc = (name: string) => gl.getUniformLocation(program, name);
// 平行光(一组)
gl.uniform3f(loc("dirLight.direction"), -0.2, -1.0, -0.3);
gl.uniform3f(loc("dirLight.diffuse"), 0.4, 0.4, 0.4);
// …dirLight 其余项略
// 点光源数组:从 0 到 NR_POINT_LIGHTS-1 逐个灌满
for (let i = 0; i < 4; i++) {
const p = `pointLights[${i}].`;
gl.uniform3f(
loc(p + "position"),
positions[i][0],
positions[i][1],
positions[i][2],
);
gl.uniform1f(loc(p + "constant"), 1.0);
gl.uniform1f(loc(p + "linear"), 0.09);
gl.uniform1f(loc(p + "quadratic"), 0.032);
gl.uniform3f(loc(p + "diffuse"), 0.8, 0.8, 0.8);
// …该点光源其余项略
}两端都是拼出带下标的字符串名(pointLights[0].position、pointLights[1].position…)再灌值——结构体数组没有批量接口,只能这么一个字段一个字段地写,循环帮你把这件繁琐事自动化。完整可运行源码将在章末提供(M5)。
容易踩的坑
小结
- 多光源的核心只有一句:每类光封成一个函数各算各的贡献,
main里把所有贡献相加 - 三个函数
CalcDirLight/CalcPointLight/CalcSpotLight各返回一个vec3贡献;内部就是前几章的 Phong,差异(衰减、锥形)锁在各自函数里 - 多个同类光(点光源)用 uniform 数组
pointLights[NR_POINT_LIGHTS]+for循环累加;CPU 侧要带下标逐元素灌满 - 别用
=覆盖result(会只剩最后一盏)、别漏灌数组元素(会多出黑灯)、当心多盏叠加过曝顶白 - 至此光照篇收官:Phong(基础光照)+ 材质 + 光照贴图 + 多类投光物 + 多光源叠加 = 一套能用的完整光照
练习
问题 1(改代码型) 现在 main 里只有 1 个平行光 + 4 个点光源 + 1 个聚光。要求改成「0 个平行光 + 8 个点光源 + 0 个聚光」。说出你要改哪几处:着色器里改什么、main 里改什么、CPU 侧改什么?
问题 2(独立实现题) 不看上面的代码,写出 CalcDirLight 这个函数的完整签名和函数体。已知有全局 material(含 ambient/diffuse/specular/shininess)和传入的平行光 light(含 direction 和 ambient/diffuse/specular)。目标:返回这盏平行光对当前片段的 Phong 贡献。
问题 3(问答题) 为什么点光源要声明成定长数组 pointLights[NR_POINT_LIGHTS]、而不能像「灯有几盏就来几个」那样随用随加?这给 main 里的循环带来什么约束?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 多光源
一个场景里同时存在多盏光(类型可以不同),同一个表面会同时接到它们的光。本章的做法是让每盏光各自算出「它对这块表面的颜色贡献」,再把所有贡献相加,就是这块表面的最终颜色。详见本章「多光源:最终色 = 每盏光的贡献相加」一节。
- 光贡献函数
把「某一类光对一块表面的 Phong 计算」整段封装成的一个 GLSL 函数(如
CalcPointLight),返回一个vec3——这盏光对当前片段的颜色贡献。好处是main里一行调用就拿到一盏光的贡献,代码清爽、可复用、可循环。详见本章「把每盏光的计算装进一个函数」与 §6。- uniform 数组
GLSL 里一段连续、定长的同类型 uniform(如
pointLights[4])。配合#define定个数、用下标pointLights[i]取第i个,就能在着色器里循环处理多盏同类光。长度必须是编译期常量,且 CPU 侧要带下标逐个元素灌满,漏灌的会是默认全 0(黑灯)。详见本章 §6 第一、五步。- 平行光
一种「投光物」:假设光源无限远,照过来的全是同方向的平行光线(比如太阳光)。它只有方向、没有位置,也不随距离衰减——场景里各处受到的方向都一样。本章用
CalcDirLight处理它。详见上一章「投光物」与本章「要应付的三类光」。- 点光源
一种「投光物」:有一个具体位置、向四面八方发光的灯泡,光照强度随距离衰减(越远越暗)。点光源是本章里要放好几个、用 uniform 数组 + 循环处理的那一类,用
CalcPointLight处理。详见上一章「投光物」与本章「要应付的三类光」。- 聚光
一种「投光物」:从一个位置朝一个方向射出一个锥形光束,只照亮锥内、锥外不照(边缘还能做柔和过渡),像手电或舞台射灯。它同时有位置、方向、切光角和距离衰减,用
CalcSpotLight处理。详见上一章「投光物」与本章「要应付的三类光」。- 衰减
光照强度随「光源到表面的距离」变弱的处理(越远越暗)。点光源 / 聚光常用
1 / (常数项 + 一次项·d + 二次项·d²)这个衰减系数,乘到环境 / 漫反射 / 镜面三项上。它来自上一章「投光物」,本章在CalcPointLight/CalcSpotLight里直接复用。详见本章 §6 第三、四步。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- multiple lights:在“多光源函数化、数组上传与贡献累加”中由lighting program 的光源 uniform 数组与 Calc* 函数负责解释其输入、受控状态和可观察结果;运行时以uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图定位它的第一处变化。
- uniform array:在“多光源函数化、数组上传与贡献累加”中由lighting program 的光源 uniform 数组与 Calc* 函数负责解释其输入、受控状态和可观察结果;运行时以uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图定位它的第一处变化。
- lighting function:在“多光源函数化、数组上传与贡献累加”中由lighting program 的光源 uniform 数组与 Calc* 函数负责解释其输入、受控状态和可观察结果;运行时以uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“上传所有 light 元素,逐灯调用对应函数并累加返回值”发生后,lighting program 的光源 uniform 数组与 Calc* 函数应怎样改变光源数量、数组元素、每灯贡献、累加器和最终颜色;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
多光源函数化、数组上传与贡献累加:状态合同
让一盏平行光、多个点光和聚光分别求值后只在最终颜色处累加
验证场景
官方教程正式概念
logl-14 · 基线帧
multiple lights:固定 context、资源内容与输入事件,执行“上传所有 light 元素,逐灯调用对应函数并累加返回值”
冻结输入:multiple lights
lighting program 的光源 uniform 数组与 Calc* 函数记录光源数量、数组元素、每灯贡献、累加器和最终颜色
结果:得到可重复的初始 GL 状态与资源身份
观测:uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图中的初始快照
预期:lighting program 的光源 uniform 数组与 Calc* 函数得到可复查结果,并持续满足“每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“上传所有 light 元素,逐灯调用对应函数并累加返回值”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态”。
CPU command · GL state · GPU result
多光源函数化、数组上传与贡献累加:五段轨迹
当前观测:uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图中的初始快照
不变量:每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态
实验三:单故障与同输入恢复
注入“循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图一起恢复才算修复。
Single fault · first divergence · replay
多光源函数化、数组上传与贡献累加:反例与恢复
故障:循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯”
每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态
uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图
最小可重放检查
unit: logl-14
owner: lighting program 的光源 uniform 数组与 Calc* 函数
state_or_resource: 光源数量、数组元素、每灯贡献、累加器和最终颜色
command: 上传所有 light 元素,逐灯调用对应函数并累加返回值
pass_invariant: 每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态
single_fault: 循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯
required_evidence: uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。