光源颜色、物体反射率与场景着色器
光源颜色、物体反射率与场景着色器:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释「我们看到的物体颜色」是入射光与表面反射率共同作用的结果
- 能计算两个 RGB 颜色逐通道相乘后的结果,并判断哪个通道因光源缺失而归零
- 能解释「光的颜色 × 物体颜色」在后续光照模型中的位置与局限
- 能区分被照物体着色器与灯源立方体着色器,并说明为什么两者可共享 VBO、需要各自 VAO
- 能回答珊瑚色物体在纯绿光、白光和无光条件下分别呈现什么颜色及原因
为什么你看到的「红苹果」会随灯光改变
随手拿起一个红苹果,你会理所当然地觉得「它就是红的」。可把它端进伸手不见五指的暗房,红色去哪了?灯一关,苹果就成了一团黑。可见颜色不是苹果自己揣在兜里的东西——没有光,什么颜色都谈不上。
换个想法:苹果更像一个挑食的反射器。照过来的光里混着各种颜色,苹果把不合胃口的那些「吃掉」(吸收),只把红色那一份弹回来。弹回你眼睛里的,才是你看到的颜色。所以你看到的红,其实是「光送来的红,被苹果留下并反射回来」的红。
这一章要解决的就是这件事:在屏幕上画一个物体时,它最终显什么色,到底由谁说了算?想不清这条,后面整篇光照——影子、高光、明暗——全是空中楼阁;想清了,它就是一切的地基。
物体颜色:反射回来的那部分光
先把直觉变精确。在图形学里,一个物体的「颜色」指的不是它发光,而是它的↡物体的颜色不是它自身发出的,而是它「反射」回来的那部分光的颜色。白光照过来,物体吸收掉一部分颜色、反射剩下的,反射回你眼睛的就是你看到的色。所以同一个物体在不同颜色的光下会显出不同的样子。——它面对一束光时,会反射哪些颜色、吸收哪些颜色。我们说「这个物体是珊瑚红」,真正的意思是:照白光时,它主要把红色、加一点绿和蓝反射出来,其余吃掉。
为什么要这么绕?因为这正贴合现实:同一个物体,换一种光照,看上去就变样了。白炽灯下暖、冷白灯下冷、舞台红灯下整个发红——物体本身没变,反射规则没变,变的是照过来的光。把「颜色 = 反射」这件事认下来,才解释得了这些日常现象。
光源颜色:照过来的那束光是什么色
另一半是光本身。↡照射到物体上的那束光的颜色。比如太阳光/白炽灯近似白光(红绿蓝都强),舞台灯可能是纯红、纯绿等有色光。光源颜色决定了「有哪些颜色的光可供物体反射」。就是照过来那束光的颜色:太阳光、白炽灯近似白光(红、绿、蓝三种成分都很足);舞台上的红灯、绿灯则是有色光(只有某一两种成分强,别的几乎没有)。
关键点在这:物体只能反射「光里本来就有的」颜色。光里没有的成分,物体再想反射也无米下锅。一个红苹果在纯绿光下——绿光里压根没有红色成分可供它反射,红就弹不回来了,于是它看上去发暗、发黑。这就埋下了下一节那条核心规则。
核心规则:反射所见 = 光色 × 物色
把两半合起来,就得到本章最重要的一句话:
你最终看到的颜色 = 光源颜色 × 物体颜色,而且是红配红、绿配绿、蓝配蓝地分开相乘。
这种「对应分量各自相乘」的算法叫↡把两个颜色(各有红/绿/蓝三个数)的对应分量分别相乘:红×红、绿×绿、蓝×蓝,得到一个新颜色。不是把六个数加在一起,而是三组各算各的。在 GLSL 里对两个 vec3 直接写 * 就是这个意思。:颜色都用 (红, 绿, 蓝) 三个 0~1 的数表示,相乘时红跟红乘、绿跟绿乘、蓝跟蓝乘,互不串门。直觉上它就是在问每个通道:「这种颜色的光(光色),物体愿意反射回多少比例(物色)?」两个一乘,就是真正弹回你眼里的量。
为什么这条规则在整个↡一套用来算「物体表面最终显什么颜色」的计算流程。本章是它最朴素的起点(只有光色×物色);后续会逐步加入环境光、漫反射、镜面高光等项,让明暗和光泽更真实,但「光色×物色」这个内核始终在最里层。里是地基?因为后面要学的环境光、漫反射、镜面高光,本质都是在更精细地算出「这块表面到底被多少光照到、是什么色的光」——也就是把上面那个「光源颜色」算得更讲究;而「乘上物体颜色得到反射所见」这一步,自始至终都在最里层。先把这条朴素规则攥牢,后面只是往里填料。下面的 Demo 就让你亲手乘一乘。
分三步核对颜色乘法
先不要把 lightColor * objectColor 当成一句需要背的代码。按数据来源拆开:光源决定表面收到哪些通道,物体颜色决定每个通道保留多少比例,着色器才把两者逐通道相乘并输出。
读取光源颜色
先看 lightColor。某个通道是 0,表示表面根本没有收到该颜色的光,后续无论物体多愿意反射都只能得到 0。
动手:拖两个颜色,看它们相乘
下面是本章的主角。画布分三栏,从左到右就是这条规则的三步分解:第一步——左栏是物体本色(假设白光下它长这样);第二步——给它乘上中栏照过来的光色;第三步——得到右栏两者逐分量相乘的反射所见,也就是你真正会看到的颜色。换句话说:先看物体本色,再乘光色,最后得反射所见,三栏并排把这步乘法摊在眼前。右侧两个颜色控件分别驱动「物体色」和「光色」这两个 uniform,拖一下,右栏立刻跟着变。
初始就是经典例子:珊瑚红的物体((1.0, 0.5, 0.31))被白光((1.0, 1.0, 1.0))照——白光各分量都是 1,乘下来物体原封不动,所以右栏 = 左栏,物体显出本色。
猜一猜:把中间的「光色」拖成纯绿(红、蓝都拉到 0,只留绿),这个珊瑚红物体的「反射所见」会偏成什么色?是更红、更绿,还是发暗发黑?先在脑子里算一下红配红、绿配绿、蓝配蓝,再拖控件验证。
实时演示加载中…
谜底:纯绿光是 (0, 1, 0),珊瑚红物体是 (1.0, 0.5, 0.31),逐分量相乘 = (0×1.0, 1×0.5, 0×0.31) = (0, 0.5, 0)——红、蓝两个通道被绿光「掐灭」(光里没有这两种成分,物体无从反射),只剩半亮的绿。所以珊瑚红物体在绿光下会显成一抹发暗的绿。把光色拉回纯白 (1, 1, 1),乘下来又恢复成物体本色——这正是「白光显本色」的由来。
代码:一行就是全部规则
把上面 Demo 的核心摊开看,本章的全部规则其实就浓缩在片段着色器的一行乘法里。物体色和光色各是一个 uniform(你的程序用 setVec3 / gl.uniform3f 传进去,做法和上一篇「着色器」章一样),相乘后输出:
// 片段着色器
#version 330 core
out vec4 FragColor;
uniform vec3 objectColor; // 物体色(白光下的本色)
uniform vec3 lightColor; // 光源颜色
void main() {
// 逐分量相乘:红×红、绿×绿、蓝×蓝 —— 这一行就是本章的核心
FragColor = vec4(lightColor * objectColor, 1.0);
}const fragmentSrc = `#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
out vec4 FragColor;
uniform vec3 objectColor; // 物体色(白光下的本色)
uniform vec3 lightColor; // 光源颜色
void main() {
// 逐分量相乘:红×红、绿×绿、蓝×蓝 —— 这一行就是本章的核心
FragColor = vec4(lightColor * objectColor, 1.0);
}`;GLSL 里两个 vec3 直接写 *,做的就是逐分量相乘——lightColor * objectColor 一行,红配红、绿配绿、蓝配蓝全办妥,不用你手写三遍。两端的 GLSL 源码完全一样,唯一差异在那行版本声明和精度声明:
至于怎么把 objectColor / lightColor 的值从程序里传进这两个 uniform(getUniformLocation 问位置、uniform3f 写值、先 useProgram),和上一篇「着色器」章一模一样,这里不再重复。本章要你记住的,就是 main 里那一行乘法。
官方示例的两个立方体与两个着色器
为了让光源位置在场景中可见,官方示例还绘制一个缩小的灯源立方体。它和被照物体可以共享同一份立方体位置 VBO,但分别绑定 objectVAO 与 lightVAO:VAO 记录的是“怎样解释缓冲并启用哪些属性”,不是顶点数据本身。两次 draw call 也使用不同 shader program:物体程序输出 lightColor * objectColor,灯源程序直接输出灯光颜色。
// 同一个 position VBO,分别配置两个 VAO
glBindVertexArray(objectVAO);
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
glBindVertexArray(lightVAO);
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
lightingShader.use();
glBindVertexArray(objectVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);
lightCubeShader.use();
glBindVertexArray(lightVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);这段代码的复刻要点不是“必须复制两份顶点”,而是共享 VBO、分开 VAO、分开 program、分开 draw call。后续加入法线时,物体 VAO 会多出法线属性,而灯源 VAO 仍只需位置,这种分离会更有价值。
容易踩的坑
小结
- 物体颜色不是物体自带的,而是它反射回来的那部分光——没有光就没有颜色
- 光源颜色决定「有哪些颜色的光可供反射」;物体只能反射光里本来就有的成分
- 核心规则:反射所见 = 光色 × 物色,红配红、绿配绿、蓝配蓝逐分量相乘
- 白光
(1,1,1)乘下来不改变物体,所以显本色;有色光会掐灭某些分量,物体就偏色 - 这条「光色 × 物色」是整个光照模型的最里层地基,后续只是把「光色」算得越来越讲究
练习
问题 1(问答型) 一个纯蓝物体 (0, 0, 1),分别在白光 (1, 1, 1) 和纯红光 (1, 0, 0) 下,看上去各是什么色?为什么?
问题 2(改 Demo 代码型) 在上面的可编辑 Demo 里,把画布改成只显示一整块「反射所见」(去掉左中两栏的物体色/光色对照),整块都是 光色 × 物色 的结果。提示:删掉分栏的 if,让 col 直接等于 reflected。
问题 3(问答型) 后面要学的 Phong 光照里有「环境光、漫反射、镜面高光」三项。按本章的认知,这三项算的到底是「光色」那一半,还是「物色」那一半?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 物体颜色(反射特性)
物体的颜色不是它自己发出来的,而是它反射回来的那部分光的颜色。一束光照过来,物体吸收掉一部分颜色、把没吸收的反射回去,反射进你眼睛的就是你看到的色。所以同一个物体在不同颜色的光下会显得不一样。详见本章「物体颜色」一节。
- 光源颜色
照到物体上的那束光本身的颜色。太阳光、白炽灯接近白光(红绿蓝成分都很足),舞台红灯/绿灯则是有色光(只有某一两种成分)。光源颜色决定了「有哪些颜色的光可供物体反射」——光里没有的颜色,物体再想反射也没有。详见本章「光源颜色」一节。
- 逐分量相乘
把两个颜色(各有红、绿、蓝三个数)的对应分量分别相乘:红×红、绿×绿、蓝×蓝,得到一个新颜色——不是把六个数加在一起,而是三组各算各的。在 GLSL 里对两个
vec3直接写*就是这个意思。本章「光色 × 物色」用的就是它。详见本章「核心规则」一节。- 光照模型
一套用来算「物体表面最终显什么颜色」的计算流程。本章是它最朴素的起点(只有「光色 × 物色」);后续会逐步加入环境光、漫反射、镜面高光等项,让明暗和光泽更真实,但「光色 × 物色」这个内核始终在最里层。详见本章「核心规则」一节。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- color:在“光源颜色、物体反射率与场景着色器”中由lamp/object 两个 program 与各自颜色 uniform负责解释其输入、受控状态和可观察结果;运行时以当前 program、uniform location/value、输入颜色和 framebuffer 取样定位它的第一处变化。
- light color:在“光源颜色、物体反射率与场景着色器”中由lamp/object 两个 program 与各自颜色 uniform负责解释其输入、受控状态和可观察结果;运行时以当前 program、uniform location/value、输入颜色和 framebuffer 取样定位它的第一处变化。
- object color:在“光源颜色、物体反射率与场景着色器”中由lamp/object 两个 program 与各自颜色 uniform负责解释其输入、受控状态和可观察结果;运行时以当前 program、uniform location/value、输入颜色和 framebuffer 取样定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“分别绑定灯与物体 program,上传颜色并绘制光源代理和受光物体”发生后,lamp/object 两个 program 与各自颜色 uniform应怎样改变lightColor、objectColor、program 绑定和片段输出;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
光源颜色、物体反射率与场景着色器:状态合同
验证物体反射率与入射光色逐通道相乘后才成为片段颜色
验证场景
官方教程正式概念
logl-09 · 基线帧
color:固定 context、资源内容与输入事件,执行“分别绑定灯与物体 program,上传颜色并绘制光源代理和受光物体”
冻结输入:color
lamp/object 两个 program 与各自颜色 uniform记录lightColor、objectColor、program 绑定和片段输出
结果:得到可重复的初始 GL 状态与资源身份
观测:当前 program、uniform location/value、输入颜色和 framebuffer 取样中的初始快照
预期:lamp/object 两个 program 与各自颜色 uniform得到可复查结果,并持续满足“物体输出逐通道等于入射光色乘反射率,uniform 写入正确 program”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“分别绑定灯与物体 program,上传颜色并绘制光源代理和受光物体”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“物体输出逐通道等于入射光色乘反射率,uniform 写入正确 program”。
CPU command · GL state · GPU result
光源颜色、物体反射率与场景着色器:五段轨迹
当前观测:当前 program、uniform location/value、输入颜色和 framebuffer 取样中的初始快照
不变量:物体输出逐通道等于入射光色乘反射率,uniform 写入正确 program
实验三:单故障与同输入恢复
注入“查询的是 object program 的 location,却在 lamp program current 时上传颜色”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有当前 program、uniform location/value、输入颜色和 framebuffer 取样一起恢复才算修复。
Single fault · first divergence · replay
光源颜色、物体反射率与场景着色器:反例与恢复
故障:查询的是 object program 的 location,却在 lamp program current 时上传颜色
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“查询的是 object program 的 location,却在 lamp program current 时上传颜色”
物体输出逐通道等于入射光色乘反射率,uniform 写入正确 program
当前 program、uniform location/value、输入颜色和 framebuffer 取样
最小可重放检查
unit: logl-09
owner: lamp/object 两个 program 与各自颜色 uniform
state_or_resource: lightColor、objectColor、program 绑定和片段输出
command: 分别绑定灯与物体 program,上传颜色并绘制光源代理和受光物体
pass_invariant: 物体输出逐通道等于入射光色乘反射率,uniform 写入正确 program
single_fault: 查询的是 object program 的 location,却在 lamp program current 时上传颜色
required_evidence: 当前 program、uniform location/value、输入颜色和 framebuffer 取样复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。