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 格式。它适合作为最终输出,却不适合承担整条渲染流程:后处理需要读取上一阶段的结果,场景可能使用 HDR 格式,RenderScale 也会让场景分辨率低于屏幕分辨率。因此,场景通常先画进离屏 RenderTexture,最后再搬到 Backbuffer。
这次搬运就是全屏 Blit:读取整张纹理,再写入另一张纹理。1080p RGBA 纹理单程约 8 MB,一读一写约 16 MB,60 帧时接近 1 GB/s。移动 GPU 与 CPU 共享内存带宽,这类额外搬运会直接增加功耗和发热。
默认帧序

默认情况下,颜色缓冲按下面的顺序流转:
- MainCamera 的 Opaque / Transparent Pass 把场景写入
CameraColorAttachmentA。尺寸由屏幕分辨率和 RenderScale 共同决定。 - 开启后处理时,UberPost 读取 A、写入
CameraColorAttachmentB。同一张纹理不能同时读写,所以需要 A/B Ping-Pong。 - UICamera 以 Don't Clear 的方式,把 UI Alpha 混合到 B。
- 最后一台 UICamera 执行 FinalBlit,把 B 写入 Backbuffer。
问题不只在 FinalBlit。UI 在第三步就已经画进低分辨率 RT,最后又随整张画面一起放大,边缘自然会糊。
分离思路
在场景结束、Overlay UI 开始之前插入一个 RenderPass:把低分辨率场景放大到屏幕原始尺寸的新缓冲,后续 UI 全部画在这块原尺寸缓冲上。

完整方案的帧序是:
- MainCamera Opaque / Transparent:场景写入低分辨率
_CameraColorAttachmentA。 - MainCamera PostProcessPass:A 写入 B,并交换缓冲。
- MainCamera SplitRenderScalePass:B 放大到原尺寸 A,再交换缓冲。
- UICamera 0…N Transparent:UI 写入原尺寸 A。
- 最后一台 UICamera FinalBlit:A 写入 CameraTarget。
这样能把场景和 UI 的分辨率拆开,也可以继续传递场景深度,支持头顶血条、气泡等遮挡判断。不过完整方案会额外增加 Color + Depth Blit,还要多申请一块原尺寸 Color + Depth RT,带宽和显存开销都不小。如果不需要深度遮挡、也不想多一块 RT,可以让 UI 相机直写 Backbuffer,见 UI相机分离 方案2UI相机分离 方案2移动端用 RenderScale 降低场景分辨率时,UI 也会跟着变糊。[[UI相机分离 方案1|方案一]]是在 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 + 一次 Fi。
URP 如何管理颜色缓冲
实现的关键不是自己创建一张 RT,而是让 URP 后续的 Overlay 和 FinalBlit 都认可新的原尺寸缓冲。需要先看 RenderTargetBufferSystem 和 UniversalRenderer 如何维护状态。
A/B 缓冲与描述符
源码:LocalPackages/com.unity.render-pipelines.universal@14.0.12/Runtime/RenderTargetBufferSystem.cs
internal sealed class RenderTargetBufferSystem
{
struct SwapBuffer
{
public RTHandle rtMSAA;
public RTHandle rtResolve;
public string name;
public int msaa;
}
SwapBuffer m_A, m_B; // ← 两块缓冲 A、B
static bool m_AisBackBuffer = true; // ← 当前哪块是"背缓冲"
static RenderTextureDescriptor m_Desc; // ← 尺寸/格式设定,注意是 static!
FilterMode m_FilterMode;
// backBuffer = 当前正在画的那块;frontBuffer = 另一块
ref SwapBuffer backBuffer
{
get { return ref m_AisBackBuffer ? ref m_A : ref m_B; }
}
ref SwapBuffer frontBuffer
{
get { return ref m_AisBackBuffer ? ref m_B : ref m_A; }
}
m_AisBackBuffer 决定当前使用 A 还是 B,m_Desc 决定 RT 的尺寸和格式。后处理读 A 写 B、再交换身份,依赖的就是这套机制。
public void Swap() // 第 64 行
{
m_AisBackBuffer = !m_AisBackBuffer; // A/B 身份对调
}
void ReAllocate(CommandBuffer cmd) // 第 69 行
{
var desc = m_Desc; // ← 按 m_Desc 的尺寸去分配显存
...
RenderingUtils.ReAllocateIfNeeded(ref m_A.rtResolve, desc, ...);
RenderingUtils.ReAllocateIfNeeded(ref m_B.rtResolve, desc, ...);
...
}
public void SetCameraSettings(RenderTextureDescriptor desc, FilterMode filterMode) // 第 94 行
{
desc.depthBufferBits = 0;
m_Desc = desc; // ← 把传进来的描述符尺寸写进 static m_Desc
m_FilterMode = filterMode;
...
}
SetCameraSettings 更新 m_Desc,ReAllocate 再按它分配 RT。谁最后调用 SetCameraSettings,谁就决定颜色缓冲的尺寸。
Overlay 为什么会拿到低分辨率缓冲
UniversalRenderer.cs 的相机 setup 会在每台相机渲染前执行:
// 777–782 行:构造颜色缓冲的描述符
//Set rt descriptors so preview camera's have access should it be needed
var colorDescriptor = cameraTargetDescriptor; // ← cameraTargetDescriptor 已经乘过 RenderScale = 低分辨率
colorDescriptor.useMipMap = false;
colorDescriptor.autoGenerateMips = false;
colorDescriptor.depthBufferBits = (int)DepthBits.None;
m_ColorBufferSystem.SetCameraSettings(colorDescriptor, FilterMode.Bilinear); // ← 尺寸写进 static m_Desc
cameraTargetDescriptor 已经乘过 RenderScale,因此这里写入 m_Desc 的是低分辨率尺寸。接下来 Base 和 Overlay 分别进入两个分支:
// 784–826 行:Base 相机分支
if (cameraData.renderType == CameraRenderType.Base)
{
...
// 810 行:Base 才会真正创建离屏 RT
if (intermediateRenderTexture)
CreateCameraRenderTarget(context, ref cameraTargetDescriptor, ...);
...
// 824 行:Base 的活动颜色目标 = 缓冲系统的背缓冲(此刻是低分辨率的 A)
m_ActiveCameraColorAttachment = createColorTexture
? m_ColorBufferSystem.PeekBackBuffer()
: m_XRTargetHandleAlias;
m_ActiveCameraDepthAttachment = createDepthTexture
? m_CameraDepthAttachment
: m_XRTargetHandleAlias;
}
else
{
// 827–838 行:Overlay 相机分支
cameraData.baseCamera.TryGetComponent<UniversalAdditionalCameraData>(out var baseCameraData);
var baseRenderer = (UniversalRenderer)baseCameraData.scriptableRenderer;
if (m_ColorBufferSystem != baseRenderer.m_ColorBufferSystem)
{
m_ColorBufferSystem.Dispose();
m_ColorBufferSystem = baseRenderer.m_ColorBufferSystem; // ← Overlay 直接接管 Base 的缓冲系统
}
m_ActiveCameraColorAttachment = m_ColorBufferSystem.PeekBackBuffer(); // ← 复用同一块缓冲!
m_ActiveCameraDepthAttachment = baseRenderer.m_ActiveCameraDepthAttachment;
...
}
Base 把场景画进低分辨率 A,Overlay 随后接管 Base 的 m_ColorBufferSystem,继续使用同一块 A。更麻烦的是,Overlay setup 也会执行前面的 SetCameraSettings,再次把 m_Desc 写回低分辨率。只在 Base 阶段放大 RT 不够,还要阻止 Overlay 重置尺寸。
FinalBlit 在哪里放大画面
最后一台相机收尾时,URP 会把当前活动颜色缓冲写入屏幕:
if (lastCameraInTheStack) // Overlay 是栈里最后一个
{
...
var sourceForFinalPass = m_ActiveCameraColorAttachment; // = 那块低分辨率缓冲(含场景+UI)
...
// 1292–1296 行:把它 Blit 到屏幕(Backbuffer)
if (!cameraTargetResolved)
{
m_FinalBlitPass.Setup(cameraTargetDescriptor, sourceForFinalPass);
EnqueuePass(m_FinalBlitPass); // ← 低分辨率 → Backbuffer,整张放大
}
...
}
因此有两个明确的模糊点:Overlay 在低分辨率缓冲上画 UI,FinalBlit 再把包含 UI 的整张缓冲放大。修复位置应该在第一个模糊点之前。
复用 SwapColorBuffer 切换目标
URP 已有的 SwapColorBuffer 会交换 A/B,并按当前 m_Desc 分配新的 BackBuffer:
internal override void SwapColorBuffer(CommandBuffer cmd)
{
m_ColorBufferSystem.Swap(); // ① A/B 身份对调
if (m_ActiveCameraDepthAttachment.nameID != BuiltinRenderTextureType.CameraTarget)
ConfigureCameraTarget(
m_ColorBufferSystem.GetBackBuffer(cmd),
m_ActiveCameraDepthAttachment);
else
ConfigureCameraColorTarget(m_ColorBufferSystem.GetBackBuffer(cmd));
// ② 活动颜色目标 = 新的背缓冲。GetBackBuffer 内部会调 ReAllocate,按当前 m_Desc 分配
m_ActiveCameraColorAttachment = m_ColorBufferSystem.GetBackBuffer(cmd);
cmd.SetGlobalTexture("_CameraColorTexture", m_ActiveCameraColorAttachment.nameID);
cmd.SetGlobalTexture("_AfterPostProcessTexture", m_ActiveCameraColorAttachment.nameID);
}
调用它之前把 m_Desc 改成屏幕尺寸,交换出来的新 BackBuffer 就是原尺寸。随后把旧缓冲中的低分辨率场景 Blit 进去,Overlay 和 FinalBlit 便会沿用新的活动目标。
实现
环境前提
- Linear 色彩空间。
- URP 14.0.12 源码已复制到
main_proj/LocalPackages/,允许直接修改。 - 场景
Assets/Art/UI/UI.unity使用 Base 主相机 + Overlay UI 相机;UI 相机已经加入 Camera Stack,Canvas 使用 Screen Space - Camera 并指向 UI 相机。 - 当前生效档为 High Fidelity,RenderScale 小于 1。验证值为 0.257,实际建议 0.7~0.8。
- 主相机关闭后处理。
单独写 RendererFeature 不能解决这个问题。Assets/Scripts 无法访问 URP 的 internal 成员;即使把场景 Blit 到自建 RT,Overlay setup 仍会把缓冲描述符改回低分辨率,并继续使用 Base 的内部缓冲。因此需要同时修改 URP 源码和 RendererFeature。
开放颜色缓冲尺寸接口
在 RenderTargetBufferSystem.cs 中增加:
// 加在 RenderTargetBufferSystem.cs 里
// 设置颜色缓冲描述符的宽高(分离到原生尺寸用);m_Desc 为 static,改一次对 Base/Overlay 共享生效
public void SetColorBufferSize(int width, int height)
{
m_Desc.width = width;
m_Desc.height = height;
}
在 Base 和 Overlay 之间传递分离状态
Base 和 Overlay 使用不同的 UniversalRenderer 实例,普通成员字段无法传递状态。这里沿用 m_Desc 的共享方式,在 UniversalRenderer.cs 字段区增加静态状态:
internal static bool s_UISplitActive;
internal static int s_UISplitWidth;
internal static int s_UISplitHeight;
静态状态会跨帧保留,所以每帧 Base setup 开始时必须复位。把下面代码放在原 778 行、构造颜色描述符之前:
if (cameraData.renderType == CameraRenderType.Base)
s_UISplitActive = false; // Base 初始分配用低分辨率,场景照常低分辨率渲染
只对 Base 复位,才能形成 Base 为 false、执行分离 Pass 后变为 true、Overlay 继续读取 true 的顺序。
阻止 Overlay 把尺寸改回去
在构造 colorDescriptor 后、调用 SetCameraSettings 前增加判断:
colorDescriptor.depthBufferBits = (int)DepthBits.None;
// ↓↓↓ 在这里插入 ↓↓↓
if (s_UISplitActive)
{
colorDescriptor.width = s_UISplitWidth;
colorDescriptor.height = s_UISplitHeight;
}
// ↑↑↑ 让 Overlay 进来时,写进 m_Desc 的是原尺寸,而不是低分辨率 ↑↑↑
m_ColorBufferSystem.SetCameraSettings(colorDescriptor, FilterMode.Bilinear);
Base setup 时标志为 false,场景仍使用低分辨率;Overlay setup 时标志为 true,写入 m_Desc 的是屏幕原始尺寸。
切换缓冲并放大场景
在 UniversalRenderer 中增加入口:
public void SplitColorToScreenSize(CommandBuffer cmd, int width, int height)
{
s_UISplitActive = true; // ① 通知后续 Overlay:进入分离,走 2c 原尺寸分支
s_UISplitWidth = width;
s_UISplitHeight = height;
var source = m_ActiveCameraColorAttachment; // ② 先存住"低分辨率场景颜色"(Swap 后它会易主)
m_ColorBufferSystem.SetColorBufferSize(width, height); // ③ 改动1:把 m_Desc 尺寸改成原尺寸
SwapColorBuffer(cmd); // ④ 3.4:切缓冲,GetBackBuffer 按新 m_Desc 分配 → 原尺寸空缓冲
// 此刻 m_ActiveCameraColorAttachment = 原尺寸空 RT
Blitter.BlitCameraTexture(cmd, source, m_ActiveCameraColorAttachment); // ⑤ 低分辨率场景放大拷进原尺寸缓冲
// ⑥ 颜色变原尺寸了,深度还是低分辨率 → framebuffer 尺寸不匹配会报错。
// 补一块原尺寸空深度顶上(不做遮挡所以不拷场景深度)
var depthDesc = m_ActiveCameraColorAttachment.rt.descriptor;
depthDesc.graphicsFormat = GraphicsFormat.None; // 纯深度,无颜色通道
depthDesc.depthStencilFormat = k_DepthStencilFormat; // 复用类里已有的深度格式常量(44/47行)
RenderingUtils.ReAllocateIfNeeded(
ref m_CameraDepthAttachment,
depthDesc,
name: "_CameraDepthAttachment_UISplit");
m_ActiveCameraDepthAttachment = m_CameraDepthAttachment;
ConfigureCameraTarget(
m_ActiveCameraColorAttachment,
m_ActiveCameraDepthAttachment); // ⑦ 绑定新的原尺寸颜色+深度
}
必须先保存 source,因为 SwapColorBuffer 之后 m_ActiveCameraColorAttachment 已经指向新的空缓冲。颜色切到原尺寸后,深度也要换成同尺寸,否则 framebuffer 的颜色、深度附件尺寸不一致。这里不需要 UI 遮挡,所以只创建空深度,不复制低分辨率场景深度。
在透明物体之后插入 Pass
新建 Assets/Scripts/Render/UISplitRender/UISplitRenderFeature.cs 和 UISplitRenderPass.cs。Feature 只处理 Base 相机、RenderScale 小于 1、并且存在 Overlay Camera Stack 的情况:
// 对应"只在 Base、降了分辨率、有 UI 相机才做"
if (cameraData.renderType != CameraRenderType.Base)
return;
if (cameraData.renderScale >= 1.0f)
return;
var add = cameraData.camera.GetUniversalAdditionalCameraData();
if (add == null || add.cameraStack == null || add.cameraStack.Count == 0)
return;
renderer.EnqueuePass(m_pass);
Pass 的执行逻辑:
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var renderer = renderingData.cameraData.renderer as UniversalRenderer;
int w = renderingData.cameraData.camera.pixelWidth; // 原生屏幕宽(不含 RenderScale)
int h = renderingData.cameraData.camera.pixelHeight;
var cmd = CommandBufferPool.Get("UISplitRender");
renderer.SplitColorToScreenSize(cmd, w, h);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
renderPassEvent 设为 RenderPassEvent.AfterRenderingTransparents。当前主相机关闭后处理,这个时机正好位于 Base 场景结束和 Overlay 开始之间。尺寸必须取 camera.pixelWidth / pixelHeight;cameraTargetDescriptor 已乘过 RenderScale,拿到的仍是低分辨率。
最后,把 UISplitRenderFeature 挂到 High Fidelity 使用的 Assets/URP/URP-HighFidelity-Renderer.asset。
涉及文件如下:
| 文件 | 改动 |
|---|---|
Assets/Scripts/Render/UISplitRender/UISplitRenderFeature.cs | RendererFeature 入口与注入条件 |
Assets/Scripts/Render/UISplitRender/UISplitRenderPass.cs | 调用 SplitColorToScreenSize 的分离 Pass |
.../universal@14.0.12/Runtime/UniversalRenderer.cs | 共享状态、Base 复位、描述符覆盖和分离入口 |
.../universal@14.0.12/Runtime/RenderTargetBufferSystem.cs | SetColorBufferSize |
Assets/URP/URP-HighFidelity-Renderer.asset | 挂载 Feature |