4.2 平面着色与色块划分

平面着色给每个面一个平色,色块划分就是决定这个面该归哪一档亮度。本节把一块 12 面 lowpoly 宝石按整数 shadeLevel 分成 4 个色块,算出每档面数与最大色块占比 416‰,用 PIL 按同一份 palette 真生成平面色样本 PNG,并给出跨语言 digest 与色块超预算的负例。

平面着色的光照模型——法线怎么算、点积怎么把方向变成亮度、光栅化怎么插值——是站内 《光照与阴影》 那一篇的地盘,本节不重推原理。本节只做低多边形侧的一件事:把「这块面该多亮」从光照模型里抽出来,变成一个整数档位,再按档位把面归成色块。4.1 算清了有多少面,这一节算这些面该分成几块颜色。

4.2.1 平面着色只给一个面一个颜色

平面着色下,一个面内部颜色处处相同(法线不插值),所以「面」就是天然的最小着色单元。若把每个面都给一个独立颜色,12 个面就是 12 种颜色——那不像低多边形,像打碎的玻璃。低多边形的做法是把面归进少数几档亮度:同一档的面用同一种颜色,色块由此而来。

于是低多边形侧要定的不是「每个面什么颜色」,而是两件事:分几档、每个面归哪档。档位是整数(0 最亮、越大越暗),由光照模型算出后量化;归哪档由美术按形体的朝向定。两者都是整数,可复算。

4.2.2 色块划分:从面到块

色块 = 亮度档相同的面集合,色块数 = 实际用到的档数。三个整数口径:

口径定义判据
色块数用到的档数(非零档计数)≤ colorBlockBudget
色块占比该档面数 × 1000 // 总面数最大块不宜过半
每色块面数总面数 // 色块数反映平均块大小

colorBlockBudget 与主题的明度阶梯绑定:lowpoly 主题从 bg 到 ink 一共 5 档,所以最多 5 个色块。档数超过阶梯就没有对应的 token 颜色可用了。

4.2.3 D 层配置

一块 12 面 lowpoly 宝石(12 边正多边形从中心扇状三角化,中心扇必然无重叠地铺满凸多边形),每个面带一个整数 shadeLevel;palette 就是这 5 档明度的 sRGB 8-bit 三元组——同一份 palette 既是绘制输入,又是验收判据。

{
  "project": "stardust-inn",
  "theme": "lowpoly",
  "specVersion": 1,
  "note": "4.2 平面着色与色块划分:一块 lowpoly 宝石,12 边正多边形从中心点扇状三角化(中心扇必然无重叠地铺满凸多边形),每个面带一个整数 shadeLevel(面亮度层级)。平面着色的光照模型原理见 graphics/(外链),本节只消费层级编号、只做色块账。色块 = shadeLevel 相同的面集合,色块数 = 用到的层级数。colorBlockBudget=5 对应 lowpoly 主题从 bg 到 ink 的 5 档明度阶梯;palette 就是这 5 档的 sRGB 8-bit 三元组。shadeLevel 0 最亮、4 最暗。全部整数,跨语言逐位一致。",
  "colorBlockBudget": 5,
  "shadeLevels": 5,
  "palette": [[200, 220, 235], [150, 178, 200], [100, 126, 150], [64, 84, 102], [30, 42, 54]],
  "canvas": { "width": 128, "height": 128, "bg": [30, 42, 54] },
  "asset": {
    "id": "gem",
    "cn": "宝石",
    "vertices": [
      [114, 64], [107, 89], [89, 107], [64, 114], [39, 107], [21, 89],
      [14, 64], [21, 39], [39, 21], [64, 14], [89, 21], [107, 39], [64, 64]
    ],
    "faces": [
      { "v": [12, 0, 1], "shade": 1 },
      { "v": [12, 1, 2], "shade": 2 },
      { "v": [12, 2, 3], "shade": 3 },
      { "v": [12, 3, 4], "shade": 3 },
      { "v": [12, 4, 5], "shade": 2 },
      { "v": [12, 5, 6], "shade": 1 },
      { "v": [12, 6, 7], "shade": 0 },
      { "v": [12, 7, 8], "shade": 0 },
      { "v": [12, 8, 9], "shade": 1 },
      { "v": [12, 9, 10], "shade": 1 },
      { "v": [12, 10, 11], "shade": 0 },
      { "v": [12, 11, 0], "shade": 1 }
    ]
  },
  "generated": [
    { "id": "lowpoly_blocks", "file": "prism/out/gen/st04_blocks.png", "width": 128, "height": 128, "mode": "RGB", "distinctColors": 5 }
  ]
}

4.2.4 S 层:色块账 + 真生成样本

下面这段是 S 层脚本:算每档面数与千分比、最大色块、每色块平均面数,判色块数是否超预算;再用 PIL 按同一份 palette 把每个面填成平面色,落 out/gen/st04_blocks.png:

#!/usr/bin/env python3
# file: sim/st04_blocks.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st04_blocks.py
"""4.2 平面着色与色块划分的 S 层整数账。

读 config/st04_blocks.json 的一块 lowpoly 宝石:每个面带一个整数 shadeLevel。色块 = shadeLevel
相同的面集合,色块数 = 用到的层级数。本节只消费层级编号、只做色块账——平面着色的光照模型
(法线、点积、光栅化插值)原理见 graphics/,不在这里重推。

算:面数、每档色块的面数与千分比、最大色块占比、平均每色块面数(整数地板除)、色块数是否
超 colorBlockBudget。再用 PIL 按同一份 palette 把每个面填成平面色,落 out/gen/st04_blocks.png
(规范既是生成器又是闸门:palette 与 shadeLevel 同时是绘制的输入与验收的判据)。
产出 out/st04_blocks.json + out/gen/st04_blocks.png。全部整数,跨语言逐位一致。
"""
import hashlib
import json
import os
import sys

from PIL import Image, ImageDraw

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG = os.path.join(ROOT, "config/st04_blocks.json")
OUT, GEN = os.path.join(ROOT, "out"), os.path.join(ROOT, "out/gen")


def analyze(cfg):
    asset = cfg["asset"]
    faces = asset["faces"]
    total = len(faces)
    hist = {s: 0 for s in range(cfg["shadeLevels"])}
    for f in faces:
        hist[f["shade"]] += 1
    blocks = [{"shade": s, "faces": hist[s], "blockPermille": hist[s] * 1000 // total}
              for s in range(cfg["shadeLevels"]) if hist[s] > 0]
    max_block = max(blocks, key=lambda b: b["faces"])
    return {
        "id": asset["id"], "cn": asset["cn"], "faceCount": total,
        "blocksUsed": len(blocks), "blocks": blocks,
        "maxBlockFaces": max_block["faces"], "maxBlockShade": max_block["shade"],
        "maxBlockPermille": max_block["blockPermille"],
        "facesPerBlock": total // len(blocks),
    }


def render(cfg, path):
    """按 palette[shade] 平面填色,纯 2D 多边形填充(无需渲染器)。"""
    cv, verts = cfg["canvas"], cfg["asset"]["vertices"]
    im = Image.new("RGB", (cv["width"], cv["height"]), tuple(cv["bg"]))
    d = ImageDraw.Draw(im)
    for f in cfg["asset"]["faces"]:
        tri = [tuple(verts[i]) for i in f["v"]]
        d.polygon(tri, fill=tuple(cfg["palette"][f["shade"]]))
    im.save(path)


def main() -> int:
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    acc = analyze(cfg)
    digest = hashlib.sha256("\n".join(
        f'{i}:{f["shade"]}' for i, f in enumerate(cfg["asset"]["faces"])).encode("utf-8")).hexdigest()[:16]
    report = {
        "project": cfg["project"], "theme": cfg["theme"],
        "colorBlockBudget": cfg["colorBlockBudget"], "shadeLevels": cfg["shadeLevels"],
        "palette": cfg["palette"], "canvas": cfg["canvas"],
        "faceCount": acc["faceCount"], "blocksUsed": acc["blocksUsed"],
        "blocks": acc["blocks"], "maxBlockFaces": acc["maxBlockFaces"],
        "maxBlockShade": acc["maxBlockShade"], "maxBlockPermille": acc["maxBlockPermille"],
        "facesPerBlock": acc["facesPerBlock"],
        "blockOk": acc["blocksUsed"] <= cfg["colorBlockBudget"],
        "headroom": cfg["colorBlockBudget"] - acc["blocksUsed"],
        "digest": digest,
    }
    os.makedirs(GEN, exist_ok=True)
    with open(os.path.join(OUT, "st04_blocks.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    render(cfg, os.path.join(GEN, "st04_blocks.png"))

    print(f'{acc["id"]} faces={acc["faceCount"]} blocksUsed={acc["blocksUsed"]} '
          f'budget={cfg["colorBlockBudget"]} headroom={report["headroom"]}')
    for b in acc["blocks"]:
        print(f'  shade={b["shade"]} faces={b["faces"]:>3} blockPermille={b["blockPermille"]:>4}')
    print(f'maxBlock shade={acc["maxBlockShade"]} faces={acc["maxBlockFaces"]} '
          f'permille={acc["maxBlockPermille"]} facesPerBlock={acc["facesPerBlock"]}')
    print(f'blockOk={report["blockOk"]}')
    print(f'digest={digest}')
    print("wrote out/st04_blocks.json out/gen/st04_blocks.png")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/st04_blocks.py,再用「一处生成」门禁回读 PNG:

gem faces=12 blocksUsed=4 budget=5 headroom=1
  shade=0 faces=  3 blockPermille= 250
  shade=1 faces=  5 blockPermille= 416
  shade=2 faces=  2 blockPermille= 166
  shade=3 faces=  2 blockPermille= 166
maxBlock shade=1 faces=5 permille=416 facesPerBlock=3
blockOk=True
digest=66d6260fc9b3b21c
wrote out/st04_blocks.json out/gen/st04_blocks.png
== prism_rungen: 声明 1 / FAIL 0 / WARN 12

prism_rungen.py 按同一份 spec 回读 PNG 的尺寸(128×128)、模式(RGB)与颜色数(5)——4 档面颜色加 1 个背景色,一个不差。这就是「规范既是生成器,又是闸门」。

4.2.5 P 层:同一套色块口径的 TS 内核

下面这段是 P 层 TS 内核,与 Python 逐条对应——同一个直方图、同一个地板除千分比、同一份 digest 行格式:

// file: core/src/st04/blocks.ts
// tier: P
// verify: node --test 'core/test/st04_*.test.ts'
// 4.2 平面着色与色块划分的 P 层内核。与 sim/st04_blocks.py 同一套整数口径:色块 = shadeLevel
// 相同的面集合;色块数 = 用到的层级数;色块占比 = 面数 * 1000 // 总面数(整数地板除)。
// 本节只消费整数 shadeLevel,平面着色的光照模型原理见 graphics/(外链)。整数类,逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Face = { v: number[]; shade: number };

export type BlockRow = { shade: number; faces: number; blockPermille: number };

/** 各亮度档的面数直方图(长度 = 档数,含 0 的档)。 */
export function shadeHistogram(faces: Face[], levels: number): number[] {
  const hist: number[] = [];
  for (let i = 0; i < levels; i += 1) hist.push(0);
  for (const f of faces) hist[f.shade] += 1;
  return hist;
}

/** 用到的色块数 = 直方图里非零档的个数。 */
export function blocksUsed(hist: number[]): number {
  return hist.filter((n) => n > 0).length;
}

/** 色块占比(千分比),整数地板除。 */
export function blockPermille(count: number, total: number): number {
  return Math.floor((count * 1000) / total);
}

/** 逐档色块行:只保留用到的档。 */
export function blockRows(faces: Face[], levels: number): BlockRow[] {
  const hist = shadeHistogram(faces, levels);
  const total = faces.length;
  const rows: BlockRow[] = [];
  for (let s = 0; s < levels; s += 1) {
    if (hist[s] > 0) rows.push({ shade: s, faces: hist[s], blockPermille: blockPermille(hist[s], total) });
  }
  return rows;
}

/** 最大色块的面数与占比。 */
export function maxBlock(rows: BlockRow[]): BlockRow {
  let best = rows[0];
  for (const r of rows) if (r.faces > best.faces) best = r;
  return best;
}

/** digest 行:面序号:shade,与 Python 同序同格式。 */
export function faceLines(faces: Face[]): string[] {
  return faces.map((f, i) => `${i}:${f.shade}`);
}

4.2.6 跨语言对照

P 层测试 core/test/st04_blocks.test.ts 读 out/st04_blocks.json,断言色块数、逐档面数、千分比、最大色块与 digest 逐位一致。实跑 node --test 'core/test/st04_*.test.ts'(本节 4 条):

blocks digest = 66d6260fc9b3b21c
✔ 跨语言:色块数、逐档面数与千分比 == Python 产物(逐位一致)
✔ 跨语言:最大色块与 digest 逐位一致
✔ 正例:4 个色块 ≤ 5 预算,最大色块占 416‰
✔ 负例:色块数超预算必须 fail,而不是静默放过
ℹ pass 13
ℹ fail 0

两侧 digest 都是 66d6260fc9b3b21c。负例把 12 个面轮流走满 5 档,色块数正好压到预算 5——判据是「色块数 vs 预算」,不是面数;预算收紧到 4,同一块宝石立刻 fail。

4.2.7 读表与边界

亮度档面数色块占比说明
shade 0(最亮)3250‰朝向左上的面
shade 15416‰最大色块
shade 22166‰
shade 32166‰朝向右下的面
  • 4 个色块 ≤ 5 档预算,余量 1。 最大色块 shade 1 占 416‰,没过半,形体不会糊成一坨单色。
  • 色块数是风格指标,不是画质指标。 块太少 → 像纸片;块太多 → 像碎玻璃。lowpoly 的「低」就低在这个数上,和 4.1 的面数预算一起构成风格的两条边。
  • 本节不含真渲染与画师主观判断(标 X)。out/gen/st04_blocks.png 是按 palette 程序化填充的位图,只用来核对色块数与颜色数;「这块宝石好不好看」仍需人工评审。
  • 整数口径的代价:千分比地板除会丢尾数,12 面这种小网格尤其粗;本套接受这个代价,换取 S 层与 P 层的逐位一致。

4.1 管有多少面,本节管这些面分成几块色。4.3 把色块往下推一层:这些色块在引擎里怎么落到顶点上、占几个材质槽。

阅读导航:上一节:4.1 面数与顶点预算 · 下一节:4.3 低多边形的引擎接入约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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