1.1 从场景到像素
渲染管线在站内已经有人讲透了:/graphics-pipeline-theory/ 那篇讲的是现代可编程管线——顶点着色器、图元装配、光栅化、片元着色器这几个阶段分别跑在哪、由谁调度、API 怎么把它们串起来。本卷要回答的是另一个问题:同一条管线,如果不用 GPU、不用浮点、只用 CPU 上的整数,还能不能跑出逐像素可断言的图像? 这一章就是答案的骨架。
这不是一次「不用 GPU 的怀旧实验」。它解决的是一个很具体的工程问题:当你怀疑一张图不对时,能不能把「哪里不对」定位到某一次乘法的舍入上。 浮点渲染做不到这一点——同一段着色器换一块显卡、换一版驱动,最低位就可能不一样;而整数运算在 Python 与 TypeScript 下是逐字节可复现的,差异一旦出现,一定是你写错了,不是环境在抖。
所以本卷的核心主张只有一句话:一份场景规格,三种宿主消费,两条一致性纪律。 这一节先把「一份规格怎么变成一张图」这条主干跑通,后面 15 章都在给它加精度、加细节。
贯穿案例:工程 lantern 与《烛影山谷》
本卷有一个贯穿始终的工程,代号 lantern,它渲染的场景叫《烛影山谷》(lantern-hollow)。这个场景是程序化生成的:一片用整数哈希噪声堆出来的起伏地形,三个材质不同的八面体细分球,一盏暖色点光源加一个方向光,一条由若干关键帧定义的相机路径。不引入任何第三方素材——没有 .obj,没有贴图文件,所有顶点和颜色都是算出来的。
选择程序化生成不是为了炫技,而是为了让「可复现」这件事没有漏洞。如果场景来自外部模型文件,那么「渲染结果不一致」就多了一种可能:文件读错了、坐标系不一样、导出时精度丢了。全部算出来之后,输入只剩一份 JSON 和一个种子,任何差异都只能来自渲染代码本身。
四步,每一步都能用整数完成
先看全局。把一帧拆开,其实是四步流水:
- 顶点变换——把每个顶点的世界坐标乘上「视图矩阵 × 投影矩阵」,得到它在裁剪空间里的位置。本卷用 Q16.16 定点:一个数占 32 位,高 16 位是整数部分,低 16 位是小数部分,
1.0记作65536,于是1.5是98304、0.5是32768。矩阵乘法的每一项都是整数乘加,最后算术右移 16 位降精度。 - 三角形组装——把变换后的顶点投到屏幕,得到 Q6 子像素坐标(1 像素 = 64 单位),再按索引把顶点拼成三角形,同时丢掉相机后方的顶点。这一步只是查表和拼接,没有任何实数运算。
- 光栅化——对每个三角形,用整数边函数判断每个像素中心在不在三角形内,用深度缓冲决定谁在前。边函数是两次整数乘法相减,覆盖判定是一次符号比较。
- 逐像素着色——把顶点颜色按重心权重插值到像素上。权重是边函数值,深度与颜色是「权重 × 顶点值求和,再整除面积」,全是整数。
四步里没有一步天然需要浮点。顶点变换用的是矩阵乘,本来就是整数乘加;光栅化用的是半平面判定,本来就是整数比较;插值用的是重心坐标,本来就是「加权和 ÷ 面积」。浮点在这里不是必需品,只是历史选择——早期硬件用浮点是因为它好写、够快,而本书要的是「好验证」。
还有一层量级上的保证值得先摆出来。屏幕坐标用 Q6 时最大约 800 × 64 ≈ 2^15.6,边函数是两段这样的乘积相减,上界 2^31.2;插值分子是「权重 ≤ 2^31.2 × 值」,深度是 Q16.16,所以最坏情况约 2^49.6,仍然小于 2^53。这意味着逐像素层不需要大整数——Python 的 int 与 numpy 的 int64、JavaScript 的 Number(float64 的整数精确区)都能精确表示。只有顶点变换那一层,因为矩阵乘的中间量会超过 2^53,才必须用任意精度整数(Python int / JS BigInt)。
一份规格,三种宿主
这份规格会被三处消费,各自产出不同的东西,用不同的口径验收:
| 宿主 | 实现 | 产物 | 一致性口径 |
|---|---|---|---|
| S 层 | Python + numpy 的 CPU 软光栅化器与光追器 | PNG、中间缓冲、JSON/CSV/SVG | 基准真源 |
| P 层 | TypeScript 定点光栅化内核,node --test | 同尺寸像素缓冲 | 与 S 层 逐字节相同(sha256) |
| W 层 | 浏览器 WebGL2 着色器真渲染 | canvas 像素 | 与 S 层 按声明容差一致 |
三者的输入完全相同,产出必须能用一句话说清「为什么它俩该相等」或「为什么它俩不该相等」。S 层和 P 层都是 CPU 上的整数实现,运算顺序被刻意对齐,所以该相等;W 层跑在另一套光栅化规则上,所以不该相等,只能用容差断言。这一节只做 S 层——先把基准真源立起来。
为什么必须是整数
如果只是「能跑」,浮点当然也能跑。但本卷给自己加了两条纪律,它们决定了后面每一章的写法。
第一条:整数与定点必须逐字节一致。 同一份规格,Python 实现与 TypeScript 实现要产出完全相同的像素缓冲——比对的是 sha256,对象是像素数组本身,不是 PNG 文件。为什么不是 PNG?因为 PIL 和自写编码器的压缩参数不同,容器字节必然不同,拿容器比对证明不了任何事。这条纪律是可证伪的:只要有一处运算顺序或舍入方式不一样,sha256 立刻分叉。
第二条:浮点只按声明容差比对。 浏览器里的 WebGL2 走的是另一套光栅化规则——top-left 覆盖判据、子像素精度、浮点插值,都和 CPU 上的整数实现不同,不可能逐位一致。所以第三层一律用声明容差(MAE、PSNR、覆盖归属差异)来断言,而且必须配一个故意写错的负例,证明这套判据真的会拒绝错的东西。顺带说明:本机 headless 环境的 WebGL2 后端是 ANGLE/SwiftShader 的软件实现,不是真实 GPU,所以本卷不会把任何差异归因于硬件。
两条纪律之外还有一条铁律,来自一个实测的坑:Q16.16 的一切降精度运算一律用算术右移 >>,禁用除法。 原因是 Python 与 JavaScript 的整除语义不同——-1 >> 16 两边都等于 -1,但 -1 // 65536 等于 -1,而 JS 的 -1n / 65536n 等于 0n。一个负数就能让两条实现分道扬镳。逐像素层确实会用整除(num // area),但只在分子已知非负的地方用——面积大于零、权重非负、深度非负——此时 floor 与向零截断恰好重合,代码里必须注明这个前提。
可运行产物:一次跑出规模统计与 PNG
下面这个脚本是全书最短的完整管线:读规格、建几何、变换、光栅化、落盘,一步不少。它只依赖 sim/cglib.py 里的公共库——定点标量、向量、矩阵、投影、地形与球体生成、光栅化、PNG 落盘全部复用,不重写任何底层运算。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_01_1_scene_stats.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_01_1_scene_stats.py
"""1.1 从场景到像素:最小可跑脚本。
一份 config/scene_spec.json,四步走完,全程整数:
1 顶点变换 Q16.16,Python int;mat_look_at x mat_perspective = MVP
2 三角形组装 世界顶点 -> 屏幕 Q6 子像素(project + to_screen)
3 光栅化 整数边函数定覆盖 + 深度缓冲(cglib.rasterize,numpy int64)
4 逐像素着色 顶点色的重心插值,与深度共用同一套整数除法
产物:out/cg_raster_01_1_scene.png、out/cg_raster_01_1_stats.json
"""
import hashlib
import json
import os
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def shade(normal, albedo, light, ambient):
"""顶点光照:Lambert + 环境项,全整数(k 为 Q16.16)。"""
ndl = cglib.v_dot(normal, light["dirQ16"])
ndl = ndl if ndl > 0 else 0
k = ambient + cglib.fmul(ndl, light["intensityQ16"])
k = k if k < cglib.ONE else cglib.ONE
return tuple((albedo[i] * k) >> 16 for i in range(3))
def geometry(spec):
"""程序化几何:起伏地形 + 三个材质球。返回 (verts, tris, vcolors)。"""
amb, dl = spec["ambientQ16"], spec["directionalLight"]
terr = spec["terrain"]
verts, tris, vcolors = [], [], []
tv, tt = cglib.build_terrain(
terr["gridN"], terr["extentQ16"], terr["heightScaleQ16"], terr["noiseSeed"]
)
lo, hi, hs = terr["albedoQ8"], terr["albedoHighQ8"], terr["heightScaleQ16"]
verts += tv
for _, y, _ in tv:
# 高度归一化到 [0, ONE]:y 非负、hs > 0,故 // 与截断一致
t = min((y << 16) // hs if hs else 0, cglib.ONE)
alb = tuple(lo[i] + (((hi[i] - lo[i]) * t) >> 16) for i in range(3))
vcolors.append(shade((0, cglib.ONE, 0), alb, dl, amb)) # 地形法线取竖直向上
tris += [(a, b, c) for a, b, c in tt]
for sp in spec["spheres"]:
sv, st = cglib.build_sphere(sp["centerQ16"], sp["radiusQ16"], sp["subdiv"])
base = len(verts)
verts += sv
for v in sv:
n = cglib.v_norm(cglib.v_sub(v, sp["centerQ16"]))
vcolors.append(shade(n, sp["albedoQ8"], dl, amb))
tris += [(a + base, b + base, c + base) for a, b, c in st]
return verts, tris, vcolors
def main():
t0 = time.perf_counter()
spec = cglib.load_spec()
w, h = spec["width"], spec["height"]
# 步骤 1:顶点变换。视图矩阵 x 投影矩阵 = MVP,全 Q16.16 整数矩阵链。
cam = spec["camera"]
aspect = cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h))
view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
proj = cglib.mat_perspective(cam["tanHalfFovYQ16"], aspect, cam["nearQ16"], cam["farQ16"])
mvp = cglib.mat_mul(proj, view)
verts, tris, vcolors = geometry(spec)
# 步骤 2:三角形组装。每个顶点投影到屏幕 Q6;相机后方的顶点记 None。
scr = []
for v in verts:
p = cglib.project(mvp, v)
scr.append(None if p is None else (*cglib.to_screen(p[0], p[1], w, h), p[2]))
rtris, dropped = [], 0
for a, b, c in tris:
sa, sb, sc = scr[a], scr[b], scr[c]
if sa is None or sb is None or sc is None:
dropped += 1
continue
rtris.append((*sa, *vcolors[a], *sb, *vcolors[b], *sc, *vcolors[c]))
# 步骤 3 + 4:光栅化。整数边函数判覆盖,整数除法插深度与颜色。
color, depth, covered, culled = cglib.rasterize(rtris, w, h, spec["backgroundQ8"])
out = os.path.join(ROOT, "out")
png = cglib.save_png(os.path.join(out, "cg_raster_01_1_scene.png"), color)
px = np.clip(color, 0, 255).astype(np.uint8)
digest = hashlib.sha256(px.tobytes()).hexdigest()
stats = {
"spec": spec["name"],
"width": w,
"height": h,
"vertices": len(verts),
"triangles_total": len(tris),
"triangles_projected": len(rtris),
"triangles_dropped": dropped,
"triangles_culled": culled,
"triangles_rasterized": len(rtris) - culled,
"pixels_covered": int(covered.sum()),
"coverage_permille": int(covered.sum()) * 1000 // (w * h),
"sha256": digest,
"png_bytes": png,
"ms": round((time.perf_counter() - t0) * 1000, 1),
}
with open(os.path.join(out, "cg_raster_01_1_stats.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"spec : {stats['spec']}")
print(f"canvas : {w}x{h} (subpixel Q6, depth Q16.16)")
print(f"vertices : {stats['vertices']}")
print(f"triangles : {stats['triangles_total']} total")
print(f" projected : {stats['triangles_projected']} ({stats['triangles_dropped']} dropped)")
print(f" culled : {stats['triangles_culled']} (backface, area <= 0)")
print(f" rasterized : {stats['triangles_rasterized']}")
print(f"pixels covered : {stats['pixels_covered']} ({stats['coverage_permille']} permille)")
print(f"sha256(rgb) : {digest}")
print(f"png : out/cg_raster_01_1_scene.png ({png} bytes)")
print(f"elapsed : {stats['ms']} ms")
return 0
if __name__ == "__main__":
sys.exit(main())
跑一次:
# snippet: 本机实跑命令(工作区为 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_raster_01_1_scene_stats.py
spec : lantern-hollow
canvas : 320x240 (subpixel Q6, depth Q16.16)
vertices : 487
triangles : 896 total
projected : 896 (0 dropped)
culled : 290 (backface, area <= 0)
rasterized : 606
pixels covered : 43731 (569 permille)
sha256(rgb) : bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
png : out/cg_raster_01_1_scene.png (14055 bytes)
elapsed : 71.6 ms
用到的底座 API
这一节没有自己写任何底层运算,全部复用 sim/cglib.py:
| 函数 | 作用 |
|---|---|
load_spec(path=None) | 读 config/scene_spec.json |
build_terrain / build_sphere | 程序化几何生成,全整数 |
mat_look_at / mat_perspective / mat_mul | 视图矩阵、投影矩阵、矩阵乘(Q16.16) |
project(mvp, v) | 世界坐标 → NDC 坐标与归一化深度 |
to_screen(ndc_x, ndc_y, w, h) | NDC → 屏幕 Q6 子像素坐标 |
rasterize(tris, w, h, bg) | 整数边函数 + 深度缓冲,返回颜色 / 深度 / 覆盖 / 剔除 |
save_png(path, color) | 落盘 PNG |
底座的每个函数都和 TypeScript 侧 core/src/lantern/*.ts 里的同名函数逐字对齐——同样的运算顺序、同样的舍入。这正是「逐字节一致」能成立的前提:不是两边各自实现「差不多」,而是两边照着同一份规格做同一件事。第 2 章会把每个函数拆开讲它为什么这么写。
这组数字各是什么
487 个顶点 / 896 个三角形来自程序化几何:16×16 的地形网格是 17×17 = 289 个顶点、16×16×2 = 512 个三角形;三个八面体细分两次的球各 128 个面、共 384 个三角形,顶点数取决于细分时的边中点复用。全部由噪声和八面体细分算出来,没有导入任何外部模型。
896 projected / 0 dropped 说明所有顶点都落在相机前方——本场景的相机朝谷地看,视锥没切掉任何东西。这个计数留着,是因为后面加相机路径、加视锥裁剪时它会变成非零,那时它就是最直接的回归信号。
290 culled / 606 rasterized 是背面剔除的结果:896 个三角形里,有 290 个在屏幕空间是逆时针的背面(边函数面积 area <= 0),直接跳过;剩下 606 个才进覆盖循环。约三分之一的剔除率,对封闭球体加地形来说是个合理数字。
43731 个覆盖像素(569‰) 是最终被至少一个三角形覆盖的像素数,占 320×240 = 76800 个像素的 56.9%。这个数字把「几何规模」和「屏幕占比」连了起来——它既是几何正确性的粗指标,也是后面算填充率的分母。
最后是 sha256:bac65977…38ced。这是像素缓冲的摘要,也是本卷最硬的一把尺子。等到第 2 章接上 TypeScript 定点内核,我们会用同一个场景跑出同一个摘要;到第 13 章,这条比对会扩成跨语言、跨实现的逐字节证明。
14055 字节是 PNG 文件大小。它和 sha256 一起出现,正是为了提醒:容器字节不能当判据,像素缓冲才能。同一个像素缓冲用 PIL 和自写编码器写出来,文件大小和字节都会不同,但 sha256 必须一致。
71.6 ms 是 320×240、896 个三角形在纯 Python 下的耗时——它不快,也不打算快。本卷的性能数字只在同机同版本之间比较,用来观察「加一个特性贵多少」,不跨语言外推。
到这里,一条完整的链路已经跑通:一份 JSON 进去,一张 PNG 加一份统计出来。下一节把那个 JSON 逐字段拆开看——它的每个字段都有明确的量纲、取值范围和整数约束,这些约束不是文档里的建议,而是可执行的断言。
阅读导航:上一节:目录 · 下一节:1.2 场景规格的字段 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。