文章

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 下面。
  • 通过 ViewportTextureUISubViewport 的内容贴到 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 基本思路

大致流程很简单:

  1. 在外围节点上捕获输入事件(_gui_input_Input 或对应信号)。
  2. 把事件坐标从“外层坐标系”转换到 SubViewport 的本地坐标系。
  3. 调用 SubViewport.PushInput(event) 把事件送进去。

在本文这个方案里,可以直接利用 SubViewportFinalTransform2D 来简化这一步。

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 的各种“自动行为”反复周旋。

License:  CC BY 4.0