很多渲染 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 / BaseColor | sRGB | 感知编码的颜色 |
| Emissive | sRGB | 颜色 |
| Normal | 线性 | 方向向量,非颜色 |
| Roughness / Metallic | 线性 | 物理量 |
| Height / AO | 线性 | 物理量 |
| Lightmap | sRGB(或线性 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 色调映射算子对比
| 算子 | 公式(简化) | 特点 |
|---|---|---|
| Clamp | min(c, 1) | 硬截断,高光死白 |
| Reinhard | c / (1 + c) | 简单、柔和,但偏灰 |
| Extended Reinhard | c(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 的行业标准,流程为:
- 输入变换:sRGB/Rec.709 → ACES 宽色域(AP0/AP1)。
- RRT(Reference Rendering Transform):把场景线性映射到显示参考。
- 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 管线是渲染的「地基工程」,本文脉络如下:
- 传输函数:sRGB 用近似
x^2.2的曲线在 8 bit 下避免色阶,是存储与显示的编码,不是计算空间。 - 线性工作流:所有光照、混合、Mipmap 在线性空间进行,只在采样与输出时转换,这是纪律而非可选项。
- HDR 与色调映射:浮点中间缓冲保留高光能量,色调映射把无限动态范围压进显示范围,ACES 因色相稳定成为事实标准。
- 宽色域:Display P3 / Rec.2020 只有在全链路正确时才有意义,需要色彩空间元数据配合。
- 曝光:自动曝光用对数平均或直方图百分位统计,配合时间平滑,避免画面随相机闪烁。
- 工程落地:一次线性化、一次色调映射、一次编码,UI 与 3D 的合成顺序需明确。
色彩管线做对了,画面「自然就对」;做错了,再好的光照与模型也救不回来。它是那种「做对了没人夸、做错了人人骂」的系统,却值得每个渲染工程师吃透。建议继续阅读 https://plumephp.com/graphics-post-processing/ 理解后处理如何依赖线性 HDR,以及 https://plumephp.com/graphics-texture-memory/ 了解纹理格式与色彩空间的存储细节。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。