4.1 sRGB 与线性空间:美术为什么要在意

美术在 sRGB 里取色、在引擎里混合,却很少问这两件事是否发生在同一个空间。本节用一份 sRGB 8-bit 真源复算传递函数、相对亮度与对比度,实测「琥珀与天蓝色相完全不同、对比度却一模一样」,说明对比度只由亮度决定,并给出跨语言的容差口径。

站内 45 篇的渲染原理专题里,色彩管理与 HDR 管线 讲的是色彩管理的渲染原理口径——显示设备的色彩空间转换、传输函数与 HDR 色调映射;本节不重推那套原理,只从美术侧回答一个更靠前的问题:美术取色、混合、配色时,究竟工作在哪一个色彩空间里。另一篇 数据可视化中的无障碍配色 讲的是图表口径的色盲与对比度;本节取的是游戏美术调色板视角,判据(WCAG 对比度、感知距离)相同,但落地场景是角色、UI 与场景色。第 3 章把主辅强调色与 token 系统定了下来,本章接着往下走:那些 token 的数值本身,在数学上意味着什么。

4.1 sRGB 与线性空间:美术为什么要在意

4.1.1 存下来的数字不是光的量

美术在色板里点一个 #808080(128,128,128),直觉上它是「正中间」的灰。但在物理光强上,它只有白色的约 21.6%——因为 sRGB 是感知编码(近似 gamma 2.2),它把暗部的刻度拉长、亮部压扁,好让 8-bit 的 256 级在肉眼看来分布均匀。这个编码对「存图」是对的,对「算光」是错的。

于是出现了两类典型错误:

  • 在 sRGB 空间里混合。把红色和深色各取一半,直接对 8-bit 数值求平均,得到的颜色比物理正确的结果偏暗。渐变里出现脏色、贴图混合发灰,多来自这一条。
  • 在 sRGB 空间里做光照。加一盏灯、做一次衰减,本质是光的线性叠加,必须在线性空间里算。在 sRGB 里加光,暗部会被过度提亮。

正确做法只有一句:存的是 sRGB,算的是 linear。取色、显示用 sRGB;混合、光照、插值先把 sRGB 转成 linear,算完再转回 sRGB。

4.1.2 D 层:把样例写成配置

下面这份是 D 层配置,落在 config/sp04_space.json:它只声明要复算的样例——一对「色相完全不同」的颜色、一段 sRGB 阶梯、一对混合端点。色彩真源一律是 sRGB 8-bit 整数三元组,hex 只是显示形态。

{
  "observation": "公开可观察,观察时点 2026-10;sRGB 传递函数与 WCAG 相对亮度系数是公开规格,本文件只声明要复算的样例,不引入任何私有素材",
  "note": "第 4 章色彩空间样例:一份 sRGB 8-bit 整数三元组清单,供 sRGB↔linear 复算与「对比度只由亮度决定」的实测",
  "hueInvariant": {
    "note": "两色色相完全不同(琥珀 vs 天蓝),用来实测「对比度只由相对亮度决定、与色相无关」",
    "amber": [245, 158, 11],
    "sky": [56, 189, 248],
    "against": "bg"
  },
  "linearRamp": [0, 16, 32, 64, 96, 128, 160, 192, 224, 255],
  "blend": {
    "note": "两个端点色各半的 50% 混合,分别在 sRGB 空间与 linear 空间做,比较结果",
    "a": [239, 68, 68],
    "b": [31, 41, 51]
  }
}

4.1.3 S 层:复算传递函数、亮度与对比度

下面这段是 S 层脚本,读共享真源 visual_spec.json(只读)与上面的配置,复算三件事:sRGB→linear 单通道传递函数、WCAG 相对亮度与对比度 ×10、以及同一次混合在 sRGB 空间与 linear 空间的差异。sRGB→linear 用与 core/src/sp00/spec.ts 同源的常数(阈值 0.04045/12.92、指数 2.4/1.055/0.055),不引第三方库。

#!/usr/bin/env python3
# file: sim/sp04_srgb.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp04_srgb.py
"""sRGB 与线性空间(第 4.1 节 S 层)。

读共享真源 config/visual_spec.json(只读)与 config/sp04_space.json:复算 sRGB↔linear 传递函数、
WCAG 相对亮度与对比度 ×10,实测「琥珀 vs 天蓝:亮度几乎相同、对比度同值」,并比较两种混合。
落盘 out/sp04_srgb.json + .csv。转换常数与 core/src/sp00/spec.ts 同源;整数类逐位一致、派生量按 1e-9 容差。
"""

from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")

SRGB_THRESHOLD, SRGB_DIVISOR, SRGB_OFFSET, SRGB_SCALE, SRGB_GAMMA = 0.04045, 12.92, 0.055, 1.055, 2.4
LUMA_R, LUMA_G, LUMA_B = 0.2126, 0.7152, 0.0722


def srgb_to_linear(c: int) -> float:
    s = c / 255
    return s / SRGB_DIVISOR if s <= SRGB_THRESHOLD else ((s + SRGB_OFFSET) / SRGB_SCALE) ** SRGB_GAMMA


def linear_to_srgb_channel(v: float) -> int:
    s = v * SRGB_DIVISOR if v <= 0.0031308 else SRGB_SCALE * (v ** (1 / SRGB_GAMMA)) - SRGB_OFFSET
    return max(0, min(255, round(s * 255)))


def luminance(rgb) -> float:
    return LUMA_R * srgb_to_linear(rgb[0]) + LUMA_G * srgb_to_linear(rgb[1]) + LUMA_B * srgb_to_linear(rgb[2])


def contrast10(a, b) -> int:
    la, lb = luminance(a), luminance(b)
    hi, lo = max(la, lb), min(la, lb)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def blend_srgb(a, b) -> list:
    """在 sRGB 8-bit 空间直接取中点(错误做法)。整数地板除,逐位一致。"""
    return [(a[i] + b[i]) // 2 for i in range(3)]


def blend_linear(a, b) -> list:
    """先转 linear、取中点、再转回 sRGB(正确做法)。"""
    return [linear_to_srgb_channel((srgb_to_linear(a[i]) + srgb_to_linear(b[i])) / 2) for i in range(3)]


def main() -> int:
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    cfg = json.load(open(os.path.join(ROOT, "config/sp04_space.json"), encoding="utf-8"))
    os.makedirs(OUT, exist_ok=True)

    ramp = [{"srgb": v, "linear": round(srgb_to_linear(v), 12)} for v in cfg["linearRamp"]]

    hi = cfg["hueInvariant"]
    bg = spec["tokens"][hi["against"]]
    amber, sky = hi["amber"], hi["sky"]
    lum_amber, lum_sky = luminance(amber), luminance(sky)
    cr_amber, cr_sky = contrast10(amber, bg), contrast10(sky, bg)

    ba, bb = cfg["blend"]["a"], cfg["blend"]["b"]
    mixed_srgb, mixed_linear = blend_srgb(ba, bb), blend_linear(ba, bb)
    blend_delta = max(abs(mixed_srgb[i] - mixed_linear[i]) for i in range(3))

    tokens_vs_bg = sorted(
        ({"token": n, "contrast10_vs_bg": contrast10(rgb, bg)} for n, rgb in spec["tokens"].items()),
        key=lambda r: r["token"],
    )

    summary = {
        "source": "sim/sp04_srgb.py", "specVersion": spec["specVersion"],
        "hueInvariant": {
            "amber": amber, "sky": sky, "against": hi["against"], "bg": bg,
            "luminanceAmber": round(lum_amber, 12), "luminanceSky": round(lum_sky, 12),
            "luminanceDiff": round(abs(lum_amber - lum_sky), 12),
            "contrast10Amber": cr_amber, "contrast10Sky": cr_sky, "equalContrast": cr_amber == cr_sky,
        },
        "ramp": ramp, "midGray": {"srgb": 128, "linear": round(srgb_to_linear(128), 12)},
        "blend": {"a": ba, "b": bb, "inSrgb": mixed_srgb, "inLinear": mixed_linear,
                  "maxChannelDelta": blend_delta},
        "tokensVsBg": tokens_vs_bg,
    }
    with open(os.path.join(OUT, "sp04_srgb.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    with open(os.path.join(OUT, "sp04_srgb.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["srgb", "linear"])
        for r in ramp:
            w.writerow([r["srgb"], r["linear"]])

    print(f"hueInvariant amber lum={lum_amber:.10f} sky lum={lum_sky:.10f} diff={abs(lum_amber - lum_sky):.10f}")
    print(f"contrast10 amber/bg={cr_amber} sky/bg={cr_sky} equal={cr_amber == cr_sky}")
    print(f"midGray 128 -> linear {srgb_to_linear(128):.6f}")
    print(f"blend srgb={mixed_srgb} linear={mixed_linear} maxDelta={blend_delta}")
    print("wrote out/sp04_srgb.json out/sp04_srgb.csv")
    return 0


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

本机实跑输出:

hueInvariant amber lum=0.4389035899 sky lum=0.4401325599 diff=0.0012289699
contrast10 amber/bg=69 sky/bg=69 equal=True
midGray 128 -> linear 0.215861
blend srgb=[135, 54, 59] linear=[177, 56, 60] maxDelta=42
wrote out/sp04_srgb.json out/sp04_srgb.csv

逐条读:琥珀 [245,158,11] 与天蓝 [56,189,248] 的相对亮度分别是 0.4389035899 与 0.4401325599,只差 0.0012;它们对同一个底 bg 的对比度都是 69(equal=True)。两色在色相上一个是橙一个是青,隔了大半圈,对比度却一模一样——因为 WCAG 对比度只由相对亮度决定,与色相无关。这条结论后面 4.3 还会再用一次。混合那一行也直白:同样两个端点各取一半,sRGB 空间得到 [135,54,59]、linear 空间得到 [177,56,60],单通道最大差 42——这就是「在错误空间里混合会偏暗」的量化版本。

4.1.4 P 层:TS 内核与跨语言对照

下面这段是 P 层 TS 内核,复用 core/src/sp00/spec.ts 的 srgbToLinear/luminance/contrast10(转换常数与 Python 同源),只新增逆传递函数与两种混合。它不重写任何常数——这是「三处消费同一份规范」不被退化成「三处各写一遍」的关键。

// file: core/src/sp04/space.ts
// tier: P
// verify: node --test 'core/test/sp04_*.test.ts'
// 第 4.1 节 P 层内核:sRGB ↔ linear 的 TS 侧消费者。**复用 core/src/sp00/spec.ts 的
// srgbToLinear / luminance / contrast10**,不重写转换常数(两语言同源)。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
import { srgbToLinear, luminance, contrast10, SRGB_DIVISOR, SRGB_SCALE, SRGB_OFFSET, SRGB_GAMMA } from "../sp00/spec.ts";

export type RGB = number[];

/** linear → sRGB 8-bit 单通道(逆传递函数),四舍五入到整数。 */
export function linearToSrgbChannel(v: number): number {
  const s = v <= 0.0031308 ? v * SRGB_DIVISOR : SRGB_SCALE * Math.pow(v, 1 / SRGB_GAMMA) - SRGB_OFFSET;
  return Math.max(0, Math.min(255, Math.round(s * 255)));
}

/** 在 sRGB 8-bit 空间直接取中点(错误做法,会偏暗)。整数地板除,逐位一致。 */
export function blendSrgb(a: RGB, b: RGB): RGB {
  return [0, 1, 2].map((i) => Math.floor((a[i] + b[i]) / 2));
}

/** 先转 linear、取中点、再转回 sRGB(正确做法)。 */
export function blendLinear(a: RGB, b: RGB): RGB {
  return [0, 1, 2].map((i) => linearToSrgbChannel((srgbToLinear(a[i]) + srgbToLinear(b[i])) / 2));
}

/** 两色对同一底的对比度 ×10 —— 整数类,逐位一致。 */
export function pairContrast10(a: RGB, bg: RGB): number {
  return contrast10(a, bg);
}

/** 相对亮度(派生量,跨语言按容差断言)。 */
export function relLuminance(rgb: RGB): number {
  return luminance(rgb);
}

配套的 P 层测试读 Python 落盘的 out/sp04_srgb.json(不是同一份源码),做跨语言对照:整数类逐位一致、色彩派生量容差内一致。

// file: core/test/sp04_space.test.ts
// tier: P
// verify: node --test 'core/test/sp04_*.test.ts'
// 第 4.1 节跨语言对照:读 sim/sp04_srgb.py 落盘的 out/sp04_srgb.json,断言 TS 侧复算
// 「整数类逐位一致、色彩派生量容差内一致」,并锁死「对比度只由亮度决定」这条实测。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { srgbToLinear, contrast10 } from "../src/sp00/spec.ts";
import { linearToSrgbChannel, blendSrgb, blendLinear, pairContrast10, relLuminance } from "../src/sp04/space.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const spec = JSON.parse(readFileSync(resolve(ROOT, "config/visual_spec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp04_srgb.json"), "utf8"));
const TOL: Record<string, number> = spec.tolerance;

test("sRGB → linear 阶梯:容差内一致", () => {
  let maxDelta = 0;
  for (const row of py.ramp) maxDelta = Math.max(maxDelta, Math.abs(srgbToLinear(row.srgb) - row.linear));
  assert.ok(maxDelta <= TOL.linear, `最大偏差 ${maxDelta} > ${TOL.linear}`);
});

test("逆传递函数可往返:整数类逐位一致", () => {
  for (const row of py.ramp) assert.equal(linearToSrgbChannel(srgbToLinear(row.srgb)), row.srgb);
});

test("对比度 ×10:整数类逐位一致", () => {
  for (const row of py.tokensVsBg) {
    assert.equal(pairContrast10(spec.tokens[row.token], py.hueInvariant.bg), row.contrast10_vs_bg, row.token);
  }
});

test("对比度只由亮度决定:琥珀与天蓝对比度相同(色相无关)", () => {
  const h = py.hueInvariant;
  assert.equal(h.contrast10Amber, h.contrast10Sky);
  assert.equal(pairContrast10(h.amber, h.bg), pairContrast10(h.sky, h.bg));
  assert.equal(contrast10(h.amber, h.bg), 69);
  assert.equal(contrast10(h.sky, h.bg), 69);
});

test("亮度几乎相同:容差内一致,且差 < 0.002", () => {
  const h = py.hueInvariant;
  const la = relLuminance(h.amber);
  const ls = relLuminance(h.sky);
  assert.ok(Math.abs(la - h.luminanceAmber) <= TOL.linear);
  assert.ok(Math.abs(ls - h.luminanceSky) <= TOL.linear);
  assert.ok(Math.abs(la - ls) < 0.002, `亮度差 ${Math.abs(la - ls)}`);
});

test("混合:sRGB 空间与 linear 空间结果确实不同(这就是美术要在意线性的原因)", () => {
  const b = py.blend;
  assert.deepEqual(blendSrgb(b.a, b.b), b.inSrgb);
  assert.deepEqual(blendLinear(b.a, b.b), b.inLinear);
  assert.ok(Math.max(...[0, 1, 2].map((i) => Math.abs(blendSrgb(b.a, b.b)[i] - blendLinear(b.a, b.b)[i]))) > 0);
});

test("中点 128 在 linear 空间只有约 0.216(sRGB 不是线性刻度)", () => {
  assert.ok(Math.abs(srgbToLinear(128) - 0.215861) < 1e-6);
  assert.ok(srgbToLinear(128) < 0.25);
});

本机跑 node --test 'core/test/sp04_*.test.ts'(本章三节共用 sp04_ 前缀,一次跑全 14 条):

✔ 二色觉模拟:8 个 token × 3 型,RGB 8-bit 整数逐位一致
✔ 对比度 ×10:整数类逐位一致,并判定 danger/surface 是否过下限(含真实违例)
✔ 等亮度红/绿:正常可区分,绿色盲下塌陷到阈值以下
✔ token 对 ok/danger:亮度差把红绿从塌陷里救回来(模拟后仍可区分)
✔ OKLab / OKLCH:8 个 token 容差内一致
✔ 感知均匀性:OKLCH 等间隔取色的相邻距离极差 < HSL 等间隔取色
✔ 对比度相同不等于感知相同:琥珀与天蓝 OKLab 距离很大
✔ sRGB → linear 阶梯:容差内一致
✔ 逆传递函数可往返:整数类逐位一致
✔ 对比度 ×10:整数类逐位一致
✔ 对比度只由亮度决定:琥珀与天蓝对比度相同(色相无关)
✔ 亮度几乎相同:容差内一致,且差 < 0.002
✔ 混合:sRGB 空间与 linear 空间结果确实不同(这就是美术要在意线性的原因)
✔ 中点 128 在 linear 空间只有约 0.216(sRGB 不是线性刻度)
ℹ tests 14
ℹ pass 14
ℹ fail 0

跨语言摘要(digest):整数类(对比度 ×10)逐位一致,0 处不符;色彩派生量在声明容差 1e-9 内一致,sRGB→linear 单通道的实测最大偏差 4.891643e-13。这正是「sRGB 8-bit 整数是真源、linear 是派生量」这条纪律的落地——派生量跨语言不按逐位比,只能按容差断言。

4.1.5 真渲染与真显示设备不可复现

上面的数字都是同机相对量级,绝对数值不可移植。要强调一条边界:在真实显示设备上核对颜色、用色度计测屏、在 HDR 显示器上看色调映射——全部需要真实硬件,命令行不可复现(本节一律标 X)。命令行能算的是相对亮度、对比度与空间转换这些数值账,它不代替「在真机上用眼睛看」。

小结

  • sRGB 是感知编码,存图用它是对的;算光必须转 linear,否则混合发暗、加光失真。
  • 一份 config/sp04_space.json 声明样例,S 层 Python 与 P 层 TS 读同一份 visual_spec.json、用同源常数各自复算,整数类逐位一致、派生量容差内一致(最大偏差 4.891643e-13)。
  • 实测锁死一条反直觉结论:琥珀与天蓝色相完全不同,对比度却都是 69——对比度只由亮度决定,与色相无关。这解释了「对比度合格 ≠ 配色好看 ≠ 色相有区分度」,也直接引出下一节。

下一节换一个感知均匀的空间:既然对比度看不见色相差,我们就需要一个能度量色相差的距离。

阅读导航:上一节:3.3 跨场景色彩一致性 · 下一节:4.2 OKLab/OKLCH:感知均匀的色彩距离 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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