14.3 中间缓冲的可视化
14.1 与 14.2 把深度、法线、覆盖率、线框四张图各自算了出来。但如果每张图都写一段自己的导出代码——这个用 cglib.save_gray_png、那个手搓 PNG、另一个直接 np.save——那这套调试视图就只是四段一次性脚本,改一处口径要改四处,也没法被机器核对。本节把它们收进一个导出器:同一条导出路径、同一套断言、一份可核对的清单。站内的 GPU 剖析与调试
关心的是「从哪里取到这些缓冲」,本节关心的是「取到之后怎么把一份调试产物做得可复现、可核对」。
一张图回答不了一个问题
调试渲染时最常见的低效不是「看不到图」,而是「看到一张图却判断不了对不对」。一张成片里的绿色可能是「法线朝上」,也可能是「材质本来就是绿的」;一片亮斑可能是「计数高」,也可能是「深度近」。单张图不带元数据,就永远在猜。
所以一份合格的调试产物不是「一张 PNG」,而是一组同名、可交叉核对的文件:图 + 参数 + 校验值。图给人看,参数给下一次复现用,校验值给机器核对用。三者缺一,这份产物就退回成「截图」。
调试产物的四条标准
- 无损:写出的 PNG 回读回来,像素必须与内存缓冲逐点相同(PNG 是无损格式,所以这里该是等号,不是「近似」)。
- 可复现:映射参数(波段数、级数、距离/深度边界)必须随图一起存,否则同一个缓冲换个参数就画出另一张图。
- 可核对:每张图带一份 sha256,跨机器、跨运行比对时不必再比像素。
- 自包含:联系表要把图内嵌进去,而不是引用外部文件——一份产物应该能单独被打开、被传递、被归档。
第 1 条和第 3 条容易混淆:回读断言管的是「写盘没写坏」,sha256 管的是「两次运行产出的字节是否相同」。前者是单次运行内的自洽,后者是跨运行的一致性。
联系表:为什么用 SVG 而不是拼大图
把四张图拼成一张大图有两种做法:用 PIL 拼成位图,或者用 SVG 做联系表。本节选后者,理由是位图拼接会引入一次重采样风险——如果哪天有人把图放大了再拼,像素就悄悄变了,而回读断言查不出来(它只查单张 PNG,不查拼好的大图)。
SVG 联系表把每张 PNG 以 data:image/png;base64,... 内嵌进 <image href>,浏览器打开时是原样解码,不经任何重采样。标题用 <text> 写,也不依赖任何字体文件。整张 SVG 用纯 xml.etree 生成——不引第三方 SVG 库,避免多一个依赖。
有一个 xml.etree 的坑值得记住:ET.register_namespace("", SVG) 之后不能再显式给根元素设 xmlns 属性,否则序列化时会输出两个 xmlns,ET.fromstring 回读立刻报 duplicate attribute。命名空间注册与属性声明是两条路径,只能走一条。
S 层:统一导出器
下面这个脚本是四张图的唯一出口:views() 从同一份 buffers() 结果派生出四个视图,write_png() 统一写盘并回读,contact_sheet() 统一拼 SVG,最后落一份含全部 sha256 与映射参数的清单 JSON。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_14_3_export.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_14_3_export.py
"""14.3 中间缓冲的可视化:一个统一导出器 + 一张联系表 + 一份可核对的清单。
三张调试缓冲都来自同一次 buffers() 调用,导出也走同一条路径:每个视图产出一张
8 位 PNG(灰度或 RGB)、一份 sha256、一条「回读像素 == 内存缓冲」的断言;最后拼成
一张 SVG 联系表 —— 用纯 xml.etree 生成、PNG 以 base64 data URI 内嵌、再用
ET.fromstring 回读校验结构与内嵌字节。清单 JSON 里连映射参数一起存,便于事后复现。
为什么不直接用 cglib.save_gray_png:那个函数会按缓冲自身的最值做自动对比度,
而这里每个视图的灰度**已经在 S 层算好了**(14.1 的分段映射、计数 ×64),
再归一化一次就把调试视图的口径改掉了。
产物:out/cg_raster_14_3_{depth,normal,coverage,wireframe}.png、
out/cg_raster_14_3_contact.svg、out/cg_raster_14_3_export.json。
"""
import base64
import hashlib
import io
import json
import os
import sys
import xml.etree.ElementTree as ET
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cg_raster_14_buffers as B # noqa: E402
SVG = "http://www.w3.org/2000/svg"
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ET.register_namespace("", SVG)
def views(b, spec):
"""四个视图:深度 / 法线 / 覆盖率 / 线框叠加 —— 都从同一份缓冲派生。"""
w, h = spec["width"], spec["height"]
near, far = spec["camera"]["nearQ16"], spec["camera"]["farQ16"]
cov = b["covered"]
band, edges_d, edges_z = B.gray_piecewise(b["depth"], cov, near, far)
nrm = np.zeros((h, w, 3), dtype=np.uint8)
nrm[cov] = B.encode_normal(b["normal"][cov])
counts = np.clip(b["counts"] * 64, 0, 255).astype(np.uint8)
wf, segs = B.wireframe_mask(b["rtris"], w, h)
overlay = np.clip(b["color"], 0, 255).astype(np.uint8).copy()
overlay[wf == 1] = (255, 255, 255)
return [("深度:按距离分段 8x32", "depth", band, "L"),
("法线:插值后编码 RGB", "normal", nrm, "RGB"),
("覆盖率:整数计数 x64", "coverage", counts, "L"),
("线框叠加:第 7 章 Bresenham", "wireframe", overlay, "RGB")], segs
def write_png(path, arr, mode):
"""写 PNG 并立刻用 PIL 回读:像素必须与内存缓冲逐点相同(PNG 无损,故是等号)。"""
Image.fromarray(arr, mode).save(path)
data = open(path, "rb").read()
back = np.asarray(Image.open(io.BytesIO(data)).convert(mode))
assert back.shape == arr.shape and np.array_equal(back, arr), f"{os.path.basename(path)} 回读不一致"
return data, hashlib.sha256(data).hexdigest()
def contact_sheet(cells, w, h, cols=2, pad=10, bar=20):
"""纯 xml.etree 生成的 SVG 联系表:每格一张内嵌 PNG + 一行标题。"""
rows = (len(cells) + cols - 1) // cols
root = ET.Element(f"{{{SVG}}}svg", {
"width": str(cols * (w + pad) + pad), "height": str(rows * (h + bar + pad) + pad),
"viewBox": f"0 0 {cols * (w + pad) + pad} {rows * (h + bar + pad) + pad}",
"style": "background:#0f172a"})
for i, (title, name, arr, mode) in enumerate(cells):
x = pad + (i % cols) * (w + pad)
y = pad + (i // cols) * (h + bar + pad)
ET.SubElement(root, f"{{{SVG}}}text", {"x": str(x), "y": str(y + 14),
"fill": "#e2e8f0", "font-size": "13"}).text = title
ET.SubElement(root, f"{{{SVG}}}image", {
"x": str(x), "y": str(y + bar), "width": str(w), "height": str(h),
"href": "data:image/png;base64," + base64.b64encode(cells_data[name][0]).decode()})
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
cells_data = {}
def main():
spec = cglib.load_spec()
w, h = spec["width"], spec["height"]
b = B.buffers(spec)
cells, segs = views(b, spec)
out = os.path.join(ROOT, "out")
views_stat = []
for title, name, arr, mode in cells:
path = os.path.join(out, f"cg_raster_14_3_{name}.png")
data, digest = write_png(path, arr, mode)
cells_data[name] = (data, digest)
views_stat.append({"view": name, "mode": mode, "bytes": len(data), "sha256": digest,
"nonzero": int((arr != 0).sum() if arr.ndim == 2 else (arr.sum(axis=2) != 0).sum())})
svg_path = os.path.join(out, "cg_raster_14_3_contact.svg")
svg = contact_sheet(cells, w, h)
with open(svg_path, "wb") as fh:
fh.write(svg)
back = ET.fromstring(svg)
imgs = back.findall(f".//{{{SVG}}}image")
assert len(imgs) == len(cells) == 4, "联系表必须正好内嵌 4 张图"
for el, (_, name, _, _) in zip(imgs, cells):
raw = base64.b64decode(el.get("href").split(",", 1)[1])
assert raw == cells_data[name][0], f"内嵌的 {name} 字节与导出的 PNG 不一致"
_, edges_d, edges_z = B.gray_piecewise(b["depth"], b["covered"], spec["camera"]["nearQ16"], spec["camera"]["farQ16"])
stat = {"canvas": [w, h], "covered": int(b["covered"].sum()), "wireframe_segments": segs,
"mapping": {"bands": 8, "levels": 32, "near_q16": spec["camera"]["nearQ16"],
"far_q16": spec["camera"]["farQ16"],
"band_edges_dist_q16": [int(x) for x in edges_d],
"band_edges_depth_q16": [int(x) for x in edges_z]},
"views": views_stat,
"svg": {"bytes": len(svg), "sha256": hashlib.sha256(svg).hexdigest(), "cells": len(imgs)},
"assertions": {"png_roundtrip": True, "svg_roundtrip": True,
"svg_cells": len(imgs), "embedded_bytes_match": True}}
with open(os.path.join(out, "cg_raster_14_3_export.json"), "w", encoding="utf-8") as fh:
json.dump(stat, fh, ensure_ascii=False, indent=2)
print(f"视图 : {len(cells)} 个,全部回读像素与内存缓冲逐点相同")
for v in views_stat:
print(f" {v['view']:9s} {v['mode']:3s} {v['bytes']:7d}B 非零像素 {v['nonzero']:6d} sha256 {v['sha256'][:16]}")
print(f"联系表 : {len(svg)}B 2x2 内嵌 {len(imgs)} 张,ET.fromstring 回读通过,内嵌字节逐张相同")
print(f"映射参数 : 8 波段 x 32 级,距离边界 [{edges_d[0]}, {edges_d[-1]}] Q16.16,"
f"深度阈值 [{int(edges_z[0])}, {int(edges_z[-1])}]")
print(f"线框 : {segs} 条边(正面三角形),叠加视图写进联系表第 4 格")
return 0
if __name__ == "__main__":
sys.exit(main())
导出器里有一处与 cglib.save_gray_png 的有意分歧:那个函数会按缓冲自身的最值做自动对比度归一化,而这里每个视图的灰度已经在 S 层算好了(14.1 的分段映射、14.2 的计数 ×64),再归一化一次就把调试视图的口径改掉了。调试视图的灰度是有语义的,不能当普通图片处理。
三个断言
导出器跑完会过三条断言,它们分别守不同的东西:
- PNG 回读:
back.shape == arr.shape and np.array_equal(back, arr)。守「写盘没写坏」,且明确用等号而不是容差。 - 联系表格数:
len(imgs) == len(cells) == 4。守「联系表没漏格」——少内嵌一张图,肉眼很难发现。 - 内嵌字节:逐张比
base64.b64decode(href) == 导出的 PNG 字节。守「内嵌的是这张图,不是别的图」。
第三条最容易被忽略:内嵌 base64 时若循环变量写错,很可能把 A 的字节塞进 B 的格子,而格数断言照样通过。只有逐字节比对才拦得住。
实跑输出:
视图 : 4 个,全部回读像素与内存缓冲逐点相同
depth L 5838B 非零像素 43701 sha256 c485c97ddd6bea6e
normal RGB 5100B 非零像素 43731 sha256 09af3098354d3ffe
coverage L 1358B 非零像素 43731 sha256 2ba5ca177343fe7d
wireframe RGB 19836B 非零像素 76800 sha256 3f55fdf3088a15ae
联系表 : 43670B 2x2 内嵌 4 张,ET.fromstring 回读通过,内嵌字节逐张相同
映射参数 : 8 波段 x 32 级,距离边界 [390964, 3018546] Q16.16,深度阈值 [60515, 65334]
线框 : 1818 条边(正面三角形),叠加视图写进联系表第 4 格
注意 depth 视图的非零像素是 43701,比覆盖区 43731 少 30。这不是漏写——第一波段的灰度值从 0 开始(idx * levels + ... 且 idx = 0),最靠近相机的 30 个像素恰好映射到灰度 0,于是「非零像素」少算了它们。这正说明灰度 0 与「未覆盖」在视觉上不可分:调试视图的灰度值语义与「有没有画」的语义不共享编码,判覆盖要看 covered 掩码而不是看灰度非零。
W 层:浏览器侧的同名视图
S 层导出的是文件,W 层的页面导出的是同一个视图的浏览器版本:把回读缓冲画进三个 2D canvas,主画布则是 WebGL2 的默认帧缓冲。做法是 putImageData 逐像素拷,不做任何缩放——与 S 层的「回读即相同」是同一套思路。
# snippet: 把回读缓冲画进 2D canvas(浏览器侧的同名视图,摘自 lantern/web/build_raster14.mjs)
const paint = (id, data, chans) => {
const c = document.getElementById(id);
c.width = W;
c.height = H;
const ctx = c.getContext("2d");
const img = ctx.createImageData(W, H);
for (let p = 0; p < W * H; p += 1) {
const s = p * chans;
img.data[p * 4] = data[s];
img.data[p * 4 + 1] = chans === 1 ? data[s] : data[s + 1];
img.data[p * 4 + 2] = chans === 1 ? data[s] : data[s + 2];
img.data[p * 4 + 3] = 255;
}
ctx.putImageData(img, 0, 0);
};
paint("depth", gray, 1);
paint("normal", nrm3, 3);
paint("coverage", Uint8Array.from(cnt1, (v) => Math.min(255, v * 64)), 1);
页面的就绪标志必须推迟到两次 requestAnimationFrame 之后——老坑:冷启动首个页面读 canvas 像素恒空白。验证脚本先等这个标志,再截图。
web/verify_raster14.mjs 的最后一段统计主渲染路径与 S 层成片的差异,并把整页截成 out/shots/raster14_gl.png——这就是浏览器侧产物的「清单 + 校验值」:
④ 主渲染路径 : MAE 0.2848 / PSNR 47.79 dB;覆盖 43729 vs 43731(不同 6 px,0.08‰)
截图 : /Users/bingrong.yan/.cgbook/lantern/out/shots/raster14_gl.png
canvasDrawn : true 预热后主画布非背景像素 : 43729
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
本机 headless 的 WebGL2 后端是 ANGLE/SwiftShader 的软件实现,不是真实 GPU;这里的差异只说明「两条独立路径算出了同一帧」,不得归因于真实硬件,也不构成「GPU 与 CPU 逐位相等」的证据。
四张图、一份清单、一张自包含的联系表——这就是第 14 章交付的调试视图。下一章从「怎么看见」转到「怎么造」:从零生成网格顶点。
阅读导航:上一节:14.2 覆盖率与线框 · 下一节:15.1 程序化网格生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。