源码仓库: github.com/awe0w0/WorldWidgetSceneBlur

最近在 UE 里处理一个看起来很普通、真正动手后却一路钻进 Renderer 的问题:我想在场景空间 WidgetComponent 里放一个 UMG BackgroundBlur,让它像玻璃一样模糊后面的三维场景。
屏幕空间 UI 里这件事很自然,拖一个控件、调一下 Blur Strength 就能工作。可同一套 Widget 放进 World Space 后,玻璃还在,后面的世界却没有被模糊。继续调半径、透明度和材质都没用,因为问题根本不在参数,而在它能采到什么。
最后把修复整理成了一个 Runtime 插件 WorldWidgetSceneBlur。它不要求改相机,不依赖额外后处理体积,也不用给蓝图补初始化节点。Widget 里还是原来的 BackgroundBlur,只是模糊真正发生的位置被挪到了场景渲染管线里。
SceneColor 到底丢在了哪里
World Space Widget 并不是直接画进场景颜色。Slate/UMG 会先把控件树绘制到一张独立 Render Target,随后 WidgetComponent 再把这张 UI 纹理贴到场景中的平面或圆柱面上。
原生 SBackgroundBlur 所处的上下文仍然是这张 UI Render Target,所以它能看到的是 Widget 自己已经画出来的内容,而不是摄像机当前的 SceneColor。换句话说,不是 SceneColor 读失败了,而是原生模糊从一开始就没有站在能读到 SceneColor 的渲染阶段里。
这也是整个插件的功能目的:保留 UMG 的编辑和参数语义,把采样与模糊移到 PostOpaque,让世界空间 UI 的玻璃区域直接处理真实场景。
技术路径先画成一条线
这条链路最后收敛成了下面几步:
- 游戏线程在每个 World 的 Actor Tick 后扫描 World Space
WidgetComponent。 - 从 WidgetTree 找出可见的
UBackgroundBlur,读取几何、强度、半径、圆角和透明度。 - 把控件区域换算成世界空间四边形,只把不含 UObject 的纯数据送进渲染线程。
- 渲染线程按
FSceneInterface保存数据,在PostOpaque拿到当前 View 的SceneColor与SceneDepth。 - 先横向、再纵向执行两遍可分离高斯模糊。
- 用四边形范围、透视正确 UV、圆角 SDF 和场景深度生成遮罩,再 Alpha 合成回
SceneColor。
用一行更像渲染图的写法就是:
UBackgroundBlur -> World Quad -> Render Command -> PostOpaque
-> SceneColor Copy -> Gaussian X -> Gaussian Y
-> Rounded/Depth Mask -> SceneColor
游戏线程只负责收集,不碰 GPU 资源
插件把扫描放在 FWorldDelegates::OnWorldPostActorTick。这里会严格过滤 World、Feature Level、组件注册状态与 Widget Space,只接管当前场景中真的需要渲染的 World Space Widget。
找到 UBackgroundBlur 后,先读取 CachedGeometry。它给出的仍是 Widget 绘制坐标,所以还要结合 DrawSize、Pivot 和组件 Transform,把四个角还原到世界空间。最终跨线程传输的数据很克制:四个世界坐标、四个 UV、局部尺寸、圆角、模糊强度、核半径、透明度、法线和深度测试开关。
struct FWorldWidgetSceneBlurQuad
{
FVector WorldPositions[4];
FVector2f WidgetUVs[4];
FVector2f WidgetLocalSize;
FVector4f CornerRadii;
FVector WorldNormal;
float BlurStrength;
float KernelRadius;
float Opacity;
bool bTwoSided;
bool bDepthTest;
};
这里故意不保存 UObject*。游戏线程把数组 Move 进 ENQUEUE_RENDER_COMMAND,渲染线程再按 Scene 存到 QuadsByScene。编辑器多 World、PIE 和 Game View 同时存在时,也不会把一个场景的玻璃画到另一个场景里。
圆柱 Widget 也没有被当成一整张平面硬贴。插件会根据弧度和可见宽度把模糊区域切成多个四边形,每一片保留自己的 UV 区间。这样多一点顶点,换来弧面上的位置和采样都不至于明显穿帮。
先关掉原生模糊,再让场景接管
如果新 Pass 直接叠上去,Slate 仍会先模糊一次 Widget Render Target,最终效果就会变成 UI 内部模糊和场景模糊叠加。插件会递归找到缓存 Slate 树里的 SBackgroundBlur,暂时把原生 Blur Strength 设为 0,同时保存 WidgetComponent 原来的 BlendMode 和材质覆盖。
被接管的组件切到 Transparent 合成,插件停用、控件隐藏、World 清理或组件不再参与渲染时,再把这些状态完整恢复。这个细节不负责画出效果,却决定了插件能不能长期留在项目里,而不是一个退出 PIE 后就把 Widget 状态弄乱的实验补丁。
在 PostOpaque 重新接上 SceneColor
真正的入口是 Renderer 的 RegisterPostOpaqueRenderDelegate。这个阶段不透明场景已经存在,插件也能从 FPostOpaqueRenderParameters 取得 View、Viewport、ColorTexture、DepthTexture 和 RDG Builder。
第一步不是直接读写 ColorTexture,而是先复制一份:
FRDGTextureRef SceneColorCopy = GraphBuilder.CreateTexture(
Parameters.ColorTexture->Desc,
TEXT("WorldWidgetSceneBlur.SceneColorCopy"));
AddCopyTexturePass(GraphBuilder, Parameters.ColorTexture, SceneColorCopy);
同一张 GPU 纹理不能在一个 Pass 里既作为输入采样又作为输出写回。复制出来的 SceneColorCopy 是后续模糊的稳定输入,原 SceneColor 则留到最后一遍做 Alpha 合成。
接下来把世界空间四边形投影到当前 View。每个顶点除了屏幕像素坐标,还会保存设备深度和 1/ClipW。后者不是多余数据,它用来在像素着色器里做透视正确的 UV 插值。玻璃斜着面对相机时,如果只做普通线性插值,圆角和采样都会沿透视方向悄悄变形。
两遍高斯,不做整屏模糊
模糊本体是两遍可分离高斯。第一遍沿 X 轴采样到临时 RDG 纹理,第二遍沿 Y 轴采样,同时完成遮罩与合成。
float4 SampleGaussian(float2 PixelPosition, float2 Direction)
{
const float Radius = max(BlurParameters.x, 0.5f);
const float Sigma = max(BlurParameters.y, 0.5f);
const int HalfSampleCount = clamp((int)ceil(Radius), 1, 32);
const float SampleSpacing = Radius / (float)HalfSampleCount;
const float InverseTwoSigmaSquared = 0.5f / (Sigma * Sigma);
float4 AccumulatedColor = 0.0f;
float AccumulatedWeight = 0.0f;
[loop]
for (int SampleIndex = -32; SampleIndex <= 32; ++SampleIndex)
{
if (abs(SampleIndex) <= HalfSampleCount)
{
const float Offset = (float)SampleIndex * SampleSpacing;
const float Weight = exp2(-(Offset * Offset) * InverseTwoSigmaSquared * 1.44269504089f);
const float2 SamplePixel = clamp(
PixelPosition + Direction * Offset,
ViewRectMinMax.xy + 0.5f,
ViewRectMinMax.zw - 0.5f);
AccumulatedColor += Texture2DSampleLevel(
InputSceneColor,
InputSceneColorSampler,
SamplePixel * SceneTextureInvSize,
0.0f) * Weight;
AccumulatedWeight += Weight;
}
}
return AccumulatedColor / max(AccumulatedWeight, 0.00001f);
}
二维高斯如果按一个完整方形核采样,成本会随半径近似平方增长。拆成横纵两次一维采样后,成本更接近线性。插件还会先算出四边形的屏幕包围盒,横向 Pass 只多扩一个 Blur Margin,纵向 Pass 只画 Widget 本身的 DrawRect,并没有为了一个小玻璃块去模糊整张画面。
模糊半径也会跟随四边形投影尺寸缩放。Widget 离相机远近变化时,屏幕上的玻璃强度不会突然像换了一套参数。
圆角、透视和深度都在第二遍收口
四边形会被拆成两个屏幕空间三角形。像素着色器先算重心坐标,判断像素落在哪个三角形内,再用 1/ClipW 修正后的权重插值 Widget UV。
圆角不是额外贴一张遮罩图,而是用局部尺寸和四个角半径计算 Rounded Rectangle SDF。fwidth 给边缘留出大约一个像素的抗锯齿宽度,所以圆角在不同分辨率下不会锯成一圈小台阶。
const float SignedDistance = length(max(DistanceToInnerCorner, 0.0f))
+ min(max(DistanceToInnerCorner.x, DistanceToInnerCorner.y), 0.0f)
- Radius;
const float AntiAliasWidth = max(fwidth(SignedDistance), 0.5f);
return saturate(0.5f - SignedDistance / AntiAliasWidth);
深度遮挡则直接读取当前 SceneDepthTexture。UE 的目标 RHI 使用反向 Z,场景物体比 Widget 更靠近相机时,遮罩会被清零。这样玻璃板藏到墙后面时不会穿墙继续模糊前景;如果 Widget 材质原本关闭了深度测试,插件也会尊重这个设置。
最后输出的 RGB 是纵向模糊结果,Alpha 是圆角、控件透明度和深度共同得到的 Mask。C++ 侧用 SourceAlpha / InverseSourceAlpha 混合状态,把这块局部结果压回原来的 SceneColor。
版本耦合和实际代价
这不是一段完全不碰引擎内部的通用插件。FViewInfo、SceneRendering.h 和 RenderPostOpaque 都属于 Renderer 私有接口;UE 还需要把 Renderer/Private 与 Renderer/Internal 显式加入 Include Path。以后升级引擎,最先该看的就是 WorldWidgetSceneBlur.Build.cs、PostOpaque 参数和深度比较方向。
当前实现最低编译到 SM5,单方向 Shader 采样循环上限是 32。每个可见模糊区域会有一张横向临时纹理和两个局部 Fullscreen Pass,所以它比普通透明 Widget 贵,但开销被限制在控件投影包围盒内。没有有效 BackgroundBlur 时不会创建这些 Pass。
在 UE 的实际测试里,插件 DLL 已被编辑器正常加载,FWorldWidgetSceneBlurHorizontalPS 与 FWorldWidgetSceneBlurPS 两个全局像素着色器也完成了真实编译。上面的截图就是 World Space Widget 直接模糊后方地形与模型后的画面,不是单独烘出来的 UI 贴图。
使用上反而没什么新东西
插件启用后,使用方式仍然是熟悉的三步:
- 在 UMG Widget 中加入
BackgroundBlur,正常设置 Strength、Radius 与 Corner Radius。 - 用
WidgetComponent显示这个 Widget。 - 把 Widget Space 设置为
World。
不需要额外蓝图节点,也不用把项目所有场景都套进一个后处理方案。对美术和 UI 来说,它还是原来的 UMG 控件;对 Renderer 来说,背后多了一条把控件几何接到 SceneColor 的局部渲染路径。
这个问题最有意思的地方也在这里。表面上只是“背景模糊拿不到 SceneColor”,实际修复却横跨了 Slate、UMG、世界空间几何、游戏线程与渲染线程、RDG、全局 Shader、透视插值和深度缓冲。最后使用者什么都不用多做,才算这条绕了很远的技术路径真正收口。
