《计算机图形学》卷二《着色、光照与纹理》附录 A · 着色公式、材质参数表与数据集索引

《计算机图形学》卷二《着色、光照与纹理》附录 A:全卷共用的 Q16.16 定点常数表(1/π、9/π、3/π)、逐像素层的量级上界推导、六个着色公式的整数形式速查(Lambert、Phong、Blinn-Phong、GGX、Smith、Schlick)、材质参数表、能量守恒残差汇总,以及各章产物的数据集索引与复现命令。所有数字来自本机实跑。

这是《计算机图形学》卷二《着色、光照与纹理》的附录 A。它把全卷散落在各章的常数、公式、参数与产物集中到一处,方便在写代码时对照。

附录分两部分。前半是约定:Q16.16 定点常数表、逐像素层的量级上界、六个着色公式的整数形式。这些约定一旦违反,跨语言逐位一致立刻崩——它们不是「建议」,是硬约束。后半是索引:材质参数、能量守恒的残差汇总、各章产物落在哪个文件、怎么一条命令复现。

本卷所有数字都来自本机实跑,凡是引用具体数值的地方都注明了它出自哪一节。没有估算值,也没有从别处抄来的数字。

A.1 定点常数表

全卷的着色运算只有一种数值格式:Q16.16 定点(1.0 表示为 65536)。屏幕空间另有一套更粗的格式:Q6(1 像素 = 64 单位),用来做子像素精确的覆盖判定。两者之间的换算只允许出现在明确标注的位置。

下面这张表里的每一个常数都被至少两章引用。它们是「整数化」的落点:把一个无理数变成一个整数,误差就固定在最低位上,不会随运算次数漂移。

常数值含义主要出处
ONE65536Q16.16 的 1.0全卷
SUB / SUB16 / 64子像素位数 / 1 像素的单位数第 1、3、4 章
TWO_PI4117752π 的 Q16.16(π = 205887)第 2、5、6 章
INV_PI208611/π,Lambert 的归一化系数第 1、2 章
PHONG_NORM1877499/π,n = 16 的归一化 Phong第 1、2 章
BLINN_NORM625833/π,n = 16 的归一化 Blinn第 1、2 章

为什么这些常数必须固定下来:INV_PI 少四分之一个最低位,直接导致 Lambert 的半球能量积分落在 65533 而不是 65536——残差 3。这个残差本身无害(千分之 0.046),但它是可复现的:换一门语言、换一台机器,只要用同一个常数,残差就还是 3。如果换成运行期算 1/π,两侧的浮点实现稍有不同,残差就变成随机量,逐字节一致立刻失效。

A.2 量级上界

逐像素着色层可以用 JavaScript 的 Number(即 float64),但前提是所有中间量都落在 2^53 以内。这不是经验判断,是可以算出来的上界。下面这个脚本是附录 A 的唯一数据源,它把上界算了一遍:

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_appendix_a.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_appendix_a.py
"""附录 A 的数据源:全卷共用的 Q16.16 定点常数表与量级上界。

这些常数被第 1、2、6、7 章反复引用,集中列在这里。第二部分是**量级上界**:
它决定某一层能用 `Number` 还是必须用 `BigInt`——这是全卷最容易出错的地方,
因为 JS 的 `Number` 只在 2^53 以内能精确表示整数,而矩阵链的中间量远超这个界。

脚本只用整数运算(算术右移与 `bit_length`),与 Python / TypeScript 两侧一致。
"""

import sys

ONE = 65536

CONSTANTS = [
    ("ONE", 65536, "Q16.16 的 1.0"),
    ("SUB", 6, "屏幕子像素位数"),
    ("SUB1", 64, "1 像素 = 64 单位"),
    ("TWO_PI", 411775, "2π(π = 205887)"),
    ("INV_PI", 20861, "1/π,Lambert 的归一化系数"),
    ("PHONG_NORM", 187749, "9/π,n = 16 的归一化 Phong"),
    ("BLINN_NORM", 62583, "3/π,n = 16 的归一化 Blinn"),
]

# 逐像素层:屏幕 Q6 最大 320×64,边函数是两个坐标差的乘积
MAX_SCREEN = 320 * 64
MAX_EDGE = 2 * MAX_SCREEN * MAX_SCREEN
MAX_INTERP = MAX_EDGE * ONE

MAGNITUDE = [
    ("屏幕坐标(Q6)", MAX_SCREEN, "画布宽 320,1 像素 = 64 单位"),
    ("边函数", MAX_EDGE, "两个坐标差的乘积"),
    ("法线插值分子", MAX_INTERP, "边函数 × Q16.16 法线"),
    ("三项累加", 3 * MAX_INTERP, "重心坐标三项求和"),
]

LIMIT = 1 << 53


def main():
    print("== 定点常数 ==")
    for name, value, note in CONSTANTS:
        print(f"{name:<12} {value:>8}  {note}")
    print()
    print(f"== 量级上界(Number 精确上界 2^53 = {LIMIT})==")
    for name, value, note in MAGNITUDE:
        bits = value.bit_length()
        safe = "OK" if value < LIMIT else "OVERFLOW"
        print(f"{name:<14} {value:>20}  2^{bits:<3} {safe:<9} {note}")
    print()
    worst = MAGNITUDE[-1][1]
    print(f"最大中间量 / 2^53 = {worst * 1000 // LIMIT}‰")
    print(f"结论:逐像素层可以用 Number + Math.floor;矩阵链必须留在 BigInt 里。")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑输出:

# snippet: sim/cg_shade_appendix_a.py 的实跑输出(本机,exit 0)
== 定点常数 ==
ONE             65536  Q16.16 的 1.0
SUB                 6  屏幕子像素位数
SUB1               64  1 像素 = 64 单位
TWO_PI         411775  2π(π = 205887)
INV_PI          20861  1/π,Lambert 的归一化系数
PHONG_NORM     187749  9/π,n = 16 的归一化 Phong
BLINN_NORM      62583  3/π,n = 16 的归一化 Blinn

== 量级上界(Number 精确上界 2^53 = 9007199254740992)==
屏幕坐标(Q6)                      20480  2^15  OK        画布宽 320,1 像素 = 64 单位
边函数                       838860800  2^30  OK        两个坐标差的乘积
法线插值分子               54975581388800  2^46  OK        边函数 × Q16.16 法线
三项累加                164926744166400  2^48  OK        重心坐标三项求和

最大中间量 / 2^53 = 18‰
结论:逐像素层可以用 Number + Math.floor;矩阵链必须留在 BigInt 里。

读法:最坏情况下的中间量是 2^48,只占 2^53 的 18‰,余量充足。这里的上界是保守的——它假定屏幕坐标取满整个画布宽度,实际参与运算的边函数通常小一到两个数量级。反过来,矩阵链的中间量在 Q16.16 下会达到 2^60 以上,所以卷一的矩阵运算全部留在 BigInt 里,本卷沿用这一约定。

A.3 着色公式的整数形式

下表给出六个公式在 Q16.16 下的实现要点。「降精度」一列是重点:所有降精度必须走算术右移 >>,禁用 / 与 //——Python 的 // 是 floor,BigInt 的 / 是向零截断,两者在负数上差 1,而法线分量恰好可正可负。

公式整数形式降精度出处
Lambertk = ambient + fmul(max(0, N·L), intensity)>>第 1.1 节
Phong 高光fmul(PHONG_NORM, pow_int(max(0, R·V), n))平方链 + >>第 1.2 节
Blinn-Phongfmul(BLINN_NORM, pow_int(max(0, N·H), n))平方链 + >>第 1.2 节
GGX 分布D = a² / (π·((N·H)²(a²−1)+1)²)整数除法 + >>第 6.1 节
Smith 几何项G = G1(L)·G1(V),G1 = 2(N·X) / ((N·X)+√(a²+(1−a²)(N·X)²))fsqrt + fdiv第 6.1 节
Schlick 菲涅尔F = F0 + (1−F0)·(1−V·H)^5平方链 + >>第 6.1 节

幂次一律用平方链:x^16 只需要 4 次乘法(而不是 15 次),x^32 需要 5 次。第 1.2 节实测了这件事——平方链与逐次相乘的结果逐位相同,而乘法次数从 32 降到 5。这不是微优化,它让「高光指数」这个参数在整数下变得便宜,从而可以放心地把它做成材质字段。

A.4 材质参数表

卷二把材质表示成一组整数参数,全部落在 Q16.16 或 8 位通道上。三种宿主读的是同一份表。

参数格式范围含义
albedoQ88 位 ×30–255基础色(绝缘体的漫反射色,金属的 F0)
specularQ16Q16.160–65536镜面反射率(金属度工作流的中间量)
shininessQ16Q16.16≥ 65536高光指数(Phong / Blinn 用),整数
roughnessQ16Q16.160–65536粗糙度,映射到 α 时取平方
metalnessQ16Q16.160–65536金属度,0 是绝缘体、65536 是金属
normalScaleQ16Q16.160–65536法线贴图的强度缩放

不变量:albedoQ8 的三个通道彼此独立;metalnessQ16 + dielectricQ16 == ONE(绝缘体分量是它的补);混合材质的权重之和必须等于 ONE——第 2.3 节把这条写成了可跑断言,权重和为 2·ONE 的负例会把能量推到 130854,越界并被测试抓住。

A.5 能量守恒残差汇总

「能量守恒」在本卷被操作化成一个整数断言:把 BRDF 在半球上的积分用固定的求积网格估出来,断言它 ≤ ONE。求积网格是 64 个极角带 × 8 个方位 = 512 个方向,极角带中点取 cosθ_k = (2k+1)/128——这是精确有理数,不需要三角函数表。

BRDF积分估计残差 / 越界出处
Lambert(ρ = 1)65533−3(999.9542‰)第 2.1 节
归一化 Phong(n = 16)65321−215(求积差)第 2.2 节
归一化 Blinn(n = 16)69886+4350,越界约 6.69%第 2.2 节
未归一化 Phong(n = 16)22800只有 34.8%第 2.2 节
线性混合(5 组权重)65533 / 65479 / 65426 / 65373 / 65321全部 ≤ ONE第 2.3 节
分层朴素相加78640越界第 2.3 节
分层按 (1−F) 缩放60946守恒第 2.3 节

Blinn 越界是真实性质,不是求积假象:第 2.2 节用闭式有理数 4097/3840 独立验证过。它的归一化系数是在 R·V ≈ N·H 这个近似下推出的,而 N·H 的瓣比 R·V 更宽,于是积分确实超过 1。这个结论推翻了「Blinn 是 Phong 的严格改进」这个流行说法——它更省一次反射向量的构造,但代价是能量上界失守。第 6 章用微表面理论把这件事讲清楚。

A.6 数据集索引

各章产物落在工作区 lantern/ 下。基准类产物是跨章共用的,章节类产物各章自建。

文件类型内容出处
config/scene_spec.json规格场景唯一真源(71 字段,全整数)卷一第 1 章
config/cg_shade_07_srgb_lut.json规格sRGB 解码的 256 项冻结查表第 7.1 节
out/cg_shade_baseline.json基准逐顶点 / 逐像素双基线 + 频率差异卷二入口
out/shade_hollow.png图像逐像素着色的基线帧卷二入口
out/shade_gouraud.png图像逐顶点着色的对照帧卷二入口
out/shade_depth.png图像深度缓冲的灰度图卷二入口
out/shade_blend.rgb / .png图像混合材质的像素缓冲与 PNG第 2.3 节
out/cg_shade_<章>_<节>_*.json数据各节的实测统计(误差、残差、扫描表)各章

基准摘要(卷二入口的实测,全书共用):

逐顶点 (Gouraud)  sha256 bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
逐像素 (per-pixel) sha256 0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de
覆盖               43731 像素(569‰)  剔除 290  丢弃 0
着色频率差异       41261 像素(537.2526‰)  MAE 3.5764  PSNR 37.06 dB  最大通道差 48
覆盖掩码差异       0.0‰

A.7 复现命令

全部命令在 lantern/ 目录下执行,Python 一律用 /opt/homebrew/bin/python3(系统自带的 3.9 没有 numpy)。

# snippet: 本卷的复现命令(全部在 lantern/ 目录下执行)
cd ~/.cgbook/lantern

# 卷二入口:双基线 + 着色频率差异
/opt/homebrew/bin/python3 sim/render_shade.py
node core/tools/render_shade_ts.ts          # 断言与 Python 侧 sha256 逐字节相同

# 本附录的数据源
/opt/homebrew/bin/python3 sim/cg_shade_appendix_a.py

# P 层测试(本卷全部章节)
node --test 'core/test/cg_shade_*.test.ts'

三条硬约束,违反其一跨语言逐位一致必然崩:① 降精度一律用算术右移,定点除法用显式向零截断的实现;② 逐像素层的中间量必须落在 2^53 内(见 A.2),矩阵链留在 BigInt;③ 整数类可以写「逐字节相同」,感知类派生量按声明容差断言,正文不写「GPU 与 CPU 逐位相等」。

想按顺序读,从目录篇 开始;想先看公式在哪一章用,回到第 1.1 节 与第 6.3 节 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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