14.2 覆盖率与线框
14.1 把深度与法线变成了两张能读的图。这一节处理剩下两张:覆盖率与线框。它们回答的是另一类问题——不是「这个像素有多远、朝哪边」,而是「这个像素被画了几次、三角形的边落在哪」。站内的 GPU 剖析与调试 里,覆盖统计是判断 overdraw 与填充率瓶颈的常用抓手;本节关心的是这份统计本身怎么算得可断言——它是整数,所以两条不变量就能把它钉死,不必靠肉眼看图。
二值覆盖不够用
cglib.rasterize 返回的覆盖掩码是二值的:每个像素记 0 或 1。这对渲染够用,对调试不够。二值覆盖回答不了的问题包括:
- 一个像素被几个三角形覆盖过——这是 overdraw 的直接度量;
- 共享边有没有被两个三角形同时算进去,还是被恰好算了一次;
- 退化三角形(面积为零或近乎为零)留下了什么痕迹;
- 同一次覆盖里,是哪个三角形最终赢了深度测试。
这些问题都需要计数而不只是布尔值。所以覆盖率图存的是整数:每个像素记它被多少个正面三角形覆盖过。计数内核沿用同一套约定——area > 0 才算正面、三个边函数均 $\ge 0$ 才算覆盖、像素中心取 px * 64 + 32——但它累加的是次数而不是布尔值。
要特别注意的是「覆盖」与「最终写入」是两回事。深度测试决定哪个三角形赢,覆盖率统计的是有多少个三角形参与了竞争。一个被三个三角形覆盖的像素,最终只写了一次颜色,但它的 overdraw 是 3。调试视图要的正是后者。
这也是为什么覆盖率图必须与成片分开存:成片是「竞争的结果」,覆盖率是「竞争的规模」,两者各自回答不同的问题。把它们混进同一张图,就再也分不清「这个像素颜色怪」是因为谁赢了、还是因为参与的人太多。
两条不变量
计数一旦是整数,就能被两条不变量钉住:
- 双重计数:所有像素的计数之和,必须等于每个三角形自己覆盖数之和(实测 $47673 = 47673$)。前者是像素侧、后者是三角形侧,两条路径完全独立——像素侧累加的是二维数组,三角形侧累加的是逐个三角形的标量。
- 二值化后回到基线:计数 $> 0$ 的像素集合必须与
cglib.rasterize的二值掩码逐像素相同,总数 43731——也就是第 6 章那条卷一基线。
第二条尤其重要:它把「新写的计数内核」和「已被反复验证的二值内核」绑在一起。计数内核任何一处口径写错(比如像素中心取成 px * 64 而不是 px * 64 + 32),这条立刻会红。两条不变量一条管总量、一条管集合,合起来覆盖了计数内核的全部自由度。
实跑的直方图(次数 → 像素数):
画布 : 320x240,正面 606 三角形(剔除 290 背面)
不变量(1) 双重计数 : 像素侧 47673 == 三角形侧 47673
不变量(2) 二值覆盖 : 43731(== 卷一基线 43731)
计数直方图 : 0次:33069px 1次:40193px 2次:3141px 3次:390px 4次:7px
最大计数 : 4(重叠像素 3538 个,占覆盖像素 80‰)
把直方图展开成表,占比分别按全画布 76800 与覆盖区 43731 算:
| 覆盖次数 | 像素数 | 占全画布 | 占覆盖区 |
|---|---|---|---|
| 0(未覆盖) | 33069 | 431‰ | — |
| 1 | 40193 | 523‰ | 919‰ |
| 2 | 3141 | 41‰ | 72‰ |
| 3 | 390 | 5‰ | 9‰ |
| 4 | 7 | 0.09‰ | 0.16‰ |
$40193$ 这个数说明绝大多数覆盖像素只被画了一次;重叠的 3538 个像素占覆盖区 80‰,全部集中在少数几处几何交叠的位置——这正是 overdraw 的分布形态:它不平均,而是集中在交叠处。
直方图里还藏着一个容易忽略的事实:$33069 + 40193 + 3141 + 390 + 7 = 76800 = 320 \times 240$,五个桶恰好铺满画布。这是「计数」相对「二值掩码」的另一个好处——它把未覆盖像素也纳入了分布,而二值掩码只告诉你「有多少像素被覆盖」,对「多少像素没被覆盖」要靠减法。
S 层:计数内核 + 线框
下面的脚本跑完两条不变量、打直方图,再用第 7 章的整数 Bresenham 画线框,最后导出三张 PNG。整个脚本只依赖 cg_raster_14_buffers(三张缓冲的共享引擎)与第 7 章的直线模块,不含任何新的光栅化逻辑——覆盖率与线框都是从同一份 rtris(已投影、已组装的光栅化输入)派生的。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_14_2_coverage_wireframe.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_14_2_coverage_wireframe.py
"""14.2 覆盖率与线框:整数覆盖计数、双重计数不变量、第 7 章 Bresenham 画的线框叠加。
二值覆盖掩码只能回答「这个像素被画过吗」;调试时需要的是「被画了几次」——
共享边、T 型接缝、退化三角形的病征全藏在计数值里。故覆盖率图存的是整数计数,
两条不变量把它钉住:
(1) 像素侧求和 == 三角形侧求和(双重计数);
(2) 计数 > 0 的像素集合 == cglib.rasterize 的二值掩码,且总数 == 第 6 章的基线 43731。
线框用第 7 章的整数 Bresenham 画每个正面三角形的三条边,叠加在成片之上。
产物:out/cg_raster_14_2_{coverage,wireframe,overlay}.png 与 .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
import cg_raster_14_buffers as B # noqa: E402
BASELINE_COVERED = 43731 # 卷一基线:sim/render_raster.py 的《烛影山谷》覆盖像素数
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def main():
spec = cglib.load_spec()
w, h = spec["width"], spec["height"]
b = B.buffers(spec)
counts, covered, per_tri = b["counts"], b["covered"], b["per_tri"]
# 不变量 (1):像素侧求和 == 三角形侧求和
pixel_side = int(counts.sum())
tri_side = int(sum(per_tri))
assert pixel_side == tri_side, "像素侧与三角形侧的覆盖计数必须相等(双重计数)"
# 不变量 (2):二值化后与基线一致
binary = int((counts > 0).sum())
assert binary == int(covered.sum()) == BASELINE_COVERED, "二值覆盖必须等于卷一基线 43731"
assert int(counts.min()) == 0 and int(counts.max()) >= 2, "必须有未覆盖像素与至少一处重叠"
# 计数直方图(count -> 像素数)
hist = np.bincount(counts.ravel())
hist = [(int(k), int(v)) for k, v in enumerate(hist) if v]
# 线框:第 7 章的整数 Bresenham,只画正面三角形的边
mask, segs = B.wireframe_mask(b["rtris"], w, h)
wf_px = int(mask.sum())
inside = int((mask.astype(bool) & covered).sum())
out = os.path.join(ROOT, "out")
cov_gray = np.clip(counts * 64, 0, 255).astype(np.uint8)
png_cov = cglib.save_gray_png(os.path.join(out, "cg_raster_14_2_coverage.png"), cov_gray.astype(np.int64))
wf_gray = (mask * 255).astype(np.uint8)
png_wf = cglib.save_gray_png(os.path.join(out, "cg_raster_14_2_wireframe.png"), wf_gray.astype(np.int64))
overlay = np.clip(b["color"], 0, 255).astype(np.uint8).copy()
overlay[mask == 1] = (255, 255, 255)
png_ov = cglib.save_png(os.path.join(out, "cg_raster_14_2_overlay.png"), overlay.astype(np.int64))
stat = {"canvas": [w, h], "covered_binary": binary, "baseline": BASELINE_COVERED,
"pixel_side_sum": pixel_side, "tri_side_sum": tri_side,
"triangles_drawn": len(per_tri), "triangles_culled": b["culled"],
"max_count": int(counts.max()), "histogram": hist,
"wireframe_pixels": wf_px, "wireframe_segments": segs,
"wireframe_inside": inside, "wireframe_inside_permille": inside * 1000 // max(1, wf_px),
"png_bytes": {"coverage": png_cov, "wireframe": png_wf, "overlay": png_ov}}
with open(os.path.join(out, "cg_raster_14_2_coverage_wireframe.json"), "w", encoding="utf-8") as fh:
json.dump(stat, fh, ensure_ascii=False, indent=2)
print(f"画布 : {w}x{h},正面 {len(per_tri)} 三角形(剔除 {b['culled']} 背面)")
print(f"不变量(1) 双重计数 : 像素侧 {pixel_side} == 三角形侧 {tri_side}")
print(f"不变量(2) 二值覆盖 : {binary}(== 卷一基线 {BASELINE_COVERED})")
print(f"计数直方图 : " + " ".join(f"{k}次:{v}px" for k, v in hist))
print(f"最大计数 : {int(counts.max())}(重叠像素 {int((counts > 1).sum())} 个,"
f"占覆盖像素 {int((counts > 1).sum()) * 1000 // binary}‰)")
print(f"线框 : {segs} 条边,落笔 {wf_px} 像素,其中 {inside} 落在覆盖区内"
f"({inside * 1000 // max(1, wf_px)}‰)")
print(f"产物 : coverage {png_cov}B / wireframe {png_wf}B / overlay {png_ov}B")
return 0
if __name__ == "__main__":
sys.exit(main())
coverage 视图把计数 $\times 64$ 压进 8 位(0/1/2/3/4 → 0/64/128/192/255),故四种计数在图上分得开;overlay 是成片叠白色线框。乘 64 而不是乘 51($255/5$)是有意的:$\times 64$ 是移位,整数运算里零成本,而 4 次覆盖恰好落在 255,正好占满 8 位——多一次覆盖就会溢出,但本场景最大计数就是 4,正好卡在边界上。
线框:只描正面三角形
线框复用第 7 章的整数 Bresenham,两个细节值得单独说:
- 只画正面三角形。背面已被剔除,把它的边也描出来等于把看不见的几何画在图上——调试视图最容易犯的错就是「多画了一点」,因为它看起来更完整。
- 屏幕 Q6 到像素的取整用
(v + 32) >> 6,与第 7 章 DDA 的+32768半像素上偏置同口径。整数直线光栅化对取整方式极其敏感,两处口径必须一致,否则线框会整体偏移半个像素。
线框还有一个隐含选择:画不画共享边。相邻三角形共享的边会被画两次(每个三角形各画一次),但在掩码上只留一个像素。这不是 bug——线框本来就是「三角形的边集」,共享边画两次与画一次在掩码上等价,只在落笔计数上有差别。本节报的 1818 条边是「正面三角形数 × 3」,即每条共享边都被算了两次。
实测:606 个正面三角形给出 1818 条边($606 \times 3$ ✓),落笔 8477 个像素,其中 8423 个落在覆盖区内(993‰)。剩下 54 个像素在覆盖区外,是边线取整与覆盖判据在边界上的必然错位——整数 Bresenham 取的是「离理想直线最近的像素」,覆盖判据取的是「像素中心是否在三角形内」,两者在边界上不必重合。这 54 个像素不该被当成错误抹掉:它们是两条不同判据的真实差集,抹掉反而掩盖了取整口径。
换一个角度:8423/8477 = 993‰ 这个比例本身也是一条哨兵。如果哪天线框的实现被改成浮点 DDA,或者取整偏置被去掉,这个比例会立刻从 993‰ 掉到九成出头——因为线框会整体偏移,落到覆盖区外的像素成倍增加。所以「落笔像素的覆盖内占比」可以当作线框实现是否与第 7 章同口径的廉价检查。
W 层:让 GPU 独立算一遍覆盖率
覆盖率通道要的不是深度测试,是加法:关掉深度测试、打开加法混合 blendFunc(ONE, ONE),每个覆盖像素的片元往 R 通道加 $1/255$,回读后 R 通道就是整数计数。注意加的是 $1/255$ 而不是 1.0:混合在归一化浮点空间做,存回 8 位正好是一个整数单位,加 1.0 会立刻饱和到 255。
# snippet: 覆盖率通道:关掉深度测试 + 加法混合,每次覆盖加 1/255(摘自 lantern/web/build_raster14.mjs)
const FRAG_COUNT = `#version 300 es
precision highp float;
out vec4 o;
void main() { o = vec4(1.0 / 255.0, 0.0, 0.0, 0.0); }`;
// pass() 内:加法通道开混合、普通通道关掉
if (o.additive) { gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); } else { gl.disable(gl.BLEND); }
...
const cnt1 = flip(pass({ pr: P.count, depthTest: false, fbo: true, additive: true }), 1);
这一趟必须关掉深度测试。若开着深度测试,只有最前面的三角形能通过,计数永远只到 1,overdraw 就统计不出来。这与主渲染路径的差别正是「调试视图需要放宽渲染状态」的典型例子:同一份几何、同一套顶点缓冲,只是渲染状态不同,产出的却是完全不同的一张图。
web/verify_raster14.mjs 把 GPU 回读的计数与 S 层的整数缓冲逐像素比,同时把两条负例(深度映射方向反了、法线未归一化)也塞进同一批断言:
# snippet: 覆盖率与负例判据:GPU 计数 vs S 层整数缓冲;反例必须被拒绝(摘自 lantern/web/verify_raster14.mjs)
let cSum = 0, sSum = 0, cMax = 0, cDiffPx = 0;
for (let p = 0; p < PX; p += 1) {
cSum += gl.cnt[p];
sSum += sCounts[p];
if (gl.cnt[p] !== sCounts[p]) cDiffPx += 1;
if (gl.cnt[p] > cMax) cMax = gl.cnt[p];
}
...
if (!posUp) fail.push("① 正向深度映射不是非降于距离");
if (revUp) fail.push("① 负例(映射方向反了)未被单调性判据拒绝");
if (Math.abs(cSum - sSum) / sSum * 1000 > 20) fail.push("③ 覆盖率计数和差异超出 20‰");
if (rBad === 0) fail.push("② 负例(法线未归一化)未被模长判据拒绝 —— 判据无区分力");
实跑(NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_raster14.mjs):
③ 覆盖率 : GPU 和 47659 vs S 层 47673(差 -0.29‰);最大 4 vs 4
计数不同像素 : 40 / 76800
② 法线图 : 8 位通道差 MAE 0.6696 / PSNR 44.33 dB / 最大 246 ← 判据:MAE <= 1.0 且 PSNR >= 30
负例(×2) : 模长 [65537, 113512],越界上 2310/43729 px,被拒绝 = true
① 深度 Q16.16 : 覆盖 43729 vs S 层 43731(掩码不同 6 px)
单调性 : 正向非降 = true;反向映射被拒绝 = true(271 段均值口径)
把覆盖率的三项数字并排看:
| 量 | GPU 回读 | S 层缓冲 | 差 |
|---|---|---|---|
| 计数总和 | 47659 | 47673 | -14(-0.29‰) |
| 最大计数 | 4 | 4 | 0 |
| 计数不同像素 | 40 | — | 40 / 76800 |
GPU 侧计数总和 47659 比 S 层的 47673 少 14,最大计数一致(4),计数不同的像素只有 40/76800。差异的方向是有讲头的:GPU 的 top-left 规则保证共享边像素恰好归属一个三角形,而 CPU 的 $w_0, w_1, w_2 \ge 0$ 判据会把共享边同时算给两个三角形——所以 CPU 侧偏大。这正是 13.2 讨论过的口径差,只是这次它出现在计数上而不是掩码上。
差异只有 14(-0.29‰),远小于判据的 20‰ 门槛,但方向可解释、量级可预期,不是「碰巧对上」。40 个计数不同的像素全部落在共享边上——数量级与「共享边像素数」一致,也印证了这个归因。
两条负例都被拒绝,说明这批判据有区分力:越界上 2310/43729 px 与 反向映射被拒绝 = true 不是自动成立的,是断言真的在拦。如果哪天有人把深度映射写成反向、或把顶点法线忘了归一化,这两条会立刻把构建打红,而不是安静地画出一张「看起来也还行」的图。
下一节把四张图收进一个统一导出器,并说明一份调试产物应该长什么样。
阅读导航:上一节:14.1 深度图与法线图 · 下一节:14.3 中间缓冲的可视化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。