5.1 色阶带与描边参数

NPR 卡通渲染把连续光照量化成几段色阶带、把轮廓画成一条描边。本节只做这两个参数的规范与整数账:四组色阶带变体的边界怎么定、为什么四组宽度都恰好和为 1000,描边宽度的千分比在四个高度下如何用整数上取整算出像素数,并用 PIL 依同一份参数真生成色阶带样本图。真实渲染观感标 X。

站内 渲染原理专题 是光照模型与后处理实现的家,本节不重推它;那篇 游戏美术风格指南 用提纲讲「风格要统一、要有辨识度」,本节换一个更硬的口径——把「几条色阶带、边界落在哪、描边多宽」写成可算、可校验的整数参数。上一节 4.3 低多边形的引擎接入约束 讲的是顶点属性与材质槽的接入账,本节把同一条思路用到 NPR 的两个核心参数上:色阶带(ramp band)与描边(outline)。

色阶带:把连续光照量化成 N 段

NPR 与写实最直观的差别,是光照不是连续渐变的:一束光打在球上,写实渲染出一整条明暗过渡,卡通渲染只给两三段颜色。这个「量化」由两件事决定——段数 N 与每段的起点(边界)。光照值用千分比 l10 ∈ [0, 1000) 表示,边界是一串升序整数 boundaries10;某点落在哪一段,就是「小于等于 l10 的边界有几个」——一个纯整数比较,不需要浮点。

段数不是随便挑的。段数越多越接近写实、越少越「卡通」,但段数一变,四段色的宽度就变:段宽必须显式写下来,否则不同人做同一套主题会得到不同的明暗比例。下表是本节的四组变体(完整定义见 config/st05_ramp.json),四组宽度都恰好和为 1000——这条和恒等式就是色阶带规范的第一道闸门:

变体段数边界 boundaries10各段宽度 widths10
toon-22500500, 500
toon-33340, 670340, 330, 330
toon-44250, 500, 750250, 250, 250, 250
toon-55200, 400, 600, 800200, 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 光照分层的视觉规范 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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