外观
Niagara-ScreenUV
约 1744 字大约 6 分钟
2026-04-22
一句话结论
WorldPositionToScreenUV 与 DecodeXxx 节点上的 ApplyViewportOffset 同时为 true,导致视口偏移被应用两次;Standalone 下因 ViewRect == BufferSize 巧合抵消不暴露问题,PIE 和 DLSS 下 ViewRect != BufferSize 后错位显现。修复:将 DecodeXxx 节点的 ApplyViewportOffset 改为 false。
一、问题描述
现象
项目中美术使用 Niagara GBuffer DataInterface 采样屏幕空间信息(Roughness / Metallic / Specular / SceneColor),UV 来自将世界坐标投影到屏幕的函数 WorldPositionToScreenUV。
- ✅ Standalone 模式:粒子采样位置与屏幕内容对齐,行为符合预期。
- ❌ Editor 内 Play / PIE 模式:粒子采样位置与屏幕内容明显错位。
- ❌ 开启 DLSS 等超分后:错位进一步放大。
初始怀疑
最初怀疑是 DLSS / FSR / TSR 等超分技术在 Niagara GPU Sim 的 View UniformBuffer 填充时机上出了问题,导致 View.ScreenPositionScaleBias 对应的 ViewRect 与实际 SceneTexture 不一致。
二、问题代码
美术使用的是 /Niagara/Functions/GBuffer/WorldPositionToScreenUV 函数,其核心 HLSL 如下:
ScreenUV = float2(0,0);
IsInsideView = true;
#if GPU_SIMULATION
FLWCVector3 LwcWorldPosition = MakeLWCVector3(GetEngineOwnerLWCTile(), WorldPosition);
float3 TranslatedWorldPosition = LWCToFloat(LWCAdd(LwcWorldPosition, PrimaryView.TileOffset.PreViewTranslation));
float4 SamplePosition = float4(TranslatedWorldPosition, 1);
float4 ClipPosition = mul(SamplePosition, View.TranslatedWorldToClip);
float2 ScreenPosition = ClipPosition.xy / ClipPosition.w;
if (all(abs(ScreenPosition.xy) <= float2(1, 1)))
{
IsInsideView = true;
// ⚠️ 这一步已经把 NDC 映射到了 BufferUV 空间(包含 ViewRect 偏移)
ScreenUV = ScreenPosition * View.ScreenPositionScaleBias.xy + View.ScreenPositionScaleBias.wz;
}
else
{
IsInsideView = false;
}
#endifNiagara 图中,输出的 ScreenUV 被同时送入四个 Decode 节点:
| 节点 | ApplyViewportOffset 默认值 |
|---|---|
| DecodeRoughness | true |
| DecodeMetallic | true |
| DecodeSpecular | true |
| DecodeSceneColor | true |
三、根因分析
两次偏移应用
第一次(WorldPositionToScreenUV 内部):
ScreenUV = ScreenPosition * View.ScreenPositionScaleBias.xy + View.ScreenPositionScaleBias.wz;View.ScreenPositionScaleBias 由 FSceneView 在初始化时根据 ViewRect 和 BufferSize 烘焙而成,这步运算已经得到 BufferUV(也即包含了 ViewRect 在 Buffer 中的偏移和缩放)。
第二次(GBuffer DataInterface 内部,当 ApplyViewportOffset=true):
UV = UV * View.ViewSizeAndInvSize.xy * View.BufferSizeAndInvSize.zw;这一步的设计语义是把 ViewportUV([0,1] 视口内局部坐标)转换到 BufferUV。它假设输入 UV 是 ViewportUV。
问题在于:上游给它的 已经是 BufferUV,却被 Decode 节点当作 ViewportUV 再乘了一次 ViewSize/BufferSize 的系数。
为什么 Standalone 能正常工作
| 模式 | ViewRect vs BufferSize | ViewSize/BufferSize | 第二次应用的效果 |
|---|---|---|---|
| Standalone(全屏,无 DLSS) | 相等 | ≈ 1.0 | no-op,侥幸正确 |
| PIE(窗口模式) | Buffer 大于 ViewRect(编辑器复用 RT) | < 1 | UV 整体缩小,错位 |
| PIE / Standalone + DLSS | SecondaryViewRect 显著小于 BufferSize | 明显 < 1 | 错位幅度放大 |
Standalone 模式下工作是巧合而非正确——依赖
ViewSize/BufferSize == 1这个恰好成立的条件。
Epic 官方设计意图
Epic 工程师 Stu 在 UE 论坛(2021)关于类似问题的回复中明确说明了 ApplyViewportOffset 这个 pin 的存在原因:
"You need to apply the viewport offset to your UVs... We will add a pin to do this automatically, as sometimes you want this to be applied and other times you do not."
这个布尔参数的存在本身就说明链路上有且仅有一次应用偏移是预期用法。两端都设 true 是误用。
四、解决方案
方案 A:取消 Decode 节点的 ApplyViewportOffset(已采纳)
在 Niagara 图中,将每个 DecodeRoughness / DecodeMetallic / DecodeSpecular / DecodeSceneColor 节点的 ApplyViewportOffset 勾选取消(设为 false)。
- 原因:
WorldPositionToScreenUV已经输出 BufferUV,下游直接使用即可。 - 优势:美术可直接在 NS 里操作,无需动代码。
- 验证:Editor/PIE/Standalone/DLSS 各模式均对齐。
方案 B:改 WorldPositionToScreenUV 输出 ViewportUV
若 WorldPositionToScreenUV 是项目自定义节点(非引擎内置),可改为输出 [0,1] 的 ViewportUV:
// 改成输出 ViewportUV 而不是 BufferUV
ScreenUV = ScreenPosition * float2(0.5, -0.5) + 0.5;然后 Decode 节点保持 ApplyViewportOffset=true。
- 语义更清晰:节点间传递的始终是 ViewportUV,由 DataInterface 负责最终转换。
- 代价:需要改动一个被广泛使用的函数脚本,可能影响其他引用点。
方案对比
| 维度 | 方案 A | 方案 B |
|---|---|---|
| 修改范围 | Niagara 图内节点参数 | 底层 HLSL 函数 |
| 美术可自改 | ✅ | ❌ |
| 影响其他用法 | 仅当前系统 | 所有引用该函数的地方 |
| 语义清晰度 | 一般 | 更好 |
| 回归风险 | 低 | 中 |
五、验证清单
修复后按以下场景逐一验证对齐:
| 场景 | 修复前 | 修复后(方案 A) |
|---|---|---|
| PIE 全屏 + DLSS Off | ❌ | ✅ |
| PIE 全屏 + DLSS Quality | ❌ | ✅ |
| PIE 全屏 + DLSS Performance | ❌(错位更大) | ✅ |
| PIE 窗口化(不全屏)+ DLSS Off | ❌(关键回归点) | ✅ |
| Standalone | ✅(巧合) | ✅ |
| Standalone + DLSS | ❌ | ✅ |
关键测试:PIE 窗口化 + DLSS Off,是能把"双倍偏移"问题和"DLSS 本身接入问题"区分开的测试点。
六、经验教训
1. 模式差异问题的排查思路
当出现 "Standalone OK / PIE 坏" 的差异时,首先考虑两种模式下哪些渲染资源是共享的、哪些是独立的:
- PIE 模式下 SceneRenderTarget 常被多 Viewport 复用,
BufferSize > ViewRect是常态。 - Standalone 模式下 Buffer 通常与 ViewRect 一致。
- 任何依赖
BufferSize / ViewRect比值的代码在这两种模式下行为不同。
2. 布尔参数的"双开"陷阱
当 API 提供了一个 Apply某某 的布尔开关时,它通常意味着"链路上应用一次"。如果上下游都开启,往往导致重复应用。设计 API 或排查问题时,都要意识到这类参数的"应用次数"语义。
3. "巧合正确" 的识别
如果代码在 A 环境工作、在 B 环境不工作,而两者之间的差异恰好能让某个计算"刚好退化为无操作"(如乘 1、加 0),那就要警惕 A 环境可能是靠巧合在工作。这类 bug 在切换环境、升级引擎、接入新技术(如 DLSS)时集中爆发。
4. DLSS 接入的连锁影响
DLSS 的 SecondaryViewRect 让 ViewRect / BufferSize 的比值显著偏离 1,这会暴露所有原本依赖这个比值≈1 的隐藏 bug。接入 DLSS / FSR / TSR 前,应该系统性检查:
- 所有使用
View.ScreenPositionScaleBias的地方 - 所有使用
View.ViewSizeAndInvSize / View.BufferSizeAndInvSize的地方 - 所有 Niagara GBuffer / SceneTexture 采样节点的
ApplyViewportOffset设置 - 自定义后处理材质中的 ScreenUV / BufferUV 语义
5. 视口空间语义术语
在 UE 的渲染代码里明确区分这两个概念,能避免大量类似问题:
- ViewportUV:[0,1] 范围,相对当前视口局部,与分辨率无关。
- BufferUV:相对整个 SceneRenderTarget 的 UV,包含 ViewRect 在 Buffer 中的偏移和缩放。
转换关系:
BufferUV = ViewportUV * (ViewSize / BufferSize) + (ViewRectMin / BufferSize)
= ViewportUV * View.ViewSizeAndInvSize.xy * View.BufferSizeAndInvSize.zw
+ View.ViewRectMin.xy * View.BufferSizeAndInvSize.zw七、相关代码参考
Niagara 内置函数路径
/Niagara/Functions/GBuffer/WorldPositionToScreenUV
/Niagara/Functions/Localspace/TransformPositionView UniformBuffer 关键字段
| 字段 | 含义 |
|---|---|
View.ScreenPositionScaleBias | NDC→BufferUV 的打包常量(含 ViewRect 偏移) |
View.ViewSizeAndInvSize | (ViewRect.W, ViewRect.H, 1/W, 1/H) |
View.BufferSizeAndInvSize | (BufferSize.W, BufferSize.H, 1/W, 1/H) |
View.ViewRectMin | ViewRect 在 Buffer 中的左上角偏移 |
View.TranslatedWorldToClip | 带 TAA/DLSS jitter 的 VP 矩阵 |
PrimaryView.TileOffset.PreViewTranslation | LWC tile 偏移(Primary / Secondary View 通用) |
论坛参考
- Why is the data you get from the GBuffer through Niagara inconsistent in how it is mapped? — Epic 工程师 Stu 对
ApplyViewportOffset设计意图的回复。
八、TL;DR 给美术的话
当 Niagara 粒子使用 GBuffer 采样(DecodeRoughness/Metallic/Specular/SceneColor)时,如果 UV 来自
WorldPositionToScreenUV,一定要把 Decode 节点上的ApplyViewportOffset关掉。不然在 PIE 或 DLSS 下会错位。
