1.3 最小渲染器的骨架
1.1 那个脚本能把图画出来,但它是一条直线:所有事都发生在 main() 里,读规格、建几何、算矩阵、投影、光栅化、落盘,从上到下一条道走到黑。这样的脚本能验证「链路通不通」,却没法回答「哪一段慢」「哪一段错了」「换一个实现要改哪几行」。所以这一节做一件事:把它拆成四段——拆分依据是站内 /graphics-pipeline-theory/
那篇讲的可编程管线阶段,那篇讲这些阶段在 API 里怎么暴露、由谁调度,本节讲的是同一条管线在 CPU 上落地时,每一段该有什么样的函数签名。分界点不是随便切的,每一段都有明确的输入、输出和一条不变量。
四段的边界怎么切
拆分的标准是:每一段都能单独跑、单独测、单独换实现。
- 阶段 0:规格 ——
load_spec(path=None) -> dict。唯一的职责是读 JSON 并返回字典。它之所以独立成段,是因为三种宿主消费的必须是同一份规格:Python 读它、TypeScript 读它、浏览器页面内联它,读到的东西要一模一样。把「从哪读」收敛成一个函数,将来换路径、换格式都只动一处。 - 阶段 1:几何 ——
build_geometry(spec) -> (verts, tris, vcolors)。输入是规格,输出是世界坐标下的顶点、三角形索引、顶点颜色。这一段完全不碰相机,也不知道屏幕有多大——它只回答「场景里有什么」。地形和球体都在这里生成,顶点光照也在这里算完。 - 阶段 2:屏幕 ——
to_screen_coords(spec, verts) -> (screen, mvp)。这一段才引入相机:先用mat_look_at与mat_perspective合成 MVP,再把每个顶点投影到屏幕 Q6 坐标。它的输出是屏幕坐标 + 归一化深度,相机后方的顶点标成None。配套的assemble_triangles负责把屏幕坐标与顶点色拼成光栅化输入,并把带None顶点的三角形整块丢掉。 - 阶段 3:像素 ——
rasterize_pixels(rtris, width, height, bg) -> (color, depth, covered, culled)。输入已经是屏幕空间三角形,输出是像素缓冲、深度缓冲、覆盖掩码和剔除计数。这一段完全不知道三维世界的存在——它看到的是二维三角形和颜色,仅此而已。
这条边界不是审美问题。「几何不知道相机、像素不知道三维」这两条不变量,正是跨语言逐字节一致能成立的原因。 如果几何阶段偷看了相机参数,那么 P 层就必须在完全相同的时机、用完全相同的顺序去偷看,否则两边立刻分叉。把每段的输入输出钉死,跨实现的对齐就从「祈祷」变成了「结构上必然」。
可运行产物:骨架 + 第一帧
下面这个脚本就是重构后的骨架。四段各是一个函数,render_frame() 把它们串起来并逐段计时,main() 只负责落盘和打印。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_01_3_render_frame.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_01_3_render_frame.py
"""1.3 最小渲染器的骨架:四段 + 可复用签名。
把 1.1 的平铺脚本拆成四个有名字、可单独调用的阶段:
规格 load_spec(path=None) -> dict
几何 build_geometry(spec) -> (verts, tris, vcolors)
屏幕 to_screen_coords(spec, verts) -> (screen, mvp)
像素 rasterize_pixels(rtris, width, height, bg) -> (color, depth, covered, culled)
render_frame(spec) 串起四段并逐段计时。S 层与 P 层共用同一份规格:P 层把
build_geometry 换成定点内核的同名函数,屏幕与像素两段逐字对齐,故像素缓冲
的 sha256 相同。产物:out/cg_raster_01_3_frame.png、out/cg_raster_01_3_stages.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__)))
# ------------------------------------------------------------- 阶段 0:规格
def load_spec(path=None):
"""读场景规格;默认 config/scene_spec.json,也可指向本章自己的 config。"""
return cglib.load_spec(path)
# ------------------------------------------------------------- 阶段 1:几何
def shade_vertex(normal, albedo, light, ambient):
"""顶点光照:Lambert + 环境项,全整数(k 为 Q16.16)。"""
ndl = cglib.v_dot(normal, light["dirQ16"])
ndl = ndl if ndl > 0 else 0
k = min(ambient + cglib.fmul(ndl, light["intensityQ16"]), cglib.ONE)
return tuple((albedo[i] * k) >> 16 for i in range(3))
def build_geometry(spec):
"""程序化几何:起伏地形 + 三个材质球。返回 (verts, tris, vcolors)。"""
amb, dl, terr = spec["ambientQ16"], spec["directionalLight"], spec["terrain"]
lo, hi, hs = terr["albedoQ8"], terr["albedoHighQ8"], terr["heightScaleQ16"]
verts, tris, vcolors = [], [], []
tv, tt = cglib.build_terrain(
terr["gridN"], terr["extentQ16"], terr["heightScaleQ16"], terr["noiseSeed"]
)
verts += tv
for _, y, _ in tv:
t = min((y << 16) // hs if hs else 0, cglib.ONE) # y 非负、hs > 0
alb = tuple(lo[i] + (((hi[i] - lo[i]) * t) >> 16) for i in range(3))
vcolors.append(shade_vertex((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_vertex(n, sp["albedoQ8"], dl, amb))
tris += [(a + base, b + base, c + base) for a, b, c in st]
return verts, tris, vcolors
# ------------------------------------------------------------- 阶段 2:屏幕
def to_screen_coords(spec, verts):
"""视图 x 投影 = MVP(Q16.16 整数矩阵链),再投影到屏幕 Q6。
返回 (screen, mvp):screen[i] 为 (sx, sy, depth) 或 None(顶点在相机后方)。
"""
w, h, cam = spec["width"], spec["height"], 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)
screen = []
for v in verts:
p = cglib.project(mvp, v)
screen.append(None if p is None else (*cglib.to_screen(p[0], p[1], w, h), p[2]))
return screen, mvp
def assemble_triangles(tris, screen, vcolors):
"""屏幕坐标 + 顶点色拼成光栅化输入;任一顶点在相机后方则整三角形丢弃。"""
rtris, dropped = [], 0
for a, b, c in tris:
sa, sb, sc = screen[a], screen[b], screen[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]))
return rtris, dropped
# ------------------------------------------------------------- 阶段 3:像素
def rasterize_pixels(rtris, width, height, background):
"""整数边函数判覆盖 + 整数除法插深度与颜色,深度缓冲做可见性。"""
return cglib.rasterize(rtris, width, height, background)
# ------------------------------------------------------------- 串起来
def render_frame(spec):
"""规格 -> 几何 -> 屏幕坐标 -> 像素,四段各计一次耗时。"""
marks = {}
t = time.perf_counter()
verts, tris, vcolors = build_geometry(spec)
marks["geometry_ms"] = round((time.perf_counter() - t) * 1000, 1)
t = time.perf_counter()
screen, mvp = to_screen_coords(spec, verts)
marks["screen_ms"] = round((time.perf_counter() - t) * 1000, 1)
t = time.perf_counter()
rtris, dropped = assemble_triangles(tris, screen, vcolors)
marks["assemble_ms"] = round((time.perf_counter() - t) * 1000, 1)
t = time.perf_counter()
color, depth, covered, culled = rasterize_pixels(
rtris, spec["width"], spec["height"], spec["backgroundQ8"]
)
marks["pixels_ms"] = round((time.perf_counter() - t) * 1000, 1)
return {"color": color, "depth": depth, "covered": covered, "culled": culled,
"dropped": dropped, "vertices": len(verts), "triangles": len(tris),
"projected": len(rtris), "mvp": mvp, "marks": marks}
def main():
t0 = time.perf_counter()
spec = load_spec()
fr = render_frame(spec)
w, h = spec["width"], spec["height"]
px = np.clip(fr["color"], 0, 255).astype(np.uint8)
digest = hashlib.sha256(px.tobytes()).hexdigest()
png = cglib.save_png(os.path.join(ROOT, "out", "cg_raster_01_3_frame.png"), fr["color"])
stages = {"spec": spec["name"], "width": w, "height": h,
"vertices": fr["vertices"], "triangles_total": fr["triangles"],
"triangles_projected": fr["projected"], "triangles_dropped": fr["dropped"],
"triangles_culled": fr["culled"],
"triangles_rasterized": fr["projected"] - fr["culled"],
"pixels_covered": int(fr["covered"].sum()),
"coverage_permille": int(fr["covered"].sum()) * 1000 // (w * h),
"sha256": digest, "png_bytes": png, "marks_ms": fr["marks"],
"total_ms": round((time.perf_counter() - t0) * 1000, 1)}
with open(os.path.join(ROOT, "out", "cg_raster_01_3_stages.json"), "w", encoding="utf-8") as fh:
json.dump(stages, fh, ensure_ascii=False, indent=2)
m = fr["marks"]
print(f"spec : {stages['spec']} ({w}x{h})")
print(f"geometry : {fr['vertices']} verts / {fr['triangles']} tris [{m['geometry_ms']} ms]")
print(f"screen : {fr['projected']} projected / {fr['dropped']} dropped [{m['screen_ms']} ms]")
print(f"assemble : {len(fr['mvp'])}-elem MVP, {fr['projected']} tris packed [{m['assemble_ms']} ms]")
print(f"pixels : {fr['culled']} culled / {stages['triangles_rasterized']} rasterized [{m['pixels_ms']} ms]")
print(f"covered : {stages['pixels_covered']} ({stages['coverage_permille']} permille)")
print(f"sha256(rgb): {digest}")
print(f"png : out/cg_raster_01_3_frame.png ({png} bytes)")
print(f"total : {stages['total_ms']} ms")
return 0
if __name__ == "__main__":
sys.exit(main())
跑一次:
# snippet: 本机实跑命令(工作区为 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_raster_01_3_render_frame.py
spec : lantern-hollow (320x240)
geometry : 487 verts / 896 tris [2.1 ms]
screen : 896 projected / 0 dropped [0.9 ms]
assemble : 16-elem MVP, 896 tris packed [0.2 ms]
pixels : 290 culled / 606 rasterized [25.6 ms]
covered : 43731 (569 permille)
sha256(rgb): bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
png : out/cg_raster_01_3_frame.png (14055 bytes)
total : 59.5 ms
逐段读结果
四段的输出和 1.1 完全对得上:487 个顶点、896 个三角形、290 个背面剔除、43731 个覆盖像素、bac65977…38ced——同一个场景、同一份规格,重构前后像素缓冲的 sha256 一字不差。这是重构的安全网:只要拆完之后摘要变了,就一定是我在拆分时改了运算顺序。
真正新增的信息是逐段耗时。几何 2.1 ms、屏幕 0.9 ms、组装 0.2 ms、像素 25.6 ms。四个数字摆在一起,结论非常直白:光栅化一段就吃掉了全部时间的八成以上,而顶点变换(几何 + 屏幕)加起来才 3 ms。
这个比例决定了后面各章的重心。顶点变换在 896 个三角形上是「几千次矩阵乘」,再慢也是毫秒级;光栅化是「对每个三角形的包围盒逐像素判覆盖」,复杂度跟屏幕面积成正比,所以它才是瓶颈。等到第 6 章讲边函数、第 11 章讲超采样时,「每像素多算几次」这件事的代价会直接体现在这一段上。
16-elem MVP 这一行是个廉价但有用的断言:MVP 矩阵永远是 16 个元素,如果哪天它变成 9 或 20,说明矩阵乘被写坏了。
S 层与 P 层如何共用同一份规格
骨架拆好之后,「三种宿主消费同一份规格」这件事才真正可操作。关键在于:每一段的签名都是跨语言的契约。
P 层是同一套算法的 TypeScript 定点内核。它把 build_geometry 换成 core/src/lantern/mesh.ts 与 scene.ts 里的同名函数,to_screen_coords 对应 project.ts,rasterize_pixels 对应 raster.ts——函数边界一致,运算顺序一致,舍入方式一致。于是同一份 scene_spec.json 在两边跑出来的像素缓冲,sha256 相同:
canvas : 320x240
vertices : 487
triangles : 896 drawn / 896 total (0 dropped, 290 culled)
pixels covered: 43731 (569‰)
sha256(rgb) : bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced
elapsed : 20 ms
上面这段是 P 层工具 core/tools/render_raster_ts.ts 的输出。摘要和 S 层的完全一样——这就是「逐字节一致」的证据,而不是口号。
这份一致还配了一套断言,core/test/cg_raster_01_scene.test.ts 里 7 条全绿:
✔ 场景装配:顶点数、三角形数与 Python 侧一致
✔ 光栅化:剔除数与覆盖像素数与 Python 侧一致
✔ 【核心】像素缓冲 sha256 与 Python 侧逐字节相同
✔ 负例 A:环绕顺序反转 -> 画的是背面,像素缓冲必须分叉
✔ 负例 B:深度方向反转(1-z)-> 远处盖住近处,像素缓冲必须分叉
✔ 顺序敏感性:z-buffer 用严格小于,仅共享边像素受绘制顺序影响
✔ 定点不变量:深度缓冲只被更近的片元改写(单调性)
ℹ tests 7
ℹ pass 7
ℹ fail 0
(输出省略了每条的耗时。)注意第三行和它下面两行:一致性断言必须配负例。负例 A 把三角形环绕顺序反转,画的是背面;负例 B 把深度反转成 1-z,远处盖住近处。这两处只要有一点没对齐,sha256 就会分叉——判据能抓住它们,才说明「逐字节相同」这句话有意义。第 13 章会把这条链路从冒烟场景扩到完整场景,逐条拆开证明跨语言的每一个运算为什么必须这么写。
至于 W 层(浏览器里的 WebGL2 着色器),它不该和 S 层逐字节相同:光栅化规则、插值精度、覆盖判据都不同,所以那一层用声明容差断言,并且同样配一个负例。本机 headless 环境的 WebGL2 后端是 ANGLE/SwiftShader 的软件实现,不是真实 GPU,所以本卷不会把任何差异归因于硬件。这部分留给第 11、12 章。
到这里,第 1 章的三块地基都打完了:一条能跑的链路(1.1)、一份有边界的规格(1.2)、一个能换实现的骨架(1.3)。下一章从最小的地方开始填——向量、点、齐次坐标,以及它们为什么必须定点化。
阅读导航:上一节:1.2 场景规格的字段 · 下一节:2.1 向量运算的定点化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。