聊聊UI分辨率分离与线性空间下的GammaUI

整理了下近期以及过去做过的这方面的一些功能, 挑出点有意思的东西或思路谈谈. 这篇没有什么代码之类的东西, 就纯是碎碎念.

UI 分辨率分离

这年头在移动端做 3D 游戏, 分辨率基本是没可能拉满的, 最高基本也就八九百这样子, 低配下甚至六百多都有可能. 虽然场景怎么低分辨率怎么糊都好说, 但 UI 是万万不能糊的, 一糊这游戏也别玩了. 所以场景&UI 分辨率分离就成了一个项目必需的 feature.

最简单也是最直接的方案就是给 UI 单独来一张 RT, 我们叫他 UITarget. 场景在低分辨率的 CameraAttachment 上渲完后 Blit 到高分辨率的 UITarget 上, 再交给 UI 相机绘制 UI.

听着很美好, 但实际并不能用. 因为这个方案对于带宽本就不富裕的移动端打击是致命的, 多一张 1080p 的 RT 简直是要了手机的老命.

于是就有了优化方案, 借用系统提供的 Backbuffer 作为 UITarget. Backbuffer 默认为手机硬件的原生分辨率, 无论任何情况下渲染管线的最终目标都会是它, 在 URP 正常的流程中, 管线会在最后一个相机渲染完后调用 FinalBlit 将当前的 CameraAttechment Blit 到 Backbuffer 上.

考虑一个常规情况, 即同时存在主相机(开后处理)与 UI 相机(不开后处理), 那么 URP 的默认流程是这样的:

  • MainCamera[Opaque/Transparent Pass]: 场景渲染到 CameraColorAttachmentA
  • MainCamera[UberPost]: CameraColorAttachmentA 渲染到 CameraColorAttachmentB
  • UICamera[Transparent Pass]: UI 渲染到 CameraColorAttachmentB
  • UICamera[Final Blit]: CameraColorAttachmentB 渲染到 Backbuffer

我们只需要稍微改动一下管线, 让主相机做完后处理后直接 Blit 到 Backbuffer:

  • MainCamera[Opaque/Transparent Pass]: 场景渲染到 CameraColorAttachmentA
  • MainCamera[UberPost]: CameraColorAttachmentA 渲染到 Backbuffer
  • UICamera[Transparent Pass]: UI 直接渲染到 Backbuffer

这样一来, 不仅在没有性能开销的情况下实现了 UI 分辨率分离, 甚至还优化掉了 FinalBlit 的带宽, 可以说是可喜可贺. 后续可以通过 Screen.SetResolution()调整 Backbuffer 也就是 UI 的分辨率, 然后在其基础上调整 RenderScale 也就是场景分辨率.

这个方案有一些注意事项和技术限制:

其一, 非 UI 相机的后处理 RenderTarget, 只有最后一个非 UI 相机的后处理才应该且必需 Blit 到 Backbuffer. 这个基本就是工程问题, 处理完善管线中相关的逻辑代码就好.

其二, UI 相机必须是最后一个相机且不支持后处理. 前者没什么好说的, 但后者还是有些蛋疼.

一般来说 Backbuffer 是无法 SetTexture 到材质的, 也就无法做后处理 Blit. 但如果是调用 cmd.Blit()且当前的 RenderTarget 就是 Backbuffer 的话, 就可以神奇的拿到 Backbuffer! 此时 FrameDebug 上显示是 Grab RenderTexture, 原因是 cmd.Blit()内部对此做了特殊处理, 将操作改为了曾经 Bulitin 时代的 GrabPass.(大概)

所以拿 Backbuffer 直接做后处理也并非不行...但因为要 Blit, 之前省掉的 RT 和带宽又回来了...那这可能还不如上一个方案, GrabPass 说不定比 Blit 还要更耗. 所以这一块就得和项目组好好对一下了, 如果说 UI 后处理只是在过剧情时出现、用完就关, 那开启 UI 后处理时动态关掉这个功能也不是不行.

此外, Linear 空间下 Backbuffer 的格式应该是 RGBA32_SRGB, 有些设备或模拟器并不支持 SRGB 格式的 Backbuffer, 提供的是 RGBA32_UNorm. URP 原本的做法是在 FinalBlit 时判断一下 CameraData.requireSrgbConversion, 如果为 true 则表示 Backbuffer 为 RGBA32_UNorm 需要手动做 LinearToSRGB 也就是常说的 GammaCorrection(Pow0.45). 我个人测试下来的结果貌似目前只有在模拟器上 SRGB Backbuffer 是不受支持的, 问题不大, 干脆不支持就不开这功能得了.

还有就是因为 Backbuffer 不支持 HDR, 所以 UI 材质上也不能用 HDR 颜色, 这个应该无伤大雅.

线性空间下的 GammaUI

聊完 UI 分辨率分离, 再来聊聊 UI 的颜色空间.

进入 PBR 时代以后, 3D 游戏的 ColorSpace 基本就都是 Linear 了. 这当然很好, 可以有正确的光照, 更接近物理世界的结果. 但美术们在 PS 中制作 UI 时, 却是在 Gamma 空间下工作的——这也合理, 即使在线性工作流下, 涉及视觉颜色的贴图也应该是 sRGB 的, 就比如 BaseColor.

问题在于 UI 与 BaseColor 不同, 是要层与层之间做混合的. 美术在 PS(Gamma 空间)中得出了他们认为正确的结果, 丢到引擎中效果就不对了, 因为引擎是在 Linear 空间, 二者的混合算法不同.

在 Gamma 空间下, SrcAlpha-OneMinusSrcAlpha 混合的公式是:

\[ c = Src.rgb*Src.a+Dst.rgb*(1-Src.a) \]

而在 Linear 空间下, Unity 会为勾选了 sRGB 的贴图做一次 RemoveGammaCorrection(Pow2.2)将其转换到 Linear 空间, 并在 Shader 输出(到 SRGB 格式的 RT)时再做一次 GammaCorrection(Pow0.45)将其矫正回 Gamma 空间. 即:

\[ c = (Src.rgb^{2.2}*Src.a+Dst.rgb^{2.2}*(1-Src.a))^{0.45} \]

解决方案大致分为两种.

第一种是修改美术工作流, 把美术 PS 改成线性空间, 让美术直接在线性空间下制作 UI. 这个也是很多项目的做法, 美术虽然会不爽但时间长了也就接受了, 总之就是再苦一苦美术.

第二种也就是今天要着重谈的, 让 UI 相机在 Gamma 空间工作.

也就是给 UI 单独一张 RT——没错, 又回到了上一节的论点, 这也是为什么我把这两块技术放在一起谈的原因.

思路也很简单, 既然 RGBA32_SRGB 的 Backbuffer 会自动做 GammaCorrection(Pow0.45), 那么我只要申请一张 RGBA32_UNrom 格式的 UITarget, 再把 UI 贴图取消勾选 sRGB, 然后:

  • MainCamera[UberPost]: CameraColorAttachment 渲染到 UITarget, 手动 LinearToSRGB(Pow0.45)
  • UICamera[Transparent Pass]: UI 渲染到 UITarget, 贴图取消勾选 sRGB, 在 Unorm 贴图上正确的 Gamma 混合
  • UICamera[Final Blit]: UITarget 渲染到 Backbuffer 并手动做一次 SRGBToLinear(Pow2.2). 对于 UI, Pow2.2 抵消了 Backbuffer 的自动 GammaCorrection(Pow0.45), 而对于场景, 本就需要 Backbuffer 的自动 GammaCorrection, 所以前后两次手动矫正正好互相抵消.

很好很完美, 但还是上一节的问题——带宽压力变大了. 能不能和做 UI 分辨率分离时一样不申请额外 RT, 直接在 Backbuffer 做呢?

理论上当然可以, 我们知道, Unity 会根据项目所选的颜色空间来选择 Backbuffer 格式, 若为 Gamma 则 Backbuffer 为 RGBA32_UNorm, 若为 Linear 则 Backbuffer 为 RGBA32_SRGB(前提是设备支持, 否则回退到 RGBA32_UNorm).

而想要在 SRGB 格式的 Backbuffer 上直接绘制 UI 并做正确的 Gamma 空间混合是不可能的, 除非我们能把 Linear 颜色空间下的 Backbuffer 也改为 RGBA32_UNorm, 这样一来我们只需要在场景渲染结束后, 对 CameraAttechment 手动做一次 LinearToSRGB(Pow0.45)Blit 到 Backbuffer 即可, 之后在 Backbuffer 上正常绘制 UI 就行.

那么可以改吗? 可以, 但得加钱, 改源码~