6.3 深度插值与 z-buffer
前两节解决了单个三角形的「涂不涂」与「涂什么」。真实场景里几十上百个三角形互相重叠,同一个像素会被多个三角形覆盖。留下谁的颜,就是本节的问题。答案是一个和颜色缓冲同样大小的数组——z-buffer(深度缓冲),以及一次逐像素的比较。
这一节的难点不在算法,而在一个容易说错的地方:深度测试到底用严格 < 还是 <=?很多人以为「用 < 就能让共享边像素归属与绘制顺序无关」。这是错的,本节会用一个七像素的微用例把它证伪。
z-buffer:一个像素一个最近深度
z-buffer 与颜色缓冲一一对应:每个像素存一个当前最近的深度。光栅化一个三角形时,对每个覆盖像素先插出深度 $z$,再和缓冲里已有的值比:
if (z < depth[py][px]) { depth[py][px] = z; color[py][px] = c; }
深度约定为 Q16.16,0 表示近平面、ONE=65536 表示远平面,越小越近。本节的深度阶梯场景共 76 个三角形,全部落在 $[440, 42862]$ 内,合法地待在 $[0, \text{ONE}]$ 区间。这个「越小越近、全部归一化到 $[0,1]$」的约定,让深度测试退化成一个整数比较,不需要浮点、也不需要任何 epsilon。
严格 < 还是 <=
比较符只有一个字的差别,后果却分道扬镳。设想两个三角形共享一条边,且这条边上插值出的深度恰好相等。此时:
- 用
<:第二个三角形的 $z$ 不小于缓冲里的值,测试失败,先画的赢。 - 用
<=:第二个三角形的 $z$ 等于缓冲值,测试通过,后画的赢。
本节的共享内核 cglib.rasterize 固定用 z < depth。它不暴露比较符开关,所以脚本里单独写了一个约 30 行的微内核 micro(tris, mode),其余约定(像素中心 $px\cdot64+32$、// 截断)与共享库逐字一致,只把 < / <= 做成参数。
共享边上的顺序依赖
把正方形沿对角线 $y=x$ 切成两个等深三角形(红=下三角,蓝=上三角),共享边上有 7 个像素的深度完全相等。分别用两种比较符、正反两种绘制顺序跑:
< 正向 首像素色 [220, 60, 60](先画的红赢) < 反向 [60, 140, 220](先画的蓝赢)
<= 正向 首像素色 [60, 140, 220](后画的蓝赢) <= 反向 [220, 60, 60](后画的红赢)
<正向 == <=反向 : True
读法很直白:
<让先画的赢。正序(红先)时红留下,反序(蓝先)时蓝留下——同一个像素,两种颜色,取决于谁先画。这就是顺序依赖,<一点也没有消除它。<=让后画的赢。正序时蓝留下,反序时红留下。它只是把赢家从「先画」换成「后画」,顺序依赖依旧存在。<正序的结果,恰好等于<=反序的结果(True)。这正说明两者是一枚硬币的两面,谁也不比谁「正确」。
所以正确的表述是:严格 < 让共享边像素的归属由绘制顺序决定。要真正消除顺序依赖,必须引入一个与顺序无关的确定性裁决——工业界用 top-left rule(规定共享边像素只归某个固定侧的三角形),而不是靠换比较符。这条规则如何整数化,留到第 13 章。
那完整场景为什么正反两次绘制得到 0 个不同像素?不是「顺序无关」——是这个场景的网格顶点刻意偏移了 $(13,7)$ 个 Q6 单位,共享边恰好没有穿过任何像素中心,于是没有触发等深平局。把对角线特意穿过像素中心(上面的微用例),顺序依赖立刻现形。诚实地说:这是场景选得好,不是算法好。
可运行产物:一个场景,两个微用例
下面的脚本渲染深度阶梯场景,做正反两次绘制,并跑上面那个 < / <= 微用例。它复用共享库 cglib.rasterize,不重写覆盖与插值逻辑。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_06_3_depth.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_06_3_depth.py
"""6.3 深度插值与 z-buffer:严格小于的语义、共享边归属与绘制顺序。
场景渲染走共享库 cglib.rasterize(它固定用 `z < depth`)。共享边上两个三角形的
插值深度完全相同,`z < depth` 不成立 -> 先画的赢 -> 归属由绘制顺序决定;
`<=` 只是把赢家换成后画的,顺序依赖依旧。真正消除顺序依赖的是与顺序无关的确定性
tie-break(工业界用 top-left rule)。
产物:out/cg_raster_06_3_depth.png/.rgb、out/cg_raster_06_3_depth.json。
"""
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__)))
SUB1, HALF = cglib.SUB1, cglib.SUB1 // 2
def micro(tris, mode):
"""两三角形微用例的最小对照内核:演示 `<` 与 `<=` 的差别。
共享库不暴露深度比较符开关,故这里给一个只做「深度测试 + 写色」的版本,
其余约定(像素中心 px*64+32、`//` 截断)与 cglib 完全一致。
"""
W, H = 24, 20
color = np.zeros((H, W, 3), dtype=np.int64)
depth = np.full((H, W), np.iinfo(np.int64).max, dtype=np.int64)
for t in tris:
(x0, y0, d0, c0), (x1, y1, d1, c1), (x2, y2, d2, c2) = (
(t[0], t[1], t[2], t[3:6]), (t[6], t[7], t[8], t[9:12]), (t[12], t[13], t[14], t[15:18]))
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
if area <= 0:
continue
for py in range(H):
for px in range(W):
X, Y = px * SUB1 + HALF, py * SUB1 + HALF
w0 = (x2 - x1) * (Y - y1) - (y2 - y1) * (X - x1)
w1 = (x0 - x2) * (Y - y2) - (y0 - y2) * (X - x2)
w2 = (x1 - x0) * (Y - y0) - (y1 - y0) * (X - x0)
if w0 < 0 or w1 < 0 or w2 < 0:
continue
z = (w0 * d0 + w1 * d1 + w2 * d2) // area
if (z < depth[py, px]) if mode == "lt" else (z <= depth[py, px]):
depth[py, px] = z
for ch in range(3):
color[py, px, ch] = (w0 * c0[ch] + w1 * c1[ch] + w2 * c2[ch]) // area
return color
def main():
with open(os.path.join(ROOT, "config", "cg_raster_06_zbuffer.json"), encoding="utf-8") as fh:
spec = json.load(fh)
W, H, bg = spec["width"], spec["height"], tuple(spec["backgroundQ8"])
tris = [tuple(v for k in range(3) for v in (t["xy"][k][0], t["xy"][k][1], t["d"][k], *t["c"][k]))
for t in spec["triangles"]]
fwd, fdepth, fcov, fcull = cglib.rasterize(tris, W, H, bg)
rev = cglib.rasterize(list(reversed(tris)), W, H, bg)[0]
diff = int(np.any(fwd != rev, axis=2).sum())
finite = fdepth[fdepth != np.iinfo(np.int64).max]
out = os.path.join(ROOT, "out")
png = cglib.save_png(os.path.join(out, "cg_raster_06_3_depth.png"), fwd)
cglib.save_png(os.path.join(out, "cg_raster_06_3_reversed.png"), rev)
cglib.save_gray_png(os.path.join(out, "cg_raster_06_3_depthmap.png"), fdepth)
with open(os.path.join(out, "cg_raster_06_3_depth.rgb"), "wb") as fh:
fh.write(np.clip(fwd, 0, 255).astype(np.uint8).tobytes())
# 微用例:正方形沿对角线 y=x 切开,两三角形等深(dq=100)
Q, R = (HALF, HALF), (HALF + 6 * SUB1, HALF)
S, T = (HALF + 6 * SUB1, HALF + 6 * SUB1), (HALF, HALF + 6 * SUB1)
dq, red, blue = 100, (220, 60, 60), (60, 140, 220)
mt = lambda a, b, c, col: tuple(v for p in (a, b, c) for v in (p[0], p[1], dq, *col)) # noqa: E731
t_red, t_blue = mt(Q, R, S, red), mt(Q, S, T, blue)
tie = [(px, py) for py in range(7) for px in range(7)
if (S[0] - Q[0]) * (py * SUB1 + HALF - Q[1]) - (S[1] - Q[1]) * (px * SUB1 + HALF - Q[0]) == 0]
first = lambda m: [int(v) for v in m[tie[0][1], tie[0][0]]] # noqa: E731
lt_f, lt_r = micro([t_red, t_blue], "lt"), micro([t_blue, t_red], "lt")
le_f, le_r = micro([t_red, t_blue], "le"), micro([t_blue, t_red], "le")
micro_stat = {"tie_pixels": len(tie), "lt_forward": first(lt_f), "lt_reversed": first(lt_r),
"le_forward": first(le_f), "le_reversed": first(le_r),
"lt_forward_equals_le_reversed": bool(np.array_equal(lt_f, le_r)),
"lt_reversed_equals_le_forward": bool(np.array_equal(lt_r, le_f))}
with open(os.path.join(out, "cg_raster_06_3_depth.json"), "w", encoding="utf-8") as fh:
json.dump({"canvas": [W, H], "triangles": len(tris), "culled": fcull, "covered": int(fcov.sum()),
"depth_min": int(finite.min()), "depth_max": int(finite.max()), "one": cglib.ONE,
"order_diff_pixels": diff, "red": list(red), "blue": list(blue),
"micro": micro_stat, "png_bytes": png}, fh, ensure_ascii=False, indent=2)
print(f"scene : {len(tris)} 三角形, culled={fcull}, covered={int(fcov.sum())} "
f"({int(fcov.sum())*1000//(W*H)}‰)")
print(f"深度范围 : [{int(finite.min())}, {int(finite.max())}] (ONE={cglib.ONE}) 全落在 [0, ONE]")
print(f"正向 vs 反向 : {diff} 像素颜色不同 <- 严格小于下共享边归属由顺序决定")
print(f"微用例(共享对角线等深,红=下三角,蓝=上三角;共享边 {len(tie)} 像素):")
print(f" < 正向 首像素色 {first(lt_f)}(先画的红赢) < 反向 {first(lt_r)}(先画的蓝赢)")
print(f" <= 正向 首像素色 {first(le_f)}(后画的蓝赢) <= 反向 {first(le_r)}(后画的红赢)")
print(f" <正向 == <=反向 : {micro_stat['lt_forward_equals_le_reversed']}")
print(f"png : {png} bytes")
if __name__ == "__main__":
main()
实测输出:
scene : 76 三角形, culled=0, covered=2240 (729‰)
深度范围 : [440, 42862] (ONE=65536) 全落在 [0, ONE]
正向 vs 反向 : 0 像素颜色不同 <- 严格小于下共享边归属由顺序决定
微用例(共享对角线等深,红=下三角,蓝=上三角;共享边 7 像素):
< 正向 首像素色 [220, 60, 60](先画的红赢) < 反向 [60, 140, 220](先画的蓝赢)
<= 正向 首像素色 [60, 140, 220](后画的蓝赢) <= 反向 [220, 60, 60](后画的红赢)
<正向 == <=反向 : True
png : 686 bytes
同一场景的第三种宿主:WebGL2
S 层用 CPU 定点内核渲染,W 层把同一份 config/cg_raster_06_zbuffer.json 交给浏览器里的 WebGL2。顶点着色器不做 MVP 变换,只把 S 层算好的屏幕 Q6 坐标与深度线性映射到 NDC(vec2 ndc = vec2(px.x / uW * 2.0 - 1.0, 1.0 - px.y / uH * 2.0),深度 z = aDepthQ16 / 65536.0 * 2.0 - 1.0)——于是两边的顶点逐位相同,像素差异纯粹来自光栅化规则与插值精度。
三个细节都踩过坑:GLSL 源码用 join("\n") 拼装(写成 join("\\n") 会把字面量 \n 灌进着色器);颜色必须 vColor = aColor / 255.0 归一到 $[0,1]$;canvasDrawn 必须推迟到两次 requestAnimationFrame 之后,否则冷启动首个页面读 canvas 恒空白。深度测试用 gl.depthFunc(gl.LESS)、剔除用 gl.frontFace(gl.CW),与 S 层约定对齐。
W 层判据不追求逐位相等——GPU 的 top-left rule 与浮点插值本就与 CPU 不同。它只用对舍入不敏感的统计量,并把「覆盖归属差异」单独设上限,且必须配一个负例证明判据有区分力。
// file: lantern/web/verify_raster06.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_raster06.mjs
// 卷一 ch6 的 W 层验证:WebGL2 与 CPU 定点内核在**同一份深度阶梯场景**下的对照。
//
// 判据口径(为什么不能用「逐位相等」):GPU 的光栅化规则与 CPU 不同 —— top-left rule
// 决定共享边像素归属、浮点插值与舍入方式不同。故只用对舍入不敏感的统计量,并把
// 「覆盖归属差异」单独设上限;判据必须配负例,否则 PASS 不可信。
// 本机 headless 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU,差异不得归因于真实硬件。
import { createRequire } from "node:module";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const OUT = join(ROOT, "out");
mkdirSync(join(OUT, "shots"), { recursive: true });
const BG = JSON.parse(readFileSync(join(ROOT, "config", "cg_raster_06_zbuffer.json"), "utf8")).backgroundQ8;
const golden = new Uint8Array(readFileSync(join(OUT, "cg_raster_06_3_depth.rgb")));
const covered = (rgb) => {
const mask = new Uint8Array(rgb.length / 3);
for (let p = 0; p < mask.length; p += 1) {
const o = p * 3;
if (rgb[o] !== BG[0] || rgb[o + 1] !== BG[1] || rgb[o + 2] !== BG[2]) mask[p] = 1;
}
return mask;
};
function stats(actual, expect, acov, ecov) {
let diffPx = 0;
let coverDiff = 0;
let maxDiff = 0;
let sumAbs = 0;
let sumSq = 0;
const pixels = expect.length / 3;
for (let i = 0; i < expect.length; i += 3) {
const p = i / 3;
let pxDiff = 0;
for (let c = 0; c < 3; c += 1) {
const d = Math.abs(actual[i + c] - expect[i + c]);
if (d > pxDiff) pxDiff = d;
sumAbs += d;
sumSq += d * d;
}
if (pxDiff > 0) diffPx += 1;
if (pxDiff > maxDiff) maxDiff = pxDiff;
if (acov[p] !== ecov[p]) coverDiff += 1;
}
const mse = sumSq / expect.length;
return { pixels, diffPx, coverDiff, maxDiff, mae: sumAbs / expect.length,
psnr: mse === 0 ? Infinity : 10 * Math.log10((255 * 255) / mse) };
}
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`file://${join(ROOT, "web", "dist", "raster06.html")}`);
await page.waitForFunction(() => globalThis.__lantern && globalThis.__lantern.canvasDrawn, null, { timeout: 20000 });
const data = await page.evaluate(() => globalThis.__lantern);
const shot = join(OUT, "shots", "raster06_gl.png");
await page.screenshot({ path: shot });
await browser.close();
const cpu = Uint8Array.from(data.cpu.rgb);
const gl = Uint8Array.from(data.gl.rgb);
const glBad = Uint8Array.from(data.glBad.rgb);
const cpuCov = covered(cpu);
const glCov = covered(gl);
let cpuVsS = 0;
for (let i = 0; i < cpu.length; i += 1) if (cpu[i] !== golden[i]) cpuVsS += 1;
const glStats = stats(gl, cpu, glCov, cpuCov);
const badStats = stats(glBad, cpu, covered(glBad), cpuCov);
console.log(`canvasDrawn : ${data.canvasDrawn}`);
console.log(`场景 : ${data.meta.width}x${data.meta.height} / ${data.meta.triangles} 三角形`);
console.log(`CPU covered : ${data.cpu.covered}(culled ${data.cpu.culled}) W 层 covered : ${glCov.reduce((a, b) => a + b, 0)}`);
console.log(`GL 后端 : ${data.gl.unmaskedRenderer}`);
console.log(`GL 版本 : ${data.gl.version} glError : ${data.gl.glError}`);
console.log(`① 页面内 CPU 内核 vs S 层缓冲:不同字节 = ${cpuVsS} ← 必须为 0`);
console.log(`② CPU 定点内核 vs WebGL2:`);
console.log(` 任一通道差>0 : ${glStats.diffPx} / ${glStats.pixels} (${(glStats.diffPx / glStats.pixels * 1000).toFixed(2)}‰) ← 含 ±1 舍入,不作判据`);
console.log(` 覆盖归属不同 : ${glStats.coverDiff} / ${glStats.pixels} (${(glStats.coverDiff / glStats.pixels * 1000).toFixed(2)}‰) ← 主判据`);
console.log(` 最大通道差 : ${glStats.maxDiff}`);
console.log(` MAE : ${glStats.mae.toFixed(4)}`);
console.log(` PSNR : ${glStats.psnr.toFixed(2)} dB`);
console.log(` 截图 : ${shot}`);
console.log(`③ 负例(片元颜色 ×0.5): MAE=${badStats.mae.toFixed(4)} PSNR=${badStats.psnr.toFixed(2)} dB ← 必须被同一判据拒绝`);
const fail = [];
if (!data.gl.ok) fail.push("② WebGL2 上下文不可用");
if (cpuVsS !== 0) fail.push(`① 页面内 CPU 与 S 层缓冲不同(${cpuVsS} 字节)`);
if (glStats.mae > 1.0) fail.push(`② MAE ${glStats.mae.toFixed(4)} 超出 1.0`);
if (glStats.psnr < 30) fail.push(`② PSNR ${glStats.psnr.toFixed(2)} dB 低于 30 dB`);
if (glStats.coverDiff / glStats.pixels * 1000 > 20) fail.push("② 覆盖归属差异超出 20‰");
if (badStats.mae <= 1.0 && badStats.psnr >= 30) fail.push("③ 负例未被判据拒绝 —— 判据无区分力");
if (fail.length) {
console.log(`\nFAIL:\n ${fail.join("\n ")}`);
process.exit(1);
}
console.log("\nPASS");
实测输出:
canvasDrawn : true
场景 : 64x48 / 76 三角形
CPU covered : 2240(culled 0) W 层 covered : 2240
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
GL 版本 : WebGL 2.0 (OpenGL ES 3.0 Chromium) glError : 0
① 页面内 CPU 内核 vs S 层缓冲:不同字节 = 0 ← 必须为 0
② CPU 定点内核 vs WebGL2:
任一通道差>0 : 2056 / 3072 (669.27‰) ← 含 ±1 舍入,不作判据
覆盖归属不同 : 0 / 3072 (0.00‰) ← 主判据
最大通道差 : 1
MAE : 0.4076
PSNR : 52.03 dB
③ 负例(片元颜色 ×0.5): MAE=43.9439 PSNR=13.00 dB ← 必须被同一判据拒绝
PASS
读这份输出:
- ① 页面内 CPU 内核 vs S 层缓冲 = 0 字节不同。浏览器里跑的
raster.ts与 S 层共享同一份定点实现,逐字节一致——这是 S / P / W 三层同源的证据。 - ② 覆盖归属差异 = 0.00‰。主判据满分:GPU 与 CPU 认领了完全相同的像素集合。任一通道差 669.27‰ 看似很大,但最大通道差只有 1,全部是 ±1 的舍入,不作判据。MAE 0.4076、PSNR 52.03 dB 都远优于阈值(1.0 / 30 dB)。
- ③ 负例被拒绝。把片元颜色乘 0.5 后,MAE 飙到 43.9439、PSNR 掉到 13.00 dB,同一套阈值把它判死。没有这个负例,②的 PASS 就没有意义——判据可能是恒真的。
最后一条纪律:本机 headless 的 WebGL2 后端是 ANGLE ... SwiftShader 的软件实现,不是真实 GPU。上面所有差异都是软件光栅化器与 CPU 内核的差异,不得归因于真实硬件。要谈真实 GPU 行为,必须在真实设备上复跑。
下一节换一条线:从覆盖判定回到最基础的图元——怎么把一个像素宽的线段画到整数网格上。
阅读导航:上一节:6.2 重心坐标与插值 · 下一节:7.1 DDA 与 Bresenham 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。