从零向 UE 5.3 渲染管线插入一个 Screen Pass
前言
从这篇文章开始,我会直接修改 UE 的渲染管线,做一些规模不大、但足以把引擎渲染框架串起来的实验。
第一个实验选 Screen Pass:等 UE 走完常规后处理,取出最终画面做一次基于拉普拉斯算子的边缘检测,再把结果写回视口输出。效果如下:
环境是UE 5.3.2、PC 端的 Deferred Shading 路径。代码会直接写进Renderer模块和引擎的Shader目录,所以需要源码版引擎。
整件事分四步:先写 HLSL,给它配一个 C++ Shader 类型,用 RDG 声明一次绘制,最后把这步绘制插进一帧的合适位置。
如果只是想在项目里做一个描边效果,Post Process Material 显然更省事。之所以选择改引擎,是因为效果本身不是重点。我真正想弄清楚的是:一张画面在 UE 里经过了哪些阶段,C++ 如何找到一段 HLSL,RDG 如何管理输入输出,以及一个自定义的 Screen Pass 该怎么落地。
Screen Pass
先暂时抛开 UE。所谓 Screen Pass,就是把一张已经存在的二维纹理当作输入,在屏幕范围内跑一次 Shader,得到另一张二维纹理。
它不关心场景里有多少模型,也不会重新提交这些模型的顶点;GPU 只需要画一个覆盖屏幕的全屏三角形,Pixel Shader 对每个像素执行一次(为什么是一个三角形,而不是两个三角形拼成的四边形,可以参考用三角形代替四边形-后处理优化 - 知乎)。Bloom、Tonemapping、颜色校正、FXAA、SMAA 都属于这一类。
在 UE 的延迟渲染路径里,一帧可以粗略理解成下面这条流水线:
这里省略了阴影、Lumen、Nanite 等分支。本次自定义的 Pass 不参与 GBuffer 生成,也不改变光照计算,位置放在后处理之后,直接处理ViewFamilyTexture。
HLSL(usf)
要处理ViewFamilyTexture,就先从最底下的一层做起:写 Shader。文件放在:
Engine/Shaders/Private/Mustard/CustomScreenPass.usf代码如下:
#include "../Common.ush" Texture2D SceneColorTexture; SamplerState SceneColorSampler; float2 InvTextureSize; void MainPS( float4 SvPosition : SV_POSITION, out float4 OutColor : SV_Target0) { float2 UV = SvPosition.xy * InvTextureSize; float3 FinalColor = float3(0.0, 0.0, 0.0); for (int X = -1; X <= 1; ++X) { for (int Y = -1; Y <= 1; ++Y) { float2 SampleUV = UV + float2(X, Y) * InvTextureSize; float3 SampleColor = Texture2DSample( SceneColorTexture, SceneColorSampler, SampleUV).rgb; if (X == 0 && Y == 0) { FinalColor += 8.0 * SampleColor; } else { FinalColor -= SampleColor; } } } float Gray = abs(dot(FinalColor, float3(0.299, 0.587, 0.114))); OutColor = float4(Gray, Gray, Gray, 1.0); }这是一个 3×3 拉普拉斯核:
-1 -1 -1 -1 8 -1 -1 -1 -1核里所有权重之和为 0,颜色没有变化的区域会互相抵消,只有像素变化剧烈的地方才会留下较大的响应,所以最后看到的是物体轮廓和画面里的高频细节。
SV_POSITION在 Pixel Shader 中对应当前像素在渲染目标上的位置,InvTextureSize是纹理尺寸的倒数,1920×1080 对应(1 / 1920, 1 / 1080)。两者相乘得到纹理 UV,而在 UV 上加减一次InvTextureSize,恰好移动一个像素。
采样器用 Clamp 寻址,画面边缘的邻域采样不会绕到另一侧。最后用(0.299, 0.587, 0.114)把 RGB 响应转成灰度并取绝对值,这样亮到暗和暗到亮的边缘都能显示出来。
把 HLSL 接到 C++
只有.usf文件还不够。UE 需要一个 C++ Shader 类型,告诉 Shader 编译系统源文件在哪里、入口函数叫什么,以及 C++ 要传哪些参数进来。
头文件放在:
Engine/Source/Runtime/Renderer/Private/Mustard/CustomScreenPassRendering.h#pragmaonce#include"CoreMinimal.h"#include"GlobalShader.h"#include"ShaderParameterStruct.h"#include"RenderGraphResources.h"classFCustomScreenShaderPS:publicFGlobalShader{public:DECLARE_GLOBAL_SHADER(FCustomScreenShaderPS);SHADER_USE_PARAMETER_STRUCT(FCustomScreenShaderPS,FGlobalShader);BEGIN_SHADER_PARAMETER_STRUCT(FParameters,)RENDER_TARGET_BINDING_SLOTS()SHADER_PARAMETER_RDG_TEXTURE(Texture2D,SceneColorTexture)SHADER_PARAMETER_SAMPLER(SamplerState,SceneColorSampler)SHADER_PARAMETER(FVector2f,InvTextureSize)END_SHADER_PARAMETER_STRUCT()};classFRDGBuilder;classFViewInfo;classFRDGTexture;voidAddCustomScreenPass(FRDGBuilder&GraphBuilder,constFViewInfo&View,FRDGTextureRef InputTexture,FRDGTextureRef OutputTexture);Global Shader
UE 里的 Shader 不只有材质 Shader。材质 Shader 和 Material、Vertex Factory、材质排列组合绑得很紧;而全屏后处理、纹理拷贝、环境计算这类工作并不属于某个材质,更适合继承FGlobalShader。
上面几个宏各管一件事:
DECLARE_GLOBAL_SHADER为这个 C++ 类型声明 Shader 类型信息。SHADER_USE_PARAMETER_STRUCT表示走结构化参数绑定。BEGIN_SHADER_PARAMETER_STRUCT描述该 Pass 会访问的 GPU 资源和普通参数。RENDER_TARGET_BINDING_SLOTS为 Raster Pass 提供颜色、深度等输出绑定槽位。SHADER_PARAMETER_RDG_TEXTURE除了绑定纹理,也把资源访问关系交给 RDG。
参数名要和 HLSL 里的声明对上。这里的SceneColorTexture、SceneColorSampler和InvTextureSize在 C++ 与.usf中保持同名,UE 的 Shader 参数元数据才能完成绑定。
光声明还不够,还要在.cpp里注册:
#include"CustomScreenPassRendering.h"#include"PixelShaderUtils.h"#include"RenderGraphBuilder.h"#include"RenderGraphUtils.h"#include"ScreenPass.h"#include"../SceneRendering.h"IMPLEMENT_GLOBAL_SHADER(FCustomScreenShaderPS,"/Engine/Private/Mustard/CustomScreenPass.usf","MainPS",SF_Pixel);IMPLEMENT_GLOBAL_SHADER的四个参数依次是 C++ Shader 类型、Shader 虚拟路径、入口函数和 Shader Stage。这里写的/Engine/Private/...不是磁盘上的绝对路径,UE 会把它映射到Engine/Shaders/Private/...;SF_Pixel说明MainPS是 Pixel Shader。
把 Pass 接进 RDG
Shader 注册好了,接下来得让 RDG 知道这次绘制存在。创建 Pass 的代码同样放在:
Engine/Source/Runtime/Renderer/Private/Mustard/CustomScreenPassRendering.cppvoidAddCustomScreenPass(FRDGBuilder&GraphBuilder,constFViewInfo&View,FRDGTextureRef InputTexture,FRDGTextureRef OutputTexture){FCustomScreenShaderPS::FParameters*PassParameters=GraphBuilder.AllocParameters<FCustomScreenShaderPS::FParameters>();PassParameters->SceneColorTexture=InputTexture;PassParameters->SceneColorSampler=TStaticSamplerState<SF_Bilinear,AM_Clamp,AM_Clamp,AM_Clamp>::GetRHI();PassParameters->InvTextureSize=FVector2f(1.0f/InputTexture->Desc.Extent.X,1.0f/InputTexture->Desc.Extent.Y);constFScreenPassRenderTarget Output=FScreenPassRenderTarget::CreateViewFamilyOutput(OutputTexture,View);PassParameters->RenderTargets[0]=Output.GetRenderTargetBinding();TShaderMapRef<FCustomScreenShaderPS>PixelShader(View.ShaderMap);FPixelShaderUtils::AddFullscreenPass(GraphBuilder,View.ShaderMap,RDG_EVENT_NAME("CustomScreenPass"),PixelShader,PassParameters,Output.ViewRect);}FRDGBuilder
RDG 是 Render Dependency Graph。调用AddCustomScreenPass时,代码并没有立刻让 GPU 去画三角形,而是往FRDGBuilder里描述了一个节点:读哪张纹理、写哪张纹理、用哪些参数。
等整张图构建完,RDG 才会根据这些声明推导 Pass 之间的依赖、资源生命周期和 Resource Barrier,还会剔除掉没有产生有效输出的 Pass。
GraphBuilder.AllocParameters分配出来的参数结构,生命周期取决于当前 Render Graph。把 RDG 纹理写进参数结构,RDG 就知道这次绘制对资源的读写关系。
TShaderMapRef
TShaderMapRef<FCustomScreenShaderPS>PixelShader(View.ShaderMap);这行代码不会现场编译 Shader,而是从当前视图的 Shader Map 里取出已经编译好的FCustomScreenShaderPS。
AddFullscreenPass
FPixelShaderUtils::AddFullscreenPass是一层便捷封装:向 RDG 添加一个 Raster Pass,用引擎预置的全屏管线状态配好 PSO 和 Shader 参数,最后调用DrawFullscreenTriangle。
所以这次我们只写了 Pixel Shader,顶点格式和 Vertex Shader 都不用自己操心。三角形的位置和 UV 来自引擎常驻的GScreenRectangleVertexBuffer(前 4 个顶点拼成 quad,后 2 个顶点配合索引0, 4, 5拼出一个更大的三角形),Vertex Shader 则是/Engine/Private/Tools/FullscreenVertexShader.usf里的MainVS;配合SetViewport指定的区域,三个顶点就能覆盖整块 View Rect。
这里容易踩坑的是最后传进去的 View Rect。View.ViewRect表示场景的内部渲染区域;开了 Screen Percentage、动态分辨率或 TSR 之后,它可能小于显示分辨率。我们的 Pass 位于 TSR 和标准后处理之后,读写的是最终的ViewFamilyTexture,如果传View.ViewRect,Pixel Shader 就只会覆盖最终纹理左上角的一部分。
UE 在FScreenPassRenderTarget::CreateViewFamilyOutput里处理了这层区别:Raw Output 下选View.ViewRect,其余情况选面向最终显示的View.UnscaledViewRect:
constFScreenPassRenderTarget Output=FScreenPassRenderTarget::CreateViewFamilyOutput(OutputTexture,View);FPixelShaderUtils::AddFullscreenPass(GraphBuilder,View.ShaderMap,RDG_EVENT_NAME("CustomScreenPass"),PixelShader,PassParameters,Output.ViewRect);纹理的Extent仍然是底层纹理的完整尺寸,用它算InvTextureSize,UV 每次偏移才恰好跨过一个真实像素。
插入 Deferred Shading
Pass 自己能跑了,还需要将其插入到合适的位置。目标是FDeferredShadingSceneRenderer::Render,文件在:
Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cpp先 include 刚建的头文件:
#include<Mustard/CustomScreenPassRendering.h>然后在AddPostProcessingPasses之后加上:
if(ViewFamily.bResolveScene&&ViewFamilyTexture){for(int32 ViewIndex=0;ViewIndex<Views.Num();++ViewIndex){constFViewInfo&View=Views[ViewIndex];FRDGTextureRef InputCopy=GraphBuilder.CreateTexture(ViewFamilyTexture->Desc,TEXT("CustomScreenPass.InputCopy"));AddCopyTexturePass(GraphBuilder,ViewFamilyTexture,InputCopy);AddCustomScreenPass(GraphBuilder,View,InputCopy,ViewFamilyTexture);}}这里很容易顺手把ViewFamilyTexture同时当输入和输出:
// 不要这样做AddCustomScreenPass(GraphBuilder,View,ViewFamilyTexture,ViewFamilyTexture);逻辑上看着像是原地处理,但对 GPU 来说,同一份子资源同时做 Shader Resource 和 Render Target 会发生读写冲突:一个像素还在读旧图,另一个像素就把新结果写进去了,这是未定义行为;D3D12 的资源状态和 RDG 的校验也不接受这种 SRV / RTV 组合。
所以先建一张描述完全相同的InputCopy,用AddCopyTexturePass把最终画面拷一份:
ViewFamilyTexture ──Copy──> InputCopy ↑ │ └──── CustomScreenPass ───┘自定义 Pass 读InputCopy、写ViewFamilyTexture,RDG 据此安排复制和绘制的先后,并插入需要的资源状态转换。
不足之处
UE 还提供了FScreenPassTexture、FScreenPassRenderTarget、FScreenPassTextureViewport和AddDrawScreenPass等更完整的抽象。FScreenPassTexture除了纹理,还会把纹理和有效的 View Rect 绑在一起,更适合在正式的后处理链里传递。
验证
新增 C++ Shader 类型后,需要重新编译Renderer模块或整个编辑器。首次启动时,引擎会发现新的 Global Shader 并编译CustomScreenPass.usf。后续只改 HLSL 的话,打开 Shader 开发模式(r.ShaderDevelopmentMode=1),用recompileshaders changed即可,不必每次都重编 C++。
用 RenderDoc 截一帧,能看到这次自定义的 screen pass: