8.1 体素网格与体素账
渲染原理专题 是网格、面朝向与光照原理的家,移动与 PC 美术性能预算 从团队预算角度谈过面数档位。本节换一个更窄的口子:体素风格里,哪些量天然是整数、可以逐位对账。体素不是「低多边形的一种」,它有自己的记账单位——格子、体素、面。这些单位全是整数,所以体素是全书里最不需要容差的一章。
体素风格在卷三谱系里对应主题包 voxel:它的色彩 token、strokePermille 与 detailPermille 都来自同一份 config/sp16_themes.json,本节不重造参数,只把「体素网格」这一步落成可跑的账。
体素为什么是一笔整数账
体素模型就是一张三维格子表:每个格子要么空、要么被某种材料占据。只要模型是轴对齐的,下列量全部是整数,且没有任何浮点参与:
| 量 | 定义 | 为什么是整数 |
|---|---|---|
| 体素数 voxels | 非空格子数 | 计数 |
| 包围盒 bbox | 占据格子的最小外接长方体边长 | 坐标差 + 1 |
| 暴露面 exposed | 6 邻域里空着的方向数之和 | 计数 |
| 剔除面 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 体素调色板与光照简化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。