3.3 法线贴图的解压与重建
站内的 glTF 资产管线 里,法线贴图就是一张普通 PNG,8 位 RGB、无额外元数据;本节把这份 8 位数据一路还原成世界空间单位法线,并把量化误差量出来。整条链条只有三段,每一段都能逐像素断言。
三段链条
- 编码(造贴图):连续切线空间法线 $n_t$ 来自高度场中心差分,$n_t = \text{normalize}\big(-\partial h/\partial u,\ -\partial h/\partial v,\ 1\big)$;写进 8 位通道时 $v = \big\lfloor (n+\text{ONE})\cdot 255 / 2^{17} \big\rfloor$。
- 解压:$n = (v \ll 17), /, 255 - \text{ONE}$,然后再归一化一次——8 位量化后模长不再是 1,不归一化就会污染后面的光照。
- 重建:切线空间到世界空间,$N = T,n_x + B,n_y + N_g,n_z$,再归一化。
每一步都是整数,所以每一步都能拿整数断言。S 层脚本读 config/cg_shade_03_3_normalmap.json,产出贴图字节、重建视图与一份统计 JSON:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_03_3_normal_map.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_03_3_normal_map.py
"""3.3 法线贴图的解压与重建:8 位 RGB -> Q16.16 单位向量 -> 世界空间法线。
三段链条,每一步都可逐像素断言:
1. **编码**(造贴图):连续切线空间法线 n_t(Q16.16,来自高度场中心差分)
-> 8 位通道 `v = ((n + ONE) * 255) >> 17`(floor)
2. **解压**:`n = (v << 17) // 255 - ONE`,再**归一化**
——8 位量化后模长不再是 1,不归一化就会污染后面的光照
3. **重建**:切线空间 -> 世界空间 `N = T*n.x + B*n.y + Ng*n.z`,再归一化
量化误差是**可测的**:把解压回来的方向与编码前的连续方向比,得到每分量绝对误差与夹角。
本脚本把误差的 min/mean/max 都算出来,并配一个反例(漏掉 `*2 - 1` 重映射)验证判据有区分力。
产物:out/cg_shade_03_3_normalmap.bin(32x32x3 字节,W 层复用同一份字节)、
out/cg_shade_03_3_normal_view.rgb / .png(重建后的世界法线可视化)、out/cg_shade_03_3_stats.json。
"""
import json
import math
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ONE = cglib.ONE
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def encode_channel(q):
"""Q16.16 in [-ONE, ONE] -> 8 位通道:((q + ONE) * 255) >> 17(floor)。"""
return ((q + ONE) * 255) >> 17
def decode_channel(v):
"""8 位通道 -> Q16.16:((v << 17) // 255) - ONE。"""
return ((v << 17) // 255) - ONE
def sample_normal_field(x, z, seed, height_scale):
"""连续切线空间法线:n_t = normalize(-dh/du, -dh/dv, 1),中心差分步长 1.0。"""
hx = (cglib.terrain_height(x + ONE, z, seed, height_scale)
- cglib.terrain_height(x - ONE, z, seed, height_scale))
hz = (cglib.terrain_height(x, z + ONE, seed, height_scale)
- cglib.terrain_height(x, z - ONE, seed, height_scale))
return cglib.v_norm((-hx, -hz, 2 * ONE))
def reconstruct(t, b, ng, n):
"""切线空间 -> 世界空间:N = T*n.x + B*n.y + Ng*n.z,再归一化。"""
out = [cglib.fmul(t[i], n[0]) + cglib.fmul(b[i], n[1]) + cglib.fmul(ng[i], n[2])
for i in range(3)]
return cglib.v_norm(tuple(out))
def main():
spec = json.load(open(os.path.join(ROOT, "config", "cg_shade_03_3_normalmap.json"),
encoding="utf-8"))
mw, mh = spec["mapWidth"], spec["mapHeight"]
vw, vh = spec["viewWidth"], spec["viewHeight"]
seed, hs = spec["seed"], spec["heightScaleQ16"]
T, B, Ng = (tuple(spec["tangentQ16"]), tuple(spec["bitangentQ16"]), tuple(spec["normalQ16"]))
# ---- 1. 造贴图:连续法线 -> 8 位 ----
codes = np.zeros((mh, mw, 3), dtype=np.int64)
for j in range(mh):
for i in range(mw):
n_t = sample_normal_field(i * ONE, j * ONE, seed, hs)
for c in range(3):
codes[j, i, c] = encode_channel(n_t[c])
map_bytes = codes.astype(np.uint8)
with open(os.path.join(ROOT, "out", "cg_shade_03_3_normalmap.bin"), "wb") as fh:
fh.write(map_bytes.tobytes())
# ---- 2. 解压 + 3. 重建:逐纹素一遍走完,同时算量化误差与反例 ----
view = np.zeros((vh, vw, 3), dtype=np.int64)
sx, sy = vw // mw, vh // mh
raw_len_min = raw_len_max = None
comp_err_sum = comp_err_max = worst_len_delta = 0
bad_sum = 0
bad_same_texels = 0
dot_min, dot_sum, count = ONE, 0, 0
for j in range(mh):
for i in range(mw):
v = tuple(int(codes[j, i, c]) for c in range(3))
n_raw = tuple(decode_channel(v[c]) for c in range(3))
rl = cglib.v_len(n_raw)
raw_len_min = rl if raw_len_min is None else min(raw_len_min, rl)
raw_len_max = rl if raw_len_max is None else max(raw_len_max, rl)
n_dec = cglib.v_norm(n_raw)
worst_len_delta = max(worst_len_delta, abs(cglib.v_len(n_dec) - ONE))
n_ref = sample_normal_field(i * ONE, j * ONE, seed, hs)
for c in range(3):
e = abs(n_dec[c] - n_ref[c])
comp_err_sum += e
comp_err_max = max(comp_err_max, e)
d = cglib.v_dot(n_dec, n_ref)
dot_min, dot_sum, count = min(dot_min, d), dot_sum + d, count + 1
world = reconstruct(T, B, Ng, n_dec)
# 反例:漏掉 `*2 - 1` 重映射,直接把 8 位当 [0, 1] 用(解压最经典的错)
n_bad = cglib.v_norm(tuple((v[c] << 16) // 255 for c in range(3)))
bad_world = reconstruct(T, B, Ng, n_bad)
same = True
for c in range(3):
code, bad_code = encode_channel(world[c]), encode_channel(bad_world[c])
view[j * sy:(j + 1) * sy, i * sx:(i + 1) * sx, c] = code
bad_sum += abs(bad_code - code)
same = same and bad_code == code
bad_same_texels += 1 if same else 0
mean_comp_err = comp_err_sum / (count * 3)
mean_dot = dot_sum / count
clamp = lambda x: max(-1.0, min(1.0, x)) # noqa: E731
max_angle = math.degrees(math.acos(clamp(dot_min / ONE)))
mean_angle = math.degrees(math.acos(clamp(mean_dot / ONE)))
view_u8 = np.clip(view, 0, 255).astype(np.uint8)
with open(os.path.join(ROOT, "out", "cg_shade_03_3_normal_view.rgb"), "wb") as fh:
fh.write(view_u8.tobytes())
cglib.save_png(os.path.join(ROOT, "out", "cg_shade_03_3_normal_view.png"), view)
bad_mae_num = bad_sum * sx * sy
stats = {
"map": [mw, mh], "view": [vw, vh], "texels": count,
"codes_range": [int(map_bytes.min()), int(map_bytes.max())],
"decode_raw_len": [raw_len_min, raw_len_max],
"decode_norm_delta_max": worst_len_delta,
"component_err_max": comp_err_max, "component_err_mean": round(mean_comp_err, 4),
"angle_max_deg": round(max_angle, 4), "angle_mean_deg": round(mean_angle, 4),
"negative_mae": round(bad_mae_num / float(view_u8.size), 4),
"negative_same_pixels": bad_same_texels * sx * sy,
"view_mean": [round(float(view_u8[:, :, c].mean()), 4) for c in range(3)],
}
with open(os.path.join(ROOT, "out", "cg_shade_03_3_stats.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"贴图 : {mw}x{mh} 通道范围 [{stats['codes_range'][0]}, {stats['codes_range'][1]}]")
print(f"解压后未归一化模长 : [{raw_len_min / ONE:.4f}, {raw_len_max / ONE:.4f}] <- 8 位量化后不再是 1")
print(f"归一化后 | |n| - ONE | 最大 : {worst_len_delta} ({worst_len_delta / ONE:.6f})")
print(f"分量误差 : max {comp_err_max} ({comp_err_max / ONE:.6f}) "
f"mean {mean_comp_err:.1f} ({mean_comp_err / ONE:.6f})")
print(f"与连续法线夹角 : max {max_angle:.4f}° mean {mean_angle:.4f}°")
print(f"重建视图 : {vw}x{vh} -> out/cg_shade_03_3_normal_view.rgb")
print(f"反例(漏 *2-1 重映射): MAE {stats['negative_mae']} "
f"与正确视图相同的像素 {stats['negative_same_pixels']} / {vw * vh}")
return 0
if __name__ == "__main__":
sys.exit(main())
贴图 : 32x32 通道范围 [66, 255]
解压后未归一化模长 : [0.9905, 1.0024] <- 8 位量化后不再是 1
归一化后 | |n| - ONE | 最大 : 2 (0.000031)
分量误差 : max 588 (0.008972) mean 184.9 (0.002821)
与连续法线夹角 : max 0.7753° mean 0.5030°
重建视图 : 128x128 -> out/cg_shade_03_3_normal_view.rgb
反例(漏 *2-1 重映射): MAE 41.6133 与正确视图相同的像素 0 / 16384
三个数值得记住:解压后未归一化模长落在 $[0.9905, 1.0024]$(量化把模长从 1 拉开了),归一化后偏差最大只有 2;解压方向与连续方向的每分量误差 max 588($0.008972$)、mean 184.9,夹角 max $0.7753°$、mean $0.5030°$。这些是 8 位法线贴图的固有误差,不是实现缺陷——换成 16 位只是把 588 换成更小的数。
反例:漏掉 *2-1 重映射
8 位通道存的是 $[-1,1]$ 的偏移映射,解压必须做 $v \to 2v/255 - 1$。最经典的错就是忘了这一步、直接把 8 位当 $[0,1]$ 用:所有法线被推到正半球,重建出的世界法线整体偏亮。这个反例在同一份脚本里跑出来,MAE $41.6133$、与正确视图相同的像素 $0/16384$——判据必须能把它拒掉,否则「通过」没有意义。
// snippet: core/test/cg_shade_03_3_normal_map.test.ts(节选:逐字节判据与反例)
// verify: node --test 'core/test/cg_shade_03_3_normal_map.test.ts'
const correctDecode = (o: number): Vec3 => [
decodeChannel(mapBin[o]), decodeChannel(mapBin[o + 1]), decodeChannel(mapBin[o + 2]),
];
// 负例:漏掉 `*2-1` 重映射,直接把 8 位当 [0, 1] 用
const badDecode = (o: number): Vec3 => [
(BigInt(mapBin[o]) << 16n) / 255n,
(BigInt(mapBin[o + 1]) << 16n) / 255n,
(BigInt(mapBin[o + 2]) << 16n) / 255n,
];
test("解码 + 重建的整幅图像与 S 层逐字节相同", () => {
const out = buildView(correctDecode);
assert.equal(out.length, goldenView.length);
assert.equal(sha(out), sha(goldenView), "Python 与 TypeScript 的重建缓冲必须逐字节相同");
});
test("负例:漏掉 `*2-1` 重映射,重建缓冲必须与正确结果分叉", () => {
const bad = buildView(badDecode);
assert.notEqual(sha(bad), sha(goldenView), "错的重映射必须让逐字节判据分叉");
let sum = 0;
for (let k = 0; k < bad.length; k += 1) sum += Math.abs(bad[k] - goldenView[k]);
const mae = sum / bad.length;
assert.ok(mae > 40, `负例 MAE ${mae.toFixed(4)} 必须远超阈值(S 层实测 41.61)`);
});
✔ 解码 + 重建的整幅图像与 S 层逐字节相同
✔ 量化误差:解压方向与连续方向的每分量最大偏差与 S 层相同
✔ 负例:漏掉 `*2-1` 重映射,重建缓冲必须与正确结果分叉
ℹ tests 3 ℹ pass 3 ℹ fail 0
W 层:把同一件事搬进片元着色器
W 层读的是 S 层落盘的同一份 out/cg_shade_03_3_normalmap.bin,逐纹素做同一套运算,只是宿主换成了 WebGL2 片元着色器(浮点、NEAREST 采样,视图 128x128):
// snippet: web/build_shade03.mjs(片元着色器核心:解压 + 重建)
#version 300 es
precision highp float;
uniform sampler2D uMap; uniform vec3 uT, uB, uNg; uniform vec2 uMapSize;
in vec2 vUV; out vec4 o;
void main() {
ivec2 t = clamp(ivec2(floor(vUV * uMapSize)), ivec2(0), ivec2(uMapSize) - ivec2(1));
vec3 v = texelFetch(uMap, t, 0).rgb;
vec3 n = normalize(v * 2.0 - 1.0); // 8 位 -> [-1, 1]
vec3 N = normalize(uT * n.x + uB * n.y + uNg * n.z); // 切线空间 -> 世界空间
o = vec4(N * 0.5 + 0.5, 1.0);
}
片元着色器是浮点、S 层是 Q16.16 定点,两者的舍入路径不同,加上覆盖判据(top-left rule)决定共享边像素的归属,逐位一致在数学上不可能。所以只用对舍入不敏感的统计量,并把三条阈值提前声明:
| 判据 | 阈值 | 本轮实测 |
|---|---|---|
| MAE | ≤ 1.0 | 0.5068 |
| PSNR | ≥ 30 dB | 51.08 dB |
| 覆盖归属差异 | ≤ 20‰ | 0 / 16384 = 0.00‰ |
canvasDrawn : true 已绘制像素 : 16384 / 16384
贴图 / 视图 : 32x32 -> 128x128
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本 : WebGL 2.0 (OpenGL ES 3.0 Chromium) glError : 0
① S 层 Q16.16 定点 vs WebGL2 片元着色器:
任一通道差>0 : 14704 / 16384 (897.46‰) ← 含 ±1 舍入,不作判据
显著差异像素 : 0 / 16384 (0.00‰)
覆盖归属不同 : 0 / 16384 (0.00‰)
最大通道差 : 1
MAE : 0.5068 PSNR : 51.08 dB
通道差分布 : 1:14704
② 确定性 : 两遍逐字节相同 = true
③ 中心像素 : GPU [120, 255, 128] CPU(Q16.16) [119, 254, 128] 最大差 1
④ 负例(漏 *2-1): MAE=41.7874 PSNR=14.86 dB ← 必须被同一判据拒绝
PASS
有一条容易被误用:「任一通道差 > 0 的像素占比」看着很直观,实测却是 $897.46‰$——几乎所有像素都「不一致」,因为它把 ±1 的舍入也算进去了,毫无区分力。有区分力的是 MAE、PSNR 和单独统计的覆盖归属差异。另外必须说明:本机无头环境的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,上面这些差异不得归因于真实硬件;需要真实硬件数据的论断一律属于 X 档、命令行不可复现。
链在这里闭合:8 位 RGB → 切线空间单位向量 → 世界空间法线,S 层与 P 层逐字节相同,W 层按声明容差收敛,负例被同一判据拒绝。方向的重建告一段落,下一章处理「为什么有些地方根本不该有光」——阴影。
阅读导航:上一节:3.2 切线空间 · 下一节:4.1 阴影贴图的原理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。