Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›引擎色彩管理工程 2026:色调映射与 HDR 输出管线

Index

  • 一、为什么"最后一个 1% 的像素"值得写一整篇
  • 二、色彩空间的三个坐标:色度、传递函数、显示环境
  • 三、线性工作流的中间缓冲:格式约定即正确性约定
  • 四、色调映射算子实测对照:Reinhard、ACES 与 PBR Neutral
  • 五、Khronos PBR Neutral 规范精读
  • 六、曝光与自动曝光:EV 标定与适应时间常数
  • 七、色域映射:从 Rec.709 到 Display P3 与 Rec.2020
  • 八、HDR 输出链路:PQ 曲线与交换链格式
  • 九、工程落地:3D LUT、量化抖动与验证闭环
  • 9.1 3D LUT 的误差实算
  • 9.2 量化与抖动的实算
  • 9.3 一条可执行的验证闭环
  • 9.4 一条容易被忽略的区分
  • 参考文献
  • 一句话摘要

引擎色彩管理工程 2026:色调映射与 HDR 输出管线

色调映射不是渲染管线末端的一个可选滤镜,而是决定画面观感的核心环节。本文从色彩空间的三个坐标出发,逐段拆解色调映射算子、色域映射与 HDR 输出链路,用现场实算的数值对照说明 Reinhard、ACES 拟合曲线与 Khronos PBR Neutral 的响应差异,并给出曝光标定、PQ 曲线锚点、3D LUT 插值误差与抖动量的可核对判据。

2026年10月31日·约 40 分钟阅读·11,742 字·2 次阅读·博主
#游戏引擎与渲染
引擎色彩管理工程 2026:色调映射与 HDR 输出管线

Index

  • 一、为什么"最后一个 1% 的像素"值得写一整篇
  • 二、色彩空间的三个坐标:色度、传递函数、显示环境
  • 三、线性工作流的中间缓冲:格式约定即正确性约定
  • 四、色调映射算子实测对照:Reinhard、ACES 与 PBR Neutral
  • 五、Khronos PBR Neutral 规范精读
  • 六、曝光与自动曝光:EV 标定与适应时间常数
  • 七、色域映射:从 Rec.709 到 Display P3 与 Rec.2020
  • 八、HDR 输出链路:PQ 曲线与交换链格式
  • 九、工程落地:3D LUT、量化抖动与验证闭环
  • 9.1 3D LUT 的误差实算
  • 9.2 量化与抖动的实算
  • 9.3 一条可执行的验证闭环
  • 9.4 一条容易被忽略的区分
  • 参考文献
  • 一句话摘要

引擎色彩管理工程 2026:色调映射与 HDR 输出管线

把画面从线性光照空间送到人眼的最后 5% 代码,往往是整个渲染管线里最容易出错、也最少有人愿意从头读一遍的部分。这篇文章从色彩空间的三个坐标出发,把色调映射算子、色域映射、HDR 输出链路逐段拆开,用可复现的数值对照说明每一步到底改变了什么、代价是什么、哪些做法在引擎里会直接崩掉。

一、为什么"最后一个 1% 的像素"值得写一整篇

渲染管线的绝大部分工作都在解决"物理正确":光追求能量守恒,阴影求可见性,全局光照求间接光的多次弹射,材质求 BRDF 的正确求值。这些环节的共同前提是线性光照空间——辐射度可以直接相加,光源强度可以用物理单位表达,一个像素的最终值可能是 0.0037,也可能是 4800。

但显示设备不接受线性值。显示器上一个像素要么亮、要么不亮,量化发生在最后一步。于是从"物理正确"到"屏幕正确"之间需要一整套转换:把无界的线性辐射度压进有界的显示范围(色调映射),把显示设备的原始基色三角形映射到内容色域(色域映射),把线性编码变成设备需要的传输函数(传递函数),最后处理量化误差(抖动)。

这一整套东西在引擎里的地位很尴尬。它不像光追那样有 SIGGRAPH 论文可引,也不像 ECS 那样有明确的架构图;它通常以一个后处理节点、一张 3D LUT、一个 r.Tonemapper 枚举值的形式存在,出问题时表现为"看起来发灰""高光死白""UI 和场景亮度对不上"这类很难定位的观感投诉。

更麻烦的是,色彩管理不是一个数学问题,而是一串互锁的约定。传递函数约定错了,色调映射的输入就不是你以为的东西;中间缓冲格式约定错了,前面所有的 HDR 计算会被静默截断;曝光约定错了,后面所有算子的调参都建立在错误标定上。任何一个环节错了,最终画面都不会报错——它只是"看起来有点怪"。

本文的目标是把这串约定拆成可以逐条核对的形式。所有数值都是现场计算的,算法取自公开的一手规范,文末的参考文献全部指向可核对的原始出处。

二、色彩空间的三个坐标:色度、传递函数、显示环境

一个完整的色彩空间定义需要三样东西,缺一样就无法做色彩转换。

色度(chromaticity) 描述原色点。sRGB 的三个原色在 CIE xy 平面上的坐标分别是 R (0.640, 0.330)、G (0.300, 0.600)、B (0.150, 0.060),白点为 D65。这组数值可以在 W3C CSS Color Module Level 4 的 §10.2 查到,是当前最常被引用的权威副本之一。

传递函数(transfer function) 描述线性值与编码值之间的映射。sRGB 的定义不是简单的 gamma 2.2,而是一段分段函数:

EOTF (编码 → 线性):
  abs(c) <= 0.04045  →  c / 12.92
  否则              →  sign(c) * ((abs(c) + 0.055) / 1.055) ^ 2.4

这里有个常被忽略的细节:sRGB 不是纯 gamma 2.2。现场算出来的差距是:

编码值sRGB 解码纯 gamma 2.2 解码比值
0.250.0508760.0473661.0741
0.500.2140410.2176380.9835
0.750.5225220.5310490.9839

在暗部(0.25)纯 gamma 2.2 会低估线性值 7.4%,在中间调和高光则各偏 1.6%~1.7%。这不是可以忽略的误差——它决定了暗部贴图在两种约定下的明度差异是否肉眼可辨。很多"换台机器颜色就变了"的现象,根因就是某个环节用了 2.2、另一个环节用了精确 sRGB。

顺带一个值得记住的量:sRGB 传输函数的往返误差在全部 256 个码值上最大只有 1.110e-16,也就是 IEEE 双精度的舍入噪声量级。这说明 sRGB 的编码/解码本身是可逆的——任何声称"因为伽马不可逆所以必须放弃 HDR 往返"的论断都是错的,不可逆性来自后续的色调映射压缩,不是来自传递函数。

显示环境(viewing conditions) 描述这套数值针对什么观察条件。CSS Color 4 给出的 sRGB 参考条件是:白点亮度 80.0 cd/m²、黑场亮度 0.20 cd/m²、D65 白点。这一条在引擎里几乎没人用,但它是"色域映射结果是否符合预期"的判据基准——同一套色彩管理管线在 80 cd/m² 参考下标定,和在 200 cd/m² 的 HDR 屏上运行,PQ 曲线的映射点是不一样的。

三、线性工作流的中间缓冲:格式约定即正确性约定

引擎里最容易出现静默错误的地方是中间渲染目标的格式。

规则很简单但常被违反:所有参与光照计算的中间缓冲必须是浮点线性格式,不能是 sRGB 编码格式。因为光照是加法,sRGB 编码值相加不满足线性叠加。

Vulkan 规范里可用的相关格式:

  • VK_FORMAT_R16G16B16A16_SFLOAT — 线性 HDR 中间缓冲的典型选择
  • VK_FORMAT_A2R10G10B10_UNORM_PACK32 — 10-bit UNORM,交换链常见格式
  • VK_FORMAT_R8G8B8A8_SRGB — 硬件 sRGB 编码格式,只能用于最终输出或 sRGB 纹理采样,不能用于中间光照计算

注意 UNORM 与 SFLOAT 的语义区别:UNORM 格式的值被解释为「已经编码好的定点数」,硬件不会替你做任何传递函数转换;而 SFLOAT 存的就是线性值本身。这两个在着色器里看起来都是 vec4,但一个存的是 0.214、另一个存的是 0.5 的意义完全不同。

OpenGL ES 侧,浮点渲染目标由 EXT_color_buffer_float 扩展提供。该扩展的规范文本明确其作用是"允许把多种浮点格式渲染到 framebuffer object",并依赖 OpenGL ES 3.0/3.1 核心。在移动端这条能力链是硬约束——没有这个扩展,HDR 中间缓冲就只能退回到 RGB10_A2 之类的 UNORM 格式,动态范围上限被钉死。

引擎落地的三个具体检查点:

第一,检查每个 RenderTarget 的创建格式,找出那些被声明成 R8G8B8A8_SRGB 或 UNORM 却参与了光照累加的节点。第二,检查后处理链上有没有"意外的解码"——很多引擎在把结果 blit 到另一个 RT 时会隐式做 sRGB 解码或编码,这类隐式转换是色彩 bug 的重灾区。第三,检查 UI 的混合空间:UI 通常在 sRGB 编码空间合成,如果场景是线性 HDR,两者直接相加在数学上没有意义,必须先决定 UI 是在色调映射前还是后合成,以及混合在哪个空间做。

四、色调映射算子实测对照:Reinhard、ACES 与 PBR Neutral

色调映射算子的职责是把无界的线性辐射度压进 [0, 1]。经典的三种选择是 Reinhard、ACES 系列、以及为 PBR 保真设计的 Khronos PBR Neutral。

现场用同一组灰阶输入实算三者的响应(Reinhard 为逐通道 L/(1+L),ACES 为广为使用的 Narkowicz 拟合曲线 x(ax+b)/(x(cx+d)+e),参数 a=2.51, b=0.03, c=2.43, d=0.59, e=0.14;PBR Neutral 按 Khronos 规范实现):

输入 LReinhardACES 拟合PBR Neutral
0.180.1525420.2668990.140000
0.500.3333330.6163070.460000
1.000.5000000.8037970.869091
2.000.6666670.9148550.960000
4.000.8000000.9734170.983256
8.000.8888891.0000000.992258
16.000.9411761.0000000.996269
64.000.9846151.0000000.999092
1000.000.9990011.0000000.999942
10000.000.9999001.0000000.999994

这张表里最重要的一行是 L=8:Narkowicz 拟合的 ACES 曲线在输入 8 时就已经打满到 1.0,此后所有更亮的值全部被截断。而真实场景里太阳直射的镜面反射、点光源的近距离高光,线性值轻易到几百上千。

这不是说 ACES 错了——完整的 ACES 流程包含 RRT 和 ODT 两级,比 Narkowicz 拟合曲线复杂得多,拟合曲线只是被广泛引用的简化版。但它精确指出了一个工程事实:很多引擎里那个叫"ACES"的色调映射器,实际是这条会截断的拟合曲线。美术在调参时看到的"高光一片死白",往往不是曝光设错,而是算子在很低的输入值就已经饱和了。

Reinhard 的问题是低端对比度被压得太狠:中间调 0.18 被压到 0.1525,画面整体偏灰。但它有一个无可替代的优点——永远不会超过 1.0,且是单调的。

PBR Neutral 的表现介于两者之间,并且在 L ≤ 0.8 的区间里保持严格的线性偏移(下面详述)。它的独特性在于不去饱和:实测对一个饱和橙色高光 (1.0, 0.35, 0.05):

算子输出 RGB饱和度
输入1.0, 0.35, 0.050.9500
Reinhard0.5000, 0.2593, 0.04760.9048
ACES 拟合0.8038, 0.4936, 0.04430.9449
PBR Neutral0.8707, 0.2928, 0.02610.9700

五、Khronos PBR Neutral 规范精读

这一节直接把 Khronos 官方 README 里的规范公式摊开,并逐条验证它宣称的性质。

规范参数只有三个:F90 = 0.04(常见 IoR=1.5 材质在法线入射下的菲涅尔反射率)、Ks = 0.8 - F90 = 0.76(高光压缩起始阈值)、Kd = 0.15(去饱和速度)。完整算法:

x = min(R, G, B)
f = x - x²/(4·F90)   若 x <= 2·F90
    F90              否则

p  = max(R - f, G - f, B - f)
p_n = 1 - (1 - Ks)² / (p + 1 - 2·Ks)
g  = 1 / (Kd · (p - p_n) + 1)

c_out = c_in - f                              若 p <= Ks
      = (c_in - f)·(p_n/p)·g + [p_n]·(1 - g)  否则

对照 Khronos 仓库里 PBR_Neutral/pbrNeutral.glsl 的参考实现,逐行可以验证公式与代码完全对应(注意 GLSL 里 x < 0.08 对应规范里的 2·F90 = 0.08,startCompression = 0.8 - 0.04 对应 Ks)。仓库实测状态:119 stars、Apache-2.0、最后推送 2026-10-06、未归档;仓库内 PBR_Neutral/ 目录包含 README.md、config.ocio、pbrNeutral.glsl、pbrNeutral.cube(实测 5495377 字节,约 5.24 MB)与 lut-writer.mjs。

规范列出了四条性质,逐条实测:

性质一:基准色精确复现。 规范声称当 0.08 <= R,G,B <= 0.8 时 c_out = c_in - F90。实测 L=0.08/0.3/0.5/0.8 四个点,误差全部为 0.000e+0(精确到浮点零误差)。这意味着在单位白光的照射下,一个正对镜头的光滑电介质表面,其渲染出的基础色与输入基础色的差恰好是菲涅耳反射损失的那 4%——这是为电商产品渲染场景设计的核心保证。

性质二:色相不偏。 规范声称所有修改都发生在输入色与白轴 [1,1,1] 张成的平面内,因此围绕白轴的色相角不变。实测橙色 (1, 0.5, 0) 在输入 0.5 / 1 / 4 / 16 / 64 五档亮度下,输出色相角全部是 26.565°,与输入完全一致,零偏差。

性质三:可解析求逆。 规范声称该映射是 1:1 且可解析求逆。这在工程上意义重大:它意味着可以用解析逆把显示空间的像素反解回线性空间。实测二分数值求逆的往返误差:L=1 时 2.220e-16,L=4 时 1.066e-14,L=16 时 1.052e-12——在 double 精度下即是完美往返。

性质四:不硬裁剪。 实测在 L ∈ [0, 200] 区间扫描,输出最大值是 0.999711190,永远不触及 1.0;即使 L=1e6,输出也只有 0.999999942。对照前面那张表,ACES 拟合在 L=8 就已经打满,PBR Neutral 在 L=10000 时才逼近 0.999994。

去饱和行为是这套算子的关键取舍。实测橙色 (1, 0.5, 0) 在不同亮度下的色度保留比(输出色度 / 输入色度):

输入亮度色度保留比饱和度
0.51.00001.0000
1.00.86440.9823
4.00.16930.6885
16.00.01920.3076
64.00.00150.0957

高光处饱和度大幅下降,但色相角恒定——这是"向白点去饱和"而不是"向灰色去饱和"。这个行为符合人眼对过曝彩色光的反应:蜡烛火焰最亮的部分看起来是白的。model-viewer 的官方色调映射说明文档(作者 Emmett Lalish)对此有专门论述:着色金属的基础色会乘上进光,白光打在金色球上产生全饱和黄色反射,而钳位式色调映射会让高光保持死黄,ACES 这类曲线则会把亮度推向白色。

分段切换点也很值得注意。p <= Ks = 0.76 走线性分支,超过就走压缩分支。实测灰阶:L=0.8 时输出恰为 0.76(线性分支,0.8 - 0.04);L=0.85 时输出 0.801379(已进入压缩分支,线性分支会给 0.81);L=0.9 时输出 0.830588(线性分支会给 0.86)。0.8 到 0.9 之间,输出与输入的偏离从 0.04 扩大到 0.069——所有"非线性行为"都集中在这个窄区间。

六、曝光与自动曝光:EV 标定与适应时间常数

色调映射的输入是"曝光后的线性值",所以曝光标定必须和算子分开讨论。

EV 标定。 一档曝光对应 2 倍亮度:EV+1 → 2.0000、EV+2 → 4.0000、EV+3 → 8.0000。常用的标定基准是中间调 0.18 对应 18% 灰卡。若要把 0.18 提到 1.0(即让基准灰正好落在显示白),需要 EV +2.4739,也就是 5.5556 倍增益。

这个数字解释了为什么"美术觉得画面偏暗"和"物理上正确的曝光"经常打架:物理正确意味着 0.18 就是 0.18(EV 0),但 SDR 显示器的白点是 1.0,中间调必须占满整个值域的 18%——对应上面那个 2.47 EV。这两个数字之间没有对错之分,只有参考环境的选择。

动态范围跨度。 室外正午照度约 100000 lux,若场景基准仍取 0.18,所需 EV 跨度约 19.08 EV,即 555556 倍。这就是为什么 HDR 工作流必须让曝光随场景变化,而不是每关手动配一个固定值。

自动曝光的时间常数。 人眼暗适应远慢于明适应(从亮到暗要几十秒,从暗到亮几秒),引擎里的自动曝光应当复现这个非对称性:

参数τ达到 63%达到 95%
快(进入亮环境)0.3 s0.3 s0.9 s
中1.5 s1.5 s4.5 s
慢(离开亮环境)4.0 s4.0 s12.0 s

60 fps 下一帧是 0.01667 s,0.3 s 的 τ 相当于 18 帧。这给出一个可核对的实现判据:如果你的自动曝光用了逐帧 lerp 且系数很小(比如 0.05),实际 τ 会达到 0.33 s 附近,看起来就偏慢;如果用固定 0.1 的 lerp,τ 只有约 0.15 s,进出亮环境都会显得突兀。正确的做法是显式声明 τ 并按 1 - exp(-dt/τ) 计算系数,这样帧率变化时适应速度保持物理一致。

工程上还要注意:曝光计算通常是对数域平均(先转 log 再求均值),直接在线性域取均值会让一个极亮的像素独占决定权。以及测量与应用的时序:曝光通常基于上一帧或历史帧的直方图,这引入至少一帧延迟——在快速从暗到亮的场景里,这一帧的过曝是必然的,掩盖它比消除它更实际。

七、色域映射:从 Rec.709 到 Display P3 与 Rec.2020

内容色域(贴图、美术指定的颜色)和显示色域(屏幕能显示的颜色)往往不同,这个转换叫色域映射。

三种常见色域的 D65 归一化 RGB→XYZ 矩阵,现场计算结果:

Rec.709 / sRGB

[ 0.412391  0.357584  0.180481 ]
[ 0.212639  0.715169  0.072192 ]
[ 0.019331  0.119195  0.950532 ]

Display P3

[ 0.486571  0.265668  0.198217 ]
[ 0.228975  0.691739  0.079287 ]
[-0.000000  0.045113  1.043944 ]

Rec.2020

[ 0.636958  0.144617  0.168881 ]
[ 0.262700  0.677998  0.059302 ]
[ 0.000000  0.028073  1.060985 ]

三者的白点往返校验都精确通过:white(1,1,1) → XYZ = 0.950456, 1.000000, 1.089058,与 D65 目标完全一致。

色域体积比(用矩阵行列式绝对值作单位):

比值数值
Display P3 / sRGB1.2165
Rec.2020 / sRGB2.0983
Rec.2020 / Display P31.7249

一个容易被误解的点:Rec.2020 并不是 sRGB 的超集。它换了原色点,不是简单扩大。实测把 sRGB 纯红转到 Rec.2020 线性空间,得到 (0.6274, 0.0691, 0.0164);转到 Display P3 得到 (0.8225, 0.0332, 0.0171)。而把 sRGB 色立方体的采样点整体转到 Rec.2020,最大分量绝对值为 1.000000(出现在纯白处)——sRGB 的内容整体落在 Rec.2020 内,只是对应的 Rec.2020 坐标不同。理解这一点,才能解释为什么"把 sRGB 素材直接放进 Rec.2020 项目"通常不会越界,但"用 Rec.2020 画的图放进 sRGB 管线"会严重溢出色域。

亮度系数也随之改变:Rec.709 用 Y = 0.2126R + 0.7152G + 0.0722B,Rec.2020 用 Y = 0.2627R + 0.6780G + 0.0593B。绿通道权重从 0.7152 降到 0.6780,红通道从 0.2126 升到 0.2627。这意味着同一张图在两种色域下的"亮度"不是同一个数,直接用亮度阈值做的美术逻辑(描边、辉光强度、自发光遮罩)在转换后需要重新标定。

色域映射算法。 CSS Color 4 §14 给出了完整的谱系,从最差到最好:

  1. 直接钳位(clipping)——规范明确称之为"最简单且最不可接受"的方法
  2. MINDE(Minimum ΔE)——在感知均匀色彩空间里找最近的可显示色,但规范指出它对各维度等权,结果次优,且色相变化特别难看
  3. 色度削减(chroma reduction)——保持色相恒定、逐步降低色度直到落入色域。规范给出的实例:color(display-p3 1 1 0) 转成 sRGB 是 color(srgb 1 1 -0.3463),对应 oklch(0.96476 0.24503 110.23),把色度降到 0.21094 得到 oklch(0.96476 0.21094 110.23),最终映射结果是 color(srgb 0.99116 0.99733 0.00001)
  4. 带局部钳位的色度削减、EdgeSeeker、Ray Trace 等更精细的算法

规范还专门讨论了一个陷阱:过度色度削减。当色域边界上边缘很平甚至略凹时(例如很亮的黄和青),恒定亮度射线会"擦着"色域边界掠过,导致色度被削得远超必要。这解释了为什么有些明亮的黄在转换后会变成灰扑扑的颜色——不是算子错了,是色域几何形状决定的。

引擎落地的建议:实时管线里用带局部钳位的色度削减(保持色相、逐步降色度、每步局部钳位)作为默认,它在实现复杂度与观感之间平衡最好;只有做离线渲染或对色彩准确性有硬要求时才上 EdgeSeeker/Ray Trace 这类二分搜索或射线追踪方案。

八、HDR 输出链路:PQ 曲线与交换链格式

HDR 显示器接受的不是"更大的 sRGB",而是绝对亮度单位(nit)。SMPTE ST 2084 定义的 PQ 曲线把 0 到 10000 nit 映射到 [0, 1] 的信号值。

常数(实测值):

m1 = 0.1593017578    m2 = 78.84375
c1 = 0.8359375       c2 = 18.85156    c3 = 18.6875

EOTF:  L = 10000 · (max(N^(1/m2) - c1, 0) / (c2 - c3·N^(1/m2)))^(1/m1)
OETF:  N = ((c1 + c2·Y^m1) / (1 + c3·Y^m1))^m2,  其中 Y = L / 10000

锚点实测(正向编码后用 EOTF 反解验证,往返完全一致):

绝对亮度PQ 编码值EOTF 反解
0.01 nit0.0214860.010 nit
0.1 nit0.0623370.100 nit
1 nit0.1499461.000 nit
10 nit0.29969910.000 nit
100 nit0.508078100.000 nit
203 nit(漫反射白)0.580689203.000 nit
1000 nit0.7518271000.000 nit
4000 nit0.9025724000.000 nit
10000 nit1.00000010000.000 nit

203 nit 这个锚点是 HDR 制作的标准参考白。实测反查确认:PQ 码值 0.58 对应 201.67 nit,码值 0.5 对应 92.25 nit。

有一个精度上的隐含代价值得单独指出:10 bit PQ 信号里,1 nit 以下消耗了 153.5 个码值,0.1 nit 以下消耗了 63.8 个。PQ 把巨大的码值预算压在暗部,这是符合人眼光感受的,但对引擎有直接影响——暗部细节在 PQ 编码里被极度细分,如果你的内容在暗部已经因为 8-bit 中间缓冲而丢了信息,PQ 编码救不回来。HDR 输出链路上游的精度要求,比 SDR 链路高得多。

交换链格式。 Vulkan 规范中与 HDR 输出相关的格式已确认存在:VK_FORMAT_R16G16B16A16_SFLOAT(浮点,适合 HDR 直出)、VK_FORMAT_A2R10G10B10_UNORM_PACK32(10-bit UNORM,常见的交换链格式)、VK_FORMAT_R8G8B8A8_SRGB(硬件 sRGB 编码)。VK_KHR_swapchain 是交换链核心扩展。

需要说明的是:本次核查中,在所抓取的 Vulkan 规范版本里没有找到 VK_EXT_swapchain_hdr 这个扩展名。这不代表各厂商实现里不存在 HDR 交换链的能力路径,但在没有一手规范文本的情况下不应把它写进正文作为事实依据——这条只能作为"需按目标引擎与厂商文档另行确认"的提示。

浏览器侧的契约。 WebGPU 规范给出了现代浏览器里最明确的色调映射接口约定,值得引擎开发者对照设计自己的 API:

GPUCanvasToneMappingMode 有两个取值。"standard":屏幕标准动态范围内的颜色值保持不变,其余投影到标准动态范围内——投影通常通过在屏幕色彩空间里把分量钳位到 [0, 1] 实现。规范给的实例是写入 'srgb' canvas 的值 (1.035, -0.175, -0.140),在 sRGB 屏上钳位成 (1.0, 0.0, 0.0);在 Display P3 屏上先转成 (0.948, 0.106, 0.01),无需钳位。

"extended":扩展动态范围内的颜色值保持不变,其余投影到屏幕能力范围内——钳位区间可以超过 1。规范实例:写入 (2.5, -0.15, -0.15),在支持 sRGB 空间 [0, 4] 的屏幕上钳位成 (2.5, 0.0, 0.0);在支持 Display P3 空间 [0, 2] 的屏幕上,先转成 (2.3, 0.545, 0.386) 再钳位成 (2.0, 0.545, 0.386)。

这个设计对引擎 API 设计的启示是直接的:不要提供"开/关色调映射"的布尔开关,而要提供**"映射目标动态范围"这个参数**。前者只能表达 SDR,后者能同时表达 SDR、HDR、以及"内容按 extended 写、屏幕按 standard 读"这类混合情形。

WebGPU 规范还有一条容易被忽略但对引擎极重要的说明:GPUTexture 不做色彩管理,包括 -srgb 格式——它们虽然名字带 srgb,但并没有被打上 sRGB 色彩空间标签;-srgb 格式具备 gamma 编码/解码属性,其算法与 "srgb" 和 "display-p3" 中的 gamma 编码"在算法上接近"。换句话说,格式的 srgb 后缀只保证传递函数行为接近,不保证色彩空间归属一致。这两者在引擎的资源系统里必须分开建模。

九、工程落地:3D LUT、量化抖动与验证闭环

9.1 3D LUT 的误差实算

把色调映射 + 色域映射烘焙成 3D LUT 是常见做法(Khronos 的 pbrNeutral.cube 就是这个思路,实测约 5.24 MB)。

用 PBR Neutral 当被测函数,实测三线性插值误差(20 万次随机采样):

LUT 尺寸最大绝对误差平均绝对误差网格步长
16³1.7191e-21.6460e-30.06667
32³8.0101e-34.0203e-40.03226
64³3.1091e-39.6866e-50.01587

误差随尺寸翻倍而大约减半(比值 1.96 / 2.06),符合三线性插值的二阶收敛特性。这是可以直接写进采购/内存预算的判据:从 32³ 升到 64³ 换来的是误差减半,显存代价是 RGBA8 从 128 KB 涨到 1 MB。

LUT 尺寸的惯例是 N+1 而不是 N——需要同时包含精确的 0 和 1 两个端点。所以标准尺寸是 33³、65³。对应显存:33³ RGBA8 是 140.4 KB,65³ 是 1072.8 KB。

9.2 量化与抖动的实算

输出量化台阶(sRGB 编码空间的码值步长对应的线性差):

位深中间调线性步长相对步长
8 bit3.6657e-31.6982e-2
10 bit9.0701e-44.2286e-3
16 bit1.4124e-56.5985e-5

8 bit 在中间调的相对量化误差接近 1.7%,这个量级足以在暗部渐变上产生可见的等高线。实测一个直观对照:1920 像素宽的线性渐变映射到 8-bit sRGB 只有 256 个码值,平均每 7.5 个像素就有一个量化台阶,未抖动时必然可见。

**抖动(dither)**的标准做法是 TPDF(三角概率密度)噪声,即两个独立的均匀分布 [-0.5, 0.5] 之和。实测其标准差是 0.235702 码值单位,在 8-bit 下相当于满量程的 0.000924。

关键工程细节:抖动必须加在量化之前。加在量化之后只是在码值上加随机数,无法改变台阶位置。理想情况下抖动加在线性光域、加完再做传递函数编码;次优是加在编码后的码值空间(此时噪声在不同亮度处的视觉权重不再均匀)。

9.3 一条可执行的验证闭环

色彩管理问题的特点是**"看截图说不上来哪里不对"**。没有可核对的判据,团队就只能靠个人审美争论。可落地的闭环是:

  1. 建立参考色板——把一批已知的基础色(含 0.18 中灰、纯白、sRGB 三原色、若干饱和色)在标准光照下渲染出来,作为回归基准。
  2. 用相对亮度做数值断言——实算 Rec.709 权重下的相对亮度与对比度:
颜色Y对白对比度对黑对比度
白1.000001.00:121.00:1
中灰 0.50.214043.98:15.28:1
sRGB 码值 1280.215863.95:15.32:1
红0.212604.00:15.25:1
绿0.715201.37:115.30:1
蓝0.072208.59:12.44:1
黄0.927801.07:119.56:1

这张表的价值在于它把"亮度"变成了一个可断言的标量。回归测试里断言"中灰的 Y 必须在 0.2139 ~ 0.2142 之间"比断言"看起来别太暗"要可靠几个数量级。注意黄绿两色对白的对比度极低(1.07:1 和 1.37:1),这正是 UI 设计里"黄色文字在亮背景上不可读"的量化依据。

  1. 断言算子的关键性质而不是像素——比如"PBR Neutral 输出恒 ≤ 0.99972"、"输入 [0.08, 0.8] 时输出严格等于输入减 0.04"、"PQ 编码的 203 nit 锚点必须落在 0.5807 ± 0.0001"。这些断言直接对应规范条款,比截图对比更能定位回归。
  2. 把中间缓冲格式纳入自动化检查——扫描所有 RenderTarget 创建点,断言参与光照计算的节点不是 UNORM/SRGB 格式。

9.4 一条容易被忽略的区分

最后强调一个区分,它决定了文章该不该被写进引擎渲染的范畴:

色调映射是渲染管线问题,色彩管理是跨系统问题。 前者纯粹发生在 GPU 上、输入输出都在同一个渲染管线内部,属于本文讨论的范畴。后者涉及跨设备、跨应用、跨媒体的颜色意图传递——素材从 DCC 软件出来、经过资产管线、进入引擎、经过游戏、最后到不同玩家的不同屏幕上,任何一环的色彩管理决策都会影响下一环。

引擎该管的是前者,以及为后者提供正确的接口(标记资源色彩空间、声明输出色彩空间、声明传递函数版本)。至于素材制作阶段的 OCIO 配置、跨软件的色彩意图交换,那是另一条战线,不在渲染管线的责任范围内。

参考文献

  1. W3C. CSS Color Module Level 4. https://www.w3.org/TR/css-color-4/ —— §10.2 sRGB 定义与传递函数原文,§14 色域映射算法谱系(含 MINDE、色度削减、过度色度削减、EdgeSeeker、Ray Trace)
  2. Khronos Group. PBR Neutral Tone Mapper — README(完整规范公式与四条性质). https://raw.githubusercontent.com/KhronosGroup/ToneMapping/main/PBR_Neutral/README.md
  3. Khronos Group. pbrNeutral.glsl — 官方参考实现. https://raw.githubusercontent.com/KhronosGroup/ToneMapping/main/PBR_Neutral/pbrNeutral.glsl
  4. Lalish, E. Tone Mapping Considerations for Physically-Based Rendering. model-viewer 官方技术说明. https://modelviewer.dev/examples/tone-mapping
  5. ITU-R. Recommendation BT.2100-3 (02/2025): Image parameter values for high dynamic range television. https://www.itu.int/rec/R-REC-BT.2100/en
  6. Khronos Group. The Vulkan Specification — 交换链格式与扩展定义. https://registry.khronos.org/vulkan/specs/latest/html/vkspec.html
  7. Khronos Group. OpenGL ES Extension EXT_color_buffer_float(浮点渲染目标能力). https://registry.khronos.org/OpenGL/extensions/EXT/EXT_color_buffer_float.txt
  8. W3C. WebGPU Specification — §21.4.1 Canvas Color Space、§21.5 GPUCanvasToneMappingMode. https://www.w3.org/TR/webgpu/
  9. Khronos Group. ToneMapping 仓库(实测 119 stars,Apache-2.0,最后推送 2026-10-06,未归档). https://github.com/KhronosGroup/ToneMapping
  10. Blender Foundation. Blender Manual — Color Management. https://docs.blender.org/manual/en/latest/render/color_management/index.html

一句话摘要

色彩管理是把线性光照送到人眼的那串互锁约定,本文用可复现的数值对照把它拆成色调映射算子、色域映射、HDR 输出链路三段,给出每个环节的判据与踩坑点。

←返回文章列表

Related

可能也会喜欢

  • 跨队列同步工程 2026:时间线信号量与队列拓扑取舍11月1日
  • 半透明渲染工程 2026:排序、OIT 与深度剥离的取舍10月30日
  • 屏幕空间渲染工程 2026:GTAO、SSR 与时域重建10月29日

Conversation

0 条

留下你的想法

加载评论中…

New comment