如果你是被标题里“后处理”三个字吸引进来的,我先多说一句:如果你要找的是YOLO的NMS后处理流程、Hypermill五轴后处理制作,或者UG那边判断4轴变化后Z轴回零的代码,那这篇文章跟你预期的完全不是一回事。图形学语境里的后处理,指的是画面在最终显示之前要过的最后一道“出片”环节——HDR、颜色分级、颜色映射、颜色空间,这几个词拼在一起,才是一条完整且靠谱的实时画面调色链路。
我要讲的这条链路,是过去几年我在游戏引擎、可视化工具和渲染调试器里反复搭、反复踩坑后沉淀下来的东西。核心就一件事:怎么让一张渲染出来的高动态范围图像,经过一连串操作后,既保留细节和质感,又能在不同屏幕上看过去不发灰、不过曝、不带脏色。内容适合正在写渲染器的人、做TA但对调色没底的人,以及被“截图过曝”“色彩偏色”折磨过但说不清原理的朋友。
1. 后处理管线怎么搭才不白费功夫
1.1 管线里的数据到底是怎么流的
先看一条典型的SDR输出管线是什么样,后面所有的技术点都是围绕这个顺序展开的:
线性场景光(RGB, 浮点HDR) -> 曝光调整 -> Bloom / DOF / 运动模糊 -> 色调映射 -> 颜色分级 + LUT -> 伽马编码/色域压缩 -> 输出到sRGB显示器管线最前面是从PBR光照计算出来的颜色值,这个值没有被压缩过,光照强度可以远大于1,也可以非常接近0,这就是场景线性光。后处理链必须在它还没被“压扁”之前做完那些跟光有关的操作。Bloom、景深、运动模糊这些效果,如果放在色调映射之后做,高光早就被削顶了,泛光出来的效果会非常塑料,亮部一圈硬边,完全不像正经的光晕。
颜色分级和颜色映射放在色调映射后面,是引擎里最常见的做法,原因后面细讲。最后一步输出前,要把颜色转换到目标显示设备的编码空间,比如sRGB的OETF曲线,这样显示器才能解出正确的亮度。
如果目标是HDR显示器,管线会有些不同,最后不是做SDR的伽马压缩,而是把颜色编码成PQ或HLG曲线,并且保留HDR命名空间下的显示映射处理。反正无论如何,中间这一段“线性HDR帧”是绕不开的。
1.2 为什么颜色分级要放在色调映射之后
很多人第一次搭后处理链时都会有疑问:我直接对HDR颜色做饱和度和对比度调整不行吗,为什么非要先压到0到1再调?
方案A,也是多数引擎的默认方案,是“色调映射 → 颜色分级”。它的好处是美术友好,显示器上看到的就是最终画面,调起来直觉性强,拉高光和压阴影都很方便。缺点是信息已经进入0到1的窄动态范围,高位高光的细节已经被“分配”掉了,你想在分级阶段把高光从过曝边缘拉回来,基本不可能。
方案B,是“颜色分级 → 色调映射”。这种流程在电影级HDR中间片里很常见,调色师会在一个Log空间里操作,那个空间没有显示限制,能保住高光层次。但代价是数学上更难预测,你对颜色做一次非线性曲线调整,后面色调映射还会再改变一次亮度感知,两条曲线叠加,结果不拿到屏上看很难判断对不对。
我个人的做法是,大多数实时渲染项目用方案A,但把高光保护交给色调映射函数本身去处理。也就是说,在tonemap阶段就明确“哪个亮度值会被压缩到接近白色”,到了分级阶段只动色温和风格,不要再妄想纯靠拉曲线去恢复已经消失的高光细节。
1.3 管线选型不是越高级越好
这里给一张我常用的选型表,根据输出目标来决定管线配置:
| 输出目标 | 中间帧格式 | 色调映射 | 颜色分级 | 最终编码 |
|---|---|---|---|---|
| 普通SDR显示器/网络视频 | RGBA16F | 必需 | 在tonemap后 | sRGB/Rec.709 |
| HDR显示器(HDR10) | RGBA16F | 显示映射,按PQ输出 | 在显示映射前 | PQ(ST 2084) |
| 影院/高端后期 | OpenEXR | 不做固定tonemap,保留成Log | 在Log空间做 | 交给调色软件 |
| 低端移动端 | RGBA16F或RGB9E5 | 必需,尽量轻量 | 优先用预制LUT | sRGB |
很多项目死在“什么都往上加”:HDR要、动态泛光要、ACES要、还要一堆风格化LUT,结果在低端设备上带宽被打爆,帧率起不来。管线选型的核心是:先明确输出目标,再反推中间帧精度和效果数量,而不是从技术堆叠开始。
2. 颜色空间:最容易翻车的底层地图
2.1 线性光、伽马与编码曲线
场景里的光照计算是线性的,这是物理规律。但显示器不是线性的,人眼对亮度的感知也不是线性的——暗部一点点亮度变化都能看出差别,亮部要加很大的能量才能感觉到变化。所以行业里用“伽马曲线”去编码颜色,用更多的bit去保存暗部细节。
sRGB和Rec.709都带自己的OETF(光电转换),解码成线性光的公式长这样:
// 标准sRGB解码,实际上是一个分段函数 float srgbToLinear(float c) { if (c <= 0.04045) return c / 12.92; return pow((c + 0.055) / 1.055, 2.4); }做后处理时最大的坑,就是你从美术那边拿到的贴图是sRGB编码,你直接拿去做Bloom、做模糊,相当于把伽马曲线里的非线性也一并模糊了。正确做法是:采样后立刻解码到线性,所有效果都在线性空间做,最后输出前再编码回去。凡是出现“颜色发灰”“高光边缘发黑”的,十有八九是某个环节没做解码或没做编码,或者说做了两次。
很多博主喜欢用一个简单的2.2次幂近似去代替完整sRGB解码,速度更快,效果差不太远,但遇到严格线性渐变的地方,2.2近似容易在中间调产生轻微偏色。我自己在项目里优先用标准公式,只在移动端才用pow近似。
2.2 色域与白点
颜色空间不只有伽马曲线,还有色域。sRGB和Rec.709用的色域其实是同一个,但Display P3、Rec.2020、DCI-P3就完全不同了。色域决定了“纯红、纯绿、纯蓝”在CIE色度图上的坐标。一套在sRGB下看着正常饱和的颜色,切到P3或Rec.2020下,不加转换会直接溢出甚至偏色。
从线性sRGB转到线性Rec.2020,有标准的3x3矩阵参考值:
R2020 = 0.6274 * RsRGB + 0.3293 * GsRGB + 0.0433 * BsRGB G2020 = 0.0691 * RsRGB + 0.9195 * GsRGB + 0.0114 * BsRGB B2020 = 0.0164 * RsRGB + 0.0880 * GsRGB + 0.8956 * BsRGB矩阵里的数值我常年贴在工程注释里,每次接新项目都要重新核对一遍。这里还有一个没人会提醒你的细节:白点。sRGB的白点是D65,但你从DCI-P3转到Rec.2020时,白点可能不是D65,如果不做白点适应,画面就会有明显的偏绿或者偏品红。我在一个项目里遇到过所有颜色都偏绿,排查到最后才发现是素材的白点标错了,一条小参数浪费了半下午。
2.3 工作空间选择:linear-sRGB还是ACEScg
很多引擎把默认工作空间设成linear-sRGB,也就是计算在线性化的sRGB色域里跑。优点是和美术输出的贴图色彩非常接近,不需要额外转换,缺点是sRGB的色域范围并不大,遇到Rec.2020这类广色域输出目标时,色域外颜色会被夹掉,画面会出现“颜色拧不过去”的脏色。
ACEScg是一个稍微宽一点的线性工作空间,它比sRGB色域大,又没有ACES2065-1那么夸张,很多电影级实时渲染项目会优先选它。代价是工作空间里的颜色和sRGB屏上显示的颜色不会一一对应,前期开发和美术沟通要多做一层心理换算。
我的建议很实际:如果是做游戏或普通可视化,项目周期紧,linear-sRGB就够了;如果是做HDR内容、广色域输出,目标未来一定要上Rec.2020,那就趁早上ACEScg,免得后面全链路返工。
3. HDR帧、曝光与色调映射:从过曝到“能看”
3.1 为什么中间帧必须是浮点HDR
很多初次接触图形学的人不理解:我最终显示器只能显示0到1的亮度,为什么中间非要开一张RGBA16F的浮点纹理,直接在RGBA8上算不行吗?
不行。渲染出来的高光在物理世界里可能是一个超过10的数值,如果用8bit整型存储,超过1的部分全部变成1,也就是纯白。纯白区域没有层次、没有渐变,后面想做任何柔化都无能为力。更麻烦的是,8bit对暗部只有很低的精度,光线稍微弱一点的区域,数值直接跳变,画面上就会出现一条一条的色带。
浮点纹理解决了两个问题:一是动态范围,RGBA16F可以表达极小和极大的数;二是精度,线性空间里从0.001到0.002这种暗部差异,浮点数能精确记录。亮度关系在后期就有了操作空间。常见的中间帧格式有RGBA16F、RGBA32F和移动端的RGB9E5,显存和带宽有限的前提下,RGB9E5是个不错的折中,无Alpha时的很多场景完全够用。
3.2 曝光不是随便乘个系数
很多引擎里调曝光就是拖一个EV滑条,原理其实就是乘上2的EV次方:
最终曝光亮度 = 场景亮度 * 2^EVEV每增加1,整体亮度翻倍。EV=0时,中间灰大致对应0.18亮度;EV=1时,0.36亮度被推到中间灰位置。放现实摄影里,EV就是光圈、快门、ISO的组合结果,到了实时渲染这里,改曝光本质上就是对场景辐照度做一次全局缩放。
关于曝光,我的实操经验是:不要单独依赖自动曝光。自动曝光在切镜头时会闪一下,很出戏。如果场景有明确的亮度范围,直接手工锁定曝光值,对环境光、太阳光、自发光做分项调节,比靠后期硬救要稳定得多。HDR的意义就在于曝光可以后调,但前提是前期光照设计得足够“宽”,高光别直接怼到一个超大的数,不然后面怎么拉都会泛白。
3.3 色调映射:Reinhard、Filmic与ACES
色调映射的作用是把无限/高动态的亮度压缩到显示器能显示的0到1范围,同时尽量保持观感自然。最低级的做法是线性压缩,也就是把整体亮度整体除一个大数,画面立刻发灰,因为亮部被压扁了。
Reinhard是最经典的“能看”映射:
y = x / (1 + x)它的特点是简单,任何大于0的数最终都小于1,不会出现截断,但高光衰减太快,颜色会发“闷”,只好在工程里加一个曝光系数先把画面拉起来再用。
电影感的映射常用Filmic近似或ACES拟合。ACES影视化后处理常见的一段HLSL代码是:
float3 ACESFilm(float3 x) { float a = 2.51; float b = 0.03; float c = 2.43; float d = 0.59; float e = 0.14; return saturate((x * (a * x + b)) / (x * (c * x + d) + e)); }这段代码是我一直在用的“安全牌”,高光不会死白,暗部不会死黑,中间调饱和度保持得也不差。缺点是它会抹掉一部分“极端鲜艳”的颜色,所以很多游戏会在ACES之后再挂一层饱和度调整把色彩救回来。
我给几个常用映射的对比参考:
| 映射方式 | 高光表现 | 暗部表现 | 饱和变化 | 适用场景 |
|---|---|---|---|---|
| Reinhard | 柔和但发闷 | 偏灰 | 基本不变 | 快速调试 |
| Filmic/Uncharted2 | 有压缩曲线 | 保细节 | 轻微变化 | 写实渲染 |
| ACES拟合 | 过渡自然 | 干净 | 高饱和被压缩 | 电影感画面 |
| AGX | 高级胶片感 | 暗部偏青 | 有意风格化 | 风格化项目 |
换完色调映射函数后一定要检查肤色,人是视觉系统最敏感的目标。ACES拟合后皮肤的饱和度确实会降一点,不加修正的话人物会看起来偏“蜡像”。
3.4 “SDR转HDR”不是把亮度拉高
现在网上很多“SDR转HDR”的插件,本质上是把一张亮度范围0到1的SDR图像“映射”到更大的动态范围里,这属于逆向色调映射(iTMO)。最偷懒的做法是直接对亮度开1/2.2次方再乘以一个大值,结果就是高光过曝、灰雾感强、饱和度被稀释,画面极脏。
稍微靠谱的流程是亮度分层:先把图像从RGB转成亮度Y,再用大核模糊或双边滤波把图像拆成基础层和细节层,基础层负责减速压缩/扩张到HDR范围,细节层保持在局部对比度里。重建时按基础层的扩张比例去缩放RGB,最后再检查色域,超色域的颜色映射回可显示范围。
我自己做离线工具时,经常会先输出Y分量和基础层的对比图,这样能一眼看出动态范围压得够不够、细节层有没有过强。记住,这种SDR转HDR是“补救手段”,素材原本没有高动态信息,凭空“发明”出来的细节很容易失真,不适合作为高标准HDR内容的来源。
4. 颜色分级与颜色映射(LUT)的实战做法
4.1 手动调色参数到底改的是什么
颜色分级常见的控制项是曝光、白平衡、对比度、高光、阴影、饱和度,再高级一点就是Color Wheels里的Lift、Gamma、Gain。这些名词听着玄乎,数学上基本都是对颜色做分段曲线调整:
- 曝光:整体乘一个常数;
- 白平衡:R、G、B三个通道乘不同的增益;
- 对比度:围绕中间灰做线性拉伸或S型曲线;
- 高光/阴影:分别调整曲线上端和下端的斜率;
- 饱和度:把颜色往灰度方向混合,控制方向系数。
这些操作在实时渲染里如果逐个用参数实时算,美术那边会直接头大,因为参数之间会互相影响,一个色轮的改动会让另一个参数的效果也跟着变。所以颜色分级做到后面,基本都会收敛到LUT,把一堆调整烘焙成一张表,运行时只做一次查表。
4.2 为什么最终都会收敛到LUT
LUT全称查找表,在颜色映射里分1D和3D。1D LUT只能做单通道独立映射,比如R进R出,不能表达“改变饱和度导致蓝色混入绿色”这类通道之间的耦合变换。换句话说,1D LUT做不了真正的色相偏移和复杂风格化。
3D LUT是一个三维网格,输入RGB坐标直接映射到输出RGB,任何能写出来的像素级颜色变换都能被它表达。实时渲染里用LUT有三个好处:
- 性能高,运行时就是一个三线性采样,比几十个曲线计算便宜太多;
- 效果好,调色师在调色软件里把风格定好,烘焙成LUT,引擎里所见即所得;
- 跨工具协作,DAVinci Resolve里调的LUT,能和引擎里调出的LUT做统一管理,这在大团队里非常关键。
在具体工程实现上,1D LUT通常是一个256x1或1024x1的纹理,而3D LUT要么用原生的Texture3D,要么用一张宽高固定的图拼成“棋盘”,Unreal和Unity各有各的约定。我见过太多团队直接套通用LUT没检查输入空间,最后画面对不上,其实LUT本身可能没问题,问题出在“LUT是哪个颜色空间下烘焙的”这件事没对齐。
4.3 3D LUT是怎么烘焙和使用的
3D LUT的网格密度常见的有17x17x17、33x33x33、65x65x65。网格越多,精度越高,但纹理体积和采样压力也更大。17的立方是4913个点,33的立方是35937个点,65的立方直接到了27万多点。存储上,17和33是性价比很高的选择,65一般只在离线影调或者要求极高的HDR调色里才用。
烘焙LUT的核心流程我拆一下:
- 准备一张包含完整色彩范围的参考图,里面有16阶灰阶、色轮、肤色色块、纯色渐变;
- 在一台色彩校准过的显示器上,对参考图做你想要的调色操作;
- 把调色后得到的颜色和原始输入颜色一一对应,生成一个.cube文件或者烘焙成3D纹理;
- 引擎运行时,把输入颜色先转换到LUT对应的输入空间(比如线性sRGB),再查3D LUT取得最终颜色。
.cube文件的样子大概是这样:
LUT_3D_SIZE 33 0.000000 0.000000 0.000000 0.031250 0.000000 0.000000 ...不同软件导出的.cube里RGB的排列顺序可能不一样,加载引擎前最好写个自动校准程序,用几个已知颜色验证一遍,别等到项目后期才发现LUT是倒序的。
4.4 颜色映射:不只是风格化
颜色映射这个词在不同领域意思不太一样,图形学后处理里除了LUT,还经常指科学可视化里的伪彩色映射,把灰度值映射成蓝到红的高亮渐变。这个操作实现起来很简单,但经常有人忽略中间停靠点的问题,直接用lerp做两段渐变色,结果中间出现一块发灰的过渡带。正确的做法是预计算一张32位浮点的渐变LUT,颜色过渡用感知均匀的颜色空间(比如OKLab)做插值,而不是在sRGB里硬插。
5. 常见问题与排查实录
5.1 从症状到原因:一张速查表
我把自己和身边朋友遇到的问题整理成一张表,排查时照着看能省很多时间:
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 画面整体偏灰 | 伽马空间/线性空间混用 | 检查贴图采样是否解码sRGB,后处理是否在线性空间 |
| 暗部出现色带 | 帧缓冲位深不够,或没有抖动 | 换RGBA16F,输出前加少量噪声抖动 |
| 高光过曝无层次 | tonemap前太多Bloom叠加,或曝光过大 | 逐环节查看scene color,确认哪个节点变纯白 |
| 颜色偏绿/偏品红 | 白点不一致,或矩阵转换错误 | 用纯白像素验证三通道值是否相等 |
| LUT效果和调色软件里不一致 | 输入/输出颜色空间不匹配 | 确认LUT的烘焙空间和运行时输入空间完全一致 |
| HDR屏幕下饱和度发“艳” | 未做色域映射直接输出广色域 | 输出前做Rec.2020到显示色域的压缩转换 |
5.2 谷歌浏览器HDR截图过曝是怎么回事
“谷歌浏览器HDR截图过曝”这个问题,本质不是截图功能坏了,而是截图软件和浏览器的颜色处理模式不匹配。浏览器在HDR模式下的渲染输出可能是基于PQ或HLG的,截图工具拿到的是还未做显示映射的HDR帧,直接按SDR格式保存,自然就过曝发白。
另一个常见原因是浏览器窗口在切换HDR/SDR模式时,系统端对窗口内容的混合方式变了,SDR窗口的内容在HDR显示器上会被映射到较高的绝对亮度,但没有做动态范围压缩,结果截图一看亮部全白。
排查步骤我是这样做的:先看是不是全屏HDR下才出现,再换成SDR模式截同一张网页做对比,如果SDR正常HDR过曝,那问题基本出现在显示映射层,而不是网页本身。想抓HDR原帧,最好用支持HDR截图的工具或者捕获系统合成后的最终输出,而不是直接对浏览器窗口位图下手。
5.3 快速定位颜色问题的方法
我自己的习惯是会做一个多级Debug视图,依次输出:场景线性颜色、曝光后、Bloom后、色调映射前、色调映射后、LUT后、最终编码结果。把这条链连起来拍下来,看哪一段开始不对劲,问题就锁定在哪一段。很多复杂的“颜色脏、饱和度怪”问题,不是调色参数错了,而是某一环节多了一次线性-伽马转换。
这些年踩过最大的坑是:美术在Photoshop里调了一个LUT,看起来颜色很棒,进引擎后偏绿严重。我查了两天才发现,美术的Photoshop工作空间是Gamma 2.2,而引擎工作空间是线性sRGB,一个LUT在两个空间里表达的含义完全不一样。解决办法有两个,要么统一工作空间,要么对LUT做一次空间转换再烘焙。我现在都会先确认LUT的输入空间再进项目,这个习惯省掉了一大半调色问题。
最后再分享一个我觉得很值得做的小事:准备一张“空间识别图”,上面放几个颜色已知的纯色块和渐变块,在管线每个环节输出后,用屏幕取色器量一下数值。如果一节节都对得上,说明颜色空间链路是稳的,后面调风格才有底。很多时候我们焦虑,是因为不知道问题到底出在第几环,有了这条调试链,心里就会很踏实。