Godot4 实现 HDPI 高清 2D UI 后处理
在 Godot 4 中用 SubViewport + TextureRect 配合处理 2D UI(并支持后处理)
省流:本文目标是把整个 2D UI 渲染到一个 SubViewport,再用 TextureRect 显示出来,这样就可以对 UI 统一做后处理特效(模糊、描边、颜色校正等),并且在此基础上仍然支持自动分辨率缩放和正常的输入交互。
场景结构概览
先看一个典型结构(节点命名仅作示意):
Root (Node / Control / etc.)
├── World2D / GameRoot // 游戏内容(本文不是重点)
└── UIRoot (Control) // 主 UI 根节点
├── UISubViewport (SubViewport)
│ └── CanvasLayer / UIContentRoot (Control / MarginContainer / etc.)
│ └── ...(所有 UI 控件)
└── UITextureRect (TextureRect,用来显示 SubViewport 的内容)
整体思路是:
- 所有 UI 控件都挂在
UISubViewport下面。 - 通过
ViewportTexture把UISubViewport的内容贴到UITextureRect上。 - 所有后处理(Shader、Effect 等)挂在
UITextureRect或其上层节点上。
一、为什么不用 SubViewportContainer?
SubViewportContainer 的设计初衷,是一个「帮你把 SubViewport 嵌进 UI 树、自动处理输入和缩放」的封装控件,看上去是很自然的选择。但只要牵涉到渲染分辨率变化 / 分辨率缩放,它的问题就暴露出来了。
1.1 缩放和实际渲染分辨率是两件事
SubViewportContainer本身是一个Control,可以随着 UI 布局被拉伸、缩放。- 但它内部持有的
SubViewport的实际渲染分辨率,默认并不会随着外部缩放而改变。
尤其是你打算支持 Hdpi 缩放的时候,会出现 UI 逻辑分辨率与实际渲染分辨率不同的情况。
这会带来一个典型问题:
SubViewportContainer 和 SubViewport 计算自身大小的时候用的是 UI 逻辑分辨率,而内部实际渲染分辨率用的也是 UI 逻辑分辨率。但此时它们在屏幕上的实际渲染分辨率比 UI 逻辑分辨率更高。举个例子就是你的项目 Viewport 设置是 1280*720,游戏用 4K 分辨率全屏显示的时候会出现 SubViewportContainer 认为 UI 逻辑分辨率仍然是 1280*720,于是真的用 1280*720 分辨率去渲染内部组件,然后放大到实际的 4K 分辨率。
肉眼效果就是:
- UI 被简单放大,而不是按更高分辨率重新绘制;
- UI 清晰度和世界画面的清晰度对不上。
二、用 SubViewport + TextureRect 搭 UI 渲染链路
在这个方案里,我们直接放弃使用 SubViewportContainer,自己搭建一条简单、透明的渲染通路。
2.1 SubViewport 配置
先看 UISubViewport 的关键配置:
UISubViewport (SubViewport)
- transparent_bg: On
// 如果 UI 需要叠加在世界画面上,一般要开透明背景
- disable_3d: On
// 只渲染 2D UI 时可以关掉 3D,提高一点性能
- size: 实际渲染分辨率(通常和当前窗口 / 渲染分辨率一致,保证清晰度)
- size_2d_override: UI 的逻辑分辨率(你的虚拟 UI 分辨率)
- size_2d_override_stretch: On
// 必须打开,否则 UISubViewport 内部的 Control 节点不会根据 override 尺寸自动更新布局
这里约定:
size:和当前实际输出的渲染分辨率保持一致,用来保证画面清晰;size_2d_override:用来固定 UI 的逻辑坐标系(虚拟分辨率),便于布局和缩放策略统一。
这两个值后面会通过脚本,跟随实际渲染分辨率动态更新。
2.2 TextureRect 配置
UITextureRect 用来显示 UISubViewport 渲染出的内容:
UITextureRect (TextureRect)
- texture: 新建一个 ViewportTexture,将 viewport_path 指向 UISubViewport
- expand_mode: 除 Keep Size 之外的任意模式(根据项目需要)
- layout_mode: Anchors
- anchors_preset: Fill / 全屏(确保 UITextureRect 能占满渲染区域)
如果有 UI 后处理 Shader,可以直接挂在 UITextureRect.material 上:
UI 控件 → SubViewport → ViewportTexture → TextureRect(+ Shader/后处理) → 屏幕输出
剩下的一步,是在脚本里让 SubViewport 的尺寸随着窗口变化动态更新。
下面是一段 C# 代码(GDScript 做法类似):
private void OnWindowOnSizeChanged(Window root)
{
// 虚拟 UI 分辨率(2D UI 逻辑分辨率)
var uiRenderSize = root.GetVisibleRect().Size;
// 实际渲染分辨率
var renderSize = root.Size;
_uiSubViewport.Size = renderSize; // SubViewport
_uiSubViewport.Size2DOverride = new Vector2I((int)uiRenderSize.X, (int)uiRenderSize.Y);
}
public override void _Ready()
{
var root = GetTree().Root;
root.SizeChanged += () => { OnWindowOnSizeChanged(root); };
// 立即调用一次,防止启动时窗口分辨率与项目设置不一致
OnWindowOnSizeChanged(root);
}
这样就实现了:
- 窗口/渲染分辨率变化时,
SubViewport的实际渲染尺寸跟着变化; - 同时保持一套稳定的 UI 逻辑分辨率用于布局;
TextureRect再负责把这个 Viewport 的输出铺满对应区域。
三、输入事件如何转发到 SubViewport
SubViewportContainer 自动帮我们把鼠标、触摸事件转发进内部的 SubViewport。
在现在这套方案里,只有一个“裸”的 SubViewport 和“只是贴图”的 TextureRect,输入转发需要我们自己处理。
问题在于:
SubViewport是独立的渲染目标,本身不会自动接收场景树中Control的 UI 事件;TextureRect只是一个显示贴图的控件,不会自动帮你把事件往某个 Viewport 里推。
3.1 基本思路
大致流程很简单:
- 在外围节点上捕获输入事件(
_gui_input、_Input或对应信号)。 - 把事件坐标从“外层坐标系”转换到
SubViewport的本地坐标系。 - 调用
SubViewport.PushInput(event)把事件送进去。
在本文这个方案里,可以直接利用 SubViewport 的 FinalTransform2D 来简化这一步。
3.2 使用 FinalTransform2D 做坐标转换
SubViewport 提供了一个属性 FinalTransform2D,表示从当前 Viewport 坐标系到渲染目标的最终 2D 变换矩阵。我们可以用它把鼠标坐标从外层 Viewport 坐标转换到 SubViewport 内部坐标。
以下是一个 C# 示例(放在合适的节点上,例如根节点脚本):
public override void _Input(InputEvent @event)
{
if (@event is InputEventMouse mouseEvent)
{
// 用 UiSubViewport 的 FinalTransform2D 把鼠标坐标
// 从当前 Viewport 坐标系转换到 SubViewport 坐标系
var transform = _uiSubViewport.GetFinalTransform();
// 注意:Transform2D 与向量相乘是线性变换,这里直接用 *= 简化写法
mouseEvent.Position *= transform;
_uiSubViewport.PushInput(mouseEvent);
}
else
{
// 非鼠标事件(键盘、手柄等)可以直接转发
_uiSubViewport.PushInput(@event);
}
}
用这种方式,SubViewport 内的所有 UI 节点可以像平时那样接收输入事件,不需要做额外的坐标换算逻辑,也不依赖 SubViewportContainer 的“自动代理”。
四、总结
-
为什么不用
SubViewportContainer?- 它内部的
SubViewport渲染分辨率和外部缩放解耦,很难配合“整体渲染分辨率缩放”策略; - 缩放时 UI 往往是简单被拉伸,而不是按更高分辨率重新渲染,容易出现模糊、锯齿等问题;
- 很难完全绕开它自带的各种代理行为,去实现彻底自定义的渲染分辨率控制。
- 它内部的
-
改用
SubViewport + TextureRect的好处:SubViewport只负责渲染,分辨率由你完全掌控;TextureRect只负责显示和适配,可以自由挂载后处理 Shader;- 非常适合「UI 独立渲染 → 单独后处理 → 再叠加到主画面」的渲染管线设计。
-
核心难点在于输入转发:
- 在外层捕获输入(
_Input或_gui_input); - 使用
FinalTransform2D或手动坐标换算把事件坐标转换到SubViewport坐标系; - 通过
SubViewport.PushInput()推送给内部 UI。
- 在外层捕获输入(
通过这套做法,可以同时获得:
可控的 UI 渲染分辨率 + 独立的 UI 后处理管线 + 完整的交互体验,
而不需要和 SubViewportContainer 的各种“自动行为”反复周旋。