多光源函数化、数组上传与贡献累加

多光源函数化、数组上传与贡献累加:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释多光源为何等于每盏灯独立的 Phong 贡献之和
  • 能实现 CalcDirLight、CalcPointLight、CalcSpotLight 三个纯贡献函数
  • 能实现定长 pointLights 数组的 GLSL 循环与 CPU 逐元素逐字段上传
  • 能修改平行光、点光源数量和聚光开关而不破坏 result 累加结构
  • 能判断覆盖赋值、数组未灌满、贡献过曝或片段光源数过多造成的问题

为什么一个房间里的多盏灯必须独立计算再累加

前面几章你一直在跟一盏灯打交道——手电照墙、绕着立方体转的那个小亮点。可你现在所在的房间,多半不止一盏灯:头顶一盏吸顶灯把整屋照亮,桌上一盏台灯在桌面投下一小圈暖光,墙角也许还有一束射灯打在画上。

看看桌面被照亮的样子:它同时接到了吸顶灯的均匀光、台灯那圈集中的暖光——两份光叠在一起,台灯正下方那块就比别处更亮。一个表面的明暗,从来不是某一盏灯说了算,而是它接到的所有光加在一起的结果。

这章要解决的就是:怎么让一个物体同时被好几盏不同类型的灯照亮,而代码又不乱成一团?没有它,你的场景永远只能点一盏灯,平平淡淡;有了它,吸顶灯、台灯、射灯可以一起开,物体被照得有层次——这也正好给整个光照篇收个完整的尾。

多光源:最终色 = 每盏光的贡献相加

把好几盏灯同时照在一个物体上,叫 。它背后只有一句话的道理:一块表面的最终颜色,等于照到它的每一盏光各自贡献的颜色之和

这其实不新——前几章你算一盏光时,得到的就是「这盏光对这块表面的贡献」(环境 + 漫反射 + 镜面那一坨)。多光源无非是把这件事对每盏光各做一遍,然后全加起来。难点不在数学(数学还是上几章那套 Phong),而在怎么组织代码:三类光各有各的算法,要是一股脑全塞进 main,几十行纠缠在一起,没法看也没法改。

如上图,本章的整个套路就是:每类光封成一个独立的小函数,各算各的贡献;main 里把这些贡献一份份加起来。下面先认清要应付哪三类光(都来自上一章「投光物」),再讲怎么函数化、怎么累加。

要应付的三类光(投光物回顾)

上一章把光按「光怎么打出来」分成了三类,本章要让它们同时上场。第一类是 (directional light):只有方向、没有位置,不随距离变弱,像太阳。第二类是 (point light):有位置、向四周发光、离得越远越暗(按距离衰减),像灯泡。第三类是 (spotlight):从一个位置朝一个方向射出锥形光束,只照锥内,像手电。

这三类的 Phong 主体(环境 + 漫反射 + 镜面)算法是一样的,差别只在「光从哪来」「要不要按距离衰减」「要不要限制在锥内」。所以它们天生适合各写成一个函数,把这些差异关在各自函数内部。

把每盏光的计算装进一个函数

本章真正的主角,是 :给每一类光写一个 GLSL 函数,函数算完那一类光的环境 + 漫反射 + 镜面,返回一个 vec3——这盏光对当前这块片段的颜色贡献。三个函数的名字按惯例叫 CalcDirLightCalcPointLightCalcSpotLight

这么一封装,main 就清爽了:算平行光只要 CalcDirLight(...),拿回一份贡献;点光源有好几个,就用一个循环逐个调 CalcPointLight(...) 累加;最后再加上聚光。每盏光的内部细节(衰减怎么算、锥形怎么判断)都锁在各自函数里,main 只管「把各份贡献加起来」。这就是上图说的「函数化 + 累加」——本章没有新数学,整章的功夫全在这套代码组织上。具体怎么写,全在 §6。

动手:亲手加灯、看贡献叠加

先按 main 中的真实累加顺序逐步核对总颜色:

分步1 / 3

平行光初始化 result

先计算 CalcDirLight。它可以用 = 初始化 result;若场景没有平行光,则从 vec3(0.0) 开始,不能留下未初始化值。

前几章你只点过一盏光。这个 Demo 让你一次点好几盏:一个立方体,同时被一盏平行光 + 若干点光源 + 一盏聚光照亮,片段着色器对每一类光各算一份 环境 + 漫反射 + 镜面,再把所有贡献相加——正是本章 §3 那张图说的事。顶部一行可以选灯、开关、给点光源 +/- 增删;下方只显示你当前选中那一盏的参数(颜色、方位、强度,外加点光源的衰减档或聚光的切光角)。

打开就能看到默认布灯:冷色平行光打底,一盏暖橙、一盏冷青两个点光源从两侧照来,再加一盏暖白聚光——立方体不同的面被不同的灯主导,被多盏光同时照到的棱角处明显更亮,个别地方已经叠加到顶满发白

猜一猜:把两盏点光源的方位都转到立方体同一个面、再把强度都拉高,那个面会怎样?几份漫反射 / 镜面叠加上去,会不会亮到顶满、糊成一片纯白(过曝)?反过来,把点光源一盏盏删掉、再关掉平行光和聚光,画面会变成什么样——会全黑吗?动手试试,看「相加」和「减到没有」各自的样子。

可交互

多光源演示加载中…

试着把两盏点光源转到同一面、强度拉满——那个面很快顶白过曝,这正是上面「猜一猜」的答案,也对应 §7 要提醒的一个坑。再反过来:把灯一盏盏删掉、平行光和聚光也关了,立方体不会死黑,而是落到一层很弱的环境光上变暗——因为每盏光的贡献里都含一份环境项,全没了还兜着最低限度的底光。每加一盏灯就多一份贡献、每减一盏就少一份,result = 贡献₁ + 贡献₂ + … 这条加法,你现在可以亲手加减验证。

代码逐段拆解

把上面那套「函数化 + 累加」摊开成真代码。沿用前两章的约定:物体材质用一个 Material 结构体(环境/漫反射/镜面三色 + 反光度),每一类光各用自己的结构体描述。光照仍算在片段着色器里。C++/OpenGL 与 WebGL2/TS 互为镜像。

第一步:声明三类光的结构体 + 点光源的 uniform 数组

每类光打成一个结构体。平行光只有方向,点光源多了位置和三个衰减系数,聚光在点光源基础上再加方向和两个切光角余弦。最关键的一行:点光源要放好几个,于是声明成一个 ,长度用 #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;

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;           // ← 返回这盏光的贡献
}

注意三项里的 diffspecmaterial.*light.* 全是「基础光照」「材质」两章的老朋友,没有任何新东西。新的只有形式:把它们包进一个函数、返回一个 vec3。这段是纯计算,两端一字不差。点光源、聚光的函数就是在这个骨架上各加一点料。

第三步:点光源函数 CalcPointLight(多了距离衰减)

点光源比平行光多两件事:lightDir 由「光源位置 − 片段位置」算出(它有具体位置),以及按距离做 ——离得越远越暗。衰减系数乘到三项上,其余和平行光一样。

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 参数(因为要算距离)。注意函数签名里把 normalfragPosviewDir 都当参数传进来——这样函数不依赖任何外部全局,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;
}

三个函数排在一起,结构高度一致——都是「算三项 → 乘衰减/锥形因子 → 返回之和」,差异只在各自多出来的那几行(衰减、锥形)。cutOff / outerCutOff 是内外切光角的余弦intensity 的软边公式都来自上一章「投光物」,这里直接复用。原书把 CalcSpotLight 留作练习,本章给出完整版,方便你把光照篇收成一套完整的代码。

第五步:main 把三类贡献全部相加

终于到 main——也是全章的点睛。先把所有光共用的 normviewDir 备好(只算一次),然后:平行光直接调一次;点光源用 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);   // 各份贡献之和 = 最终色
}

读这段 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);
  // …该点光源其余项略
}

两端都是拼出带下标的字符串名pointLights[0].positionpointLights[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(含 directionambient/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 元素,逐灯调用对应函数并累加返回值”

状态所有者lighting program 的光源 uniform 数组与 Calc* 函数
受控状态/资源光源数量、数组元素、每灯贡献、累加器和最终颜色
触发命令上传所有 light 元素,逐灯调用对应函数并累加返回值

冻结输入:multiple lights

lighting program 的光源 uniform 数组与 Calc* 函数记录光源数量、数组元素、每灯贡献、累加器和最终颜色

结果:得到可重复的初始 GL 状态与资源身份

观测:uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图中的初始快照

预期:lighting program 的光源 uniform 数组与 Calc* 函数得到可复查结果,并持续满足“每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“上传所有 light 元素,逐灯调用对应函数并累加返回值”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态”。

CPU command · GL state · GPU result

多光源函数化、数组上传与贡献累加:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图中的初始快照

不变量:每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态

实验三:单故障与同输入恢复

注入“循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图一起恢复才算修复。

Single fault · first divergence · replay

多光源函数化、数组上传与贡献累加:反例与恢复

故障:循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯”

3. 定位首差一致

每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态

4. 清理并重放一致

uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图

最小可重放检查

unit: logl-14
owner: lighting program 的光源 uniform 数组与 Calc* 函数
state_or_resource: 光源数量、数组元素、每灯贡献、累加器和最终颜色
command: 上传所有 light 元素,逐灯调用对应函数并累加返回值
pass_invariant: 每盏灯只贡献一次且函数不偷偷复用上一盏灯的局部状态
single_fault: 循环内把 result 赋值为当前点光贡献而非 +=,只剩最后一盏灯
required_evidence: uniform 数组查询、逐灯分量、循环索引、累加结果与关闭单灯差分图

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…