《色彩管理与 HDR 管线:线性工作流、ACES 色调映射与宽色域》

色彩管线决定了画面「看起来对不对」。本文讲解 sRGB 传输函数与 Gamma 的由来、线性工作流的必要性、HDR 渲染与色调映射(Reinhard/Filmic/ACES)、宽色域与 Display P3 的落地、曝光与自动曝光系统,以及色彩管线在游戏引擎中的组织方式与常见踩坑,给出端到端可落地的色彩管理方案。

很多渲染 bug 的根源不是几何、不是光照,而是色彩管线搞错了:贴图发灰、光照过曝、半透明合成发黑、跨设备颜色不一致。这些问题单看某一帧都「差不多」,但一旦组成完整画面就处处不对劲。色彩管理是渲染管线的「地基」,它决定了从贴图采样到最终显示的每一步该用什么数值空间。本文从 sRGB 与 Gamma 讲起,贯穿线性工作流、HDR 色调映射、宽色域与曝光,给出端到端的落地方案。相关基础可参考 https://plumephp.com/graphics-pipeline-theory/ 与 https://plumephp.com/graphics-post-processing/。


一、为什么需要色彩管理

一句话:色彩管理要解决的核心问题是——「渲染计算必须在线性光空间,而存储与显示必须是非线性的」,两个空间必须正确转换。

1.1 三个空间

空间用途特性
线性光空间(Linear)光照计算、混合物理正确,可加性
非线性编码空间(sRGB)贴图存储、显示感知均匀,省位深
宽色域空间(P3/Rec.2020)HDR 显示更大色域范围

1.2 搞错的典型症状

  • 贴图发灰:把 sRGB 贴图当线性用了。
  • 光照过曝:在线性空间做了两次 Gamma。
  • 半透明发黑:在线性空间用 sRGB 值混合。
  • 跨设备偏色:没做色彩空间转换。

一句话:几乎所有的「画面发灰/发白」问题,都能归结为「该线性化的没线性化」或「该编码的没编码」。


二、sRGB 与 Gamma

一句话:人眼对暗部比对亮部敏感得多,sRGB 用近似 x^2.2 的曲线把更多位深分配给暗部,从而在 8 bit 下避免可见的色阶。

2.1 为什么需要 Gamma 编码

人眼亮度感知近似对数:从 0.01 到 0.02 的变化比 0.99 到 1.0 的变化明显得多。若用 8 bit 线性存储,暗部会出现明显 banding。sRGB 传输函数把编码值映射到光强,让位深按感知分布。

2.2 sRGB 传输函数

sRGB 是分段函数,近似 x^2.2 但有线性段(暗部避免无穷斜率):

编码(Linear → sRGB):
  if L ≤ 0.0031308:  S = 12.92 · L
  else:              S = 1.055 · L^(1/2.4) - 0.055

解码(sRGB → Linear):
  if S ≤ 0.04045:    L = S / 12.92
  else:              L = ((S + 0.055) / 1.055)^2.4
// GLSL:sRGB ↔ 线性 转换
vec3 SRGBToLinear(vec3 c) {
    return mix(c / 12.92,
               pow((c + 0.055) / 1.055, vec3(2.4)),
               step(vec3(0.04045), c));
}
vec3 LinearToSRGB(vec3 c) {
    return mix(c * 12.92,
               1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055,
               step(vec3(0.0031308), c));
}

2.3 硬件 sRGB 支持

GPU 提供 GL_SRGB8_ALPHA8 / DXGI_FORMAT_R8G8B8A8_UNORM_SRGB 等格式,采样时硬件自动做 sRGB→线性转换,写入时自动做线性→sRGB。这是最省且最正确的做法,应优先使用。

格式采样行为适用
_SRGB自动解码为线性颜色贴图
_UNORM原样返回法线、粗糙度、遮罩
_FLOAT(R16F/R11G11B10)线性 HDR中间渲染目标

2.4 哪些贴图该用 sRGB

贴图类型色彩空间原因
Albedo / BaseColorsRGB感知编码的颜色
EmissivesRGB颜色
Normal线性方向向量,非颜色
Roughness / Metallic线性物理量
Height / AO线性物理量
LightmapsRGB(或线性 HDR)视烘焙而定

一句话:颜色类贴图用 sRGB,数据类贴图用线性——这一条规则能避免大半的「发灰」bug。


三、线性工作流

一句话:线性工作流 = 「所有光照计算在线性空间进行,只在采样贴图和写出显示时做转换」。

3.1 管线中的位置

sRGB 贴图 ──[硬件 sRGB 解码]──→ 线性光空间
                                     ↓
                            光照 / 阴影 / GI 计算(线性)
                                     ↓
                            后处理(线性 HDR)
                                     ↓
                            色调映射(线性 → 显示)
                                     ↓
                            [sRGB 编码] → 显示器

3.2 混合必须在线性空间

Alpha 混合、加法混合、Mipmap 生成都必须在线性空间进行,否则会出现「越混合越暗」或「边缘发黑」:

// GLSL:正确的线性混合(在 shader 中手动做)
vec3 LinearBlend(vec3 dstLinear, vec3 srcLinear, float alpha) {
    return mix(dstLinear, srcLinear, alpha);   // 线性空间插值
}
// 错误做法:对 sRGB 值直接 mix,暗部会出现非线性偏差

3.3 Mipmap 与线性

Mipmap 平均必须在线性空间做,否则会「越远的物体越暗」。GPU 的 sRGB 纹理在生成 mipmap 时会正确线性化,但手动生成(如计算 mipmap)时必须注意。

3.4 常见错误对照

错误症状修正
双 Gamma画面发白、过曝只做一次编码
缺 Gamma画面发灰、发暗补 sRGB 解码
sRGB 空间混合半透明边缘发黑转线性再混合
数据贴图当 sRGB法线/粗糙度错误用线性格式

一句话:线性工作流的纪律是——「进光照前必须线性,出显示前必须编码」,任何一步漏掉都会以发灰或过曝的形式暴露。


四、HDR 与色调映射

一句话:HDR 渲染让亮度可以远超 1.0(太阳可达数千),色调映射负责把无限动态范围「压」进显示器的有限范围。

4.1 HDR 渲染目标

中间缓冲用浮点格式(R16G16B16A16_FLOAT 或 R11G11B10_FLOAT),允许亮度 > 1.0。这保证了:

  • 高光不会提前截断(保留细节)。
  • Bloom、DOF 等后处理有正确的能量。
  • 色调映射能在最后一步做「艺术决策」。

4.2 色调映射算子对比

算子公式(简化)特点
Clampmin(c, 1)硬截断,高光死白
Reinhardc / (1 + c)简单、柔和,但偏灰
Extended Reinhardc(1 + c/Lw²)/(1 + c)带白点控制
Filmic(Uncharted 2)分段多项式电影感,暗部有肩
ACES一组矩阵 + RRT/ODT行业标准,色相稳定
AgX现代,色相保持好Blender 4.x 默认

4.3 Reinhard

// GLSL:Reinhard 色调映射
vec3 ToneMapReinhard(vec3 c) {
    return c / (1.0 + c);
}
// 带白点(Lwhite)的扩展版本,保留高光
vec3 ToneMapReinhardExt(vec3 c, float Lwhite) {
    float Lw2 = Lwhite * Lwhite;
    return c * (1.0 + c / Lw2) / (1.0 + c);
}

4.4 Filmic(Uncharted 2)

// GLSL:Uncharted 2 Filmic 曲线
vec3 Uncharted2Tonemap(vec3 x) {
    const float A = 0.15, B = 0.50, C = 0.10, D = 0.20, E = 0.02, F = 0.30;
    return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}
vec3 ToneMapFilmic(vec3 color, float exposureBias, float whitePoint) {
    color *= exposureBias;
    vec3 curr = Uncharted2Tonemap(color);
    vec3 whiteScale = 1.0 / Uncharted2Tonemap(vec3(whitePoint));
    return curr * whiteScale;
}

4.5 ACES

ACES(Academy Color Encoding System)是目前影视与 3A 的行业标准,流程为:

  1. 输入变换:sRGB/Rec.709 → ACES 宽色域(AP0/AP1)。
  2. RRT(Reference Rendering Transform):把场景线性映射到显示参考。
  3. ODT(Output Device Transform):适配具体显示设备。

实时引擎常用 ACES 近似(Narkowicz / Hill 拟合):

// GLSL:ACES Filmic 近似(Narkowicz)
vec3 ACESFilm(vec3 x) {
    const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
    return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
算子色相保持成本采用
Reinhard中极低简单场景
Filmic中低通用
ACES高低3A、影视
AgX很高中现代渲染器

一句话:色调映射不只是「压亮度」,更是「决定高光如何褪色、暗部如何抬起」的艺术工具,ACES 因色相稳定成为事实标准。


五、宽色域与 Display P3

一句话:sRGB 只覆盖约 35% 的可见色域,Display P3 更大、Rec.2020 更大——但宽色域只有在整条管线都正确时才能发挥。

5.1 色域对比

色域覆盖范围典型设备
sRGB / Rec.709基准普通显示器
Display P3≈ sRGB 的 1.25×Apple 设备、广色域屏
Adobe RGB更广的绿/青摄影
Rec.2020最广(HDR 标准)HDR 电视

5.2 色域转换

色域转换本质是「换基向量」,用一个 3×3 矩阵(考虑白点适配):

// GLSL:sRGB → Display P3(近似,未做色适应时)
const mat3 sRGB_to_P3 = mat3(
    0.8225, 0.0332, 0.0171,
    0.1774, 0.9669, 0.0724,
    0.0000, 0.0000, 0.9108
);
vec3 ConvertToP3(vec3 linearSRGB) {
    return sRGB_to_P3 * linearSRGB;
}

5.3 HDR 显示与 PQ/HLG

HDR 显示器用 PQ(Perceptual Quantizer,ST.2084) 或 HLG 传输函数,映射到 1000~10000 nits 的亮度范围。引擎需:

  • 输出到 HDR 交换链时,用 PQ 编码。
  • 色调映射的目标峰值亮度(如 1000 nits)而非 1.0。

5.4 色彩空间的元数据

交换链与纹理应携带色彩空间元数据(如 DXGI_COLOR_SPACE_TYPE、Vulkan 的 VkColorSpaceKHR),让系统知道如何解释与显示。

元数据含义
sRGB nonlinear标准 SDR
HDR10 (PQ)HDR10 静态元数据
Display P3广色域 SDR

一句话:宽色域是「做对了不明显、做错了很显眼」——只有从贴图、渲染到输出全程正确,P3 的红色才会真正更红。


六、曝光与自动曝光

一句话:曝光是 HDR 管线的「相机快门」,自动曝光让场景从暗室到烈日都能正确成像。

6.1 曝光的作用

色调映射之前,先把场景亮度乘以一个曝光系数,让「中等亮度」落在合适区间:

// GLSL:曝光 + 色调映射
vec3 ApplyExposureAndTonemap(vec3 hdrColor, float ev100, float targetLuminance) {
    float exposure = 1.0 / (pow(2.0, ev100) * 1.2);   // EV100 → 线性曝光
    vec3 exposed = hdrColor * exposure;
    return ACESFilm(exposed);
}

6.2 自动曝光(Eye Adaptation)

自动曝光统计场景平均亮度(用降采样 + 对数平均),平滑过渡:

// HLSL:计算平均亮度(对数域,避免亮部主导)
[numthreads(8,8,1)]
void ComputeAvgLuminance(uint3 id : SV_DispatchThreadID, Texture2D<float3> hdr) {
    float3 c = hdr.Load(int3(id.xy, 0));
    float lum = dot(c, float3(0.2126, 0.7152, 0.0722));   // Rec.709 亮度
    float logLum = log(max(lum, 1e-4));
    // 降采样到 1x1,累加 logLum,最后 exp 得到几何平均
    InterlockedAdd(histogram[uint(logLum * bins)], 1);
}

6.3 直方图与百分位

更稳健的做法是亮度直方图 + 百分位裁剪(如取 50~90 分位),避免极亮/极暗像素主导曝光:

方法稳健性成本
平均亮度低低
几何平均(对数)中低
直方图百分位高中
区域加权(中心优先)高中

6.4 时间平滑

自动曝光必须平滑,否则相机动一下画面就忽明忽暗:

// C++:曝光时间平滑(暗→亮快,亮→暗慢)
float AdaptExposure(float currentEV, float targetEV, float dt,
                    float upSpeed, float downSpeed) {
    float speed = (targetEV > currentEV) ? upSpeed : downSpeed;
    return glm::mix(currentEV, targetEV, 1.0f - glm::exp(-speed * dt));
}

一句话:自动曝光的关键不在统计,而在「时间平滑」与「百分位裁剪」——否则画面会跟着相机乱闪。


七、色彩管线在引擎中的落地

一句话:引擎色彩管线的组织原则是「一次线性化、一次色调映射、一次编码」,中间全部保持线性 HDR。

7.1 完整管线

① 贴图采样(硬件 sRGB 解码 → 线性)
② 不透明渲染(线性 HDR 目标,R11G11B10F)
③ 光照 / GI / 阴影(全部线性)
④ 半透明(线性混合)
⑤ 后处理(Bloom/DOF/AA,线性 HDR)
⑥ 曝光(EV100)
⑦ 色调映射(ACES)
⑧ sRGB 编码 / PQ 编码
⑨ 输出到交换链

7.2 引擎配置示例

// C++:色彩管线配置
struct FColorPipelineSettings {
    EColorSpace WorkingSpace = EColorSpace::Linear;   // 工作空间
    ETextureFormat HDRFormat = ETextureFormat::R11G11B10F;
    ETonemapper Tonemapper   = ETonemapper::ACES;
    bool  bAutoExposure      = true;
    float ExposureCompensation = 0.0f;   // EV 补偿
    EColorSpace OutputSpace  = EColorSpace::sRGB;     // 或 DisplayP3
    float PeakNits           = 1000.0f;  // HDR 峰值亮度
};

7.3 后处理中的色彩

Bloom、DOF 等后处理必须在线性 HDR 下做,且 Bloom 的能量与阈值要基于线性亮度:

// GLSL:Bloom 的亮度阈值(线性)
vec3 BloomThreshold(vec3 linearColor, float threshold, float softKnee) {
    float lum = dot(linearColor, vec3(0.2126, 0.7152, 0.0722));
    float knee = threshold * softKnee + 1e-5;
    float soft = clamp(lum - threshold + knee, 0.0, 2.0 * knee);
    soft = soft * soft / (4.0 * knee + 1e-5);
    float contribution = max(soft, lum - threshold) / max(lum, 1e-5);
    return linearColor * contribution;
}

7.4 UI 与文本

UI 通常直接在显示空间绘制(sRGB),不参与色调映射。若 UI 与 3D 混合,需决定 UI 在色调映射前还是后合成——多数引擎选择色调映射后合成 UI,避免 UI 颜色随曝光变化。

一句话:UI 和 3D 的合成顺序(色调映射前/后)会显著影响观感,是引擎色彩管线必须明确的设计决策。


八、常见问题与工程实践(FAQ)

Q:画面整体发灰怎么办?
A:按顺序检查:贴图是否用了 sRGB 格式、是否在线性空间计算、输出是否只编码了一次、色调映射是否把黑位抬高了。

Q:半透明物体边缘发黑?
A:混合发生在了 sRGB 空间。把渲染目标设为 sRGB 格式(硬件自动线性化)或手动线性化后再混合。

Q:色调映射后颜色发闷?
A:可能是曝光不足或白点设置不当;也可能用了 Reinhard 导致对比压缩过度,换 ACES/Filmic 试试。

Q:HDR 显示器上画面过亮?
A:检查是否用 PQ 编码、峰值亮度元数据是否正确、色调映射目标是否是 nits 而非 1.0。

Q:跨平台颜色不一致?
A:统一工作空间(线性)、统一输出色彩空间元数据、注意 macOS 的 Display P3 与 Windows 的 sRGB 差异。

Q:法线贴图看起来怪?
A:法线贴图必须是线性格式,若被当作 sRGB 会引入错误的解码。

一句话:色彩问题的排查顺序永远是「贴图格式 → 计算空间 → 编码次数 → 色调映射 → 输出元数据」。


总结

色彩管理与 HDR 管线是渲染的「地基工程」,本文脉络如下:

  1. 传输函数:sRGB 用近似 x^2.2 的曲线在 8 bit 下避免色阶,是存储与显示的编码,不是计算空间。
  2. 线性工作流:所有光照、混合、Mipmap 在线性空间进行,只在采样与输出时转换,这是纪律而非可选项。
  3. HDR 与色调映射:浮点中间缓冲保留高光能量,色调映射把无限动态范围压进显示范围,ACES 因色相稳定成为事实标准。
  4. 宽色域:Display P3 / Rec.2020 只有在全链路正确时才有意义,需要色彩空间元数据配合。
  5. 曝光:自动曝光用对数平均或直方图百分位统计,配合时间平滑,避免画面随相机闪烁。
  6. 工程落地:一次线性化、一次色调映射、一次编码,UI 与 3D 的合成顺序需明确。

色彩管线做对了,画面「自然就对」;做错了,再好的光照与模型也救不回来。它是那种「做对了没人夸、做错了人人骂」的系统,却值得每个渲染工程师吃透。建议继续阅读 https://plumephp.com/graphics-post-processing/ 理解后处理如何依赖线性 HDR,以及 https://plumephp.com/graphics-texture-memory/ 了解纹理格式与色彩空间的存储细节。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「计算机图形学」更多文章

  1. 《物理模拟与渲染集成:固定步长、插值与确定性同步》
  2. 《可见性剔除系统:视锥、BVH、Hi-Z 与 GPU Driven 剔除》
  3. 《GPU 粒子与特效系统:计算着色器、Transform Feedback 与十万级粒子》