10.3 画家算法与深度排序

画家算法不逐像素做深度测试,只把三角形按质心深度从远到近画、后画者胜。本节在可分离场景里让画家与 z-buffer 逐字节相同,在相交场景里让它们出现 63 个差异像素,并说清差异的来源是质心排序在交叉处判错了局部前后。脚本用「深度 = 绘制序号的相反数」把画家算法搭在共享光栅化器上。

10.3 画家算法与深度排序

前两节都在围绕深度缓冲做文章:10.1 算它的精度,10.2 治它的冲突。这一节退回到一个更早的思路——干脆不要深度缓冲。画家算法的做法和油画一样:把要画的东西按前后排好序,从最远的开始往近处一层层盖,后画的自然盖住先画的。它和纹理层叠的取舍有点像,如果你关心纹理侧的顺序问题,可以看 纹理与显存 那篇——那篇讲的是纹理显存管理;本卷讲的是深度缓冲的定点精度与分布。

画家算法省掉了逐像素比较,代价是:它只能按整个三角形排序,无法在三角形内部判断局部前后。本节要用两个场景把这个代价量化——一个场景里画家与 z-buffer 逐字节相同,另一个场景里它们必然分歧,并说清分歧出在哪里。

画家算法:不做逐像素测试

画家算法的规则一句话:按每个三角形的质心深度从远到近排序,依次光栅化,后画的覆盖先画的。质心深度取三个顶点深度的整数平均。

它和 z-buffer 的根本区别在于「粒度」:z-buffer 在每个像素上比较深度,画家算法在每个三角形上比较一次。粒度粗,就必然有它判不准的情形——当两个三角形在空间里真的交叉、或在屏幕上互相穿插时,「谁整体在前」这个问题没有统一答案,质心排序只能给出一个全局折中。

怎么用共享光栅化器实现画家算法

本卷不想为画家算法再写一个光栅化器。观察到一个事实:共享库 cglib.rasterize 逐像素插值出的深度,在三角形内部是常数当且仅当三个顶点深度相等——因为重心权重满足 $w_0 + w_1 + w_2 = \text{area}$,常数深度插出来仍是那个常数。

于是可以玩一个转换:把第 $k$ 个要画的三角形的三个顶点深度都设成 $-k$。这样它的插值深度恒为 $-k$,而 rasterize 的深度测试是「越小越近」,$-k$ 随绘制次序单调下降——后画的(更负)自然覆盖先画的。画家语义被完整地翻译成了一次普通的 z-buffer 调用,一行覆盖/插值逻辑都不用改。

两个场景:可分离与相交

用两个场景检验它:

  • 场景 A(可分离):两块平行墙,一块在 $z = -1$(前)、一块在 $z = -2$(后),投影后前墙完整盖住后墙,两面互不穿插。这种场景里「整块前后」是明确的,画家算法和 z-buffer 应该给出逐字节相同的结果。
  • 场景 B(相交):一块水平条($y = -0.3$,$z$ 从 $-1$ 到 $-2.5$)与一块斜条在空间里真的交叉。交叉处「谁在前」逐像素不同,质心排序只能二选一,必然在交叉带判错——两法允许不同,且差异可数。

实测:0 与 63

场景 A(两面平行墙,可分离): 4 三角形,绘制顺序(远->近)= [49539, 49539, 33026, 33026]
  画家 vs z-buffer 不同像素 : 0   <- 可分离场景应逐字节相同
  画家结果 sha256          : fcdef1b7ffafd0372830020fd88a6768ca953a293161ef98e5b0e6893c7bb816
场景 B(水平条 x 竖直条,相交): 4 三角形,覆盖 920 像素
  画家 vs z-buffer 不同像素 : 63   <- 质心排序在交叉处判错局部前后
  差异占比                 : 68‰

读法:

  • 场景 A 差异 0 像素。两块平行墙质心深度分别是 $33026$(前墙)和 $49539$(后墙),排序后从远到近画($49539$ 先、$33026$ 后),结果与逐像素 z-buffer 逐字节相同。这不是巧合,是「整块前后明确」这一前提的直接后果——当场景可分离时,三角形级排序与像素级测试给出同一答案。sha256 是场景 A 结果的字节指纹,供 P 层复算。
  • 场景 B 差异 63 像素,占覆盖的 68‰。水平条与斜条在屏幕上重叠 $920$ 个像素,其中 $63$ 个落在交叉带:那里斜条时前时后,质心排序的单一选择必然有错。差异是允许的——画家算法在这类场景下本就不保证正确。这也正是当年 z-buffer 取代画家算法的原因:像素级粒度换来了对穿插场景的正确性。

可运行产物

下面的脚本渲染两个场景,分别跑画家算法与 z-buffer,统计差异像素,并把场景 A 的三角形清单落盘供 P 层重放。它复用共享库 cglib.rasterize。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_10_3_painter.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_10_3_painter.py
"""10.3 画家算法与深度排序:按质心深度排序逐三角形绘制,与 z-buffer 逐像素对照。

画家算法不做逐像素深度测试,只按三角形质心深度从远到近画,后画的盖住先画的。
本脚本用共享库 cglib.rasterize 实现它:把第 k 个要画的三角形的三个顶点深度都设成
`-k`,则插值深度恒为 `-k`(因 w0+w1+w2 == area),`<` 测试让后画的(更负)覆盖先画的
——这就是画家语义,且不重写任何覆盖/插值逻辑。

两个场景:
  A 可分离(两面平行墙,投影后一块在前一块在后,互不穿插)——画家与 z-buffer 应逐字节相同。
  B 相交(一块水平条与一块竖直条在空间里真的交叉)——质心排序会判错局部前后,两法不同。

产物:out/cg_raster_10_3_painter_a.rgb(场景 A 的画家结果,供 P 层 sha256 比对)、
      out/cg_raster_10_3_painter.json、out/cg_raster_10_3_{a,b}_painter.png、_zbuf.png。
"""

import hashlib
import json
import os
import sys

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__)))
ONE = cglib.ONE
W, H = 64, 48
BG = (12, 14, 20)


def make_mvp(tan_half, aspect, near, far):
    view0 = cglib.mat_look_at((0, 0, 0), (0, 0, -ONE), (0, ONE, 0))
    return cglib.mat_mul(cglib.mat_perspective(tan_half, aspect, near, far), view0)


def quad_tris(mvp, corners, color):
    """四个 Q16 世界角点 -> 两个屏幕空间三角形(屏幕 Q6 + Q16 深度 + 颜色)。"""
    scr = []
    for c in corners:
        r = cglib.project(mvp, c)
        if r is None:
            return None
        sx, sy = cglib.to_screen(r[0], r[1], W, H)
        scr.append((sx, sy, r[2]))
    tris = []
    for a, b, c in ((0, 1, 2), (0, 2, 3)):
        area = (scr[b][0] - scr[a][0]) * (scr[c][1] - scr[a][1]) - (scr[b][1] - scr[a][1]) * (scr[c][0] - scr[a][0])
        if area <= 0:  # 屏幕空间逆时针为正面
            b, c = c, b
        tris.append(tuple(v for p in (scr[a], scr[b], scr[c]) for v in (p[0], p[1], p[2], *color)))
    return tris


def centroid_depth(t):
    """三角形质心深度(Q16):三个顶点深度的整数平均。"""
    return (t[2] + t[8] + t[14]) // 3


def painter(tris, W, H, bg):
    """按质心深度从远到近画:第 k 个画的三角形深度设为 -k(后画者胜)。"""
    order = sorted(range(len(tris)), key=lambda i: -centroid_depth(tris[i]))
    ptris = []
    for rank, i in enumerate(order):
        t = tris[i]
        ptris.append((t[0], t[1], -rank, t[3], t[4], t[5],
                      t[6], t[7], -rank, t[9], t[10], t[11],
                      t[12], t[13], -rank, t[15], t[16], t[17]))
    color, _depth, covered, culled = cglib.rasterize(ptris, W, H, bg)
    return color, covered, culled, [int(centroid_depth(tris[i])) for i in order]


def zbuffer(tris, W, H, bg):
    color, depth, covered, culled = cglib.rasterize(tris, W, H, bg)
    return color, depth, covered, culled


def main():
    spec = cglib.load_spec()
    cam = spec["camera"]
    aspect = cglib.fdiv(cglib.f_from_int(W), cglib.f_from_int(H))
    mvp = make_mvp(cam["tanHalfFovYQ16"], aspect, cam["nearQ16"], cam["farQ16"])

    def q(v):
        return round(v * ONE)

    # 场景 A:两块平行墙(z=-1 前 / z=-2 后),投影后一块完整盖住另一块
    red, blue = (220, 90, 70), (90, 180, 220)
    wall_f = [(-q(0.8), -q(0.6), -q(1.0)), (q(0.8), -q(0.6), -q(1.0)),
              (q(0.8), q(0.6), -q(1.0)), (-q(0.8), q(0.6), -q(1.0))]
    wall_b = [(-q(1.6), -q(1.2), -q(2.0)), (q(1.6), -q(1.2), -q(2.0)),
              (q(1.6), q(1.2), -q(2.0)), (-q(1.6), q(1.2), -q(2.0))]
    tris_a = quad_tris(mvp, wall_b, blue) + quad_tris(mvp, wall_f, red)  # 反序输入也无妨,画家会重排

    pa, pca, _pculla, order_a = painter(tris_a, W, H, BG)
    za, _zda, _zca, _zculla = zbuffer(tris_a, W, H, BG)
    diff_a = int(np.any(pa != za, axis=2).sum())

    # 场景 B:一块水平条(y=-0.3,z 从 -1 到 -2.5)与一块斜条在空间里真交叉
    green, yellow = (90, 200, 120), (230, 200, 80)
    strip_h = [(-q(0.7), -q(0.3), -q(1.0)), (q(0.7), -q(0.3), -q(1.0)),
               (q(0.7), -q(0.3), -q(2.5)), (-q(0.7), -q(0.3), -q(2.5))]
    strip_s = [(-q(0.5), -q(0.8), -q(1.2)), (q(0.5), -q(0.8), -q(1.2)),
               (q(0.5), q(0.4), -q(2.2)), (-q(0.5), q(0.4), -q(2.2))]
    tris_b = quad_tris(mvp, strip_h, green) + quad_tris(mvp, strip_s, yellow)

    pb, pcb, _pcullb, _order_b = painter(tris_b, W, H, BG)
    zb, _zdb, _zcb, _zcullb = zbuffer(tris_b, W, H, BG)
    diff_b = int(np.any(pb != zb, axis=2).sum())
    cover_b = int((pcb | _zcb).sum())

    out = os.path.join(ROOT, "out")
    rgb_a = np.clip(pa, 0, 255).astype(np.uint8).tobytes()
    with open(os.path.join(out, "cg_raster_10_3_painter_a.rgb"), "wb") as fh:
        fh.write(rgb_a)
    sha_a = hashlib.sha256(rgb_a).hexdigest()
    cglib.save_png(os.path.join(out, "cg_raster_10_3_a_painter.png"), pa)
    cglib.save_png(os.path.join(out, "cg_raster_10_3_a_zbuf.png"), za)
    cglib.save_png(os.path.join(out, "cg_raster_10_3_b_painter.png"), pb)
    cglib.save_png(os.path.join(out, "cg_raster_10_3_b_zbuf.png"), zb)

    # 场景 A 的三角形清单(供 P 层重放)
    scene_a = [{"xy": [[t[0], t[1]], [t[6], t[7]], [t[12], t[13]]],
                "d": [t[2], t[8], t[14]], "c": [list(t[3:6]), list(t[9:12]), list(t[15:18])]}
               for t in tris_a]
    with open(os.path.join(out, "cg_raster_10_3_painter.json"), "w", encoding="utf-8") as fh:
        json.dump({
            "canvas": [W, H], "backgroundQ8": list(BG),
            "scene_a": {"triangles": scene_a, "painter_sha256": sha_a,
                        "painter_vs_zbuf_diff": diff_a, "draw_order": order_a},
            "scene_b": {"triangles": len(tris_b), "painter_vs_zbuf_diff": diff_b, "covered": cover_b},
        }, fh, ensure_ascii=False, indent=2)

    print(f"场景 A(两面平行墙,可分离): {len(tris_a)} 三角形,绘制顺序(远->近)= {order_a}")
    print(f"  画家 vs z-buffer 不同像素 : {diff_a}   <- 可分离场景应逐字节相同")
    print(f"  画家结果 sha256          : {sha_a}")
    print(f"场景 B(水平条 x 竖直条,相交): {len(tris_b)} 三角形,覆盖 {cover_b} 像素")
    print(f"  画家 vs z-buffer 不同像素 : {diff_b}   <- 质心排序在交叉处判错局部前后")
    print(f"  差异占比                 : {diff_b * 1000 // max(1, cover_b)}‰")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实测输出:

场景 A(两面平行墙,可分离): 4 三角形,绘制顺序(远->近)= [49539, 49539, 33026, 33026]
  画家 vs z-buffer 不同像素 : 0   <- 可分离场景应逐字节相同
  画家结果 sha256          : fcdef1b7ffafd0372830020fd88a6768ca953a293161ef98e5b0e6893c7bb816
场景 B(水平条 x 竖直条,相交): 4 三角形,覆盖 920 像素
  画家 vs z-buffer 不同像素 : 63   <- 质心排序在交叉处判错局部前后
  差异占比                 : 68‰

P 层:重放场景 A 并做逐字节比对

TypeScript 侧把画家逻辑重写进 core/src/lantern/cg_raster_10_painter.ts(centroidDepth / painterOrder / painterRasterize,同样用「深度 = 绘制序号的相反数」这一转换)。测试 core/test/cg_raster_10_3_painter.test.ts 读入 S 层落盘的场景 A 三角形清单,重放画家算法,做 sha256 逐字节比对——Python 与 TypeScript 两份实现逐字节相同,结果哈希 $fcdef1b7\dots$。

测试覆盖三个正例与一个负例:

  • 绘制顺序与 Python 侧一致(按质心深度降序,$[49539, 49539, 33026, 33026]$)。
  • 画家结果与 Python 侧逐字节相同(sha256 相等)。
  • 场景 A 里画家与 z-buffer 差异为 $0$,与金标准一致。
  • 负例:把排序方向写反(近到远)必须给出不同结果。断言反序结果与正序不同、且哈希不等于金标准——证明「排序方向」是判据的实质。

场景 B 的差异数($63$)只由 S 层统计并落盘,P 层断言其大于 $0$:相交场景下质心排序必然判错局部前后,两法不同是预期的,不是 bug。本节 5 条测试全部通过。

下一节进入第 11 章:从「画什么、盖什么」转向「采样」——一个像素到底采了多少信息,以及为什么会出现走样。

阅读导航:上一节:10.2 深度冲突与逆转 z · 下一节:11.1 采样定理与走样 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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