光源颜色、物体反射率与场景着色器

光源颜色、物体反射率与场景着色器:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释「我们看到的物体颜色」是入射光与表面反射率共同作用的结果
  • 能计算两个 RGB 颜色逐通道相乘后的结果,并判断哪个通道因光源缺失而归零
  • 能解释「光的颜色 × 物体颜色」在后续光照模型中的位置与局限
  • 能区分被照物体着色器与灯源立方体着色器,并说明为什么两者可共享 VBO、需要各自 VAO
  • 能回答珊瑚色物体在纯绿光、白光和无光条件下分别呈现什么颜色及原因

为什么你看到的「红苹果」会随灯光改变

随手拿起一个红苹果,你会理所当然地觉得「它就是红的」。可把它端进伸手不见五指的暗房,红色去哪了?灯一关,苹果就成了一团黑。可见颜色不是苹果自己揣在兜里的东西——没有光,什么颜色都谈不上。

换个想法:苹果更像一个挑食的反射器。照过来的光里混着各种颜色,苹果把不合胃口的那些「吃掉」(吸收),只把红色那一份弹回来。弹回你眼睛里的,才是你看到的颜色。所以你看到的红,其实是「光送来的红,被苹果留下并反射回来」的红。

这一章要解决的就是这件事:在屏幕上画一个物体时,它最终显什么色,到底由谁说了算?想不清这条,后面整篇光照——影子、高光、明暗——全是空中楼阁;想清了,它就是一切的地基。

物体颜色:反射回来的那部分光

先把直觉变精确。在图形学里,一个物体的「颜色」指的不是它发光,而是它的——它面对一束光时,会反射哪些颜色、吸收哪些颜色。我们说「这个物体是珊瑚红」,真正的意思是:照白光时,它主要把红色、加一点绿和蓝反射出来,其余吃掉。

为什么要这么绕?因为这正贴合现实:同一个物体,换一种光照,看上去就变样了。白炽灯下暖、冷白灯下冷、舞台红灯下整个发红——物体本身没变,反射规则没变,变的是照过来的光。把「颜色 = 反射」这件事认下来,才解释得了这些日常现象。

光源颜色:照过来的那束光是什么色

另一半是光本身。就是照过来那束光的颜色:太阳光、白炽灯近似白光(红、绿、蓝三种成分都很足);舞台上的红灯、绿灯则是有色光(只有某一两种成分强,别的几乎没有)。

关键点在这:物体只能反射「光里本来就有的」颜色。光里没有的成分,物体再想反射也无米下锅。一个红苹果在纯绿光下——绿光里压根没有红色成分可供它反射,红就弹不回来了,于是它看上去发暗、发黑。这就埋下了下一节那条核心规则。

核心规则:反射所见 = 光色 × 物色

把两半合起来,就得到本章最重要的一句话:

你最终看到的颜色 = 光源颜色 × 物体颜色,而且是红配红、绿配绿、蓝配蓝地分开相乘。

这种「对应分量各自相乘」的算法叫:颜色都用 (红, 绿, 蓝) 三个 0~1 的数表示,相乘时红跟红乘、绿跟绿乘、蓝跟蓝乘,互不串门。直觉上它就是在问每个通道:「这种颜色的光(光色),物体愿意反射回多少比例(物色)?」两个一乘,就是真正弹回你眼里的量。

为什么这条规则在整个里是地基?因为后面要学的环境光、漫反射、镜面高光,本质都是在更精细地算出「这块表面到底被多少光照到、是什么色的光」——也就是把上面那个「光源颜色」算得更讲究;而「乘上物体颜色得到反射所见」这一步,自始至终都在最里层。先把这条朴素规则攥牢,后面只是往里填料。下面的 Demo 就让你亲手乘一乘。

分三步核对颜色乘法

先不要把 lightColor * objectColor 当成一句需要背的代码。按数据来源拆开:光源决定表面收到哪些通道,物体颜色决定每个通道保留多少比例,着色器才把两者逐通道相乘并输出。

分步1 / 3

读取光源颜色

先看 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);
}

GLSL 里两个 vec3 直接写 *,做的就是逐分量相乘——lightColor * objectColor 一行,红配红、绿配绿、蓝配蓝全办妥,不用你手写三遍。两端的 GLSL 源码完全一样,唯一差异在那行版本声明和精度声明:

至于怎么把 objectColor / lightColor 的值从程序里传进这两个 uniform(getUniformLocation 问位置、uniform3f 写值、先 useProgram),和上一篇「着色器」章一模一样,这里不再重复。本章要你记住的,就是 main 里那一行乘法。

官方示例的两个立方体与两个着色器

为了让光源位置在场景中可见,官方示例还绘制一个缩小的灯源立方体。它和被照物体可以共享同一份立方体位置 VBO,但分别绑定 objectVAOlightVAO: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,上传颜色并绘制光源代理和受光物体”

状态所有者lamp/object 两个 program 与各自颜色 uniform
受控状态/资源lightColor、objectColor、program 绑定和片段输出
触发命令分别绑定灯与物体 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

光源颜色、物体反射率与场景着色器:五段轨迹

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

当前观测:当前 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. 冻结输入一致

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

2. 注入单故障一致

保持其余输入不变,仅注入“查询的是 object program 的 location,却在 lamp program current 时上传颜色”

3. 定位首差一致

物体输出逐通道等于入射光色乘反射率,uniform 写入正确 program

4. 清理并重放一致

当前 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 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

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

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

讨论

评论区加载中…