
前阵子调一个渲染器的后处理栈遇到一个特别磨人的现象编辑器里看画面饱和度正常一调高曝光就灰成一团高光区域怎么拉都拉不回来导出的HDR截图在浏览器里打开直接白屏看着像一个低劣的滤镜而不是真正的高动态范围画面。折腾了快一周最后发现所有问题都指向同一个根——整个后处理管线的HDR、颜色分级、颜色映射和颜色空间这几个环节彼此之间根本没对齐。这不是一个少见的工程问题恰恰相反很多图形学新手甚至有一定经验的开发者在第一次搭后处理管线时都会在颜色上翻车。这篇文章就基于我自己的调帧经历把这几件事拆开讲清楚并且给出一个可以直接抄作业的管线顺序和一批避坑经验。1. 后处理的本质是给画面“打光”HDR帧缓冲决定了调色上限1.1 帧缓冲精度不够亮部细节就像相片过曝一样永久丢失先理清一个共识后处理处理的对象不是某个模型、光源或材质而是整个场景渲染完之后那张“画面”。它本质上是一个全屏像素级调整器通常跑在场景主渲染之外输入是一张或多张纹理输出还是纹理。既然处理的是“画面”本身那么这张画面的数据质量就决定了一切。绝大多数图形学入门教程里帧缓冲默认是RGBA8格式每通道只有8bit存储范围是[0,1]。但真实场景的物理亮度是没有上限的。一盏灯照在粗糙金属上高光的反射亮度可能超过1甚至到几十一个强光源直接进入视野中心区域亮度可能上千。如果用RGBA8来做中间缓冲这些超过[0,1]的数字会被硬件或者Shader里的clamp直接截断成1。截断之后所有“高光1”的信息就永久消失了。这个损失有多严重类比一下你用手机拍夜景如果直出JPG格式亮部很早就过曝了回头想用修图软件把高光细节拉出来拉不出来因为JPG里根本没有存下高光的真实亮度信息。但如果你同时拍了RAW格式虽然屏幕上看去JPG也是一片白RAW里却保留了一层层的高光过渡后期可以恢复。HDR帧缓冲就是你后处理管线里的RAW文件。所以在现代渲染器里场景渲染目标Scene Color普遍是RGBA16F或RGBA32F的浮点纹理。RGBA16F用半浮点存储精度有限但动态范围足够覆盖从极暗到极亮RGBA32F是全浮点更准确带宽和内存开销也更大。实际项目里我通常优先用RGBA16F除非是后期需要高精度数值运算比如带有多次迭代的Bloom或特殊光谱处理否则没必要上32F。1.2 后处理管线里为什么不用sRGB缓冲做中间存储很多人会问显示器本身就是sRGB为什么不直接渲染到sRGB帧缓冲省一次转换这里有个关键点sRGB不是单纯的“波形”它是色彩空间同时也是一种非线性传递函数的载体。如果直接把渲染目标标记为sRGB硬件在每次写入帧缓冲时都会自动执行线性到sRGB的编码。表面上看“画面变正常了”但代价是所有大于1的线性亮度被压缩到一个很窄的范围甚至被钳制后处理流程后面的Bloom、颜色分级、色调映射就全乱套了。举个真实例子。我在一个项目里为了省一点性能和实现复杂度把中间颜色缓冲设成了RGBA8_SRGB灯光强度本来线性叠加结果高光区域出现明显的“塑料感”Bloom光晕变成白色小方块。排查了半天问题就出在这个sRGB缓冲上。中间存储必须保持线性浮点数据直到最后输出到显示设备时再编码成sRGB这是后处理色彩管线的黄金法则。所以我的建议很直接场景颜色缓冲用RGBA16F线性空间Bloom等后处理pass仍然在全屏纹理上进行不要切换到sRGB只有最后一步拷贝到Back Buffer或显示纹理时才做线性到sRGB的转换。这个顺序和“RAW处理”的逻辑完全一致——先处理数据再“显影”。2. 颜色空间线性、sRGB和伽马校正的三角关系2.1 光量叠加是线性的所以计算必须在线性空间物理世界里两束光叠加能量是直接相加的。一盏1000流明的灯和另一盏1000流明的灯同时照到一面墙墙面反射的亮度大约是单盏时的两倍。这个“相加”是线性操作。但颜色存储和传输不是线性的。几十年前CRT显示器有一个特性输入电压和屏幕亮度之间不是正比关系而是近似幂函数关系指数大约为2.2。为了让画面在显示器上看起来“正常”视频信号在源头就会先做一次反向的伽马编码即亮度提升前先除以指数。这个编码过程就是伽马校正。所以计算机里你看到的普通纹理图像如sRGB PNG存储的数值并不是线性亮度而是经过伽马编码的“显示信号值”。如果在后处理中你直接把这些非线性纹理值拿去做光照叠加、混合、卷积相当于在错误的域里做物理计算。两个在sRGB域里加起来是200%的信号值实际线性亮度可能只有150%。结果是画面整体偏灰阴影发闷亮部过曝。这个现象在图形学作业里特别常见我记得孔令德那本图形学教材的习题里也有不少涉及伽马校正的练习题当时只当数学题做等真正调后处理的时候才明白有多重要。正确做法是在Shader里对纹理采样后先通过一个Pow操作或者硬件sRGB采样把非线性值转换到线性空间所有Post-process计算都在线性空间完成最后输出前再转回sRGB。这也是为什么引擎设置里通常要选“Linear Color Space”的原因。2.2 sRGB不是“给画面加一层灰”而是显示设备的物理响应不少人以为sRGB就是一个简单的“亮度曲线”实际上它是一个完整的色彩空间规范。完整的sRGB定义包括原色坐标、白点坐标和传递函数。传递函数是一段分段函数小于0.0031308时是线性段大于时按1/2.4次幂做指数映射。笼统地说它是一个2.2伽马曲线也行但精细作业时要注意分段点。一个常见的坑你在引擎里看到的画面可能已经经过sRGB编码但你在做后处理时如果对某个pass的输入又做了一次sRGB解码就会导致颜色偏粉或偏青。比如Unity里如果设置了Linear Color Space但某个自定义Shader里没写正确的UNITY_SAMPLE_TEX2D_SAMPLER宏采样到的颜色可能是经过编码的sRGB值再拿去和线性HDR值计算结果就会多一层伽马变换。我自己踩过更惨的用RenderDoc抓帧发现中间pass的RT格式设置了R8G8B8A8_UNORM_SRGB但由于我手动在Shader里又做了一次线性化等同于“先编码再解码”画面直接变得灰暗。这类问题肉眼很难分辨最好用ColorChecker或者灰阶渐变图做验证。2.3 工作颜色空间选择从Rec.709到ACEScg有了线性空间还有一个问题线性空间的“线性”是建立在哪组原色坐标上的sRGB、Rec.709、DCI-P3、Rec.2020这些色域的RGB原色坐标不一样。如果你在一个宽色域空间比如P3里做混合然后输出到Rec.709显示设备就需要做精确的色域转换否则颜色会看起来更绿或更红。图形学最常用的几种工作空间线性sRGB或者说线性Rec.709。很多实时渲染引擎的默认值适合大多数消费级显示器。ACEScg。美国电影艺术与科学学院为了统一影视制作流程定义的宽色域空间原色范围比sRGB大特别适合处理HDR素材。它比sRGB更容易保留高饱和度颜色信息在调色时不容易超出显示色域。线性扩展有的引擎用不同白点但大多可以切换。在选择工作空间时要明白“显示器看到的永远是设备色域内的一小部分”。如果你在所有中间pass都采用宽色域ACEScg但最后只做简单的Clamp到[0,1]而没有做色域裁剪或映射那结果很可能是“饱和爆炸”——红到发荧光绿到刺眼。正确做法是保留一个输出变换比如一部ACES ToneMap sRGB输出曲线很多引擎里“ACES Tonemapping”这个选项就是干这个的。3. 颜色分级对光的重映射不是套滤镜3.1 曝光、对比度、饱和度在HDR下的正确打开方式后处理里的颜色分级Color Grading才是真正把画面从“物理正确”推到“艺术风格”的阶段。它跟滤镜不是一回事。滤镜是直接叠加颜色分级是控制光在各通道间的比例本质上是重映射。先说曝光。HDR画面里曝光就是乘一个全局系数。为什么要先做曝光再调节其他参数因为如果画面动态范围特别大比如背景天空有1000nit前景阴影只有几个nit你不把整个范围缩到一个中间灰度附近对比度调节根本不知道以谁为基准。HDR下做曝光调节只需要把线性HDR颜色乘以2^stopstop是曝光档数和相机光圈的概念一致。然后是对比度。有一个常见错误直接对RGB做color (color - 0.5) * contrast 0.5。这在LDR显示空间能看但在线性HDR空间里0.5并非感知中灰这个公式会严重改变色相和亮度平衡。更稳妥的方法是以亮度中灰为基准调节。我一般会在Log空间或者用亮度权重公式计算出Luminance再围绕中灰缩放。饱和度也一样。直接在RGB通道上调大三个系数的风险是某些通道会被截断特别是高光边缘容易脏。推荐先把RGB转到一个亮度/色度表示比如使用简单的YCoCg在色度通道上缩放再转回RGB。这样做的好处是保持亮度不变不会因为饱和度增强导致高光崩掉。3.2 3D LUT不是一张简单的图片它是有“顺序”的3D LUT是颜色分级最常用的工具。它的核心思想是把0到1范围内的所有颜色输入通过空间中的一个立方体映射到新的颜色。例如32x32x32的LUT每个维度对应R、G、B的输入值存储的是变换后的输出RGB。查表时用三线性插值这样效果能保持平滑。但LUT也有它的脾气LUT的分辨率决定精度32³是常见选择64³更好但带宽翻倍。3D LUT的输入域通常是有界的。若你的HDR画面亮度大于1直接查询LUT会丢失高光数据。所以一般流程是先做一次Log编码或ToneMap前的预缩放把HDR压到LUT能表示的范围内再查表。LUT必须和你当前的工作空间一致。如果你在ACEScg空间做了一张LUT拿到线性sRGB空间去用色彩偏十有八九。我在项目里习惯把LUT和色调映射配合起来先做一个“取色器”pass把原始HDR转成一个32x32x32的3D LUT纹理导出在外部调色软件里调整后再导入引擎。这个流程很接近电影行业里的“CDL LUT”工作流。3.3 分级和色调映射的前后顺序会直接影响成片质感这是最容易搞乱顺序的地方。简单说调色和曝光应该放在“压缩动态范围”之前还是之后我的选择是先做颜色分级再做色调映射。原因很简单色调映射是非线性压缩会改变高光和阴影的相对关系也会让颜色发生变化。如果在色调映射后再做分级操作对象是已经“被压缩”过的LDR画面高光细节已经过载阴影也已经被抬平这时拉对比度很容易产生脏高光饱和度调整也容易在边界处溢出。反过来先分级再ToneMap你是在“全动态范围数据”上修改艺术倾向压缩后的结果会自然保留高光的柔和过渡。这也是Unity Post-processing、Unreal引擎默认逻辑里Grading在Tonemapping之前的根本原因。不过有一个例外如果你只是希望快速在最终画面上打一层“滤镜式”调色比如模拟Instagram效果那放在ToneMap之后加一个后覆盖也无妨。但那种效果通常需要配合LDR输出做不了精细的色彩管理我觉得更适合小体量项目不适合严肃的图形学后处理管线。4. 色调映射HDR到LDR的降维也是画面风格的分水岭4.1 从Reinhard到ACES哪些算法在tone mapping时做了什么色调映射Tone Mapping解决的是“浮点HDR数据如何显示在0~1的LDR屏幕上”的问题。最简单的Reinhard映射是x / (1 x)。这个算法简单粗暴但输出结果对比度偏低高光压缩过快画面容易发灰。为了改善通常会在公式里加入白点参数x * (1 x / whitePoint) / (1 x)。更常用的是Filmic曲线它是通过一组分段低次曲线拟合出的S曲线暗部抬升、亮部缓降对比度适中。还有ACES是影视行业标准的一套色彩变换包含输入变换、色彩空间变换和显示变换。ACES的ToneMap曲线在暗部和高光都有很自然的“滚降”两级之间的过渡更顺滑同时还会做色相保护。我在实际项目里的经验如果追求“写实摄影感”用ACES曲线基本不会错如果追求风格化、二次元或偏手绘感画面可以用自定义Filmic曲线或者更简单的Reinhard然后配合LUT增强饱和度。关键是tone mapping之后再统一做sRGB编码不要在tone mapping前提前转sRGB否则曲线会重复。4.2 SDR转HDR到底在“转”什么别把猜测当成渲染现在网上总能看到“SDR转HDR”的字眼好像所有视频都能变成真正的HDR。但从图形学角度看这更像一种“逆色调映射”——用算法从丢失高光细节的SDR画面中重建HDR信息。可是重建和真实渲染是两回事。如果你有一个SDR画面例如普通8bit视频它的亮部早在采集或者编码时就被截断到100nit左右。转换算法只能通过边缘分析、高光检测和局部分区来“猜”哪些区域原本应该更亮然后给它们分配一个新的HDR亮度值。这个过程没有物理依据结果往往会出现光环感、灰边或者某些区域高光溢出得更脏。真正的HDR渲染应该从光源、材质、场景中直接计算出超过1的辐射亮度然后在后处理里用浮点缓冲保存它最后用色调映射输出。不是靠后期把“假高光”硬拉出来。所以我的建议是如果项目需要真正的HDR表现管线一开始就要为HDR设计如果在做SDR转HDR的工具或者兼容老内容那就老老实实承认这是“重建猜测”不要指望它能达到真实渲染的HDR质量。4.3 一个让人头疼的实际问题浏览器HDR截图过曝热词里那条“谷歌浏览器hdr截图过曝”很有代表性。我也遇到过。你在WebGL/WebGPU里渲染了一个挺正常的HDR场景浏览器里看起来色彩鲜艳、高光柔和但一用截图工具或者用户自带的截屏保存成PNG图片直接白花花一片像曝光过度了。原因通常不是渲染本身错而是HDR数据和显示格式没对齐。浏览器在渲染时如果使用高动态范围画布部分浏览器支持HDR输出它会把HDR信号送去显示器。截图工具/系统截屏只能记录SDR信号它会把超过SDR范围的亮度直接当作峰值或错误值来处理结果就是过曝。另外如果你在shader里做tonemap之后又输出了线性HDR数据到Canvas浏览器没有自动转成sRGB截图同样会过曝。解决办法有几个截图逻辑单独走一条输出路径把HDR数据经过tone mapping sRGB编码后输出到一个专用color target再截图。如果你必须截取Canvas上的实际像素确保Canvas的color space设置正确例如context canvas.getContext(2d, { colorSpace: srgb })并且渲染管线最后有正确的颜色转换。如果是自己的工具链可以调用浏览器的toDataURL之前先把Canvas绘制到离屏纹理手动完成色彩空间转换。这不是bug而是色彩管理问题。理解了HDR帧缓冲和输出显示之间的差异后就不会被“截图过曝”这种假象迷惑。5. 可落地的后处理颜色管理流程与避坑清单5.1 一套自研引擎可抄作业的管线配置给一套我以前自研引擎里验证过的后处理管线顺序直接复用没问题主场景渲染到RGBA16F线性空间纹理。Bloom等光学模拟pass在全屏线性HDR纹理上进行。曝光调节根据场景平均亮度乘系数log2均支持。颜色分级线性HDR空间通过3D LUT或参数化控制对比度、饱和度、色温等。色调映射选择ACES或Filmic曲线将HDR压到[0,1]。线性到sRGB编码可以用硬件RT格式转换或者自定义shader pow。输出到后端缓冲。注意第3步和第4步的顺序有人会把曝光放在tone mapping之后这在HDR下没有意义因为亮度范围已经被压缩曝光调节会破坏对比度平衡。0到1的LDR范围也不适合做大幅曝光调整。5.2 我在实际项目中踩过的6个坑LUT维度错误引擎内部LUT纹理是32x32x32但美术给的贴图是RGB的2D图却按3D纹理去采样结果出现彩虹条纹。把线性场景纹理直接拿去UI叠加UI层通常在sRGB空间如果场景HDR纹理输出时已经做了sRGB编码那UI叠上去之后就要小心混合方式。最稳妥的是UI单独pass不和场景HDR叠加。Tone mapping后忘了sRGB编码有些引擎自动处理但自研pipeline很容易漏。漏掉后整张画面灰暗像蒙了一层雾。在Gamma空间调参数引擎设置为Linear但某些美术工具导出的颜色数值是sRGB。直接把这些数值当作线性颜色赋值给材质后场景颜色会明显偏差。半浮点格式的精度导致暗部色带RGBA16F在很小的值附近精度有限如果场景暗部特别黑再叠加渐变光照容易出现banding。解决办法在暗部加一点噪点闪烁或者用RGBA32F做暗部特殊pass。在Shader里重复解码sRGB中间buffer明明是UNORM_SRGBShader里又用pow(2.2)解码一次等于双重解码。这是最坑的颜色偏到姥姥家。5.3 三个不需要校色仪就能验证颜色的土办法没有昂贵校色仪也能定位大部分颜色管理问题第一做一个灰度渐变图。用纯RGB的线性值从0到1渲染一个ramp纹理如果渲染结果里出现明显色偏或条纹说明色彩空间转换或者LUT出了问题。第二检查“纯白高光”。场景里放一个平行光直射一个白球查看渲染目标的反射值如果球心高光不是大于1的浮点数说明光照和材质有问题会影响整个后处理。第三把后处理中间结果单独dump出来。比如把Bloom前的纹理和Bloom后的纹理保存成EXR然后在图像软件里用拾色器看数值。如果Bloom前高光区域是10.0Bloom后变成0.2说明你的低通滤波或混合逻辑写错了。这种数值对比比肉眼靠谱得多。最后再分享一个小技巧后处理调色时尽量用一个实时示波器窗口来观察亮度直方图。很多博主会告诉你眼睛看着舒服就行但工程上知道画面里最暗点和最亮点应该落在哪个区间比“看起来不错”更重要。颜色管理没有魔法线性空间、正确的格式和正确的顺序就是这道题的标准答案。