14.3 中间缓冲的可视化

四张调试缓冲不该各写各的导出代码。本节把它们收进一个统一导出器:每张图写出 8 位 PNG、一份 sha256、一条「回读像素 == 内存缓冲」断言,再用纯 xml.etree 拼一张内嵌 base64 的 SVG 联系表,最后用 ET.fromstring 回读校验结构与内嵌字节。

14.3 中间缓冲的可视化

14.1 与 14.2 把深度、法线、覆盖率、线框四张图各自算了出来。但如果每张图都写一段自己的导出代码——这个用 cglib.save_gray_png、那个手搓 PNG、另一个直接 np.save——那这套调试视图就只是四段一次性脚本,改一处口径要改四处,也没法被机器核对。本节把它们收进一个导出器:同一条导出路径、同一套断言、一份可核对的清单。站内的 GPU 剖析与调试 关心的是「从哪里取到这些缓冲」,本节关心的是「取到之后怎么把一份调试产物做得可复现、可核对」。

一张图回答不了一个问题

调试渲染时最常见的低效不是「看不到图」,而是「看到一张图却判断不了对不对」。一张成片里的绿色可能是「法线朝上」,也可能是「材质本来就是绿的」;一片亮斑可能是「计数高」,也可能是「深度近」。单张图不带元数据,就永远在猜。

所以一份合格的调试产物不是「一张 PNG」,而是一组同名、可交叉核对的文件:图 + 参数 + 校验值。图给人看,参数给下一次复现用,校验值给机器核对用。三者缺一,这份产物就退回成「截图」。

调试产物的四条标准

  1. 无损:写出的 PNG 回读回来,像素必须与内存缓冲逐点相同(PNG 是无损格式,所以这里该是等号,不是「近似」)。
  2. 可复现:映射参数(波段数、级数、距离/深度边界)必须随图一起存,否则同一个缓冲换个参数就画出另一张图。
  3. 可核对:每张图带一份 sha256,跨机器、跨运行比对时不必再比像素。
  4. 自包含:联系表要把图内嵌进去,而不是引用外部文件——一份产物应该能单独被打开、被传递、被归档。

第 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 程序化网格生成 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.2 覆盖率与线框
  2. 14.1 深度图与法线图
  3. 10.3 画家算法与深度排序