15.3 场景图与遍历

场景图的层级变换合成与遍历序语义:world = 父 * 子 的顺序如何决定叶子落点(同一原点在 T·S 与 S·T 下分别落到 x = 1.5 与 x = 2.0),以及遍历序在不交叠场景逐字节相同、部分交叠场景差 72 个像素、完全重合时差异像素数等于覆盖像素数。

15.3 场景图与遍历

前两节把几何造出来、又选好了内存表示。本节把若干片几何摆进世界,并回答一个问题:遍历序会不会改变画面。站内的 glTF 资产管线 讲的是场景怎么从外部文件被组装出来;本节讲的是场景图自身的遍历语义——层级变换怎么合成、遍历序在什么条件下才影响最终像素。

场景图:节点、局部变换、世界变换

场景图是一棵树。每个节点带一个局部变换(本节只用到平移与缩放,都写成 Q16.16 的 4×4 矩阵),节点的世界变换由根到它的路径合成:world = 父 * 子,从根往下依次右乘。叶子节点持有一片几何,它最终的顶点位置是 mvp * world * 顶点。

顺序不能反。矩阵乘法不交换:父是「先平移 1、再缩放 0.5」而子是「平移 1」,把原点分别按 父 * 子 与 子 * 父 变换,落点一个是 $1.5$、一个是 $2.0$——脚本把这两个值钉成断言,也把「两个乘积矩阵必须不同」钉成断言。写渲染器时把层级合成写反,是最常见的静默错误之一。

遍历序与绘制顺序

遍历树时叶子被塞进绘制列表,塞入的先后就是绘制顺序。这里的 z-buffer 用严格小于比较(新深度 < 旧深度才写),于是:

  • 深度不相等的像素,顺序无关——后画的更远就被挡掉,谁先谁后都得到同一结果;
  • 深度恰好相等的像素,先画的赢——后画的深度不小于它,被丢弃。

这与第 6 章「共享边像素归属由绘制顺序决定」同源,只是那里比的是边函数的值。脚本用三组场景把这条结论量出来:apart(两片四边形完全不交叠)、overlap(部分交叠)、stack(完全重合)。正向与反向遍历各渲染一次,逐像素比对。

可运行产物

下面这个脚本把层级合成、三组场景的顺序敏感性、以及交叠场景的像素缓冲全部钉成断言,并把正向渲染的 RGB 写成 .rgb 供 TypeScript 复现:

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_15_3_scene_graph.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_15_3_scene_graph.py
"""15.3 场景图与遍历:层级变换的合成顺序,以及遍历序对绘制顺序的影响。

场景图是一棵树:每个节点带一个局部变换(平移 + 缩放),世界变换 = 父 * 子。叶子节点持有
一片几何,遍历序决定叶子被塞进绘制列表的先后。z-buffer 用严格小于比较,故顺序对普通像素
无影响 —— 只有深度完全相等的交叠像素由「先画的赢」决定(与第 6 章「共享边像素归属由绘制
顺序决定」同源)。三组场景验证这条结论:apart(不交叠,顺序无关)、overlap(部分交叠)、
stack(完全重合)。
产物:out/cg_raster_15_3_graph.json、out/cg_raster_15_3_{apart,overlap,stack}.png、
      out/cg_raster_15_3_overlap.rgb。
"""

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__)))
W, H = 64, 48
HALF = 32768  # 0.5 世界单位(叶子四边形的半边长)
COL_A = (210, 70, 60)
COL_B = (60, 140, 220)


def leaf_quad(h=HALF):
    """叶子四边形(局部坐标,z = 0):屏幕空间 y 向下,故环绕取 (0,2,1)/(0,3,2)。"""
    c0, c1 = (-h, -h, 0), (h, -h, 0)
    c2, c3 = (h, h, 0), (-h, h, 0)
    return [(c0, c2, c1), (c0, c3, c2)]


def world_chain(root, chain):
    """按「父 * 子」顺序合成层级变换:world = root * local1 * local2 * ..."""
    m = root
    for local in chain:
        m = cglib.mat_mul(m, local)
    return m


def build_mvp():
    """固定相机:eye (0,0,4)、看向原点、tanHalfFovY = 0.5。"""
    aspect = cglib.fdiv(cglib.f_from_int(W), cglib.f_from_int(H))
    view = cglib.mat_look_at((0, 0, 262144), (0, 0, 0), (0, cglib.ONE, 0))
    return cglib.mat_mul(cglib.mat_perspective(32768, aspect, 32768, 4194304), view)


def render(mvp, leaves, bg):
    """leaves: [(world_matrix, color)],按给定遍历序组装并光栅化。"""
    rtris = []
    for m, col in leaves:
        mvp_world = cglib.mat_mul(mvp, m)  # 叶子的世界变换先合成进 MVP
        for tri in leaf_quad():
            scr = []
            for v in tri:
                p = cglib.project(mvp_world, v)
                if p is None:
                    scr = None
                    break
                sx, sy = cglib.to_screen(p[0], p[1], W, H)
                scr.append((sx, sy, p[2]))
            if scr is None:
                continue
            rtris.append((scr[0][0], scr[0][1], scr[0][2], col[0], col[1], col[2],
                          scr[1][0], scr[1][1], scr[1][2], col[0], col[1], col[2],
                          scr[2][0], scr[2][1], scr[2][2], col[0], col[1], col[2]))
    color, depth, covered, culled = cglib.rasterize(rtris, W, H, bg)
    return color, covered, culled


def leaves_for(tx):
    """两层链:父、子各平移 tx -> 左叶中心 -2tx、右叶中心 +2tx。"""
    def chain(sign):
        return [cglib.mat_model(translate=(sign * tx, 0, 0)) for _ in range(2)]
    return [(world_chain(cglib.mat_identity(), chain(-1)), COL_A),
            (world_chain(cglib.mat_identity(), chain(1)), COL_B)]


def main():
    spec = cglib.load_spec()
    bg = tuple(spec["backgroundQ8"])
    mvp = build_mvp()
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    # 合成顺序:T(p)S(s) 与 S(s)T(p) 不是同一个矩阵(乘法不交换)
    par = cglib.mat_model(translate=(cglib.f_from_int(1), 0, 0), scale=(HALF, HALF, HALF))
    chd = cglib.mat_model(translate=(cglib.f_from_int(1), 0, 0))
    o = (0, 0, 0, cglib.ONE)
    pc_o = cglib.mat_vec4(cglib.mat_mul(par, chd), o)
    cp_o = cglib.mat_vec4(cglib.mat_mul(chd, par), o)
    assert cglib.mat_mul(par, chd) != cglib.mat_mul(chd, par), "父*子 与 子*父 必须不同"
    assert pc_o != cp_o, "同一原点在两种合成顺序下落到不同位置"

    scenes = {"apart": leaves_for(cglib.f_from_int(1) // 2),    # 叶子中心 x = ±1.0(不交叠)
              "overlap": leaves_for(cglib.f_from_int(1) // 8),  # 叶子中心 x = ±0.25(部分交叠)
              "stack": leaves_for(0)}                           # 叶子中心 x = 0(完全重合)

    result = {}
    for name, leaves in scenes.items():
        fwd_color, fwd_cov, fwd_cull = render(mvp, leaves, bg)
        rev_color, _, _ = render(mvp, list(reversed(leaves)), bg)
        diff = int((fwd_color != rev_color).any(axis=2).sum())
        fwd_sha = hashlib.sha256(np.clip(fwd_color, 0, 255).astype(np.uint8).tobytes()).hexdigest()
        if name == "apart":
            assert diff == 0, "不交叠场景:遍历序必须不影响图像"
        else:
            assert diff > 0, f"{name}:交叠场景必须出现顺序相关的像素"
        png = cglib.save_png(os.path.join(out, f"cg_raster_15_3_{name}.png"), fwd_color)
        if name == "overlap":
            with open(os.path.join(out, "cg_raster_15_3_overlap.rgb"), "wb") as fh:
                fh.write(np.clip(fwd_color, 0, 255).astype(np.uint8).tobytes())
        result[name] = {"covered": int(fwd_cov.sum()), "culled": fwd_cull, "diff_pixels": diff,
                        "fwd_sha256": fwd_sha, "png_bytes": png}

    stats = {"canvas": [W, H],
             "compose_order": {"origin_after_T_S": [pc_o[0], pc_o[1], pc_o[2]],
                               "origin_after_S_T": [cp_o[0], cp_o[1], cp_o[2]]},
             "scenes": result, "sha256_overlap_rgb": result["overlap"]["fwd_sha256"]}
    with open(os.path.join(out, "cg_raster_15_3_graph.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"画布               : {W}x{H},1 像素 = {cglib.SUB1} 单位")
    print(f"合成顺序 父*子     : 原点 -> {pc_o}(x = {cglib.f_to_int(pc_o[0])})")
    print(f"合成顺序 子*父     : 原点 -> {cp_o}(x = {cglib.f_to_int(cp_o[0])})")
    print("三组场景(遍历序正向 vs 反向):")
    for name in ("apart", "overlap", "stack"):
        r = result[name]
        tag = "逐字节相同" if r["diff_pixels"] == 0 else "顺序敏感"
        print(f"  {name:8s} covered={r['covered']:4d} culled={r['culled']:2d}"
              f"  diff_pixels={r['diff_pixels']:4d}  {tag}")
    print(f"overlap 正向 sha256: {result['overlap']['fwd_sha256']}")
    print(f"png                : apart {result['apart']['png_bytes']} / overlap "
          f"{result['overlap']['png_bytes']} / stack {result['stack']['png_bytes']} bytes")
    return 0


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

实测输出:

画布               : 64x48,1 像素 = 64 单位
合成顺序 父*子     : 原点 -> (98304, 0, 0, 65536)(x = 1)
合成顺序 子*父     : 原点 -> (131072, 0, 0, 65536)(x = 2)
三组场景(遍历序正向 vs 反向):
  apart    covered= 288 culled= 0  diff_pixels=   0  逐字节相同
  overlap  covered= 216 culled= 0  diff_pixels=  72  顺序敏感
  stack    covered= 144 culled= 0  diff_pixels= 144  顺序敏感
overlap 正向 sha256: dc581c058589bf3597855c36de9b4fcb67e0c0658af78df34c29a60129b2ba9e
png                : apart 199 / overlap 199 / stack 187 bytes

三行读数正好对上上面的分析:不交叠场景 diff_pixels = 0,两遍渲染逐字节相同;部分交叠场景 72 个像素分叉,正是那片交叠区;完全重合时两片四边形覆盖的 144 个像素全部由「先画的赢」决定,所以差异数等于覆盖数。另外注意 overlap 的正向 sha256 与反向 sha256 不同,图像真的变了,不是统计口径的问题。

跨语言断言:同一份场景,两种语言

层级合成与绘制列表组装在 Python 与 TypeScript 两侧必须给出同一串像素。下面的模块用 BigInt 重写同一套合成与遍历;测试读 S 侧写出的 out/cg_raster_15_3_overlap.rgb,逐字节比对,并断言 apart 场景下正反两遍的 sha256 相同。

// file: lantern/core/src/lantern/cg_raster_15_scenegraph.ts
// tier: P
// verify: node --test 'core/test/cg_raster_15_3_scene_graph.test.ts'
// 15.3 的场景图与遍历:层级变换合成 + 按遍历序组装绘制列表。
// 与 Python 侧 sim/cg_raster_15_3_scene_graph.py 逐字对齐。
//
// 顶点变换走 BigInt(Q16.16),逐像素光栅化复用共享底座 raster.ts(Number + Math.floor)。
// 层级合成一律「父 * 子」:world = root * local1 * local2 * ...;顺序反了落点就不同。

import { ONE } from "./fixed.ts";
import { type Mat4, matIdentity, matLookAt, matModel, matMul, matPerspective } from "./mat.ts";
import { project, toScreen } from "./project.ts";
import { type RTri, type RasterResult, rasterize } from "./raster.ts";
import type { Vec3 } from "./vec.ts";

export const HALF = 32768n; // 0.5 世界单位(叶子四边形半边长)

/** 叶子四边形(局部坐标,z = 0):屏幕空间 y 向下,故环绕取 (0,2,1)/(0,3,2)。 */
export function leafQuad(): [Vec3, Vec3, Vec3][] {
  const c0: Vec3 = [-HALF, -HALF, 0n];
  const c1: Vec3 = [HALF, -HALF, 0n];
  const c2: Vec3 = [HALF, HALF, 0n];
  const c3: Vec3 = [-HALF, HALF, 0n];
  return [
    [c0, c2, c1],
    [c0, c3, c2],
  ];
}

/** 按「父 * 子」顺序合成层级变换。 */
export function worldChain(root: Mat4, chain: Mat4[]): Mat4 {
  let m = root;
  for (const local of chain) m = matMul(m, local);
  return m;
}

/** 固定的正交式相机:eye (0,0,4)、看向原点,tanHalfFovY = 0.5。 */
export function buildMvp(width: number, height: number): Mat4 {
  const aspect = (BigInt(width) << 16n) / BigInt(height);
  const view = matLookAt([0n, 0n, 262144n], [0n, 0n, 0n], [0n, ONE, 0n]);
  const proj = matPerspective(32768n, aspect, 32768n, 4194304n);
  return matMul(proj, view);
}

export type SceneLeaf = { world: Mat4; color: number[] };

/** 按给定遍历序组装绘制列表并光栅化。 */
export function renderLeaves(
  mvp: Mat4,
  leaves: SceneLeaf[],
  width: number,
  height: number,
  background: readonly number[],
): RasterResult {
  const rtris: RTri[] = [];
  for (const leaf of leaves) {
    const mvpWorld = matMul(mvp, leaf.world);
    for (const tri of leafQuad()) {
      const xy: [number, number][] = [];
      const d: number[] = [];
      let ok = true;
      for (const v of tri) {
        const p = project(mvpWorld, v);
        if (p === null) {
          ok = false;
          break;
        }
        const [sx, sy] = toScreen(p.ndcX, p.ndcY, width, height);
        xy.push([Number(sx), Number(sy)]);
        d.push(Number(p.depth));
      }
      if (!ok) continue;
      rtris.push({ xy, d, c: [leaf.color, leaf.color, leaf.color] });
    }
  }
  return rasterize(rtris, width, height, background);
}

/** 逐像素差异计数(任一通道不同即计一个像素)。 */
export function diffPixels(a: Uint8Array, b: Uint8Array): number {
  let n = 0;
  for (let i = 0; i < a.length; i += 3) {
    if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2]) n += 1;
  }
  return n;
}

node --test 'core/test/cg_raster_15_3_scene_graph.test.ts' 实测 6 条断言全绿:

ℹ tests 6  ℹ pass 6  ℹ fail 0

最后一条是负例:把渲染实现改成「只画遍历到的最后一个叶子」,apart 场景立刻随顺序分叉——这条负例的作用是证明 apart 的 diff == 0 有区分力:判据不拒绝一个明显错误的实现,它的 PASS 就不可信。

阅读导航:上一节:15.2 网格的表示与加载 · 下一节:16.1 光栅化管线的装配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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