这是《计算机图形学》卷二《着色、光照与纹理》的附录 A。它把全卷散落在各章的常数、公式、参数与产物集中到一处,方便在写代码时对照。
附录分两部分。前半是约定:Q16.16 定点常数表、逐像素层的量级上界、六个着色公式的整数形式。这些约定一旦违反,跨语言逐位一致立刻崩——它们不是「建议」,是硬约束。后半是索引:材质参数、能量守恒的残差汇总、各章产物落在哪个文件、怎么一条命令复现。
本卷所有数字都来自本机实跑,凡是引用具体数值的地方都注明了它出自哪一节。没有估算值,也没有从别处抄来的数字。
A.1 定点常数表
全卷的着色运算只有一种数值格式:Q16.16 定点(1.0 表示为 65536)。屏幕空间另有一套更粗的格式:Q6(1 像素 = 64 单位),用来做子像素精确的覆盖判定。两者之间的换算只允许出现在明确标注的位置。
下面这张表里的每一个常数都被至少两章引用。它们是「整数化」的落点:把一个无理数变成一个整数,误差就固定在最低位上,不会随运算次数漂移。
| 常数 | 值 | 含义 | 主要出处 |
|---|---|---|---|
ONE | 65536 | Q16.16 的 1.0 | 全卷 |
SUB / SUB1 | 6 / 64 | 子像素位数 / 1 像素的单位数 | 第 1、3、4 章 |
TWO_PI | 411775 | 2π 的 Q16.16(π = 205887) | 第 2、5、6 章 |
INV_PI | 20861 | 1/π,Lambert 的归一化系数 | 第 1、2 章 |
PHONG_NORM | 187749 | 9/π,n = 16 的归一化 Phong | 第 1、2 章 |
BLINN_NORM | 62583 | 3/π,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,而法线分量恰好可正可负。
| 公式 | 整数形式 | 降精度 | 出处 |
|---|---|---|---|
| Lambert | k = ambient + fmul(max(0, N·L), intensity) | >> | 第 1.1 节 |
| Phong 高光 | fmul(PHONG_NORM, pow_int(max(0, R·V), n)) | 平方链 + >> | 第 1.2 节 |
| Blinn-Phong | fmul(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 位通道上。三种宿主读的是同一份表。
| 参数 | 格式 | 范围 | 含义 |
|---|---|---|---|
albedoQ8 | 8 位 ×3 | 0–255 | 基础色(绝缘体的漫反射色,金属的 F0) |
specularQ16 | Q16.16 | 0–65536 | 镜面反射率(金属度工作流的中间量) |
shininessQ16 | Q16.16 | ≥ 65536 | 高光指数(Phong / Blinn 用),整数 |
roughnessQ16 | Q16.16 | 0–65536 | 粗糙度,映射到 α 时取平方 |
metalnessQ16 | Q16.16 | 0–65536 | 金属度,0 是绝缘体、65536 是金属 |
normalScaleQ16 | Q16.16 | 0–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 节 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。