1.1 从场景到像素

渲染一帧到底发生了什么?本节把整条链路压成顶点变换、三角形组装、光栅化、逐像素着色四步,用一份 config/scene_spec.json 跑出 487 顶点、896 三角形、43731 个覆盖像素并落盘一张 PNG;每一步都只用整数与定点,为后面三种宿主消费同一份规格打下地基。

1.1 从场景到像素

渲染管线在站内已经有人讲透了:/graphics-pipeline-theory/ 那篇讲的是现代可编程管线——顶点着色器、图元装配、光栅化、片元着色器这几个阶段分别跑在哪、由谁调度、API 怎么把它们串起来。本卷要回答的是另一个问题:同一条管线,如果不用 GPU、不用浮点、只用 CPU 上的整数,还能不能跑出逐像素可断言的图像? 这一章就是答案的骨架。

这不是一次「不用 GPU 的怀旧实验」。它解决的是一个很具体的工程问题:当你怀疑一张图不对时,能不能把「哪里不对」定位到某一次乘法的舍入上。 浮点渲染做不到这一点——同一段着色器换一块显卡、换一版驱动,最低位就可能不一样;而整数运算在 Python 与 TypeScript 下是逐字节可复现的,差异一旦出现,一定是你写错了,不是环境在抖。

所以本卷的核心主张只有一句话:一份场景规格,三种宿主消费,两条一致性纪律。 这一节先把「一份规格怎么变成一张图」这条主干跑通,后面 15 章都在给它加精度、加细节。

贯穿案例:工程 lantern 与《烛影山谷》

本卷有一个贯穿始终的工程,代号 lantern,它渲染的场景叫《烛影山谷》(lantern-hollow)。这个场景是程序化生成的:一片用整数哈希噪声堆出来的起伏地形,三个材质不同的八面体细分球,一盏暖色点光源加一个方向光,一条由若干关键帧定义的相机路径。不引入任何第三方素材——没有 .obj,没有贴图文件,所有顶点和颜色都是算出来的。

选择程序化生成不是为了炫技,而是为了让「可复现」这件事没有漏洞。如果场景来自外部模型文件,那么「渲染结果不一致」就多了一种可能:文件读错了、坐标系不一样、导出时精度丢了。全部算出来之后,输入只剩一份 JSON 和一个种子,任何差异都只能来自渲染代码本身。

四步,每一步都能用整数完成

先看全局。把一帧拆开,其实是四步流水:

  1. 顶点变换——把每个顶点的世界坐标乘上「视图矩阵 × 投影矩阵」,得到它在裁剪空间里的位置。本卷用 Q16.16 定点:一个数占 32 位,高 16 位是整数部分,低 16 位是小数部分,1.0 记作 65536,于是 1.5 是 98304、0.5 是 32768。矩阵乘法的每一项都是整数乘加,最后算术右移 16 位降精度。
  2. 三角形组装——把变换后的顶点投到屏幕,得到 Q6 子像素坐标(1 像素 = 64 单位),再按索引把顶点拼成三角形,同时丢掉相机后方的顶点。这一步只是查表和拼接,没有任何实数运算。
  3. 光栅化——对每个三角形,用整数边函数判断每个像素中心在不在三角形内,用深度缓冲决定谁在前。边函数是两次整数乘法相减,覆盖判定是一次符号比较。
  4. 逐像素着色——把顶点颜色按重心权重插值到像素上。权重是边函数值,深度与颜色是「权重 × 顶点值求和,再整除面积」,全是整数。

四步里没有一步天然需要浮点。顶点变换用的是矩阵乘,本来就是整数乘加;光栅化用的是半平面判定,本来就是整数比较;插值用的是重心坐标,本来就是「加权和 ÷ 面积」。浮点在这里不是必需品,只是历史选择——早期硬件用浮点是因为它好写、够快,而本书要的是「好验证」。

还有一层量级上的保证值得先摆出来。屏幕坐标用 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 场景规格的字段 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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