1. 画面发灰、暗部发紫、高光刺眼时先别急着调灯光我在实际项目里碰到过太多类似的情况美术把灯光和材质调了一下午场景远看还行近看总觉得假——暗部透着一种说不出的灰阴影边缘发紫亮部像糊了一层雾饱和度忽高忽低。换一台显示器看颜色又不一样。最后定位来定位去问题根本不在模型、不在贴图、不在灯光的数值而是项目的色彩空间从一开始就没搞对。Unity URP 里反复出现的 Gamma 矫正Gamma Correction就是为了解决这一整类问题。简单说人眼、显示器和渲染器的工作方式不一样渲染结果不能直接丢给屏幕必须在关键环节做一次非线性的编码和解码。如果漏掉了这一步或者做错了位置光照、阴影、渐变、混合全都会失真。这篇文章我会把 Gamma 矫正的前因后果、Unity 和 URP 里的具体开关、以及调试过程中容易踩的坑按顺序讲清楚。适合刚接触 URP 的图形开发者也适合被画面问题折磨但不知道从哪下手的朋友。看完之后你至少能回答三个问题Gamma 2.2 这个数字从哪来为什么要在线性空间做光照计算在 URP 里我到底该勾选哪些选项。2. 2.2 这个数字从哪来显示器、人眼和 sRGB 编码的那笔旧账在进入 Unity 操作之前先把 Gamma 这个东西的来龙去脉理一遍。搞清它的历史你后面读各种渲染文档时看到 sRGB、2.2、0.45 这些数字就不会再发怵。2.1 CRT 时代的物理限制你可能听说过伽马值最开始是从显像管CRT里来的。老式 CRT 显示器的亮度与控制电压并不是线性关系亮度大致正比于电压的 2.2 次方到 2.5 次方。也就是说如果你给显示器一个0.5 亮度的电压信号它实际显示的物理亮度只有 0.5 的 2.2 次方约等于 0.218而不是你想的 0.5。这是硬件层面的事实谁也绕不过去。为了补偿这种非线性图形系统在把图像数据传给显示器之前会先做一次预加重把亮度值取 1/2.2 次方。这样经过显示器的 2.2 次方掰弯之后最终到人眼的亮度就恢复成线性关系了。这个编码过程就是 Gamma 编码而显示器端的匹配过程就是 Gamma 解码。到了今天虽然我们用的基本是液晶屏和 OLED色彩空间标准如 sRGB已经把当年 CRT 的这个特性继承下来了。sRGB 的传递函数就是在 CRT 响应基础上做近似得到的所以2.2 次方这个数字至今依然无处不在。2.2 人眼的感光曲线决定了编码方式但如果你以为 Gamma 矫正单纯是为了迁就老硬件那就小看它了。人眼的感光能力本身也是非线性的我们对暗部的亮度变化极其敏感对亮部的亮度变化相对迟钝。形象的比喻是人眼像一台自动增益的相机在昏暗环境里能分辨很细微的亮度差在强光下反而麻木。这就带来一个存储问题。如果直接用 8 位整数存储线性的亮度值暗部只有 0 到 1 之间很小的一段数值可用经典问题就是暗部渐变出现一条条色带。反过来如果存储的是已经做过 Gamma 编码的数值也就是把资源更多地分配给人眼敏感的暗部那么 8 位就能存出观感上比较均匀的亮度分辨率。所以你现在看到的所有普通 8 位 PNG、JPG 贴图本质上都是经过了 Gamma 编码的 sRGB 数据。它们里面存的数值几乎都不是物理上的线性亮度而是便于人眼观看的感知亮度。理解这一点是理解 Unity 里所有 sRGB 开关的基础。2.3 sRGB 标准的精确曲线sRGB 具体来说并不是简单粗暴的0.45 次方它在接近黑的低值区有一段线性分支避免曲线在零点斜率过大。工程上常简化为 2.2 的幂运算但严谨的转换曲线是下面这两段// sRGB 到线性解码 float3 sRGBToLinear(float3 c) { float3 lo c / 12.92; float3 hi pow((c 0.055) / 1.055, 2.4); return c 0.04045 ? lo : hi; } // 线性到 sRGB编码 float3 LinearToSRGB(float3 c) { float3 lo c * 12.92; float3 hi 1.055 * pow(c, 1.0 / 2.4) - 0.055; return c 0.0031308 ? lo : hi; }在游戏开发里你用pow(c, 2.2)和pow(c, 1/2.2)做近似绝大多数时候误差可以忽略。但如果你的项目涉及高精度后期调色最好还是用标准的 sRGB 曲线避免暗部偏色。3. 为什么光照计算必须在线性空间进行用两杯水理解错误加倍现在到了整篇文章最核心的部分。Gamma 不只是给画面调个亮度它的关键影响在于你在某个色彩空间里做的数学运算结果是否符合物理世界。3.1 乘法只在线性空间里才对渲染的本质是大量乘法漫反射是颜色乘以光照强度阴影是颜色乘以遮蔽系数体积雾是颜色乘以透射率。这些乘法有一个隐藏前提参与运算的数值必须是物理上的线性亮度。我举个具体数字例子。假设 A 表面真正接收到的光量是 0.5B 是 1.0物理上 A 就是 B 的一半亮度。在 sRGB 编码下0.5 存成约 0.731.0 仍然是 1.0。如果我们在 Gamma 空间里直接拿 0.73 去做光照乘法比如乘一个 0.5 的衰减系数得到 0.365送到显示器上显示的物理亮度是 0.365 的 2.2 次方约 0.118。可是正确的物理结果应该是 0.5 乘 0.5等于 0.25。结果比真实情况暗了一半还多。换句话说在 Gamma 空间做乘法每一个乘数都会被额外的幂曲线搅和一遍最后得到的是一个指数错误的值。你觉得场景脏灰暗部死黑经常就是因为这个。3.2 插值和混合也同样受影响光照计算不只是乘法还有大量线性插值纹理的双线性过滤、Mipmap 渐变、透明混合、顶点色插值、阴影渐变。双线性过滤如果直接对 sRGB 编码的数值求平均得到的中间值在物理亮度上就不等于两端亮度的线性平均。一个最经典的例子黑白渐变贴图黑是 0白是 1在 Gamma 空间里对编码值取平均得到 0.5显示出来物理亮度变成 0.22 左右而正确的线性平均值对应的显示亮度应该是 0.5。结果就是渐变中间发暗过渡不自然。这也是为什么现代 GPU 在采样 sRGB 格式纹理时会在滤波之前先解码成线性值保证所有插值都发生在线性空间。如果纹理没有正确标记为 sRGBGPU 就会拿编码值直接滤波Mipmap 过得越多画面越暗越糊。3.3 渲染方程对空间的要求更严格换成 PBR 渲染问题就更严重了。基于物理的 BRDF 都建立在能量守恒的基础上传统的高光项里有大量的pow、法线点积、高光次幂运算。这些公式推导时假设输入输出是线性辐照度。你拿着一个 Gamma 编码的 albedo 贴图和一个 Gamma 编码的光照颜色去算高光形状、菲涅尔曲线、能量守恒全部会错位。用一个直观的类比线性空间是国际单位制Gamma 空间是市斤和英里。你可以在买菜时用市斤出门说英里但你要是把1 加仑当1 升拿去算发动机功率结果必然是灾难。Gamma 矫正就是给渲染管线的每一段装上单位换算器让所有的乘除、积分、插值统一发生在物理正确的线性单位里。4. Unity 和 URP 在哪些环节悄悄帮你做了 Gamma 矫正理解了为什么需要线性空间接下来看 Unity 在流水线的哪些位置做了转换。URP 本身不引入新的色彩空间机制它继承的是整个 Unity 引擎的色彩空间框架只是把开关和选项摆在更显眼的位置。4.1 Player Settings 里的 Color Space一个开关切换整套流程位置在 Project Settings Player Other Settings Color Space。默认常常是 Gamma如果你做 URP 且使用了标准渲染光照建议切到 Linear。这个开关并不只是改一个标记它改变的是整条渲染链路的默认行为环节Gamma 空间模式Linear 空间模式sRGB 纹理采样不解码直接用编码值硬件/着色器自动解码为线性值光照计算在 Gamma 空间做在线性空间做材质颜色、灯光的数值按 Gamma 空间理解按线性值参与运算后处理输入基本是 Gamma 空间的 LDR 数据可能是线性 HDR 数据最终输出到屏幕不做额外编码编码成 sRGB 交给显示器在 Linear 模式下Unity 会把你从 Inspector 里选择的颜色、灯光的颜色和强度等从 sRGB 转成线性参与光照计算之后再编码回 sRGB 输出。这一套对开发者几乎是透明的。4.2 纹理导入设置里的 sRGB 开关不是所有贴图都是颜色贴图比 Color Space 更容易被忽视的是每张纹理的 sRGB 开关。Unity 导入贴图时默认在sRGB (Color Texture)上打勾。这个勾的含义是这张贴图里面存的是经过 Gamma 编码的颜色数据采样时要先解码。但是一张贴图如果存的不是颜色而是数据——法线、粗糙度、金属度、AO、高度、遮罩——那它里面的数值是信息不是给人看的颜色。如果你给这些贴图勾上 sRGB本来线性的数据会被当作 Gamma 编码去解码等于把数值做了 2.2 次方材质属性全乱套。对于普通 URP 项目我的建议是这样贴图类型导入类型sRGB 开关Albedo / Base Color / DiffuseDefault勾选自发光贴图做颜色用Default勾选法线贴图Normal Map自动取消Metallic / Roughness / SmoothnessDefault 或数据型导入取消AO / 环境遮蔽Default 或数据型导入取消高度图 / 位移图Default 或数据型导入取消各类遮罩如流沙、血迹 MaskDefault取消UI 中用到的普通颜色贴图Default勾选这里有一个特别容易翻车的点如果一张颜色贴图被错误地取消了 sRGB在 Linear 模式下采样后拿到的数值偏亮因为编码值被当成线性值直接用画面会大面积灰白。反过来法线贴图被错误勾选 sRGB整个模型的光照会出现很离谱的凹凸方向偏差。我在某项目里排查了好几天才发现是批量导入工具忘了分类型。4.3 渲染目标、后处理与最终的编码出口说完了输入看输出。在 Linear 模式下Unity 的相机渲染目标通常使用线性格式的缓冲后处理也发生在线性空间。直到最后写回屏幕前才会把线性结果编码成 sRGB。如果你的管线最终接了 Tonemapping色调映射这一步还会和 ACES 等算子配合。在 URP 里需要注意几个具体选项URP Asset 的 HDR 开关开启后相机缓冲使用 HDR 格式后处理可以拿到更高动态范围线性工作流的意义更完整。关闭 HDR 后很多数值会被截断到 0~1高光细节容易丢失。Volume 组件里的 Color Grading ModeURP 提供了 LDR 和 HDR 两种颜色分级模式。LDR 模式在低动态范围内工作兼容性好HDR 模式在线性 HDR 空间做分级能保留更多亮部信息。如果你的项目在 Linear 模式且开启 HDR建议优先用 HDR 模式做调色。TonemappingURP 的 Volume 里有 Tonemapping常用 ACES 和 Neutral。ACES 本身就是一套为线性场景设计的算子你在 Linear 空间里使用它结果最接近影视方案的电影感。如果在 Gamma 空间强行套 ACES很容易出现饱和度异常、暗部发青一类的副作用。Dithering线性空间的高精度计算配合 8 位输出暗部渐变经常出现色带。URP 的后处理里可以开 Dithering 来抖掉色带尤其是移动端强烈建议开。4.4 光照强度在两种空间下的差异还有一个在切换色彩空间时常被忽略的连带影响光照强度。同一个场景从 Gamma 切到 Linear灯光数值立刻变暗了。这不是 bug而是以前在 Gamma 空间里做的乘法本身带了额外的幂曲线结果被人为拉亮了。切到 Linear 之后你需要重新调整灯光强度和曝光参数。我见过很多团队切完 Linear 之后第一反应是画面怎么这么暗然后把所有灯光强度调高了一倍。这当然可以但更好的做法是用 URP 的曝光和 Tonemapping 来重新建立画面的亮部基准。也就是说先把灯光数值设置成物理上合理的范围再用后处理的 Exposure、Color Adjustments 这些参数去做画面整体的明暗适配而不是跟灯光强度死磕。5. URP 项目里切换 Linear 的完整配置清单与避坑经验知道了原理下面是一份可以直接抄作业的实操流程以及我实际项目里踩过的一些坑。5.1 从 Gamma 切换到 Linear 的六个步骤先备份项目。切色彩空间会导致所有材质的观感、烘焙光照结果发生变化这一步没法完全无损回退。修改 Color Space 为 Linear。Project Settings Player Other Settings Color Space。检查纹理资产。用资源扫描工具批量过一遍所有贴图的 sRGB 勾选状态。颜色贴图保留勾选数据贴图取消法线贴图确认导入类型为 Normal Map。重新生成光照相关资源。如果有烘焙光照贴图、光照探针、反射探针需要重新烘焙。否则烘焙结果和实时光照的空间不一致场景会有拼接感和错误的明暗。打开 URP Asset 检查 HDR 与后处理设置。建议开启 HDR后处理 Volume 里把 Tonemapping 先选 ACESColor Grading Mode 选 HDR观察画面。重新调整灯光与后处理参数。从主光源强度开始再调环境光和反射探针强度最后调曝光、对比度、饱和度。这个顺序很重要否则你会在错误的地基上修修补补。5.2 排查链路一张图片灰蒙蒙到底怪谁如果切完 Linear 之后画面依然怪我一般按下面这个顺序排查这个顺序帮我在好几个项目里省了大量时间先用 Frame Debugger 看中间渲染目标。URP 的 Frame Debugger 能列出每个 RenderPass 的输入输出。如果错误的迹象在Lit Pass输出时就已经存在说明是材质、灯光、纹理的问题如果 Lit Pass 正常、到了后处理阶段才变怪说明问题出在 Post-processing 的配置或 Volume 参数上。检查场景里有没有漏网的非 Linear 材质。某些第三方商店资源、老项目迁移过来的 Shader内部可能硬编码了pow或者缺少UNITY_DECLARE_TEX2D之类的宏导致它一直在 Gamma 空间计算。排查办法是把怀疑对象单独拎到一个只有平行光的空场景里对比。用 RenderDoc 检查实际采样格式。如果框架排查不出来的话用 RenderDoc 抓一帧直接看某张贴图在 GPU 端到底是什么格式。如果贴图本应是线性数据但 GPU 标注成了R8G8B8A8_SRGB那问题就在导入设置反过来也一样。检查 UI 和粒子材质。UI 的默认材质和很多粒子的软粒子材质走的是另一套混合逻辑。Linear 空间下如果 UI 贴图没有正确标记 sRGB或者用了带 LDR 假设的 ShaderUI 整体会偏色。粒子尤其明显因为粒子大量依赖 Alpha 混合混合在错误空间下会形成明显的黑边。5.3 自定义 Shader 里的手动 Gamma 处理URP 自带的 Lit Shader 已经默认正确处理了线性空间但你自己写的 Custom Shader 不一定。最常踩的坑是写了一个简单的采样函数发现画面怎么都对不上。一个比较稳妥的做法是不要在 Shader 里写死必须线性还是必须 Gamma而是依赖 Unity 的宏和硬件标记。用TEXTURE2D和SAMPLER宏声明纹理用SAMPLE_TEXTURE2D采样让 Unity 根据纹理的 sRGB 标记自动决定是否解码。如果纹理标记正确硬件采样本身就完成了 sRGB 到线性的转换。TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); half4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv);需要特别注意的情况是你从 RenderTexture 里采样数据。RenderTexture 创建时是否 sRGB 取决于它的格式和读写标记。如果它是一个普通的 HDR 中间缓冲你希望样本保持线性如果它是一个用于 UI 展示的 RGB 缓冲你又希望它是 sRGB。一个救命习惯是在脚本里创建 RenderTexture 时显式指定色彩空间不要依赖默认值RenderTexture rt new RenderTexture(width, height, 0, RenderTextureFormat.ARGBHalf, RenderTextureReadWrite.Linear);5.4 移动端性能与 Gamma 模式的取舍最后聊一个现实问题移动端是不是非得用 Linear不是。很多面向低端机型的 URP 项目为了把宝贵的 ALU 运算花在刀刃上会选择保持 Gamma 模式。Gamma 空间渲染通常意味着少做很多解码/编码某些老 GPU 上能省出一截性能尤其在带宽受限的 8 位渲染目标上。但是风格化不等于随便做。如果强制留在 Gamma 模式你至少要意识到自己在做什么光照是物理失真的别把阴影和高光的数值跟 PBR 手册里的参考值对照。贴图导入的 sRGB 开关在 Gamma 模式下的意义完全不同几乎可以全部保持默认但如果你后期把项目切到 Linear这些纹理设置会全部变成雷区。后处理的 Tonemapping 尽量用 Neutral 而不是 ACES因为 ACES 在线性空间外使用容易翻车。如果团队里有美术在 Linear 和 Gamma 两种预览之间切换观察我的建议是固定一条底线项目启动时就定好色彩空间所有渲染相关资产和 Shader 都按这个空间来工作。最怕的就是项目做到一半再切那几乎是给所有材质和灯光做一次大手术。6. 几个反直觉的结论以及我的后处理调参习惯原理和操作都讲完了最后分享几条我自己的经验性结论它们都来自真实项目里被反反复复调过的教训。第一个反直觉的结论是画面发灰有时候不是对比度低而是纹理解码错误导致亮部过曝全局被拉平。把错误标记的贴图修正之后画面会立刻清透很多根本不需要额外加强对比度。第二个反直觉的结论是在 Linear 空间里阴影往往真的会更暗、更扎实。这是因为 Gamma 空间会把中间调抬亮掩盖了很多暗部细节。第一次换过去的人会不习惯但这是物理正确的结果。别急着把环境光和 Shadow Intensity 拉满先把后处理的 Exposure 和 ACES 曲线调好阴影信息其实还在。第三个反直觉的结论是很多号称夜景好看的色调都是 Tonemapping 和 Color Grading 在起作用跟 Gamma 矫正本身关系不大。线性渲染只是提供了干净的素材最后把画面变成什么样还是后处理的事。Gamma 矫正解决的是渲染是否正确而画面好不好看永远是调色师的活儿。我个人的后处理调参习惯是先关掉所有 Tonemapping 和 Color Grading只保留最简单的 Gamma 矫正项把灯光调到一个接近物理合理的范围。确认基础光照干净之后再一层层加 ACES、加曝光、加色彩校正。这个顺序能帮你快速分辨某一个问题到底是光照问题还是后期问题而不是在叠加的变量里瞎猜。如果你现在正被 URP 画面问题卡住不妨先检查色彩空间和纹理标记这两项说不定比调三天灯光更管用。