8.1 体素网格与体素账

体素风格看起来只是「把东西做成方块」,落到规范上却是一笔干净的整数账:格子里有多少体素、包围盒多大、多少个面被相邻体素挡掉、多少格完全埋在内部。本节把《星屑旅店》的体素灯具用 8 个轴对齐盒描述,光栅化成网格,算体素数、包围盒、暴露面与剔除面,并让 Python 与 TypeScript 各自独立复算、逐位对齐。

8.1 体素网格与体素账

渲染原理专题 是网格、面朝向与光照原理的家,移动与 PC 美术性能预算 从团队预算角度谈过面数档位。本节换一个更窄的口子:体素风格里,哪些量天然是整数、可以逐位对账。体素不是「低多边形的一种」,它有自己的记账单位——格子、体素、面。这些单位全是整数,所以体素是全书里最不需要容差的一章。

体素风格在卷三谱系里对应主题包 voxel:它的色彩 token、strokePermille 与 detailPermille 都来自同一份 config/sp16_themes.json,本节不重造参数,只把「体素网格」这一步落成可跑的账。

体素为什么是一笔整数账

体素模型就是一张三维格子表:每个格子要么空、要么被某种材料占据。只要模型是轴对齐的,下列量全部是整数,且没有任何浮点参与:

量定义为什么是整数
体素数 voxels非空格子数计数
包围盒 bbox占据格子的最小外接长方体边长坐标差 + 1
暴露面 exposed6 邻域里空着的方向数之和计数
剔除面 culled体素数 × 6 − 暴露面整数减法
表面 / 内部体素有暴露面的 / 一面都没暴露的格子数计数

这里最容易被忽略的是内表面剔除:一个被六面邻居包住的体素,一个面都不用画。它就是「体素账」相对「面数预算」的净增量——同样的体素数,内部格越多,最终要提交的面越少。

模型即数据:轴对齐盒

与其手写几百个格子,不如用轴对齐盒(AABB)描述模型:给每个盒一个最小角、最大角和材料字符,光栅化时后写的盒覆盖先写的。下面这份 D 层配置就是《星屑旅店》的体素灯具,8 个盒拼出底座、灯身、灯芯和灯顶。

{
  "project": "stardust-inn",
  "theme": "voxel",
  "note": "《星屑旅店》的体素灯具模型:用 8 个轴对齐盒(AABB)光栅化成体素网格。paletteMap 把材料字符映射到 sp16_themes.json 里 voxel 主题的 token 名,颜色真源仍是 sRGB 8-bit 整数三元组。lightTiers 是 6 个面朝向的整数亮度千分比(简化光照,不做实时求值)。全部整数,无浮点。",
  "grid": { "size": [5, 5, 7] },
  "paletteCap": 16,
  "paletteMap": { "B": "surface", "M": "muted", "A": "accent" },
  "lightTiers": [
    { "dir": "+z", "permille": 1000, "tier": 3 },
    { "dir": "+x", "permille": 760, "tier": 2 },
    { "dir": "+y", "permille": 720, "tier": 2 },
    { "dir": "-x", "permille": 680, "tier": 1 },
    { "dir": "-y", "permille": 640, "tier": 1 },
    { "dir": "-z", "permille": 550, "tier": 0 }
  ],
  "boxes": [
    { "id": "base", "min": [0, 0, 0], "max": [4, 4, 1], "color": "B" },
    { "id": "mid", "min": [0, 0, 2], "max": [4, 4, 2], "color": "M" },
    { "id": "wall-b", "min": [0, 0, 3], "max": [4, 0, 4], "color": "M" },
    { "id": "wall-f", "min": [0, 4, 3], "max": [4, 4, 4], "color": "M" },
    { "id": "wall-l", "min": [0, 1, 3], "max": [0, 3, 4], "color": "M" },
    { "id": "wall-r", "min": [4, 1, 3], "max": [4, 3, 4], "color": "M" },
    { "id": "core", "min": [2, 2, 3], "max": [2, 2, 4], "color": "A" },
    { "id": "roof", "min": [0, 0, 5], "max": [4, 4, 6], "color": "B" }
  ],
  "budget": { "trisLow": 1500, "materialSlotsLow": 2 },
  "generated": [
    { "id": "voxel_slices", "file": "prism/out/gen/st08_voxel_slices.png", "width": 280, "height": 144, "mode": "RGB" },
    { "id": "voxel_shading", "file": "prism/out/gen/st08_voxel_shading.png", "width": 288, "height": 96, "mode": "RGB" }
  ]
}

paletteMap 把材料字符指回 sp16_themes.json 的 token 名,而不是复制颜色值——这样体素模型与整卷其余部分共用同一份色彩真源。B(结构)取 surface、M(灯身)取 muted、A(灯芯)取 accent。lightTiers 本节先放着,8.2 才用它做调色板上限的账。

S 层:体素账

下面这段是 S 层脚本,读上面那份配置,光栅化后算体素账,并用 PIL 把每个 z 切片画成一张小图拼成示意图 PNG。全部整数,无浮点。

#!/usr/bin/env python3
# file: sim/st08_voxel_account.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st08_voxel_account.py
"""卷三 8.1 体素网格与体素账(S 层)。

读 config/st08_voxel.json:把 8 个轴对齐盒光栅化成体素网格,算**整数账**——体素数 /
包围盒 / 暴露面 / 被相邻面剔除的面 / 表面体素与内部体素,全整数地板除。PIL 生成体素
切片示意图 PNG(每个 z 切片一张 5×5 小图拼成总图)。落 out/st08_voxel_account.json 供
P 层对照。颜色真源是 sp16_themes.json 里 voxel 主题的 sRGB 8-bit 整数三元组(只读)。"""
import hashlib
import json
import os
import sys

from PIL import Image, ImageDraw

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NBR = [(1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)]


def load():
    cfg = json.load(open(os.path.join(ROOT, "config/st08_voxel.json"), encoding="utf-8"))
    th = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    return cfg, next(t for t in th["themes"] if t["id"] == cfg["theme"])


def rasterize(cfg):
    """轴对齐盒填进网格;后写的盒覆盖先写的。"""
    grid = {}
    for b in cfg["boxes"]:
        for x in range(b["min"][0], b["max"][0] + 1):
            for y in range(b["min"][1], b["max"][1] + 1):
                for z in range(b["min"][2], b["max"][2] + 1):
                    grid[(x, y, z)] = b["color"]
    return grid


def analyze(cfg, theme):
    grid = rasterize(cfg)
    w, h, d = cfg["grid"]["size"]
    faces = {p: sum(1 for n in NBR if (p[0] + n[0], p[1] + n[1], p[2] + n[2]) not in grid)
             for p in grid}
    exposed = sum(faces.values())
    surface = sum(1 for f in faces.values() if f > 0)
    per = {}
    for m in grid.values():
        per[m] = per.get(m, 0) + 1
    per = {k: per[k] for k in sorted(per)}
    xs = [p[0] for p in grid]
    ys = [p[1] for p in grid]
    zs = [p[2] for p in grid]
    n = len(grid)
    rep = {"project": cfg["project"], "theme": cfg["theme"], "gridSize": [w, h, d],
           "gridCells": w * h * d, "voxelCount": n, "emptyCells": w * h * d - n,
           "boundingBox": [max(xs) - min(xs) + 1, max(ys) - min(ys) + 1, max(zs) - min(zs) + 1],
           "totalFaces": n * 6, "exposedFaces": exposed, "culledFaces": n * 6 - exposed,
           "surfaceVoxels": surface, "interiorVoxels": n - surface, "perMaterial": per,
           "occupancyPermille": n * 1000 // (w * h * d),
           "palette": {ch: theme["tokens"][tok] for ch, tok in cfg["paletteMap"].items()}}
    rep["canonical"] = json.dumps({k: rep[k] for k in ("voxelCount", "exposedFaces", "culledFaces",
                                  "surfaceVoxels", "interiorVoxels", "perMaterial")},
                                  separators=(",", ":"), ensure_ascii=False)
    rep["digest"] = hashlib.sha256(rep["canonical"].encode()).hexdigest()[:16]
    return rep, grid


def draw(grid, palette, cfg, path):
    """每个 z 切片画成一张小图拼成总图;空格用暗背景色。"""
    w, h, d = cfg["grid"]["size"]
    cell, gap, pad, cols = 12, 8, 8, 4
    rows = (d + cols - 1) // cols
    img = Image.new("RGB", (pad * 2 + cols * w * cell + (cols - 1) * gap,
                            pad * 2 + rows * h * cell + (rows - 1) * gap), (24, 20, 32))
    dr = ImageDraw.Draw(img)
    for z in range(d):
        ox = pad + (z % cols) * (w * cell + gap)
        oy = pad + (z // cols) * (h * cell + gap)
        for x in range(w):
            for y in range(h):
                px, py = ox + x * cell, oy + (h - 1 - y) * cell
                dr.rectangle([px, py, px + cell - 1, py + cell - 1],
                             fill=tuple(palette.get(grid.get((x, y, z)), (24, 20, 32))),
                             outline=(16, 14, 22))
    img.save(path)


def main():
    cfg, theme = load()
    rep, grid = analyze(cfg, theme)
    os.makedirs(os.path.join(ROOT, "out/gen"), exist_ok=True)
    json.dump(rep, open(os.path.join(ROOT, "out/st08_voxel_account.json"), "w", encoding="utf-8"),
              ensure_ascii=False, indent=2, sort_keys=True)
    draw(grid, rep["palette"], cfg, os.path.join(ROOT, "out/gen/st08_voxel_slices.png"))
    print(f"grid={rep['gridSize']} cells={rep['gridCells']} voxels={rep['voxelCount']} "
          f"empty={rep['emptyCells']} occupancyPermille={rep['occupancyPermille']}")
    print(f"bbox={rep['boundingBox']} totalFaces={rep['totalFaces']} exposed={rep['exposedFaces']} "
          f"culled={rep['culledFaces']}")
    print(f"surfaceVoxels={rep['surfaceVoxels']} interiorVoxels={rep['interiorVoxels']} "
          f"perMaterial={rep['perMaterial']}")
    print(f"digest={rep['digest']}")
    print("wrote out/st08_voxel_account.json + out/gen/st08_voxel_slices.png")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/st08_voxel_account.py(同机相对量级,绝对数值不可移植):

grid=[5, 5, 7] cells=175 voxels=159 empty=16 occupancyPermille=908
bbox=[5, 5, 7] totalFaces=954 exposed=238 culled=716
surfaceVoxels=148 interiorVoxels=11 perMaterial={'A': 2, 'B': 100, 'M': 57}
digest=7f5d4ed68312e8d4
wrote out/st08_voxel_account.json + out/gen/st08_voxel_slices.png

读出来:175 个格子里 159 个被占(occupancyPermille=908),包围盒 5×5×7。总共 954 个「格 × 面」,其中 238 个是暴露面、716 个被相邻体素挡掉——内表面剔除一口气砍掉了四分之三。还有 11 个体素是内部格(六面全被包住,一个面都不画),它们集中在灯身与底座之间那层实心区。perMaterial 显示 B=100 / M=57 / A=2,灯芯只有 2 格,正是那根从灯身中间穿出来的强调色柱子。

P 层:同一笔账的跨语言对照

下面这段是 P 层 TypeScript 内核 core/src/st08/voxel.ts。它把同一套规则再写一遍:rasterize 用同一套覆盖顺序填格,exposedFaces 数 6 邻域,voxelAccount 用整数地板除算千分比。它不读 Python 的源码,读的是 Python 落盘的 JSON。

// file: core/src/st08/voxel.ts
// tier: P
// verify: node --test 'core/test/st08_*.test.ts'
// 体素网格内核:轴对齐盒 → 体素网格 → 整数账(体素数 / 包围盒 / 暴露面 / 剔除面)。
// 与 sim/st08_voxel_account.py 同口径:整数加总与地板除,逐位一致;不做任何浮点。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Vec3 = number[];
export type Box = { id: string; min: Vec3; max: Vec3; color: string };
export type VoxelModel = { grid: { size: Vec3 }; boxes: Box[] };

export const NEIGHBORS: [string, Vec3][] = [
  ["+x", [1, 0, 0]], ["-x", [-1, 0, 0]],
  ["+y", [0, 1, 0]], ["-y", [0, -1, 0]],
  ["+z", [0, 0, 1]], ["-z", [0, 0, -1]],
];

export function vkey(x: number, y: number, z: number): string {
  return `${x},${y},${z}`;
}

/** 轴对齐盒光栅化成体素网格;后写的盒覆盖先写的。 */
export function rasterize(model: VoxelModel): Map<string, string> {
  const grid = new Map<string, string>();
  for (const b of model.boxes) {
    for (let x = b.min[0]; x <= b.max[0]; x++) {
      for (let y = b.min[1]; y <= b.max[1]; y++) {
        for (let z = b.min[2]; z <= b.max[2]; z++) grid.set(vkey(x, y, z), b.color);
      }
    }
  }
  return grid;
}

export function positions(grid: Map<string, string>): number[][] {
  return [...grid.keys()].map((k) => k.split(",").map(Number));
}

/** 一个体素的暴露面数:6 邻域里空着的方向数。 */
export function exposedFaces(grid: Map<string, string>, x: number, y: number, z: number): number {
  let n = 0;
  for (const [, d] of NEIGHBORS) if (!grid.has(vkey(x + d[0], y + d[1], z + d[2]))) n++;
  return n;
}

export function boundingBox(grid: Map<string, string>): Vec3 {
  const ps = positions(grid);
  if (ps.length === 0) return [0, 0, 0];
  const lo = [0, 1, 2].map((i) => Math.min(...ps.map((p) => p[i])));
  const hi = [0, 1, 2].map((i) => Math.max(...ps.map((p) => p[i])));
  return [hi[0] - lo[0] + 1, hi[1] - lo[1] + 1, hi[2] - lo[2] + 1];
}

export type VoxelAccount = {
  gridSize: number[]; gridCells: number; voxelCount: number; emptyCells: number;
  boundingBox: number[]; totalFaces: number; exposedFaces: number; culledFaces: number;
  surfaceVoxels: number; interiorVoxels: number; perMaterial: Record<string, number>;
  occupancyPermille: number;
};

/** 体素账:全整数地板除。 */
export function voxelAccount(model: VoxelModel): VoxelAccount {
  const grid = rasterize(model);
  const [w, h, d] = model.grid.size;
  const cells = w * h * d;
  const voxels = grid.size;
  let exposed = 0;
  let surface = 0;
  const perMaterial: Record<string, number> = {};
  for (const [k, color] of grid) {
    const [x, y, z] = k.split(",").map(Number);
    const f = exposedFaces(grid, x, y, z);
    exposed += f;
    if (f > 0) surface++;
    perMaterial[color] = (perMaterial[color] || 0) + 1;
  }
  const sorted: Record<string, number> = {};
  for (const k of Object.keys(perMaterial).sort()) sorted[k] = perMaterial[k];
  return {
    gridSize: [w, h, d], gridCells: cells, voxelCount: voxels, emptyCells: cells - voxels,
    boundingBox: boundingBox(grid), totalFaces: voxels * 6, exposedFaces: exposed,
    culledFaces: voxels * 6 - exposed, surfaceVoxels: surface, interiorVoxels: voxels - surface,
    perMaterial: sorted, occupancyPermille: Math.floor((voxels * 1000) / cells),
  };
}

测试 core/test/st08_voxel.test.ts 里,跨语言那两条读 out/st08_voxel_account.json 逐位比对,再比 canonical 摘要的 sha256。本机实跑 node --test 'core/test/st08_*.test.ts'(本节 5 条,三节合计 16 条):

✔ 正例:盒光栅化后体素数 = 各盒并集 (0.53175ms)
✔ 跨语言:体素数 / 包围盒 / 暴露面 / 剔除面逐位一致(整数类) (1.038458ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.663833ms)
✔ 负例:孤立单体的 6 面全暴露;3×3×3 实心块的中心格 0 暴露 (0.147083ms)
✔ 负例:盒为空集时体素数为 0,occupancyPermille 为 0 (0.075ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

两条负例各守一个方向:孤立单体必须报 6 个暴露面、3×3×3 实心块的中心格必须报 0 暴露面(否则「内表面剔除」是假的);空模型必须返回 0 而不是崩在 min() 上。跨语言 digest 7f5d4ed68312e8d4 与 Python 侧逐位相同——同一份配置,两侧独立复算,整数账一位不差。

自检表

检查项合格标准
体素数可复算盒光栅化后体素数 = 各盒并集的格数
包围盒可复算由占据格坐标差 + 1 得出,不写死
内表面剔除暴露面 + 剔除面 = 体素数 × 6
内部格识别六面被包的格子暴露面为 0
跨语言一致整数类逐位一致,digest 相同

体素账算清之后,下一个问题是颜色:159 个体素、3 种材料、6 个面朝向——这些数乘起来会不会超过调色板上限?下一节把「光照简化」拆成「档位折叠」,算这笔乘法。

阅读导航:上一节:7.3 X:笔触质量 · 下一节:8.2 体素调色板与光照简化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则