Material/Light 结构体与 Phong 材质预设
Material/Light 结构体与 Phong 材质预设:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释 Material 的 ambient、diffuse、specular、shininess 四个字段分别控制什么
- 能区分 Material 表面反射属性与 Light 入射光强度,并逐项写出它们的配对关系
- 能实现 GLSL 结构体 uniform 及 CPU 侧带点成员名的完整上传代码
- 能修改材质预设与灯光强度,调出金、翡翠和哑光橡胶的不同主色与高光
- 能判断镜面颜色越界、shininess 填错、漏传结构体成员或把 light/material 混用造成的画面问题
为什么同样的光照在金属和橡胶上差这么多
上一章你已经能给物体打一束光,让它有了明暗和一块高光。可现实里,把一束一模一样的光打在不同东西上,看着天差地别:一枚金戒指亮得晃眼、有一小块刺目的高光;旁边一块黑橡胶垫却闷闷的,高光糊成一大片、甚至看不太出来。光没变,变的是表面本身。
差别就在「这个表面是怎么反光的」。金属几乎把照来的光原样弹回去,而且只在很小一块角度上集中反射,于是又亮又有锐利的高光点;橡胶吸掉大半、只散散地反射一点,高光摊开成一大片、显得哑。要在屏幕上画出「这是金的、那是橡胶的」,光靠调光是不够的——得给每个物体配一套「它该怎么反光」的说明书。
这一章讲的就是这本说明书:怎么用几个数把「表面怎么反光」描述清楚,让同一束光打上去,有的看着像金属、有的像塑料、有的像橡胶。配齐了这套数,质感就对了;配不齐,再好的光也只能照出一坨「说不清是什么做的」的东西。
材质:一个物体的「反光说明书」
我们把「这个表面该怎么反光」这套属性,打包叫做这个物体的 ↡描述一个物体表面「怎么反光」的一组属性。在 Phong 光照里它正好对上三项:环境色、漫反射色、镜面色,外加一个反光度(shininess)。换一套材质数值,同一束光下物体就从金属变成塑料、橡胶。(material)。上一章的 Phong 光照已经把光分成了三项——四面八方的环境光、正对光源更亮的漫反射、特定角度的镜面高光——材质要做的,就是给每一项都配一个「这个表面在这一项上反射什么颜色、反射多少」。
所以一份材质正好是四样东西:环境色(物体在底光下的基色)、漫反射色(物体的主色调,决定「它是什么颜色的」)、镜面色(高光那一小块反射出的颜色,金属常带金属本色、塑料多是白),再加一个反光度。前三样都是颜色(红绿蓝三个数),最后一样是单个数。换一套这四样数,同一束光照下来,物体就从金子变成橡胶。这四样怎么在着色器里摆放、怎么参与计算,是 §6 的重头戏。
反光度:高光是一个锐点,还是一大片
四样里最不像「颜色」的是↡一个数,控制镜面高光的大小和锐利程度。数值越高,高光越小越集中、越锐利(像光滑金属上那个刺眼的小亮点);数值越低,高光越大越散、越柔(像哑光橡胶上糊开的一片微光)。在着色器里它是 pow(..., shininess) 的指数。(shininess)。它一个数就管住了高光的「大小和锐利度」:
- 反光度高(比如 128、256):高光收成很小的一个锐点,亮得集中——这是光滑金属、抛光塑料的样子。
- 反光度低(比如 8、16):高光摊开成一大片、边缘模糊、不刺眼——这是哑光橡胶、磨砂表面的样子。
它在着色器里就是镜面项里那个「次方的指数」:指数越大,只有反射方向几乎正对视线时高光才亮得起来,于是高光被压成一个小点;指数小,稍微偏一点也还挺亮,高光就铺开了。这一项你待会能在 §5 的 Demo 里直接拖着看——把「高光指数」从小拖到大,亲眼看高光怎么从一大片缩成一个锐点。
为什么三项都要「材质色 × 光强」
回想「颜色」那一章的核心规则:你看到的 = 光色 × 物色。材质这一章只是把这条规则用到三项上各乘一遍。光本身也分三项强度(用一个 ↡描述「光本身」三项强度的一组属性,和材质一一对应:环境强度、漫反射强度、镜面强度。通常环境调得很低(免得到处发灰)、漫反射是光的主色(多为亮白)、镜面拉到最强(让高光够亮)。它和材质相乘,得到每一项真正反射进眼睛的颜色。装着:环境强度、漫反射强度、镜面强度),于是 Phong 三项各自都是「这一项的材质色 × 这一项的光强」:
- 环境 = 光的环境强度 × 材质环境色
- 漫反射 = 光的漫反射强度 × 材质漫反射色 ×(还要乘上面朝光源的程度)
- 镜面 = 光的镜面强度 × 材质镜面色 ×(还要乘上那个反光度次方)
这么拆的好处是两边都能单独调:调光(比如把环境强度压低,免得整个场景发灰)只动 Light 那三项;调质感(金属还是橡胶)只动 Material 那四样。它俩相乘,才是最终弹进眼睛的颜色。这正是上一章 Phong 三项的「升级版」——把每一项里写死的颜色,换成了「材质色 × 光强」。
动手:把同一个物体调出金属感和橡胶感
先按配置顺序逐项核对,避免看到最终画面异常时同时怀疑七个 uniform:
选一整套材质参数
同时设置 ambient、diffuse、specular 与 shininess。不要只改 diffuse 主色;高光颜色和指数不变时,物体往往只是换色而没有真正换质感。
下面是本章的主 Demo(和上一章同一个 Phong 光照演示)。它的三个滑块——环境强度、镜面强度、高光指数——正好对应材质的三个维度:环境项、镜面项、反光度。我们就用它来感受反光度和镜面对「光泽感」的影响。
Demo 默认是三项材质项全开的完整效果。点画布下方的「从头分步看」,可以按材质三项逐层叠:先只环境项(均匀底色)、再叠漫反射项(主色铺开)、最后叠镜面项(光泽登场)——这样能看清「材质的光泽感究竟由哪一项贡献」。
猜一猜:把高光指数拉到很高(比如 256)、镜面强度拉满,物体表面会像什么材质?再把高光指数压到很低(比如 8)、镜面也调小,又像什么?先在脑子里想想「锐利的小亮点 vs 糊开的一大片」,再拖滑块验证。
光照演示加载中…
这个 Demo 的物体颜色是固定的,所以它只演示「光泽感」这一半——也就是反光度和镜面让表面看起来或光滑或哑。材质的另一半是颜色(金子是暖金色、翡翠是绿色、橡胶可能是黑色),那一半不靠这个 Demo 改,而是靠下面 §6 的「材质预设表」——照抄表里的 ambient/diffuse/specular/shininess 四样数,就能把物体调成金、翡翠、橡胶等不同质感。Demo 负责让你摸到 shininess 的手感,预设表负责给你一整套能照抄的数。
代码逐段拆解
把材质落到代码上,分两摊:着色器里用两个结构体把材质和光各自打包(GLSL 两端几乎一样),CPU 侧把这些数当 uniform 一个个灌进去(C++/WebGL2 互为镜像)。
第一步:在着色器里声明 Material 结构体
GLSL 支持像 C 那样定义结构体,把材质四样打成一个包。这就是 ↡GLSL 里把材质四样属性打包成一个的结构体:ambient(环境色)、diffuse(漫反射色)、specular(镜面色)三个 vec3,外加 shininess(反光度)一个 float。用 uniform Material material; 声明后,着色器里就能写 material.diffuse 取各项。:
#version 330 core
struct Material {
vec3 ambient; // 环境色:底光下的基色
vec3 diffuse; // 漫反射色:物体的主色调
vec3 specular; // 镜面色:高光那一小块的颜色
float shininess; // 反光度:高光的大小 / 锐利度
};
uniform Material material; // 整包当一个 uniform 用#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
struct Material {
vec3 ambient; // 环境色:底光下的基色
vec3 diffuse; // 漫反射色:物体的主色调
vec3 specular; // 镜面色:高光那一小块的颜色
float shininess; // 反光度:高光的大小 / 锐利度
};
uniform Material material; // 整包当一个 uniform 用声明成结构体只是为了把零散的四样归拢——着色器里就能写 material.diffuse、material.shininess,比起四个散落的 uniform 整齐多了。两端 GLSL 几乎一致,只差版本声明那行,以及 WebGL2 片段着色器必带的精度声明:
第二步:再声明一个 Light 结构体
光本身也拆成三项强度,同样打成一个包。注意它和材质一一对应:材质有环境/漫反射/镜面三种色,光就有环境/漫反射/镜面三种强度,待会一一相乘。
struct Light {
vec3 position; // 光源位置(上一章已用过)
vec3 ambient; // 环境强度:通常调得很低
vec3 diffuse; // 漫反射强度:光的主色(多为亮白)
vec3 specular; // 镜面强度:通常拉到最强 (1,1,1)
};
uniform Light light;struct Light {
vec3 position; // 光源位置(上一章已用过)
vec3 ambient; // 环境强度:通常调得很低
vec3 diffuse; // 漫反射强度:光的主色(多为亮白)
vec3 specular; // 镜面强度:通常拉到最强 (1,1,1)
};
uniform Light light;为什么环境强度要调得很低、镜面要拉到最强?因为环境光是「四面八方都有的底光」,调高了整个物体会发灰、失去明暗;而镜面是那一小块高光,拉满才够亮够「精神」。这三项就是上一章那几个零散强度的「打包版」。
第三步:片段着色器里三项各「材质 × 光」
核心来了。上一章的 Phong 三项,现在每一项都改成「这一项的光强 × 这一项的材质色」。和「颜色」章的 光色 × 物色 是同一个套路,只是乘了三遍:
void main() {
// 环境 = 光环境强度 × 材质环境色
vec3 ambient = light.ambient * material.ambient;
// 漫反射 = 光漫反射强度 × (面朝光源的程度 × 材质漫反射色)
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * (diff * material.diffuse);
// 镜面 = 光镜面强度 × (反光度次方 × 材质镜面色)
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * (spec * material.specular);
FragColor = vec4(ambient + diffuse + specular, 1.0);
}void main() {
// 环境 = 光环境强度 × 材质环境色
vec3 ambient = light.ambient * material.ambient;
// 漫反射 = 光漫反射强度 × (面朝光源的程度 × 材质漫反射色)
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * (diff * material.diffuse);
// 镜面 = 光镜面强度 × (反光度次方 × 材质镜面色)
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = light.specular * (spec * material.specular);
FragColor = vec4(ambient + diffuse + specular, 1.0);
}逐项看:环境项直接两色相乘;漫反射项在中间多乘了个 diff(面越正对光源越大,就是上一章的 N·L);↡Phong 三项里的高光那一项——视线方向几乎正对「光的反射方向」时才亮起来的那一小块亮斑。这里它等于 光镜面强度 × 反光度次方 × 材质镜面色。反光度(shininess)越高,这块高光越小越锐。项多乘了个 spec(就是 pow(..., material.shininess),反光度在这里登场)。三项相加就是最终颜色。两端 GLSL 这段一字不差,平台差异只在前面那两行声明。
第四步:CPU 侧把材质和光灌进去
着色器声明好了 uniform,CPU 侧得把具体数值塞进去——颜色用 setVec3 / gl.uniform3f,反光度那个单数用 setFloat / gl.uniform1f。结构体成员在 CPU 侧用带点的名字(如 "material.diffuse")定位:
// 材质:这里是一组「珊瑚红塑料」样的数(换成下方预设表任意一行即可变质感)
shader.setVec3("material.ambient", 1.0f, 0.5f, 0.31f);
shader.setVec3("material.diffuse", 1.0f, 0.5f, 0.31f);
shader.setVec3("material.specular", 0.5f, 0.5f, 0.5f);
shader.setFloat("material.shininess", 32.0f);
// 光:环境压低、漫反射中等、镜面拉满
shader.setVec3("light.ambient", 0.2f, 0.2f, 0.2f);
shader.setVec3("light.diffuse", 0.5f, 0.5f, 0.5f);
shader.setVec3("light.specular", 1.0f, 1.0f, 1.0f);const p = gl.getUniformLocation.bind(gl, program); // 取位置的简写
// 材质:换成下方预设表任意一行即可变质感
gl.uniform3f(p("material.ambient"), 1.0, 0.5, 0.31);
gl.uniform3f(p("material.diffuse"), 1.0, 0.5, 0.31);
gl.uniform3f(p("material.specular"), 0.5, 0.5, 0.5);
gl.uniform1f(p("material.shininess"), 32.0);
// 光:环境压低、漫反射中等、镜面拉满
gl.uniform3f(p("light.ambient"), 0.2, 0.2, 0.2);
gl.uniform3f(p("light.diffuse"), 0.5, 0.5, 0.5);
gl.uniform3f(p("light.specular"), 1.0, 1.0, 1.0);官方示例:让 Light 属性随光色变化
官方示例还让光源颜色随时间变化,并由它派生更弱的环境光与中等强度的漫反射光。这样能直观看出 Material 是稳定的表面属性,而 Light 会随灯光颜色和强度变化:
vec3 lightColor;
lightColor.x = sin(glfwGetTime() * 2.0f);
lightColor.y = sin(glfwGetTime() * 0.7f);
lightColor.z = sin(glfwGetTime() * 1.3f);
vec3 diffuseColor = lightColor * vec3(0.5f);
vec3 ambientColor = diffuseColor * vec3(0.2f);
shader.setVec3("light.ambient", ambientColor);
shader.setVec3("light.diffuse", diffuseColor);
shader.setVec3("light.specular", 1.0f, 1.0f, 1.0f);这里环境光最终约为 lightColor × 0.1,漫反射约为 lightColor × 0.5,镜面保持白色。三项强度来自同一盏灯但用途不同;若直接把三项都设成完整 lightColor,环境底光会过强,画面容易发灰。
照抄就能用:材质预设表
「哪几个数配出金、哪几个配出橡胶」是门经验活——好在前人已经测好了一整张表,照抄即可。下面是几组常用 ↡前人测好的一批现成材质数值——每一行给出一种真实材料(翡翠、金、橡胶等)的 ambient / diffuse / specular 三色和 shininess 反光度。照抄整行灌进 uniform,物体立刻有了对应质感,不必自己一个个试。(数值来自经典的 OpenGL 材质表,shininess 已乘成 0~256 区间,可直接填)。把任意一行的四样灌进上面第四步那几个 uniform,并把光的三项强度都设成 (1,1,1)(这样物体严格按预设显示),物体就有了对应质感:
材质 ambient(R,G,B) diffuse(R,G,B) specular(R,G,B) shininess
翡翠 Emerald 0.0215, 0.1745, 0.0215 0.07568, 0.61424, 0.07568 0.633, 0.7278, 0.633 76.8
金 Gold 0.24725,0.1995, 0.0745 0.75164, 0.60648, 0.22648 0.62828, 0.5558, 0.36607 51.2
红宝石Ruby 0.1745, 0.01175,0.01175 0.61424, 0.04136, 0.04136 0.72781, 0.62696, 0.62696 76.8
青塑料Cyan 0.0, 0.1, 0.06 0.0, 0.50980, 0.50980 0.50196, 0.50196, 0.50196 32.0
绿橡胶Green 0.0, 0.05, 0.0 0.4, 0.5, 0.4 0.04, 0.7, 0.04 10.0横向看每一行是一种材质;纵向看四列正是着色器里 material 的四样。规律一眼可见:金属类(金、翡翠、红宝石)反光度高(51~77),高光小而锐、镜面色还带本色(金的镜面偏暖);橡胶类反光度低(10),高光摊得开、显哑。改质感不用懂物理,照抄一行即可。
容易踩的坑
小结
- 材质是物体的「反光说明书」:环境色 + 漫反射色 + 镜面色三种颜色,外加一个反光度
- Phong 三项各自都是「材质色 × 光强」——和「颜色」章的光色×物色同一个套路,乘了三遍
- **反光度(shininess)**控高光大小:高 → 小而锐(金属感)、低 → 大而散(橡胶感)
- 着色器里用
struct Material/struct Light打包,片元写material.* / light.*;CPU 侧用setVec3灌颜色、setFloat灌反光度 - 调质感不用懂物理:照抄材质预设表一行(翡翠/金/橡胶……)灌进 uniform 即可
练习
问题 1(改 Demo / 调材质型) 在 §5 的 Demo 里调出两种质感:①高反光的光滑金属感(锐利小高光);②哑光橡胶感(高光摊成一大片)。分别说出你怎么动那三个滑块、为什么。
问题 2(问答型) 材质里有 material.ambient/diffuse/specular,光里也有 light.ambient/diffuse/specular,名字一模一样。它俩各自描述的是什么?为什么要分成两套、而不是合并成一套?
问题 3(独立实现题) 不看上面的代码,写出片段着色器里镜面项那几行:从 material 和 light 出发,把镜面高光算出来。目标效果:反光度由 material.shininess 控制、高光颜色和强度由 material.specular 与 light.specular 决定。(已知 norm、lightDir、viewDir 都已算好。)
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 材质
描述一个物体表面「怎么反光」的一组属性。在 Phong 光照里正好对上三项颜色——环境色、漫反射色、镜面色,再加一个反光度。换一套材质数值,同一束光下物体就从金属变成塑料、橡胶。详见本章「材质」一节。
- Material 结构体
GLSL 里把材质四样属性打包成一个的结构体:
ambient(环境色)、diffuse(漫反射色)、specular(镜面色)三个vec3,外加shininess(反光度)一个float。用uniform Material material;声明后,着色器里就能写material.diffuse取各项。详见本章「第一步」。- Light 结构体
描述「光本身」三项强度的一组属性,和材质一一对应:环境强度、漫反射强度、镜面强度。通常环境调得很低(免得到处发灰)、漫反射是光的主色(多为亮白)、镜面拉到最强(让高光够亮)。它和材质相乘,得到每一项真正反射进眼睛的颜色。详见本章「第二步」。
- 反光度
一个数,控制镜面高光的大小和锐利程度。数值越高,高光越小越集中、越锐利(像光滑金属上那个刺眼的小亮点);数值越低,高光越大越散、越柔(像哑光橡胶上糊开的一片微光)。在着色器里它是
pow(..., shininess)的指数。详见本章「反光度」一节。- 镜面反射
Phong 三项里的高光那一项——视线方向几乎正对「光的反射方向」时才亮起来的那一小块亮斑。这里它等于 光镜面强度 × 反光度次方 × 材质镜面色。反光度越高,这块高光越小越锐。详见本章「第三步」。
- 材质预设
前人测好的一批现成材质数值——每一行给出一种真实材料(翡翠、金、橡胶等)的
ambient/diffuse/specular三色和shininess反光度。照抄整行灌进 uniform,物体立刻有了对应质感,不必自己一个个试。详见本章「材质预设表」。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- material:在“Material/Light 结构体与 Phong 材质预设”中由当前 lighting program 的 Material/Light uniform 集负责解释其输入、受控状态和可观察结果;运行时以program ID、active uniforms、材质输入、三项光照值与高光像素分布定位它的第一处变化。
- shininess:在“Material/Light 结构体与 Phong 材质预设”中由当前 lighting program 的 Material/Light uniform 集负责解释其输入、受控状态和可观察结果;运行时以program ID、active uniforms、材质输入、三项光照值与高光像素分布定位它的第一处变化。
- light properties:在“Material/Light 结构体与 Phong 材质预设”中由当前 lighting program 的 Material/Light uniform 集负责解释其输入、受控状态和可观察结果;运行时以program ID、active uniforms、材质输入、三项光照值与高光像素分布定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“选择材质预设,上传结构体字段并绘制同一几何体”发生后,当前 lighting program 的 Material/Light uniform 集应怎样改变ambient/diffuse/specular、shininess、光源三分量和 uniform location;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
Material/Light 结构体与 Phong 材质预设:状态合同
把 Material 与 Light 结构体字段逐项上传并观察 shininess 对高光形状的作用
验证场景
官方教程正式概念
logl-11 · 基线帧
material:固定 context、资源内容与输入事件,执行“选择材质预设,上传结构体字段并绘制同一几何体”
冻结输入:material
当前 lighting program 的 Material/Light uniform 集记录ambient/diffuse/specular、shininess、光源三分量和 uniform location
结果:得到可重复的初始 GL 状态与资源身份
观测:program ID、active uniforms、材质输入、三项光照值与高光像素分布中的初始快照
预期:当前 lighting program 的 Material/Light uniform 集得到可复查结果,并持续满足“每个字段写入当前 program 的有效 location,shininess 只改变镜面指数”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“选择材质预设,上传结构体字段并绘制同一几何体”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“每个字段写入当前 program 的有效 location,shininess 只改变镜面指数”。
CPU command · GL state · GPU result
Material/Light 结构体与 Phong 材质预设:五段轨迹
当前观测:program ID、active uniforms、材质输入、三项光照值与高光像素分布中的初始快照
不变量:每个字段写入当前 program 的有效 location,shininess 只改变镜面指数
实验三:单故障与同输入恢复
注入“缓存另一个 program 的 location 后复用,材质字段静默写到 -1 或错误位置”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有program ID、active uniforms、材质输入、三项光照值与高光像素分布一起恢复才算修复。
Single fault · first divergence · replay
Material/Light 结构体与 Phong 材质预设:反例与恢复
故障:缓存另一个 program 的 location 后复用,材质字段静默写到 -1 或错误位置
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“缓存另一个 program 的 location 后复用,材质字段静默写到 -1 或错误位置”
每个字段写入当前 program 的有效 location,shininess 只改变镜面指数
program ID、active uniforms、材质输入、三项光照值与高光像素分布
最小可重放检查
unit: logl-11
owner: 当前 lighting program 的 Material/Light uniform 集
state_or_resource: ambient/diffuse/specular、shininess、光源三分量和 uniform location
command: 选择材质预设,上传结构体字段并绘制同一几何体
pass_invariant: 每个字段写入当前 program 的有效 location,shininess 只改变镜面指数
single_fault: 缓存另一个 program 的 location 后复用,材质字段静默写到 -1 或错误位置
required_evidence: program ID、active uniforms、材质输入、三项光照值与高光像素分布复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。