8.2 体素调色板与光照简化
体素的光照原理——法线、着色模型、光照与阴影的实现——归 渲染原理专题 ,本节不重推。本节只问一个规范侧的问题:体素风格不做实时求值光照时,颜色数是怎样被「档位」锁死的。
体素美术的通行做法是把每个面朝向的亮度预先烘成固定档位:朝上的面最亮,朝下的面最暗,四个侧面居中。渲染时不再做逐像素求值,只按面朝向查表取一个整数千分比,乘到基础色上。这条路线把光照从「运行时求值」降级成「离线配色」,代价是颜色的数量会随着档位数乘起来。
最终颜色数 = 基础色数 × 档位数
设模型用到 m 种基础材料,每个材料按 k 个档位着色,那么最终出现的颜色数最多是 m × k。体素主题包给的上限是 paletteCap = 16。8.1 的灯具用了 3 种材料(B / M / A),于是:
| 方案 | 档位数 k | 颜色数 m × k | 是否越界(上限 16) |
|---|---|---|---|
| 逐朝向直接着色 | 6 | 18 | 越界 2 色 |
| 折叠成 4 个 tier | 4 | 12 | 余量 4 色 |
「折叠」不是随手砍——它把语义相近的朝向并成同一档。灯具的 lightTiers 里 +x(760‰)与 +y(720‰)都归 tier 2,-x(680‰)与 -y(640‰)都归 tier 1,+z(1000‰)单独是 tier 3,-z(550‰)单独是 tier 0。每个 tier 取组内最大千分比作代表档位,于是 6 个朝向收成 4 个档位,颜色数从 18 降到 12。这条折叠规则是数据(tier 字段),不是口头约定。
S 层:调色板账
下面这段是 S 层脚本,读同一份配置,算基础色数、逐朝向颜色数与折叠后颜色数,并用 PIL 生成「基础色 + 4 档明暗」色带 PNG。着色是整数地板除,全程无浮点。
#!/usr/bin/env python3
# file: sim/st08_palette_light.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st08_palette_light.py
"""卷三 8.2 体素调色板与光照简化(S 层)。
体素美术不做实时求值光照,而是把每个面朝向的亮度**烘成固定档位**。于是「最终颜色数」
= 基础色数 × 档位数——这条乘法就是调色板上限的账。脚本读 config/st08_voxel.json:
① 基础色数 = 模型实际用到的材料数;② 逐朝向(6 档)着色的颜色数;③ 把朝向折叠成
4 个 tier 后的颜色数。着色是整数地板除(通道 × 档位千分比 // 1000)。PIL 生成
「基础色 + 4 档明暗」色带 PNG。落 out/st08_palette_light.json 供 P 层对照。"""
import hashlib
import json
import os
import sys
from PIL import Image, ImageDraw
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
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 shade(color, permille):
"""整数地板除着色:通道 × 档位千分比 // 1000。"""
return [v * permille // 1000 for v in color]
def analyze(cfg, theme):
mats = sorted({b["color"] for b in cfg["boxes"]})
base = {ch: theme["tokens"][cfg["paletteMap"][ch]] for ch in mats}
raw = sorted({t["permille"] for t in cfg["lightTiers"]})
tp = {}
for t in cfg["lightTiers"]:
tp[t["tier"]] = max(tp.get(t["tier"], 0), t["permille"])
tiers = sorted(tp)
raw_set = {tuple(shade(base[c], p)) for c in mats for p in raw}
tier_set = {tuple(shade(base[c], tp[k])) for c in mats for k in tiers}
cap = cfg["paletteCap"]
rep = {"project": cfg["project"], "theme": cfg["theme"], "baseColorCount": len(mats),
"baseColors": base, "rawDirections": len(raw), "rawShadedColors": len(raw_set),
"tierCount": len(tiers), "tierPermille": {str(k): tp[k] for k in tiers},
"tierShadedColors": len(tier_set), "paletteCap": cap,
"rawOverCap": len(raw_set) > cap, "tierOverCap": len(tier_set) > cap,
"rawHeadroom": cap - len(raw_set), "tierHeadroom": cap - len(tier_set),
"tierShades": {c: [shade(base[c], tp[k]) for k in tiers] for c in mats}}
rep["canonical"] = json.dumps({"baseColorCount": len(mats), "rawShadedColors": len(raw_set),
"tierCount": len(tiers), "tierShadedColors": len(tier_set),
"paletteCap": cap, "tierPermille": {str(k): tp[k] for k in tiers}},
separators=(",", ":"), ensure_ascii=False)
rep["digest"] = hashlib.sha256(rep["canonical"].encode()).hexdigest()[:16]
return rep
def draw(rep, path):
"""每行一种材料:第一格基础色,后 4 格是 4 个档位的着色。"""
mats = sorted(rep["baseColors"])
cw, ch, gap, px, py = 48, 24, 4, 16, 8
cols = 1 + rep["tierCount"]
img = Image.new("RGB", (px * 2 + cols * cw + (cols - 1) * gap,
py * 2 + len(mats) * ch + (len(mats) - 1) * gap), (24, 20, 32))
dr = ImageDraw.Draw(img)
for r, m in enumerate(mats):
oy = py + r * (ch + gap)
for c, col in enumerate([rep["baseColors"][m]] + rep["tierShades"][m]):
ox = px + c * (cw + gap)
dr.rectangle([ox, oy, ox + cw - 1, oy + ch - 1], fill=tuple(col), outline=(16, 14, 22))
img.save(path)
def main():
cfg, theme = load()
rep = analyze(cfg, theme)
os.makedirs(os.path.join(ROOT, "out/gen"), exist_ok=True)
json.dump(rep, open(os.path.join(ROOT, "out/st08_palette_light.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2, sort_keys=True)
draw(rep, os.path.join(ROOT, "out/gen/st08_voxel_shading.png"))
print(f"baseColorCount={rep['baseColorCount']} cap={rep['paletteCap']}")
print(f"rawDirections={rep['rawDirections']} rawShadedColors={rep['rawShadedColors']} "
f"overCap={rep['rawOverCap']} headroom={rep['rawHeadroom']}")
print(f"tierCount={rep['tierCount']} tierPermille={rep['tierPermille']} "
f"tierShadedColors={rep['tierShadedColors']} overCap={rep['tierOverCap']} "
f"headroom={rep['tierHeadroom']}")
print(f"digest={rep['digest']}")
print("wrote out/st08_palette_light.json + out/gen/st08_voxel_shading.png")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/st08_palette_light.py(同机相对量级):
baseColorCount=3 cap=16
rawDirections=6 rawShadedColors=18 overCap=True headroom=-2
tierCount=4 tierPermille={'0': 550, '1': 680, '2': 760, '3': 1000} tierShadedColors=12 overCap=False headroom=4
digest=92f05802f511d356
wrote out/st08_palette_light.json + out/gen/st08_voxel_shading.png
逐朝向直接着色得到 rawShadedColors=18,overCap=True、headroom=-2——这是一个真实的规格违例,故意留在基线里。折叠成 4 档后 tierShadedColors=12、overCap=False、headroom=4,回到上限内。tierPermille={'0': 550, '1': 680, '2': 760, '3': 1000} 就是 4 个代表档位。
P 层:同一笔账的跨语言对照
下面这段是 P 层 TypeScript 内核 core/src/st08/palette.ts。shade 用整数地板除,tierPermille 同组取最大,paletteAccount 用集合大小数颜色数——与 Python 侧同一套规则。
// file: core/src/st08/palette.ts
// tier: P
// verify: node --test 'core/test/st08_*.test.ts'
// 体素调色板与光照简化内核:基础色数 × 档位数 = 最终颜色数,这条乘法就是调色板上限的账。
// 着色是整数地板除(通道 × 档位千分比 // 1000)。与 sim/st08_palette_light.py 同口径。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import type { Vec3 } from "./voxel.ts";
import { rasterize, type VoxelModel } from "./voxel.ts";
export type LightTier = { dir: string; permille: number; tier: number };
export type PaletteCfg = { paletteCap: number; paletteMap: Record<string, string>; lightTiers: LightTier[] };
/** 整数地板除着色:通道 × 档位千分比 // 1000。 */
export function shade(color: Vec3, permille: number): Vec3 {
return color.map((c) => Math.floor((c * permille) / 1000));
}
/** 每个 tier 取该组内最大的千分比作为代表档位。 */
export function tierPermille(tiers: LightTier[]): Record<number, number> {
const out: Record<number, number> = {};
for (const t of tiers) out[t.tier] = Math.max(out[t.tier] || 0, t.permille);
return out;
}
export type PaletteAccount = {
baseColorCount: number; rawDirections: number; rawShadedColors: number;
tierCount: number; tierPermille: Record<string, number>; tierShadedColors: number;
paletteCap: number; rawOverCap: boolean; tierOverCap: boolean;
rawHeadroom: number; tierHeadroom: number;
};
/** 用到的材料字符(去重排序)。 */
export function usedMaterials(model: VoxelModel): string[] {
return [...new Set(model.boxes.map((b) => b.color))].sort();
}
/** 调色板账:颜色数由「集合大小」决定,与顺序无关。 */
export function paletteAccount(model: VoxelModel, cfg: PaletteCfg, tokens: Record<string, Vec3>): PaletteAccount {
const mats = usedMaterials(model);
const base: Record<string, Vec3> = {};
for (const ch of mats) base[ch] = tokens[cfg.paletteMap[ch]];
const rawDirs = [...new Set(cfg.lightTiers.map((t) => t.permille))].sort((a, b) => a - b);
const tp = tierPermille(cfg.lightTiers);
const tierIds = Object.keys(tp).map(Number).sort((a, b) => a - b);
const rawSet = new Set<string>();
const tierSet = new Set<string>();
for (const ch of mats) {
for (const pm of rawDirs) rawSet.add(shade(base[ch], pm).join(","));
for (const k of tierIds) tierSet.add(shade(base[ch], tp[k]).join(","));
}
const tierPm: Record<string, number> = {};
for (const k of tierIds) tierPm[String(k)] = tp[k];
return {
baseColorCount: mats.length, rawDirections: rawDirs.length, rawShadedColors: rawSet.size,
tierCount: tierIds.length, tierPermille: tierPm, tierShadedColors: tierSet.size,
paletteCap: cfg.paletteCap, rawOverCap: rawSet.size > cfg.paletteCap,
tierOverCap: tierSet.size > cfg.paletteCap,
rawHeadroom: cfg.paletteCap - rawSet.size, tierHeadroom: cfg.paletteCap - tierSet.size,
};
}
export function paletteGrid(model: VoxelModel): Map<string, string> {
return rasterize(model);
}
测试 core/test/st08_palette.test.ts 读 out/st08_palette_light.json 逐位比对。本机实跑 node --test 'core/test/st08_*.test.ts'(本节 6 条,三节合计 16 条):
✔ 正例:着色是整数地板除,逐通道向下取整 (0.749875ms)
✔ 跨语言:基础色数 / 逐朝向颜色数 / 折叠后颜色数逐位一致(整数类) (0.223958ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.350042ms)
✔ 负例:6 档逐朝向着色超上限,折叠成 4 档后回到上限内 (0.109083ms)
✔ 负例:收紧上限后折叠方案也必须被判越界 (0.077375ms)
✔ tierPermille:同组取最大千分比,与顺序无关 (0.049625ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
第二条负例是关键的对抗:把 paletteCap 从 16 收到 10,12 色同样越界——上限是真的会拦人的闸门,不是装饰。跨语言 digest 92f05802f511d356 与 Python 侧逐位相同。
自检表
| 检查项 | 合格标准 |
|---|---|
| 颜色数可复算 | 基础色数 × 档位数 = 最终颜色数 |
| 档位折叠有据 | 折叠规则写在数据里(tier 字段),非口头约定 |
| 上限可拦 | 越界方案必须报 overCap,收紧上限后仍拦得住 |
| 着色整数化 | 通道 × 千分比地板除,无浮点 |
颜色数收进上限之后,模型还停在「体素」形态。引擎不吃体素,吃三角网格。下一节算体素 → 网格的转换账:暴露面怎么变成三角,贪心合并能省多少。
阅读导航:上一节:8.1 体素网格与体素账 · 下一节:8.3 体素的引擎接入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。