3.3 法线贴图的解压与重建

把 32x32 的 8 位法线贴图还原成 128x128 世界法线:解压 n=(v<<17)/255-ONE 后未归一化模长落在 [0.9905,1.0024],归一化后偏差最大 2,与连续法线夹角最大 0.7753°。W 层用 WebGL2 片元着色器做同一件事,与 S 层按声明容差比对 MAE 0.5068 / PSNR 51.08 dB / 覆盖差异 0‰,负例被同一判据拒绝。

3.3 法线贴图的解压与重建

站内的 glTF 资产管线 里,法线贴图就是一张普通 PNG,8 位 RGB、无额外元数据;本节把这份 8 位数据一路还原成世界空间单位法线,并把量化误差量出来。整条链条只有三段,每一段都能逐像素断言。

三段链条

  1. 编码(造贴图):连续切线空间法线 $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$。
  2. 解压:$n = (v \ll 17), /, 255 - \text{ONE}$,然后再归一化一次——8 位量化后模长不再是 1,不归一化就会污染后面的光照。
  3. 重建:切线空间到世界空间,$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.00.5068
PSNR≥ 30 dB51.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 阴影贴图的原理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配