法线贴图、TBN 与切线空间契约
法线贴图、TBN 与切线空间契约:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释法线贴图怎么用一张图逐片元扰动表面法线,在一面平整的墙上造出砖缝、凹凸的光影,却不加一个三角形——知道凹凸感是「光照上的假象」而非真几何
- 能实现 Demo 里的指定效果:开关法线贴图让同一面墙在「平」和「凹凸」之间切换、移动光源看高光与暗影沿砖缝爬动;并能说出片段着色器里采样法线后为什么要
* 2.0 - 1.0解码 - 能回答:法线图采样出来的颜色直接拿去当法线(不做
*2-1)会怎样?为什么法线图绝不能像漫反射贴图那样做 gamma 解码?
为什么给平墙贴一张「凹凸说明书」
你已经会给一面墙贴上砖墙照片了——看着是有砖纹,可只要光一斜过来就露馅:真砖墙的每道缝都会投出细小的阴影、每块砖的鼓面都会迎光发亮,而你那面贴了图的墙,无论灯怎么照都平得像一张纸,砖纹只是「印」在上面的花色,没有半点立体感。
想让砖缝真的凹下去、砖面真的鼓起来,最笨的办法是用几何体把每道缝、每个鼓面都建出来——可一面墙就得堆上几十万个三角形,多几面墙显卡就跪了。这一章换个聪明办法:再贴一张图,但这张图不画颜色,而是逐点告诉光照「这里的表面其实是朝哪个方向歪的」。光照一信,平墙就「假装」自己坑坑洼洼,砖缝的阴影、砖面的高光全冒出来了。
这一章要解决的就是这件事:怎么不加一个三角形,光靠一张图就让平整表面在光下显出真实的凹凸。没有它,你的贴图墙永远是张平纸;有了它,一面四个顶点的平墙也能在灯下坑洼分明。
法线贴图:逐片元改写表面法线
那张「凹凸说明书」就叫 ↡一张专门存「表面法线」的纹理:每个像素的 RGB 不是颜色,而是编码了「这一点的表面其实朝哪个方向」的一个法线向量。渲染时片段着色器逐片元从它采样、解码出法线,用这个被扰动过的法线去算光照——于是平整几何也能显出凹凸细节,却不增加任何三角形。(normal map)。它和漫反射贴图一样是张图、一样按 UV 采样,唯一的区别是:它每个像素存的不是颜色,而是一个法线方向。
回想基础光照那一章:一块表面有多亮,取决于它的法线 N 和光线方向的夹角(漫反射 N·L、镜面也都靠 N)。一面平墙上所有片元的 N 都一模一样(都垂直墙面),所以无论怎么照都均匀一片、没有起伏。法线贴图的把戏就在这里:渲染每个片元时,不再用那个统一的几何法线,而是从法线图里采样出一个『被扰动过的法线』来算光照。砖缝处的法线被画成微微朝下、砖面处朝上——光照一算,缝里就暗、面上就亮,凹凸感凭空出现。它在管线里就作用在片段着色器:采样、解码、然后拿这个新法线替换原来的 N 去做漫反射和镜面。
切线空间:法线图里的法线,是相对「表面自己」存的
这里藏着一个最绕、也最关键的问题:法线图里那些法线,是按什么参照系存的?
你可能以为存的是世界空间的法线。但那样的话,一张法线图就只能贴在某个固定朝向的墙上——墙一转、或者贴到地板上,里面的法线方向全错了。实际上,法线图里的法线存在一个特殊的局部参照系里,叫 ↡以表面自身为参照建立的局部坐标系。它的三个轴贴着这块表面:一个沿纹理 U 方向(切线 T)、一个沿纹理 V 方向(副切线 B)、一个垂直表面朝外(法线 N)。法线图里存的法线就是相对这个空间的——所以它默认「正对外」是 (0,0,1),整张图偏蓝。正因为参照的是表面自己而非世界,同一张法线图才能贴到任意朝向的表面上复用。(tangent space):一个贴着表面、以表面自己为参照的坐标系。
它有三个互相垂直的轴:一个沿纹理的 U 方向躺在表面上(叫↡切线空间的两个「躺在表面上」的轴。切线 T(tangent)沿纹理 U 方向、副切线 B(bitangent)沿纹理 V 方向,二者都贴着表面且互相垂直;它们和垂直表面的法线 N 一起,组成切线空间这组局部坐标基(即 TBN 矩阵的三列)。中的切线 T),一个沿纹理的 V 方向也躺在表面上(叫副切线 B),还有一个垂直表面朝外(就是几何法线 N)。下面这张图把这三个轴立在一块表面上:
关键就在这里:因为法线图里的法线参照的是「表面自己」而不是世界,所以哪怕你把这张图贴到一面竖墙、一块斜坡、还是一个球面上,里面写的「这点朝表面外偏一点」都仍然成立——同一张法线图能贴到任意朝向的表面上复用,这正是非用切线空间不可的根本原因。也正因为切线空间里「正对外」是 (0, 0, 1),法线图里绝大多数像素都接近这个值,整张法线图看上去才偏蓝紫色。
TBN 矩阵:在两个空间之间换算法线
可问题还没完:法线图给你的是切线空间里的法线,而你算光照用的光线方向、观察方向通常在世界空间(或视空间)里。两个空间的向量不能直接点乘——得先把它们换算到同一个空间。
负责这次换算的就是 ↡由切线 T、副切线 B、法线 N 三个轴当作三列拼成的 3×3 矩阵:TBN = [T | B | N]。它把一个切线空间里的向量变换到世界(或视)空间——比如把法线图采样解码出的切线空间法线,乘上 TBN 就得到世界空间法线,再和世界空间的光线方向算光照。反过来用它的转置(因为三轴正交,转置即逆),可把世界空间的光/视向量变到切线空间。两种实现二选一。:把切线 T、副切线 B、法线 N 这三个轴当作矩阵的三列拼起来,TBN = [T | B | N]。用它一乘,就能把一个切线空间的向量变到世界空间。所以最直接的做法是:从法线图采样解码出切线空间法线,乘上 TBN 得到世界空间法线,再拿去和世界空间的光线方向算光照。
反过来也行:因为 T、B、N 互相垂直(正交),TBN 矩阵的转置就等于它的逆,用转置可以把世界空间的光线、观察方向变到切线空间里,在切线空间内做光照。这就引出后面 §6 要对照的两种实现——把光/视向量变到切线空间,或把采样法线变到世界空间,逻辑等价、二选一。
采样要解码:把 [0,1] 还原成 [-1,1]
最后一个必踩的坑:法线图里每个像素是 RGB,取值范围 0~1;可法线向量的每个分量取值是 -1~1(朝左是负、朝右是正)。存图的时候做了一步压缩——把法线的 -1~1 用 normal * 0.5 + 0.5 映射进了 0~1 才能当颜色存。
所以你在着色器里 texture() 采样出来的 RGB,不是法线本身,得先做↡法线图里每个分量按 normal * 0.5 + 0.5 被压进了 0~1 的颜色范围存储;采样后必须反过来做 normal = texColor * 2.0 - 1.0,把 0~1 拉回真正的 -1~1,才能当法线用。忘了这一步,所有分量都变成正数、z 分量恒为正,光照会全错、画面整片发蓝。这一步叫法线解码。把它还原回去:normal = texColor * 2.0 - 1.0,把 0~1 拉回 -1~1。下面这张图把这步换算画清楚——基准色 RGB(0.5, 0.5, 1.0) 正好解出 (0, 0, 1),也就是「正对表面外、不扰动」:
normal * 0.5 + 0.5 后的 RGB(0~1),用前必须 normal = texColor * 2.0 - 1.0 解码回真法线 (-1~1)。基准色 RGB(0.5, 0.5, 1.0) 解出 (0, 0, 1)——正对表面外、不扰动,这就是法线图整片偏蓝的原因;颜色一偏,法线就被推歪,那里便有了凹凸。要是忘了这步 * 2.0 - 1.0、直接拿采样到的 0~1 当法线用,所有分量都成了正数、z 分量恒为正,光照会全盘算错、画面整片发蓝——这是法线贴图最经典的翻车现场(§7 还会细说)。
单步确认:法线从贴图到正确光照
真正的实现不要只记一行 TBN * normal,而要按下面三个条件逐一确认。前两步只处理法线图本身;第三步才进入光照。每完成一步,都能排除一类最常见的错误。
法线图保持线性
法线图的 RGB 是方向数据,不是展示给人的颜色。加载它时保持线性格式;只让 albedo、漫反射等颜色贴图走 sRGB 解码。否则 (0.5, 0.5, 1.0) 这样的「正对外」基准值会被错误扭曲。
动手:开关法线贴图,看平墙长出凹凸
猜一猜:下面是一面正对你的平整表面,上面用代码画了一张「砖块凹凸」的法线图,一盏点光源照着它。关着法线贴图时,表面是用平整的几何法线算光照、看着是平的。把开关打开——你觉得砖缝和砖面会怎样?再拖动光源到不同位置,凹凸感会跟着变吗?先动手,再读下面。
这面墙是片段着色器里逐像素算出来的:先用一个函数解析地算出每个像素处「砖块凹凸」该有的扰动法线(这就相当于一张程序化的法线图,不用任何外部贴图),再拿这个法线和点光源算 Blinn-Phong 光照。因为表面正对 +Z、切线空间是平凡的(T=+X, B=+Y, N=+Z),这里可以直接用解析法线做光照——把复杂的 TBN 换算留到下面 §6 讲任意朝向的情况。四个控件:法线贴图开关(关=用平的几何法线、看着是平的;开=用扰动法线、砖缝凹凸随光出现);光源 X / Y 移动点光源;凹凸强度调扰动幅度:
实时演示加载中…
谜底:把法线贴图关掉,整面墙是均匀一片、像张平纸——因为每个片元用的都是同一个几何法线 (0,0,1),光照处处一样。打开,砖缝立刻凹下去(缝边法线被推歪,迎光那侧亮、背光那侧暗)、砖面鼓起来,凹凸感凭空出现。再拖光源 X / Y:开着法线贴图时,高光和暗影会沿砖缝爬动——这正是凹凸感「随光而变」的证据;关着时怎么移光都还是均匀的平面。这就是法线贴图的全部魔法:几何还是那块平面,凹凸全是法线被扰动后「算」出来的光影假象。
一句话对比:关 = 平纸,开 = 坑洼
猜一猜:同一面四个顶点的平墙、同一盏灯,只差「有没有用法线图扰动法线」。左边没扰动、右边有扰动,你觉得几何变了吗?变的到底是什么?
把滑块拖到中线两侧对照:两边是同一块平整几何(顶点、三角形数一模一样),唯一的差别是右边在片段着色器里用法线图把每个片元的法线推歪了。左边法线处处相同 → 光照均匀 → 看着是平的;右边法线被逐片元扰动 → 缝暗面亮 → 看着坑洼。变的从来不是几何,而是光照所用的那个法线:
(0,0,1),光照处处一样 → 整面均匀、像一张平纸,砖纹只是印上去的颜色。代码对照:从顶点的 TBN 到片段的解码光照
落到真代码,法线贴图要解决两件事:①顶点着色器构造 TBN 矩阵(demo 里平面正对 +Z 太特殊,真实场景表面朝向千变万化,必须靠 TBN 换算);②片段着色器采样法线图、解码、用扰动法线算光照。先看顶点着色器:每个顶点除了位置、UV,还带一个切线 aTangent(由建模工具或代码算出),副切线可由 cross(N, T) 补出,三者组成 TBN。这里走「把法线变到世界空间」这一种实现,顶点只管把 TBN 算好传给片段:
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoords;
layout(location = 3) in vec3 aTangent; // 切线,沿纹理 U 方向(建模/代码算出)
out vec2 TexCoords;
out vec3 FragPos;
out mat3 TBN; // 切线空间 → 世界空间
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
FragPos = vec3(model * vec4(aPos, 1.0));
TexCoords = aTexCoords;
// 用法线矩阵把 T、N 变到世界空间,副切线由叉乘补出
vec3 T = normalize(mat3(model) * aTangent);
vec3 N = normalize(mat3(model) * aNormal);
T = normalize(T - dot(T, N) * N); // 施密特正交化:T 减去在 N 上的分量
vec3 B = cross(N, T); // B = N × T,凑齐第三轴
TBN = mat3(T, B, N); // 三轴当三列 → TBN 矩阵
gl_Position = projection * view * vec4(FragPos, 1.0);
}#version 300 es
in vec3 aPos;
in vec3 aNormal;
in vec2 aTexCoords;
in vec3 aTangent; // 切线,沿纹理 U 方向(建模/代码算出)
out vec2 TexCoords;
out vec3 FragPos;
out mat3 TBN; // 切线空间 → 世界空间
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
FragPos = vec3(model * vec4(aPos, 1.0));
TexCoords = aTexCoords;
// 用法线矩阵把 T、N 变到世界空间,副切线由叉乘补出
vec3 T = normalize(mat3(model) * aTangent);
vec3 N = normalize(mat3(model) * aNormal);
T = normalize(T - dot(T, N) * N); // 施密特正交化:T 减去在 N 上的分量
vec3 B = cross(N, T); // B = N × T,凑齐第三轴
TBN = mat3(T, B, N); // 三轴当三列 → TBN 矩阵
gl_Position = projection * view * vec4(FragPos, 1.0);
}注意那行施密特正交化 T = normalize(T - dot(T, N) * N):建模导出的切线未必和法线严格垂直,减去它在 N 上的投影分量,能保证 T⊥N,TBN 三轴才正交(后面误区会讲不正交的后果)。顶点把世界空间的 TBN 传下来,接力交给片段着色器——这才是法线贴图的核心:采样、解码、用扰动法线算光照。
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 FragPos;
in mat3 TBN;
uniform sampler2D diffuseMap;
uniform sampler2D normalMap; // 法线图(线性数据,不可当 sRGB 解码!)
uniform vec3 lightPos;
uniform vec3 viewPos;
void main() {
// ① 采样法线图:拿到 [0,1] 的 RGB(不是法线本身)
vec3 sampled = texture(normalMap, TexCoords).rgb;
// ② 解码:*2-1 把 [0,1] 还原成 [-1,1] 的切线空间法线
vec3 normal = normalize(sampled * 2.0 - 1.0);
// ③ 换算:乘 TBN 把切线空间法线变到世界空间
normal = normalize(TBN * normal);
// ④ 用这个被扰动的法线照常算 Blinn-Phong
vec3 color = texture(diffuseMap, TexCoords).rgb;
vec3 L = normalize(lightPos - FragPos);
vec3 V = normalize(viewPos - FragPos);
vec3 H = normalize(L + V);
float diff = max(dot(normal, L), 0.0);
float spec = pow(max(dot(normal, H), 0.0), 32.0);
FragColor = vec4(color * (0.1 + diff) + vec3(0.3) * spec, 1.0);
}#version 300 es
precision highp float; // WebGL2 片段着色器必写:声明浮点精度
out vec4 FragColor;
in vec2 TexCoords;
in vec3 FragPos;
in mat3 TBN;
uniform sampler2D diffuseMap;
uniform sampler2D normalMap; // 法线图(线性数据,不可当 sRGB 解码!)
uniform vec3 lightPos;
uniform vec3 viewPos;
void main() {
// ① 采样法线图:拿到 [0,1] 的 RGB(不是法线本身)
vec3 sampled = texture(normalMap, TexCoords).rgb;
// ② 解码:*2-1 把 [0,1] 还原成 [-1,1] 的切线空间法线
vec3 normal = normalize(sampled * 2.0 - 1.0);
// ③ 换算:乘 TBN 把切线空间法线变到世界空间
normal = normalize(TBN * normal);
// ④ 用这个被扰动的法线照常算 Blinn-Phong
vec3 color = texture(diffuseMap, TexCoords).rgb;
vec3 L = normalize(lightPos - FragPos);
vec3 V = normalize(viewPos - FragPos);
vec3 H = normalize(L + V);
float diff = max(dot(normal, L), 0.0);
float spec = pow(max(dot(normal, H), 0.0), 32.0);
FragColor = vec4(color * (0.1 + diff) + vec3(0.3) * spec, 1.0);
}四步是法线贴图的全部精髓:采样 → 解码 *2-1 → 乘 TBN 换到世界空间 → 照常算光照。剩下的漫反射、镜面和上一篇 Blinn-Phong 一模一样,只是 normal 不再是几何法线,而是从图里采出来的扰动法线。
上面走的是「把法线变到世界空间」(采样法线 × TBN)。还有等价的另一种实现——「把光 / 视向量变到切线空间」:在顶点着色器里用 TBN 的转置(正交矩阵转置即逆)把 lightPos、viewPos、FragPos 先变进切线空间传给片段,片段里采样的法线就不用再乘 TBN,直接和切线空间的光 / 视向量算光照。它的好处是把矩阵乘法挪到顶点着色器(顶点比片段少),片段更省:
// 顶点着色器片段:用 TBN 的转置把光/视/片元位置变进切线空间
mat3 TBN = transpose(mat3(T, B, N)); // 正交 → 转置 = 逆,变到切线空间
TangentLightPos = TBN * lightPos; // 光源位置 → 切线空间
TangentViewPos = TBN * viewPos; // 观察位置 → 切线空间
TangentFragPos = TBN * FragPos; // 片元位置 → 切线空间
// 片段着色器里:法线 = normalize(texture(normalMap, uv).rgb * 2.0 - 1.0)
// 直接和 TangentLightPos / TangentViewPos 算光照,无需再乘 TBN// 顶点着色器片段:用 TBN 的转置把光/视/片元位置变进切线空间
mat3 TBN = transpose(mat3(T, B, N)); // 正交 → 转置 = 逆,变到切线空间
TangentLightPos = TBN * lightPos; // 光源位置 → 切线空间
TangentViewPos = TBN * viewPos; // 观察位置 → 切线空间
TangentFragPos = TBN * FragPos; // 片元位置 → 切线空间
// 片段着色器里:法线 = normalize(texture(normalMap, uv).rgb * 2.0 - 1.0)
// 直接和 TangentLightPos / TangentViewPos 算光照,无需再乘 TBN两种实现逻辑等价:要么把采样法线推到世界空间,要么把光 / 视向量拉进切线空间,只要双方落在同一个空间,点乘就对。前者直观(法线一乘就好),后者更省(片段不做矩阵乘)。挑一种用,别两种混着——混用是下面误区里最隐蔽的坑。
容易踩的坑
小结
- 法线贴图=一张存「法线方向」的图,渲染时逐片元采样出扰动法线替换几何法线去算光照——在平整几何上造出砖缝、凹凸,却不加一个三角形
- 法线图里的法线存在切线空间(以表面自己为参照的局部坐标系),所以同一张图能贴到任意朝向的表面复用——这是它非用切线空间不可的原因
- TBN 矩阵=切线 T / 副切线 B / 法线 N 当三列拼成,负责在切线空间和世界(视)空间之间换算向量;两种实现(法线推到世界 / 光视拉进切线)逻辑等价、二选一
- 采样到的是
[0,1]的 RGB,必须* 2.0 - 1.0解码回[-1,1]才是法线;忘了就整片发蓝、光照全错 - 法线图是线性数据,绝不能像颜色贴图那样做 gamma / sRGB 解码(会毁掉法线数据)
练习
问题 1(改 Demo 代码型) 在上面那个 Demo 里,把它调成这样的目标效果:砖缝里的凹凸阴影最分明、最立体。说出你把四个控件各调到什么方向,并解释为什么这组参数最能凸显凹凸。
问题 2(独立实现题 · C 型) 给你一张法线图 normalMap 和一个已经传进片段着色器的、把切线空间变到世界空间的 TBN 矩阵(in mat3 TBN;),片元的 UV 是 TexCoords。写出片段着色器里采样法线图、解码、换算到世界空间得到最终世界空间法线 worldNormal 的关键三行。
问题 3(空间一致性排错) 你已经得到 tangentN = normalize(texture(normalMap, TexCoords).rgb * 2.0 - 1.0),而 lightPos、viewPos、FragPos 都在世界空间。请给出两种都正确的后续实现,并指出为什么 dot(tangentN, normalize(lightPos - FragPos)) 是错误的。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 法线贴图
一张专门存「表面法线」的纹理:每个像素的 RGB 不是颜色,而是编码了「这一点的表面其实朝哪个方向」。渲染时片段着色器逐片元从它采样、解码出法线,用这个被扰动过的法线去算光照——于是平整几何也能显出砖缝、凹凸的光影细节,却不增加任何三角形。详见本章「法线贴图:逐片元改写表面法线」一节。
- 切线空间
以表面自身为参照建立的局部坐标系。三个轴贴着这块表面:一个沿纹理 U 方向(切线 T)、一个沿纹理 V 方向(副切线 B)、一个垂直表面朝外(法线 N)。法线图里存的法线就是相对这个空间的——所以默认「正对外」是
(0,0,1)、整张图偏蓝。正因为参照的是表面自己而非世界,同一张法线图才能贴到任意朝向的表面上复用。详见本章「切线空间」一节。- 切线·副切线
切线空间里「躺在表面上」的两个轴。切线 T 沿纹理 U 方向、副切线 B 沿纹理 V 方向,二者都贴着表面且互相垂直;它们和垂直表面的法线 N 一起,组成切线空间这组局部坐标基(也就是 TBN 矩阵的三列)。详见本章「切线空间」一节。
- TBN 矩阵
由切线 T、副切线 B、法线 N 三个轴当作三列拼成的 3×3 矩阵:
TBN = [T | B | N]。它把一个切线空间里的向量变换到世界(或视)空间——比如把法线图解码出的切线空间法线乘上 TBN 得到世界空间法线,再去算光照。反过来用它的转置(三轴正交,转置即逆),可把世界空间的光 / 视向量变到切线空间。两种实现二选一。详见本章「TBN 矩阵」一节。- 法线解码
法线图里每个分量按
normal * 0.5 + 0.5被压进了0~1的颜色范围存储;采样后必须反过来做normal = texColor * 2.0 - 1.0,把0~1拉回真正的-1~1,才能当法线用。忘了这一步,所有分量都变成正数、z 分量恒为正,光照会全错、画面整片发蓝。详见本章「采样要解码」一节。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- normal mapping:在“法线贴图、TBN 与切线空间契约”中由mesh tangent frame、normal texture 与 lighting shader负责解释其输入、受控状态和可观察结果;运行时以T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分定位它的第一处变化。
- tangent space:在“法线贴图、TBN 与切线空间契约”中由mesh tangent frame、normal texture 与 lighting shader负责解释其输入、受控状态和可观察结果;运行时以T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分定位它的第一处变化。
- tbn:在“法线贴图、TBN 与切线空间契约”中由mesh tangent frame、normal texture 与 lighting shader负责解释其输入、受控状态和可观察结果;运行时以T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“解码法线,正交化 TBN,并把所有光照向量统一到 tangent/world space”发生后,mesh tangent frame、normal texture 与 lighting shader应怎样改变tangent/bitangent/normal、handedness、TBN、采样 RGB、[-1,1] 法线和光向量;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
法线贴图、TBN 与切线空间契约:状态合同
从 UV 导数或顶点数据建立带 handedness 的 TBN,并把法线纹理解码到正确空间
验证场景
官方教程正式概念
logl-33 · 基线帧
normal mapping:固定 context、资源内容与输入事件,执行“解码法线,正交化 TBN,并把所有光照向量统一到 tangent/world space”
冻结输入:normal mapping
mesh tangent frame、normal texture 与 lighting shader记录tangent/bitangent/normal、handedness、TBN、采样 RGB、[-1,1] 法线和光向量
结果:得到可重复的初始 GL 状态与资源身份
观测:T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分中的初始快照
预期:mesh tangent frame、normal texture 与 lighting shader得到可复查结果,并持续满足“TBN 基向量正交归一;镜像 UV 通过 handedness 恢复 bitangent 方向”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“解码法线,正交化 TBN,并把所有光照向量统一到 tangent/world space”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“TBN 基向量正交归一;镜像 UV 通过 handedness 恢复 bitangent 方向”。
CPU command · GL state · GPU result
法线贴图、TBN 与切线空间契约:五段轨迹
当前观测:T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分中的初始快照
不变量:TBN 基向量正交归一;镜像 UV 通过 handedness 恢复 bitangent 方向
实验三:单故障与同输入恢复
注入“镜像 UV 仍用 cross(N,T) 作为 B,接缝一侧凹凸方向反转”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分一起恢复才算修复。
Single fault · first divergence · replay
法线贴图、TBN 与切线空间契约:反例与恢复
故障:镜像 UV 仍用 cross(N,T) 作为 B,接缝一侧凹凸方向反转
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“镜像 UV 仍用 cross(N,T) 作为 B,接缝一侧凹凸方向反转”
TBN 基向量正交归一;镜像 UV 通过 handedness 恢复 bitangent 方向
T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分
最小可重放检查
unit: logl-33
owner: mesh tangent frame、normal texture 与 lighting shader
state_or_resource: tangent/bitangent/normal、handedness、TBN、采样 RGB、[-1,1] 法线和光向量
command: 解码法线,正交化 TBN,并把所有光照向量统一到 tangent/world space
pass_invariant: TBN 基向量正交归一;镜像 UV 通过 handedness 恢复 bitangent 方向
single_fault: 镜像 UV 仍用 cross(N,T) 作为 B,接缝一侧凹凸方向反转
required_evidence: T/B/N 点积与长度、UV handedness、采样法线、变换后法线与光照差分复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。