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

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

把画面从线性光照空间送到人眼的最后 5% 代码,往往是整个渲染管线里最容易出错、也最少有人愿意从头读一遍的部分。这篇文章从色彩空间的三个坐标出发,把色调映射算子、色域映射、HDR 输出链路逐段拆开,用可复现的数值对照说明每一步到底改变了什么、代价是什么、哪些做法在引擎里会直接崩掉。
渲染管线的绝大部分工作都在解决"物理正确":光追求能量守恒,阴影求可见性,全局光照求间接光的多次弹射,材质求 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.25 | 0.050876 | 0.047366 | 1.0741 |
| 0.50 | 0.214041 | 0.217638 | 0.9835 |
| 0.75 | 0.522522 | 0.531049 | 0.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 是在色调映射前还是后合成,以及混合在哪个空间做。
色调映射算子的职责是把无界的线性辐射度压进 [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 规范实现):
| 输入 L | Reinhard | ACES 拟合 | PBR Neutral |
|---|---|---|---|
| 0.18 | 0.152542 | 0.266899 | 0.140000 |
| 0.50 | 0.333333 | 0.616307 | 0.460000 |
| 1.00 | 0.500000 | 0.803797 | 0.869091 |
| 2.00 | 0.666667 | 0.914855 | 0.960000 |
| 4.00 | 0.800000 | 0.973417 | 0.983256 |
| 8.00 | 0.888889 | 1.000000 | 0.992258 |
| 16.00 | 0.941176 | 1.000000 | 0.996269 |
| 64.00 | 0.984615 | 1.000000 | 0.999092 |
| 1000.00 | 0.999001 | 1.000000 | 0.999942 |
| 10000.00 | 0.999900 | 1.000000 | 0.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.05 | 0.9500 |
| Reinhard | 0.5000, 0.2593, 0.0476 | 0.9048 |
| ACES 拟合 | 0.8038, 0.4936, 0.0443 | 0.9449 |
| PBR Neutral | 0.8707, 0.2928, 0.0261 | 0.9700 |
这一节直接把 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.5 | 1.0000 | 1.0000 |
| 1.0 | 0.8644 | 0.9823 |
| 4.0 | 0.1693 | 0.6885 |
| 16.0 | 0.0192 | 0.3076 |
| 64.0 | 0.0015 | 0.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 标定。 一档曝光对应 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 s | 0.3 s | 0.9 s |
| 中 | 1.5 s | 1.5 s | 4.5 s |
| 慢(离开亮环境) | 4.0 s | 4.0 s | 12.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 再求均值),直接在线性域取均值会让一个极亮的像素独占决定权。以及测量与应用的时序:曝光通常基于上一帧或历史帧的直方图,这引入至少一帧延迟——在快速从暗到亮的场景里,这一帧的过曝是必然的,掩盖它比消除它更实际。
内容色域(贴图、美术指定的颜色)和显示色域(屏幕能显示的颜色)往往不同,这个转换叫色域映射。
三种常见色域的 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 / sRGB | 1.2165 |
| Rec.2020 / sRGB | 2.0983 |
| Rec.2020 / Display P3 | 1.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 给出了完整的谱系,从最差到最好:
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)规范还专门讨论了一个陷阱:过度色度削减。当色域边界上边缘很平甚至略凹时(例如很亮的黄和青),恒定亮度射线会"擦着"色域边界掠过,导致色度被削得远超必要。这解释了为什么有些明亮的黄在转换后会变成灰扑扑的颜色——不是算子错了,是色域几何形状决定的。
引擎落地的建议:实时管线里用带局部钳位的色度削减(保持色相、逐步降色度、每步局部钳位)作为默认,它在实现复杂度与观感之间平衡最好;只有做离线渲染或对色彩准确性有硬要求时才上 EdgeSeeker/Ray Trace 这类二分搜索或射线追踪方案。
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 nit | 0.021486 | 0.010 nit |
| 0.1 nit | 0.062337 | 0.100 nit |
| 1 nit | 0.149946 | 1.000 nit |
| 10 nit | 0.299699 | 10.000 nit |
| 100 nit | 0.508078 | 100.000 nit |
| 203 nit(漫反射白) | 0.580689 | 203.000 nit |
| 1000 nit | 0.751827 | 1000.000 nit |
| 4000 nit | 0.902572 | 4000.000 nit |
| 10000 nit | 1.000000 | 10000.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 是常见做法(Khronos 的 pbrNeutral.cube 就是这个思路,实测约 5.24 MB)。
用 PBR Neutral 当被测函数,实测三线性插值误差(20 万次随机采样):
| LUT 尺寸 | 最大绝对误差 | 平均绝对误差 | 网格步长 |
|---|---|---|---|
| 16³ | 1.7191e-2 | 1.6460e-3 | 0.06667 |
| 32³ | 8.0101e-3 | 4.0203e-4 | 0.03226 |
| 64³ | 3.1091e-3 | 9.6866e-5 | 0.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。
输出量化台阶(sRGB 编码空间的码值步长对应的线性差):
| 位深 | 中间调线性步长 | 相对步长 |
|---|---|---|
| 8 bit | 3.6657e-3 | 1.6982e-2 |
| 10 bit | 9.0701e-4 | 4.2286e-3 |
| 16 bit | 1.4124e-5 | 6.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。
关键工程细节:抖动必须加在量化之前。加在量化之后只是在码值上加随机数,无法改变台阶位置。理想情况下抖动加在线性光域、加完再做传递函数编码;次优是加在编码后的码值空间(此时噪声在不同亮度处的视觉权重不再均匀)。
色彩管理问题的特点是**"看截图说不上来哪里不对"**。没有可核对的判据,团队就只能靠个人审美争论。可落地的闭环是:
| 颜色 | Y | 对白对比度 | 对黑对比度 |
|---|---|---|---|
| 白 | 1.00000 | 1.00:1 | 21.00:1 |
| 中灰 0.5 | 0.21404 | 3.98:1 | 5.28:1 |
| sRGB 码值 128 | 0.21586 | 3.95:1 | 5.32:1 |
| 红 | 0.21260 | 4.00:1 | 5.25:1 |
| 绿 | 0.71520 | 1.37:1 | 15.30:1 |
| 蓝 | 0.07220 | 8.59:1 | 2.44:1 |
| 黄 | 0.92780 | 1.07:1 | 19.56:1 |
这张表的价值在于它把"亮度"变成了一个可断言的标量。回归测试里断言"中灰的 Y 必须在 0.2139 ~ 0.2142 之间"比断言"看起来别太暗"要可靠几个数量级。注意黄绿两色对白的对比度极低(1.07:1 和 1.37:1),这正是 UI 设计里"黄色文字在亮背景上不可读"的量化依据。
最后强调一个区分,它决定了文章该不该被写进引擎渲染的范畴:
色调映射是渲染管线问题,色彩管理是跨系统问题。 前者纯粹发生在 GPU 上、输入输出都在同一个渲染管线内部,属于本文讨论的范畴。后者涉及跨设备、跨应用、跨媒体的颜色意图传递——素材从 DCC 软件出来、经过资产管线、进入引擎、经过游戏、最后到不同玩家的不同屏幕上,任何一环的色彩管理决策都会影响下一环。
引擎该管的是前者,以及为后者提供正确的接口(标记资源色彩空间、声明输出色彩空间、声明传递函数版本)。至于素材制作阶段的 OCIO 配置、跨软件的色彩意图交换,那是另一条战线,不在渲染管线的责任范围内。
色彩管理是把线性光照送到人眼的那串互锁约定,本文用可复现的数值对照把它拆成色调映射算子、色域映射、HDR 输出链路三段,给出每个环节的判据与踩坑点。
Conversation
0 条