Deep Read

UI相机分离 方案2

移动端用 RenderScale 降低场景分辨率时,UI 也会跟着变糊。方案一UI相机分离 方案1移动端常用 RenderScale 降低场景渲染分辨率,但 URP 默认会让 Overlay UI 相机复用 Base 相机的低分辨率颜色缓冲,结果是场景省下了性能,UI 也跟着变糊。 这篇记录「插 Pass 放大」方案的最小实现:场景保持低分辨率渲染,在 UI 开始绘制前把颜色缓冲切到屏幕原始尺寸,再让 UI 画上去。范围只包含颜色分辨率分离,不处理深度遮挡、后处理和 Gamma 工作流。环境为 Unity URP 14.0.12、Linear 色彩空间。 为什么 UI 会糊 从离屏 RT 到 Backbuffer 显示器读取的是 FrontBuffer,GPU 则把新一帧写入 BackBuffer。提交完成后,两块缓冲交换(Present)。这组由操作系统和驱动管理的缓冲就是交换链(Swap Chain)。对 Unity 渲染管线来说,最终目标只有一个:把完整画面写入 Backbuffer。 Backbuffer 的尺寸由 Screen.SetResolution() 决定,格式由显示设置决定。Linear 项目下通常使用 sRGB 格式。它适合作为最终输出,却不适合承担是在 UI 绘制前把场景放大到原尺寸中间 RT,再绘制 UI,最后通过 FinalBlit 上屏。效果没问题,但要多占一块原尺寸 RT,并增加一次放大 Blit。

方案二直接省掉这块 RT:场景相机完成低分辨率渲染并上屏,UI 相机随后以原生分辨率直写 Backbuffer。这样 UI 侧没有中间 RT,也没有 FinalBlit。代价是两台 Base 相机不共享深度,因此不适合需要场景深度遮挡的 UI。

核心思路

场景相机先将画面 resolve 到 Backbuffer。UI 相机作为独立 Base 相机排在其后,不清颜色,直接在现有场景上混合 UI。因为 UI 按屏幕原生尺寸绘制,所以不会受 RenderScale 影响。

它和方案一的区别只在最终画布:方案一使用原尺寸中间 RT,方案二直接使用 Backbuffer。

方案UI 绘制目标额外成本
方案一原尺寸中间 RT一块原尺寸 RT + 一次 FinalBlit
方案二Backbuffer无 UI 中间 RT,无 UI FinalBlit

为什么必须拆成两台 Base 相机

原来的 UI 相机是 Overlay,与场景相机共用 Camera Stack。URP 会让 Base 和 Overlay 共用一块中间颜色缓冲,等整个相机栈画完后再统一 resolve。场景相机使用 RenderScale 时,这块缓冲就是低分辨率,Overlay UI 只能跟着变糊。

因此,方案二不能继续使用 Overlay。UI 相机要改成独立 Base,并通过 Priority 排在场景相机之后:场景相机先完成自己的 resolve,UI 相机再以 Uninitialized 的方式保留颜色,只清深度模板,然后把 UI 叠到 Backbuffer 上。

Base 相机之间不共享深度,所以 UI 无法读取场景深度。纯 2D 屏幕空间 UI 不受影响。

URP 为什么仍会创建中间 RT

FinalBlit 的触发条件

FrontBuffer、BackBuffer、交换链和离屏 RT 的基础流程见 UI相机分离 方案1UI相机分离 方案1移动端常用 RenderScale 降低场景渲染分辨率,但 URP 默认会让 Overlay UI 相机复用 Base 相机的低分辨率颜色缓冲,结果是场景省下了性能,UI 也跟着变糊。 这篇记录「插 Pass 放大」方案的最小实现:场景保持低分辨率渲染,在 UI 开始绘制前把颜色缓冲切到屏幕原始尺寸,再让 UI 画上去。范围只包含颜色分辨率分离,不处理深度遮挡、后处理和 Gamma 工作流。环境为 Unity URP 14.0.12、Linear 色彩空间。 为什么 UI 会糊 从离屏 RT 到 Backbuffer 显示器读取的是 FrontBuffer,GPU 则把新一帧写入 BackBuffer。提交完成后,两块缓冲交换(Present)。这组由操作系统和驱动管理的缓冲就是交换链(Swap Chain)。对 Unity 渲染管线来说,最终目标只有一个:把完整画面写入 Backbuffer。 Backbuffer 的尺寸由 Screen.SetResolution() 决定,格式由显示设置决定。Linear 项目下通常使用 sRGB 格式。它适合作为最终输出,却不适合承担 的「为什么 UI 会糊」。这里关注 UniversalRenderer.cs 如何决定是否执行 FinalBlit。

FinalBlit 的职责只是把中间 RT 搬到屏幕。URP 会检查当前活动颜色目标是否已经是屏幕:

bool cameraTargetResolved =
    applyFinalPostProcessing ||
    (applyPostProcessing && !hasPassesAfterPostProcessing && !hasCaptureActions) ||
    m_ActiveCameraColorAttachment.nameID == m_XRTargetHandleAlias.nameID;  // 颜色目标 == 屏幕?

if (!cameraTargetResolved)          // 不等于屏幕(是中间 RT),才需要 FinalBlit
{
    m_FinalBlitPass.Setup(cameraTargetDescriptor, sourceForFinalPass);
    EnqueuePass(m_FinalBlitPass);
}

颜色目标由 createColorTexture 决定:

m_ActiveCameraColorAttachment = createColorTexture
    ? m_ColorBufferSystem.PeekBackBuffer()   // 中间 RT
    : m_XRTargetHandleAlias;                  // 屏幕本身

createColorTexture == true 时,颜色目标指向中间 RT,渲染结束后需要 enqueue FinalBlit;为 false 时直接指向屏幕,cameraTargetResolved 成立,FinalBlit 被跳过。

方案二需要让 UI 相机稳定走到 createColorTexture == false 的分支。

RendererFeature 会把颜色目标拉回中间 RT

只改 RequiresIntermediateColorTexture 不够。当前渲染器挂着 RendererFeature(SSAO 和方案一的 UISplitRenderFeature),Intermediate Texture 又设为 Always,Setup 中还有三处逻辑会强制创建或重新绑定中间 RT:

// ① 有 Feature + Always 就强制建色纹理
var createColorTexture = ((rendererFeatures.Count != 0 && m_IntermediateTextureMode == IntermediateTextureMode.Always) && !isPreviewCamera) || ...;

// ② renderScale<1 时 rtHandleScale != 1,会用深度纹理把色纹理一起拉回来
if (RTHandles.rtHandleProperties.rtHandleScale.x != 1.0f || ...)
    createColorTexture |= createDepthTexture;

// ③ 最隐蔽的一处:只要有 Feature,就把颜色目标重新绑回中间缓冲
if (rendererFeatures.Count != 0 && !isPreviewCamera)
    ConfigureCameraColorTarget(m_ColorBufferSystem.PeekBackBuffer());

第 ③ 处最容易漏掉。即使前面已经把 createColorTexture 压成 false,它仍会把颜色目标绑回中间缓冲。方案二必须让标记后的 UI 相机同时绕过这些入口。

实现

相机与渲染器配置

Linear 色彩空间、URP 14.0.12 本地源码副本、High Fidelity 档 RenderScale < 1 等公共前提同 UI相机分离 方案1UI相机分离 方案1移动端常用 RenderScale 降低场景渲染分辨率,但 URP 默认会让 Overlay UI 相机复用 Base 相机的低分辨率颜色缓冲,结果是场景省下了性能,UI 也跟着变糊。 这篇记录「插 Pass 放大」方案的最小实现:场景保持低分辨率渲染,在 UI 开始绘制前把颜色缓冲切到屏幕原始尺寸,再让 UI 画上去。范围只包含颜色分辨率分离,不处理深度遮挡、后处理和 Gamma 工作流。环境为 Unity URP 14.0.12、Linear 色彩空间。 为什么 UI 会糊 从离屏 RT 到 Backbuffer 显示器读取的是 FrontBuffer,GPU 则把新一帧写入 BackBuffer。提交完成后,两块缓冲交换(Present)。这组由操作系统和驱动管理的缓冲就是交换链(Swap Chain)。对 Unity 渲染管线来说,最终目标只有一个:把完整画面写入 Backbuffer。 Backbuffer 的尺寸由 Screen.SetResolution() 决定,格式由显示设置决定。Linear 项目下通常使用 sRGB 格式。它适合作为最终输出,却不适合承担 的「环境前提」。方案二额外需要:

  • 场景 Assets/Art/UI/UI.unity:Main Camera 为 Base;UI Camera 改为独立 Base,Priority 100 排在最后,Background Type = Uninitialized(不清颜色),Culling Mask 只勾 UI,关闭后处理,Output Texture 为空;Canvas 使用 Screen Space - Camera 指向 UI Camera。
  • 渲染器 URP-HighFidelity-Renderer 挂有 RendererFeature,Intermediate Texture 模式为 Always。

判定发生在 URP 包内的 UniversalRenderer,而包不能引用 Assets/Scripts,所以不能只写一个 RendererFeature。标记组件和判断逻辑都要放进 URP 包程序集。

增加三态开关

UniversalRenderPipelineAsset.cs 增加枚举、序列化字段和只读属性:

public enum UISplitMode
{
    Disable = 0,        // 关闭分离,走原生 URP
    RenderFeature = 1,  // 方案一:Overlay 栈 + 插 Pass 放大
    Backbuffer = 2,     // 方案二:独立 Base 相机直写 Backbuffer
}

[SerializeField] UISplitMode m_UISplitMode = UISplitMode.Disable;

public UISplitMode uiSplitMode
{
    get { return m_UISplitMode; }
    set { m_UISplitMode = value; }
}

URP Asset 使用自定义 Inspector,新字段不会自动显示。需要在编辑器三件套中补齐绑定和绘制:SerializedUniversalRenderPipelineAsset.cs 绑定 FindProperty("m_UISplitMode")UniversalRenderPipelineAssetUI.Skin.cs 增加标签,.Drawers.csDrawQuality 中调用 EditorGUILayout.PropertyField

标记组件

新建 BackbufferUICamera.cs 并挂到 UI 相机上,用来标记这台相机需要直写 Backbuffer。脚本放在 URP 包的 Runtime 目录:

namespace UnityEngine.Rendering.Universal
{
    [DisallowMultipleComponent]
    [RequireComponent(typeof(Camera))]
    [AddComponentMenu("Rendering/Backbuffer UI Camera (方案二)")]
    public class BackbufferUICamera : MonoBehaviour { }
}

集中判断相机条件

UniversalRenderer.cs 中集中判断,避免后面四个使用点各写一套条件:

bool IsBackbufferUICamera(ref CameraData cameraData)
{
    var urpAsset = UniversalRenderPipeline.asset;
    return urpAsset != null && urpAsset.uiSplitMode == UISplitMode.Backbuffer
        && cameraData.renderType == CameraRenderType.Base
        && cameraData.resolveFinalTarget
        && !cameraData.postProcessEnabled
        && cameraData.targetTexture == null
        && cameraData.camera.GetComponent<BackbufferUICamera>() != null;
}

绕过中间纹理逻辑

先在 RequiresIntermediateColorTexture 开头短路:

bool RequiresIntermediateColorTexture(ref CameraData cameraData)
{
    if (IsBackbufferUICamera(ref cameraData))
        return false;
    // ...原逻辑
}

Setup 中只计算一次局部标志,再处理剩下三个入口:

// createColorTexture 起点:抵消 Always 那条
bool backbufferUICamera = IsBackbufferUICamera(ref cameraData);
var createColorTexture = ((rendererFeatures.Count != 0 && m_IntermediateTextureMode == IntermediateTextureMode.Always) && !isPreviewCamera) ||
    (Application.isEditor && m_Clustering);
if (backbufferUICamera)
    createColorTexture = false;
// 各 RendererFeature 的色纹理需求 OR 完之后,再兜一次
createColorTexture &= !isPreviewCamera;
if (backbufferUICamera)
    createColorTexture = false;
// 深度也不建,否则 rtHandleScale != 1 那条会用深度把色纹理拉回来
createDepthTexture |= (renderingLayerProvidesRenderObjectPass);
if (backbufferUICamera)
    createDepthTexture = false;
// 关键:这句会把颜色目标重新绑回中间缓冲,标记相机必须跳过
if (rendererFeatures.Count != 0 && !isPreviewCamera && !backbufferUICamera)
    ConfigureCameraColorTarget(m_ColorBufferSystem.PeekBackBuffer());

处理完四个使用点后,m_ActiveCameraColorAttachment 才会稳定落到屏幕分支。此时 cameraTargetResolved 成立,UI 相机不再创建中间 RT,也不会 enqueue FinalBlit。

让方案一读取同一开关

UISplitRenderFeature.AddRenderPasses 开头增加判断,让方案一只在 RenderFeature 模式下生效:

var urpAsset = UniversalRenderPipeline.asset;
if (urpAsset == null || urpAsset.uiSplitMode != UISplitMode.RenderFeature) return;

涉及文件

文件改动
.../Runtime/Data/UniversalRenderPipelineAsset.csUISplitMode 枚举 + 字段 + 属性
.../Editor/.../SerializedUniversalRenderPipelineAsset.cs 等三件套Quality 分区下拉框
.../Runtime/BackbufferUICamera.cs方案二标记组件(新增)
.../Runtime/UniversalRenderer.csIsBackbufferUICamera + 四处绕过
Assets/Scripts/Render/UISplitRender/UISplitRenderFeature.cs方案一读开关

Inspector 中将 UI 相机设为 Base、Priority 100、Background Type = Uninitialized,Culling 只勾 UI,关闭后处理,清空 Output Texture,并从场景相机的 Camera Stack 中移除,再挂上 Backbuffer UI Camera。场景相机不需要改代码,只需确认未开启 FXAA,Upscaling Filter 使用 Linear。

验证与限制

开关切到 Backbuffer 后,Frame Debugger 中 UI Camera 应该是下面的帧序:

UI Camera
  Clear (depth+stencil)          ← 只清深度模板,不清颜色,场景被保留
  DrawTransparentObjects
    Canvas.RenderSubBatch
      Draw Mesh                  ← 直接画在 CameraTarget(Backbuffer)上

验证时看三点:

  • 只有 Clear (depth+stencil),没有 clear color,说明颜色执行 Load,场景相机的结果被保留。
  • 没有独立的 _CameraColorAttachment*
  • 没有 UI 侧的 FinalBlit。

三种模式最终差别如下:

开关UI 相机颜色目标中间 RTUI 侧 FinalBlitUI 清晰度
Disable中间缓冲(低分辨率)
RenderFeature(方案一)原尺寸中间 RT有(原尺寸)清晰
Backbuffer(方案二)Backbuffer清晰

Backbuffer 模式省掉了 UI 侧的原尺寸 RT 和 FinalBlit,但 UI 不再共享场景深度。它适合不依赖场景深度的屏幕空间 UI。