8.2 体素调色板与光照简化

体素美术通常不做实时求值光照,而是把每个面朝向的亮度烘成固定档位。于是最终颜色数 = 基础色数 × 档位数——这条乘法就是调色板上限的账。本节用《星屑旅店》的体素灯具算这笔账:6 个面朝向直接着色会得到 18 色、越过 16 色上限,折叠成 4 个档位后回到 12 色,并让 Python 与 TypeScript 各自独立复算、逐位对齐。

8.2 体素调色板与光照简化

体素的光照原理——法线、着色模型、光照与阴影的实现——归 渲染原理专题 ,本节不重推。本节只问一个规范侧的问题:体素风格不做实时求值光照时,颜色数是怎样被「档位」锁死的。

体素美术的通行做法是把每个面朝向的亮度预先烘成固定档位:朝上的面最亮,朝下的面最暗,四个侧面居中。渲染时不再做逐像素求值,只按面朝向查表取一个整数千分比,乘到基础色上。这条路线把光照从「运行时求值」降级成「离线配色」,代价是颜色的数量会随着档位数乘起来。

最终颜色数 = 基础色数 × 档位数

设模型用到 m 种基础材料,每个材料按 k 个档位着色,那么最终出现的颜色数最多是 m × k。体素主题包给的上限是 paletteCap = 16。8.1 的灯具用了 3 种材料(B / M / A),于是:

方案档位数 k颜色数 m × k是否越界(上限 16)
逐朝向直接着色618越界 2 色
折叠成 4 个 tier412余量 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 体素的引擎接入 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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