站内 渲染原理专题 是光照模型与后处理实现的家,本节不重推它;那篇 游戏美术风格指南 用提纲讲「风格要统一、要有辨识度」,本节换一个更硬的口径——把「几条色阶带、边界落在哪、描边多宽」写成可算、可校验的整数参数。上一节 4.3 低多边形的引擎接入约束 讲的是顶点属性与材质槽的接入账,本节把同一条思路用到 NPR 的两个核心参数上:色阶带(ramp band)与描边(outline)。
色阶带:把连续光照量化成 N 段
NPR 与写实最直观的差别,是光照不是连续渐变的:一束光打在球上,写实渲染出一整条明暗过渡,卡通渲染只给两三段颜色。这个「量化」由两件事决定——段数 N 与每段的起点(边界)。光照值用千分比 l10 ∈ [0, 1000) 表示,边界是一串升序整数 boundaries10;某点落在哪一段,就是「小于等于 l10 的边界有几个」——一个纯整数比较,不需要浮点。
段数不是随便挑的。段数越多越接近写实、越少越「卡通」,但段数一变,四段色的宽度就变:段宽必须显式写下来,否则不同人做同一套主题会得到不同的明暗比例。下表是本节的四组变体(完整定义见 config/st05_ramp.json),四组宽度都恰好和为 1000——这条和恒等式就是色阶带规范的第一道闸门:
| 变体 | 段数 | 边界 boundaries10 | 各段宽度 widths10 |
|---|---|---|---|
toon-2 | 2 | 500 | 500, 500 |
toon-3 | 3 | 340, 670 | 340, 330, 330 |
toon-4 | 4 | 250, 500, 750 | 250, 250, 250, 250 |
toon-5 | 5 | 200, 400, 600, 800 | 200, 200, 200, 200, 200 |
描边宽度:从千分比到像素
描边宽度不能直接写「3 像素」——同一份规范要出 64 px 的头像和 512 px 的立绘,像素数必须随分辨率缩放。所以规范里存的是相对角色高度的千分比 widthPermille,某个高度 H 下的像素数用整数上取整 (w*H + 999) // 1000 算。下面这份 D 层配置把色阶带、描边与生成物声明写在一起,是 S 层账与 P 层校验器的共同输入:
{
"version": 1,
"project": "stardust-inn",
"note": "NPR 色阶带与描边参数(D 层)。色阶带 = 把连续光照量化成 N 段:boundaries10 是每段起点的光照千分比(升序整数,严格小于 lightMax10),band 索引 = 小于等于 l10 的边界个数;widths10 由 boundaries10 推出,四组变体都恰好和为 1000。描边宽度以角色高 referenceHeightPx 的千分比给出,某高度 H 下的像素数用整数上取整 (w*H+999)//1000。shadeStepsPermille 是色阶带取色的明度梯度(整数千分比),带索引经 step = band*(len-1)//(N-1) 映射到梯度下标。全整数无浮点。真实渲染下的观感需真实渲染器,命令行不可复现(X)。",
"lightMax10": 1000,
"shadeStepsPermille": [250, 400, 550, 700, 1000],
"ramps": [
{ "id": "toon-2", "bands": 2, "boundaries10": [500] },
{ "id": "toon-3", "bands": 3, "boundaries10": [340, 670] },
{ "id": "toon-4", "bands": 4, "boundaries10": [250, 500, 750] },
{ "id": "toon-5", "bands": 5, "boundaries10": [200, 400, 600, 800] }
],
"outline": {
"referenceHeightPx": 256,
"heights": [64, 128, 256, 512],
"variants": [
{ "id": "thin", "widthPermille": 8 },
{ "id": "medium", "widthPermille": 16 },
{ "id": "bold", "widthPermille": 24 }
]
},
"generated": [
{
"id": "ramp_strip",
"file": "prism/out/gen/st05_ramp_strip.png",
"width": 256,
"height": 64,
"mode": "RGB",
"distinctColors": 5
}
]
}
S 层:参数账与色阶带样本图
下面这段是 S 层脚本,读上面那份配置算出四组宽度与三档描边的像素数,并用 PIL 依同一份参数真生成一张色阶带样本图(4 行 = 4 组变体,每行 256 px 铺满 0..999 的光照):
#!/usr/bin/env python3
# file: sim/st05_ramp.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st05_ramp.py
"""NPR 色阶带与描边参数账(S 层)。
读 config/st05_ramp.json(色阶带与描边参数)与 config/visual_spec.json(取 accent 真源色):
① 四组变体的 boundaries10 推出各带宽度,四组都恰好和为 1000;② widthPermille 在 4 个高度
下的描边像素数,上取整 (w*H+999)//1000;③ PIL 依同一份参数真生成 out/gen/st05_ramp_strip.png
(4 行 = 4 组变体,每行 256 px 铺满 0..999 光照,取色走 shadeStepsPermille 的整数梯度)。
落 out/st05_ramp.json(含 canonical 摘要与 sha256 前 16 位)供 P 层对照。全整数无浮点。
真实渲染下的观感需真实渲染器,命令行不可复现(X)。"""
import hashlib
import json
import os
import sys
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = os.path.dirname(ROOT) # generated[].file 相对 ~/.prismbook,与 prism_rungen.py 同基准
STRIP_W, ROW_H = 256, 16
def band_widths(bounds, light_max):
"""由边界推出各带宽度(整数),末带用 lightMax10 收口。"""
edges = [0] + list(bounds) + [light_max]
return [edges[i + 1] - edges[i] for i in range(len(edges) - 1)]
def band_index(l10, bounds):
"""band 索引 = 小于等于 l10 的边界个数。整数比较,无浮点。"""
return sum(1 for b in bounds if b <= l10)
def outline_px(width_permille, height_px):
"""某高度下的描边像素数:整数上取整 (w*H+999)//1000。"""
return (width_permille * height_px + 999) // 1000
def shade(c, permille):
"""按千分比压暗一个 sRGB 整数三元组,全程整数。"""
return tuple(v * permille // 1000 for v in c)
def gen_strip(cfg, accent, path):
"""4 行色阶带样本图:第 r 行 = 第 r 组变体,横向铺满 0..999 光照。"""
ramps, steps = cfg["ramps"], cfg["shadeStepsPermille"]
im = Image.new("RGB", (STRIP_W, ROW_H * len(ramps)))
for r, ramp in enumerate(ramps):
for x in range(STRIP_W):
l10 = x * cfg["lightMax10"] // STRIP_W
step = band_index(l10, ramp["boundaries10"]) * (len(steps) - 1) // (ramp["bands"] - 1)
color = shade(accent, steps[step])
for y in range(r * ROW_H, r * ROW_H + ROW_H):
im.putpixel((x, y), color)
im.save(path)
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st05_ramp.json"), encoding="utf-8"))
spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
accent, lm = spec["tokens"]["accent"], cfg["lightMax10"]
ramps = []
for r in cfg["ramps"]:
w = band_widths(r["boundaries10"], lm)
assert sum(w) == 1000, r["id"]
ramps.append({"id": r["id"], "bands": r["bands"], "boundaries10": r["boundaries10"],
"widths10": w, "minWidth10": min(w), "maxWidth10": max(w)})
heights = cfg["outline"]["heights"]
rows = [{"id": v["id"], "widthPermille": v["widthPermille"],
"pxByHeight": {str(h): outline_px(v["widthPermille"], h) for h in heights}}
for v in cfg["outline"]["variants"]]
canonical = json.dumps({"ramps": [r["widths10"] for r in ramps],
"outline": [[r["pxByHeight"][str(h)] for h in heights] for r in rows]},
separators=(",", ":"), ensure_ascii=False)
rep = {"project": cfg["project"], "version": cfg["version"], "lightMax10": lm,
"rampCount": len(ramps), "ramps": ramps,
"outline": {"referenceHeightPx": cfg["outline"]["referenceHeightPx"],
"heights": heights, "rows": rows},
"accent": accent, "shadeStepsPermille": cfg["shadeStepsPermille"],
"canonical": canonical, "digest": hashlib.sha256(canonical.encode()).hexdigest()[:16]}
os.makedirs(os.path.join(ROOT, "out/gen"), exist_ok=True)
gen_strip(cfg, accent, os.path.join(BASE, cfg["generated"][0]["file"]))
json.dump(rep, open(os.path.join(ROOT, "out/st05_ramp.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
print(f"ramps={len(ramps)} lightMax10={lm}")
for r in ramps:
print(f" {r['id']:<8} n={r['bands']} widths={r['widths10']}")
for r in rows:
px = " ".join(f"H{h}:{r['pxByHeight'][str(h)]}" for h in heights)
print(f" outline {r['id']:<7} {r['widthPermille']}permille {px}")
print(f"digest={rep['digest']}")
print("wrote out/st05_ramp.json + out/gen/st05_ramp_strip.png")
return 0
if __name__ == "__main__":
sys.exit(main())
在本机跑 /opt/homebrew/bin/python3 sim/st05_ramp.py,真实输出:
ramps=4 lightMax10=1000
toon-2 n=2 widths=[500, 500]
toon-3 n=3 widths=[340, 330, 330]
toon-4 n=4 widths=[250, 250, 250, 250]
toon-5 n=5 widths=[200, 200, 200, 200, 200]
outline thin 8permille H64:1 H128:2 H256:3 H512:5
outline medium 16permille H64:2 H128:3 H256:5 H512:9
outline bold 24permille H64:2 H128:4 H256:7 H512:13
digest=bda79b85e34d0d64
wrote out/st05_ramp.json + out/gen/st05_ramp_strip.png
读出来两件事。色阶带:toon-3 的三段是 340/330/330,不是均匀三等分——中间段略窄,是为了让「亮部」在多数光照角下占得更少、观感更「卡通」。描边:bold(24‰)在 256 px 下是 7 px,到 64 px 只剩 2 px——相对宽度不变,像素数随高度缩放,这正是「规范不写死像素」的意义。生成图 out/gen/st05_ramp_strip.png 经 prism_rungen.py 回读核对:256×64、RGB、distinctColors=5(四组变体共用同一套 5 级明度梯度),与配置声明逐位一致。
P 层:同一套判据的 TS 侧
同一套判据,TypeScript 侧独立实现一遍。它只读同一份配置,不 import Python 的任何东西:
// file: core/src/st05/ramp.ts
// tier: P
// verify: node --test 'core/test/st05_*.test.ts'
// NPR 色阶带与描边参数内核(P 层):与 sim/st05_ramp.py 共用 config/st05_ramp.json。
// 全部是整数比较与整数地板除/上取整,故两侧的带宽度、描边像素与 canonical 摘要逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Ramp = { id: string; bands: number; boundaries10: number[] };
/** 由边界推出各带宽度(整数),末带用 lightMax10 收口,四组变体都恰好和为 1000。 */
export function bandWidths(bounds: number[], lightMax: number): number[] {
const edges = [0, ...bounds, lightMax];
const out: number[] = [];
for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]);
return out;
}
/** band 索引 = 小于等于 l10 的边界个数。整数比较,无浮点。 */
export function bandIndex(l10: number, bounds: number[]): number {
let n = 0;
for (const b of bounds) if (b <= l10) n++;
return n;
}
/** 某高度下的描边像素数:整数上取整 (w*H+999)/1000,禁用 Math.ceil。 */
export function outlinePx(widthPermille: number, heightPx: number): number {
return Math.floor((widthPermille * heightPx + 999) / 1000);
}
/** 按千分比压暗一个 sRGB 整数三元组,全程整数地板除。 */
export function shade(c: number[], permille: number): number[] {
return c.map((v) => Math.floor((v * permille) / 1000));
}
/** canonical 摘要:键序固定 ramps → outline,无空白,与 Python 侧同形。 */
export function canonical(widths: number[][], outlinePxRows: number[][]): string {
return JSON.stringify({ ramps: widths, outline: outlinePxRows });
}
测试读 Python 落盘的 JSON 做跨语言对照,并带负例(边界处归哪一段、整数上取整不得用浮点)。完整测试见 core/test/st05_ramp.test.ts,本机跑 node --test 'core/test/st05_ramp.test.ts':
✔ 色阶带:边界升序、band 数自洽、四组宽度和恒为 1000 (0.8975ms)
✔ band 索引:边界处归上一带,正好落在边界上的光照计入下一带 (0.08125ms)
✔ 描边像素:整数上取整,禁用浮点;跨语言逐位一致 (0.099458ms)
✔ 取色梯度:band 经整数映射落到 shadeStepsPermille,四组变体共用 5 个色阶 (0.114708ms)
✔ 跨语言:canonical 摘要与 digest 逐位一致(整数类) (0.336958ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
跨语言摘要:两侧独立复算出的 canonical 摘要 sha256 前 16 位都是 bda79b85e34d0d64——宽度、描边像素与摘要是整数类,逐位一致。这里没有一处色彩派生量参与比较,故不涉及浮点容差。
自检表
| 检查项 | 合格标准 |
|---|---|
| 色阶带可判 | 段数、边界、宽度三者自洽,四组宽度和恒为 1000 |
| 描边可缩放 | 存相对千分比,像素数按高度整数上取整,不写死像素 |
| 账可复算 | 宽度、描边像素、摘要全整数,跨语言逐位一致 |
| 生成可核 | PNG 尺寸、模式、颜色数与配置声明逐项相符 |
最后一条边界必须说清:本节算的是参数与账,不是画面。真渲染一帧 NPR、真导出反向外扩的描边网格、在引擎里拖动色阶带参数观察观感——这些需真实环境(渲染器 / DCC),命令行不可复现,本节一律标 X,不做「实测」。下一节把色阶带落到更细的光照分层上:一层光照一个参数,档位越高层越多,而「阴影最暗、高光最亮」要写成一条会失败的判据。
阅读导航:上一节:4.3 低多边形的引擎接入约束 · 下一节:5.2 光照分层的视觉规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。