漫反射、镜面与自发光贴图
漫反射、镜面与自发光贴图:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释漫反射、镜面与自发光贴图分别控制材质的哪个输出通道
- 能实现位置、法线、UV 顶点布局以及三个 sampler2D 的片段采样代码
- 能实现三个纹理单元的绑定链,使 sampler uniform、active unit 与 texture binding 一一对应
- 能修改镜面贴图颜色与自发光强度,得到暖色高光和不受灯光影响的发光图案
- 能判断 UV 未传递、贴图绑错单元、镜面遮罩取反或把 emission 当真实光源造成的错误
为什么一个木箱能同时木头发哑、铁边发亮
上一章你已经能给整个物体配一套「反光说明书」,让它看着像金属或像橡胶。可现实里的东西很少是「纯一种材质」:一个带铁皮包角的木箱,木头部分灰扑扑、几乎不反光,可四角那圈钢边一见光就亮得晃眼。用上一章的办法,整个箱子只能共用同一套反光参数——要么整块都哑、要么整块都亮,没法让「同一个箱子上,这块木头哑、那条铁边亮」。
问题出在「整块一个值」上。上一章描述材质用的是几个固定的数:一个底色、一个反光强度,全箱子从头到尾都是这一套。可我们想要的是逐个部位不同:木头那片底色发暗、不反光;铁边那片底色发亮、反光强。一个数管不了这种「同一物体内部还分区」的细节。
这一章给出的办法很自然:既然一个数不够,那就换成一张图。用一张图说清「箱子各处分别是什么底色」,再用另一张图说清「箱子各处分别反不反光、反多强」。物体表面怎么反光,从此能精细到每一个像素——这就是本章要做的事。
光照贴图:用图来逐片段控制反光
把「材质各项用一张图来逐片段决定」这种做法,统称 ↡用纹理图来逐片段地决定材质各项(底色、反光强弱等),取代上一章那种「整块物体共用一个常量」的做法。常见两张:漫反射贴图(管各处底色)和镜面光贴图(管各处反不反光)。本质是把「纹理」和「材质」两章合到一起。(lighting map)。它其实是把你已经学过的两件事合到一起:「纹理」章教过怎么按 UV 从一张图里采样取色;「材质」章教过 Phong 三项各是「材质色 × 光强」。光照贴图就是把材质里那几个写死的常量,换成「每个片段拿自己的 UV 去图里采样得到的值」。
为什么要这么做?因为真实物体表面的属性是逐点变化的,一个常量描述不了。在 Phong 光照模型里,最值得这样精细控制的有两项:物体的底色(漫反射)和反光强弱(镜面)。于是光照贴图最常见的就是两张——一张管底色,一张管反光。下面分别看。
漫反射贴图:每个片段查自己的底色
第一张图替换的是材质的漫反射色。上一章 material.diffuse 是一个写死的颜色,整块物体共用;现在把它换成一张 ↡替换材质漫反射色的那张纹理图。原本 material.diffuse 是一个常量 vec3(整块一色),现在改成一张图,每个片段拿自己的 UV 去采样,得到属于它那个部位的底色——于是木箱的木头、铁边能有各自的颜色。(diffuse map):每个片段拿自己的 UV 去这张图里采样,取出属于它那个部位的底色。于是箱子的木头部位是木纹色、铁边部位是金属色,不再整块一个颜色。
它在着色器里的位置和上一章几乎一样——还是 Phong 的漫反射项「光强 × 底色 ×(面朝光源程度)」,只是「底色」从常量换成了一次采样。下面这张图把「常量 → 贴图」的升级画了出来:
镜面光贴图:一张「哪里反光」的灰度遮罩
第二张图替换的是材质的镜面色,叫 ↡替换材质镜面色的那张纹理图,通常是一张灰度图。每个片段采样它,取出的明暗值直接当作该处镜面高光的强度——亮的地方反光强、暗的地方不反光。木箱的钢边画成白(反光强)、木头画成黑(不反光)。(specular map)。这张图通常是一张灰度图:木箱的木头部位涂成黑、钢制边框涂成白、有点磨损的裂缝涂成深灰。
为什么用灰度?因为我们要控制的不是「反光是什么颜色」,而是「这里反不反光、反多强」——这只需要一个 0 到 1 的强弱值,正好对应灰度图里每个像素的明暗。这种「用一张图的明暗当强弱开关」的用法叫 ↡用一张灰度图(每个像素只有明暗、没有彩色)当作「强弱开关」:白(接近 1.0)表示该处效果强、黑(接近 0.0)表示该处效果弱或没有。镜面光贴图就是用它来控制各处反光强弱——白处反光强、黑处不反光。:白色(值接近 1.0)表示这里反光强、黑色(值接近 0.0)表示这里不反光。片段采样这张图,取出的明暗值直接乘到镜面高光上——白处乘 1 全亮、黑处乘 0 没有高光。
于是同一束光打到箱子上,钢边亮起锐利高光、木头部位却哑哑的,一张箱子上有了两种反光质感——这正是上一章「整块一个值」做不到的。
sampler2D:贴图在着色器里的「身份」
要在材质里放下「一张图」,得换一种类型。上一章 material.diffuse 是 vec3(三个数);现在要让它代表一张图,类型就得改成 ↡GLSL 里代表一张 2D 纹理的采样器类型(「纹理」章见过)。它本身不存图,而是「指向某个纹理单元」的句柄,配合 texture() 去采样取色。它是一种不能当普通变量赋值、只能用 uniform 声明的特殊类型。——这正是「纹理」章里采样器的那个类型。把结构体里的 vec3 diffuse; 改成 sampler2D diffuse;,这一项就从「一个颜色」变成了「一张可采样的图」。
有一点要特别记住:sampler2D 是一种特殊类型,不能像普通变量那样赋值或在函数里临时造一个,只能用 uniform 声明。所以它一定出现在 uniform Material material; 这种从 CPU 传进来的结构体里。具体怎么声明、怎么采样、CPU 侧怎么把两张图喂进去,是下面 §6 的重头戏。
自发光贴图:不经过灯光方向,直接增加颜色
官方章节还加入第三张 ↡标记表面哪些区域应直接增加亮色的纹理。采样结果不经过漫反射或镜面角度计算,而是乘强度后直接加到最终片段颜色;它只让当前表面看似发亮,不会自动照亮周围物体。(emission map)。它标记物体表面哪些区域看起来自己在发亮,例如设备屏幕、霓虹字或箱子上的电子图案。每个片段采样后把结果直接加到环境、漫反射、镜面三项之后,因此即使该表面背对灯光,自发光图案仍然可见。
自发光贴图不会参与 N·L、R·V,但它也不会真的照亮周围物体。这只是让当前表面输出额外颜色;要让附近墙面也被照亮,需要额外光源、全局光照或后处理 bloom 等机制。官方练习还可让 UV 随时间偏移,使发光图案在表面滚动,本质仍是改变 texture(material.emission, uv + offset) 的采样位置。
动手:从「常量材质」一步步换成贴图
先按材质通道逐层核对三张图各自进入哪条计算路径:
漫反射贴图:替换逐片段底色
用 TexCoords 采样 material.diffuse,同一份 baseColor 同时进入 ambient 与 diffuse。此时木纹和钢边颜色出现,但镜面仍可保持常量。
下面这个 Demo 渲染的是一个贴了图的木箱(程序化生成,木纹箱体 + 四周钢边铆钉),被一个绕它公转的点光源照亮(画面里那个小亮块就是光源)。最关键的是底部的步进条,把「贴图」这件事拆成三步累积给你看:
- 第 1 步「常量材质」——整块用一套常量底色和常量镜面强度(=上一章的样子)。转动光源,整个箱体一起明暗、一起泛高光,分不出哪是木头哪是钢边。
- 第 2 步「+漫反射贴图」——把材质底色换成从图里采样:木纹和钢边的底色出现了,整块一色变成各处各色。但镜面还是整块一个常量,高光仍是整面一起亮。
- 第 3 步「+镜面光贴图遮罩」——再乘上一张「钢边白、木头黑」的灰度遮罩当镜面强度。现在转动光源:只有钢边和铆钉随光泛起高光,木面始终哑光。
猜一猜:停在第 1 步、拖光源方位(或开自转),你会看到整块表面一起泛起高光——这是常量镜面强度的局限。那么点到第 3 步、换上「只有钢边是白、其余全黑」的灰度镜面光贴图后,同样转动光源,画面会变成什么样?先在三步之间来回点,亲眼看高光从「整面一起亮」收缩到「只剩钢边一圈亮」。
光照贴图演示加载中…
这三步正好对应 §3 讲的两张贴图:第 2 步是漫反射贴图(逐片段查底色),第 3 步是镜面光贴图(灰度遮罩控制各处反不反光)。下面 §6 的代码就是把这三步落到着色器和 CPU 侧的具体写法。
代码逐段拆解
把上一章「常量材质」改成「贴图材质」,改动集中在四处:着色器里把两项类型换掉、顶点着色器多递一份 UV、片元里把常量换成采样、CPU 侧把两张图绑上去。C++/OpenGL 与 WebGL2/TS 互为镜像。
第一步:Material 结构体把两项换成 sampler2D
上一章 material.diffuse / material.specular 是 vec3。现在把它俩换成 sampler2D——这一项就从「一个颜色」变成「一张图」。反光度 shininess 仍是单个 float,不动。
#version 330 core
struct Material {
sampler2D diffuse; // 原本是 vec3:现在是「漫反射贴图」
sampler2D specular; // 原本是 vec3:现在是「镜面光贴图」(灰度)
float shininess; // 反光度:仍是单个数,不变
};
uniform Material material; // sampler2D 只能藏在 uniform 里#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
struct Material {
sampler2D diffuse; // 原本是 vec3:现在是「漫反射贴图」
sampler2D specular; // 原本是 vec3:现在是「镜面光贴图」(灰度)
float shininess; // 反光度:仍是单个数,不变
};
uniform Material material; // sampler2D 只能藏在 uniform 里struct 写法两端完全一样,差异只在版本声明和 WebGL2 那行精度声明。但 sampler2D 有一个语言级的限制要当心:
第二步:顶点着色器把 UV 递下去
要采样贴图,每个片段得有自己的 UV。这部分和「纹理」章一模一样:顶点数据里加一份纹理坐标(属性位置 2),顶点着色器用 out 把它递给片元(中途被插值)。注意现在顶点同时带着位置、法线、UV 三样。
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords; // 新增:每个顶点的 UV
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoords; // 递给片元(会被插值)
void main() {
// ……(算 FragPos / Normal 同上一章,此处略)
TexCoords = aTexCoords; // 把 UV 原样递下去
}#version 300 es
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords; // 新增:每个顶点的 UV
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoords; // 递给片元(会被插值)
void main() {
// ……(算 FragPos / Normal 同上一章,此处略)
TexCoords = aTexCoords; // 把 UV 原样递下去
}CPU 侧别忘了像「纹理」章那样,给位置 2 配一个顶点属性指针(vertexAttribPointer + enableVertexAttribArray),把每行顶点数据里的 UV 那两列喂给它,否则片元收到的 TexCoords 会是空的。
第三步:片元把三项常量换成采样
核心改动来了。上一章三项里用的是 material.diffuse / material.specular 这两个常量;现在每处都换成「拿 TexCoords 去对应贴图采样、取 .rgb」。注意环境项也跟着漫反射贴图走——环境色几乎总是等于漫反射色,不必单独再存一张图。
void main() {
// 环境 / 漫反射的「底色」都来自漫反射贴图的同一次采样
vec3 baseColor = vec3(texture(material.diffuse, TexCoords));
vec3 ambient = light.ambient * baseColor;
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * baseColor;
// 镜面「强度」来自镜面光贴图(灰度遮罩)的采样
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specMask = vec3(texture(material.specular, TexCoords));
vec3 specular = light.specular * spec * specMask;
FragColor = vec4(ambient + diffuse + specular, 1.0);
}void main() {
// 环境 / 漫反射的「底色」都来自漫反射贴图的同一次采样
vec3 baseColor = vec3(texture(material.diffuse, TexCoords));
vec3 ambient = light.ambient * baseColor;
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = light.diffuse * diff * baseColor;
// 镜面「强度」来自镜面光贴图(灰度遮罩)的采样
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specMask = vec3(texture(material.specular, TexCoords));
vec3 specular = light.specular * spec * specMask;
FragColor = vec4(ambient + diffuse + specular, 1.0);
}逐项看:环境和漫反射共用 baseColor(漫反射贴图采样值),漫反射再多乘一个 diff(面朝光源程度,就是上一章的 N·L);镜面项里把上一章那个常量 material.specular,换成了 specMask——镜面光贴图采样出的灰度值。木头部位采到黑(specMask≈0),specular 乘出来约等于 0、没高光;钢边采到白(specMask≈1),高光照常亮。这就是「钢边亮、木头哑」的全部秘密。两端 GLSL 这段一字不差,平台差异仍只在前面那两行声明。
第四步:CPU 侧把两张图绑到不同纹理单元
着色器里有两个 sampler2D 了,CPU 侧就得把两张贴图分别喂给它们——这正是「纹理」章学过的纹理单元用法:把漫反射贴图绑到 0 号单元、镜面光贴图绑到 1 号单元,再用 uniform1i 告诉每个采样器去几号单元取图。
shader.use();
// 告诉两个采样器各去几号单元(用 1i 传整数单元号,不是纹理对象)
shader.setInt("material.diffuse", 0);
shader.setInt("material.specular", 1);
// 每帧绘制前:选中单元,绑上对应贴图
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap); // 漫反射贴图 → 0 号
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, specularMap); // 镜面光贴图 → 1 号gl.useProgram(program);
// 告诉两个采样器各去几号单元(用 1i 传整数单元号,不是纹理对象)
gl.uniform1i(gl.getUniformLocation(program, "material.diffuse"), 0);
gl.uniform1i(gl.getUniformLocation(program, "material.specular"), 1);
// 每帧绘制前:选中单元,绑上对应贴图
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, diffuseMap); // 漫反射贴图 → 0 号
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, specularMap); // 镜面光贴图 → 1 号第五步:接入自发光贴图与第 2 号纹理单元
在 Material 中再加入一个采样器,并把自发光采样值直接加到最终颜色。它不乘 diff 或 spec:
struct Material {
sampler2D diffuse;
sampler2D specular;
sampler2D emission;
float shininess;
};
vec3 emission = texture(material.emission, TexCoords).rgb
* emissionStrength;
FragColor = vec4(ambient + diffuse + specular + emission, 1.0);CPU 侧把它接到第三个纹理单元:
shader.setInt("material.emission", 2);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, emissionMap);
shader.setFloat("emissionStrength", 1.0f);WebGL2 对应 gl.uniform1i(location, 2)、gl.activeTexture(gl.TEXTURE2) 与 gl.bindTexture(...)。如果要滚动发光图案,可传入 time,在采样时写 texture(material.emission, TexCoords + vec2(0.0, time * 0.1));注意 wrap 模式需允许重复,否则 UV 超出后会停在边缘色。
到这里改造完成:同一份 Phong 代码,diffuse / specular 两项由常量换成逐片段贴图,再叠加 emission,木箱既能「木头哑、钢边亮」,也能让指定图案在背光面继续发亮。
容易踩的坑
小结
- 光照贴图把材质里写死的常量换成「逐片段从图采样的值」,让同一物体内部能分区控制反光
- 漫反射贴图替换
material.diffuse:每个片段采样取自己那个部位的底色(木纹 / 铁边各不同) - 镜面光贴图是一张灰度遮罩:白处反光强、黑处不反光,采样值直接当镜面强度乘数——木箱钢边亮、木头哑
- 自发光贴图直接加到最终片段颜色,不经过 N·L 或 R·V;它让表面看似发亮,但不会真正照亮邻近物体
- 着色器里把两项类型由
vec3改成sampler2D(只能用uniform声明);片元用texture(material.diffuse/specular, TexCoords).rgb取代常量,环境项跟随漫反射贴图 - CPU 侧把三张图绑到不同纹理单元(0 / 1 / 2 号),再用
uniform1i告诉每个采样器去几号取图
练习
问题 1(改代码型) 你想让木箱的钢边变成「反光带一点暖金色调」,而不是纯白高光。手里的镜面光贴图是灰度的。在不换贴图的前提下,怎么改片元那一行 specular,让钢边的高光偏暖?写出改法并说明原理。
问题 2(问答型) 镜面光贴图里,把某个部位画成纯黑,那一片在最终画面上会是什么样?画成纯白又会怎样?为什么漫反射贴图反而几乎不用纯黑那样的「遮罩」思路?
问题 3(独立实现题) 不看上面的代码,写出把上一章「常量材质」片元改成「漫反射贴图 + 镜面光贴图」需要改的关键几处:①结构体类型怎么变;②片元三项分别怎么取色;③CPU 侧两张图怎么喂进去。目标效果:木箱木头哑、钢边亮。
问题 4(自发光扩展题) 给木箱增加一张电子图案自发光贴图:要求背光面仍能看见图案,但图案不能改变附近物体的明暗。写出片段输出公式,并说明为什么这不等于添加了一盏灯。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 光照贴图
用纹理图来逐片段地决定材质各项(底色、反光强弱等),取代上一章那种「整块物体共用一个常量」的做法。最常见两张:漫反射贴图(管各处底色)和镜面光贴图(管各处反不反光)。本质是把「纹理」和「材质」两章合到一起。详见本章「光照贴图」一节。
- 漫反射贴图
替换材质漫反射色的那张纹理图。原本
material.diffuse是一个常量vec3(整块一色),现在改成一张图,每个片段拿自己的 UV 去采样,得到属于它那个部位的底色——于是木箱的木头、铁边能有各自的颜色。详见本章「漫反射贴图」一节。- 镜面光贴图
替换材质镜面色的那张纹理图,通常是一张灰度图。每个片段采样它,取出的明暗值直接当作该处镜面高光的强度——亮的地方反光强、暗的地方不反光。木箱的钢边画成白(反光强)、木头画成黑(不反光)。详见本章「镜面光贴图」一节。
- 灰度遮罩
用一张灰度图(每个像素只有明暗、没有彩色)当作「强弱开关」:白(接近 1.0)表示该处效果强、黑(接近 0.0)表示该处效果弱或没有。镜面光贴图就是用它来控制各处反光强弱——白处反光强、黑处不反光。详见本章「镜面光贴图」一节。
- sampler2D
GLSL 里代表一张 2D 纹理的采样器类型(「纹理」章见过)。它本身不存图,而是「指向某个纹理单元」的句柄,配合
texture()去采样取色。它是一种不能当普通变量赋值、只能用uniform声明的特殊类型,所以总藏在uniform Material material;这种结构体里。详见本章「sampler2D」一节。- 自发光贴图
标记表面哪些区域应直接增加亮色的纹理。采样结果通常乘一个强度后直接加到最终片段颜色, 不经过漫反射或镜面角度计算。它只让当前表面看起来发亮,不会自动照亮周围物体。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- diffuse map:在“漫反射、镜面与自发光贴图”中由材质 sampler、纹理单元与 mesh UV负责解释其输入、受控状态和可观察结果;运行时以纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出定位它的第一处变化。
- specular map:在“漫反射、镜面与自发光贴图”中由材质 sampler、纹理单元与 mesh UV负责解释其输入、受控状态和可观察结果;运行时以纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出定位它的第一处变化。
- sampler:在“漫反射、镜面与自发光贴图”中由材质 sampler、纹理单元与 mesh UV负责解释其输入、受控状态和可观察结果;运行时以纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“绑定多张贴图到固定 unit,设置 sampler,再按 UV 采样并参与光照”发生后,材质 sampler、纹理单元与 mesh UV应怎样改变diffuse/specular/emission 纹理、unit 绑定、UV、采样值和光照贡献;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
漫反射、镜面与自发光贴图:状态合同
用漫反射、镜面和可选自发光贴图逐片段驱动 Material 属性
验证场景
官方教程正式概念
logl-12 · 基线帧
diffuse map:固定 context、资源内容与输入事件,执行“绑定多张贴图到固定 unit,设置 sampler,再按 UV 采样并参与光照”
冻结输入:diffuse map
材质 sampler、纹理单元与 mesh UV记录diffuse/specular/emission 纹理、unit 绑定、UV、采样值和光照贡献
结果:得到可重复的初始 GL 状态与资源身份
观测:纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出中的初始快照
预期:材质 sampler、纹理单元与 mesh UV得到可复查结果,并持续满足“每个 sampler 指向其约定 unit;镜面遮罩只缩放镜面项”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“绑定多张贴图到固定 unit,设置 sampler,再按 UV 采样并参与光照”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“每个 sampler 指向其约定 unit;镜面遮罩只缩放镜面项”。
CPU command · GL state · GPU result
漫反射、镜面与自发光贴图:五段轨迹
当前观测:纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出中的初始快照
不变量:每个 sampler 指向其约定 unit;镜面遮罩只缩放镜面项
实验三:单故障与同输入恢复
注入“diffuse 与 specular sampler 都指向 unit 0,金属边和木板得到同一反光”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出一起恢复才算修复。
Single fault · first divergence · replay
漫反射、镜面与自发光贴图:反例与恢复
故障:diffuse 与 specular sampler 都指向 unit 0,金属边和木板得到同一反光
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“diffuse 与 specular sampler 都指向 unit 0,金属边和木板得到同一反光”
每个 sampler 指向其约定 unit;镜面遮罩只缩放镜面项
纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出
最小可重放检查
unit: logl-12
owner: 材质 sampler、纹理单元与 mesh UV
state_or_resource: diffuse/specular/emission 纹理、unit 绑定、UV、采样值和光照贡献
command: 绑定多张贴图到固定 unit,设置 sampler,再按 UV 采样并参与光照
pass_invariant: 每个 sampler 指向其约定 unit;镜面遮罩只缩放镜面项
single_fault: diffuse 与 specular sampler 都指向 unit 0,金属边和木板得到同一反光
required_evidence: 纹理对象/unit 表、sampler 值、UV、三张采样值与片段输出复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。