平面着色的光照模型——法线怎么算、点积怎么把方向变成亮度、光栅化怎么插值——是站内 《光照与阴影》 那一篇的地盘,本节不重推原理。本节只做低多边形侧的一件事:把「这块面该多亮」从光照模型里抽出来,变成一个整数档位,再按档位把面归成色块。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(最亮) | 3 | 250‰ | 朝向左上的面 |
| shade 1 | 5 | 416‰ | 最大色块 |
| shade 2 | 2 | 166‰ | |
| shade 3 | 2 | 166‰ | 朝向右下的面 |
- 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 低多边形的引擎接入约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。