如果你是被标题里“后处理”三个字吸引进来的我先多说一句如果你要找的是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.709HDR显示器HDR10RGBA16F显示映射按PQ输出在显示映射前PQST 2084影院/高端后期OpenEXR不做固定tonemap保留成Log在Log空间做交给调色软件低端移动端RGBA16F或RGB9E5必需尽量轻量优先用预制LUTsRGB很多项目死在“什么都往上加”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整体亮度翻倍。EV0时中间灰大致对应0.18亮度EV1时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 为什么最终都会收敛到LUTLUT全称查找表在颜色映射里分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的输入空间再进项目这个习惯省掉了一大半调色问题。最后再分享一个我觉得很值得做的小事准备一张“空间识别图”上面放几个颜色已知的纯色块和渐变块在管线每个环节输出后用屏幕取色器量一下数值。如果一节节都对得上说明颜色空间链路是稳的后面调风格才有底。很多时候我们焦虑是因为不知道问题到底出在第几环有了这条调试链心里就会很踏实。