1.3 最小渲染器的骨架

1.1 的脚本能用,但四步糊在一个 main 里。本节把它重构成规格、几何、屏幕坐标、像素四段,每段一个有名字、可单独调用的函数,再串起来渲染《烛影山谷》的第一帧并逐段计时;同时说明 S 层与 P 层如何共用同一份规格,做到像素缓冲逐字节相同。

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 向量运算的定点化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图