从零向 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 如何找到一段 HLSLRDG 如何管理输入输出以及一个自定义的 Screen Pass 该怎么落地。Screen Pass先暂时抛开 UE。所谓 Screen Pass就是把一张已经存在的二维纹理当作输入在屏幕范围内跑一次 Shader得到另一张二维纹理。它不关心场景里有多少模型也不会重新提交这些模型的顶点GPU 只需要画一个覆盖屏幕的全屏三角形Pixel Shader 对每个像素执行一次为什么是一个三角形而不是两个三角形拼成的四边形可以参考用三角形代替四边形-后处理优化 - 知乎。Bloom、Tonemapping、颜色校正、FXAA、SMAA 都属于这一类。在 UE 的延迟渲染路径里一帧可以粗略理解成下面这条流水线这里省略了阴影、Lumen、Nanite 等分支。本次自定义的 Pass 不参与 GBuffer 生成也不改变光照计算位置放在后处理之后直接处理ViewFamilyTexture。HLSLusf要处理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#includeCoreMinimal.h#includeGlobalShader.h#includeShaderParameterStruct.h#includeRenderGraphResources.hclassFCustomScreenShaderPS: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(FRDGBuilderGraphBuilder,constFViewInfoView,FRDGTextureRef InputTexture,FRDGTextureRef OutputTexture);Global ShaderUE 里的 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里注册#includeCustomScreenPassRendering.h#includePixelShaderUtils.h#includeRenderGraphBuilder.h#includeRenderGraphUtils.h#includeScreenPass.h#include../SceneRendering.hIMPLEMENT_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 接进 RDGShader 注册好了接下来得让 RDG 知道这次绘制存在。创建 Pass 的代码同样放在Engine/Source/Runtime/Renderer/Private/Mustard/CustomScreenPassRendering.cppvoidAddCustomScreenPass(FRDGBuilderGraphBuilder,constFViewInfoView,FRDGTextureRef InputTexture,FRDGTextureRef OutputTexture){FCustomScreenShaderPS::FParameters*PassParametersGraphBuilder.AllocParametersFCustomScreenShaderPS::FParameters();PassParameters-SceneColorTextureInputTexture;PassParameters-SceneColorSamplerTStaticSamplerStateSF_Bilinear,AM_Clamp,AM_Clamp,AM_Clamp::GetRHI();PassParameters-InvTextureSizeFVector2f(1.0f/InputTexture-Desc.Extent.X,1.0f/InputTexture-Desc.Extent.Y);constFScreenPassRenderTarget OutputFScreenPassRenderTarget::CreateViewFamilyOutput(OutputTexture,View);PassParameters-RenderTargets[0]Output.GetRenderTargetBinding();TShaderMapRefFCustomScreenShaderPSPixelShader(View.ShaderMap);FPixelShaderUtils::AddFullscreenPass(GraphBuilder,View.ShaderMap,RDG_EVENT_NAME(CustomScreenPass),PixelShader,PassParameters,Output.ViewRect);}FRDGBuilderRDG 是 Render Dependency Graph。调用AddCustomScreenPass时代码并没有立刻让 GPU 去画三角形而是往FRDGBuilder里描述了一个节点读哪张纹理、写哪张纹理、用哪些参数。等整张图构建完RDG 才会根据这些声明推导 Pass 之间的依赖、资源生命周期和 Resource Barrier还会剔除掉没有产生有效输出的 Pass。GraphBuilder.AllocParameters分配出来的参数结构生命周期取决于当前 Render Graph。把 RDG 纹理写进参数结构RDG 就知道这次绘制对资源的读写关系。TShaderMapRefTShaderMapRefFCustomScreenShaderPSPixelShader(View.ShaderMap);这行代码不会现场编译 Shader而是从当前视图的 Shader Map 里取出已经编译好的FCustomScreenShaderPS。AddFullscreenPassFPixelShaderUtils::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.ViewRectPixel Shader 就只会覆盖最终纹理左上角的一部分。UE 在FScreenPassRenderTarget::CreateViewFamilyOutput里处理了这层区别Raw Output 下选View.ViewRect其余情况选面向最终显示的View.UnscaledViewRectconstFScreenPassRenderTarget OutputFScreenPassRenderTarget::CreateViewFamilyOutput(OutputTexture,View);FPixelShaderUtils::AddFullscreenPass(GraphBuilder,View.ShaderMap,RDG_EVENT_NAME(CustomScreenPass),PixelShader,PassParameters,Output.ViewRect);纹理的Extent仍然是底层纹理的完整尺寸用它算InvTextureSizeUV 每次偏移才恰好跨过一个真实像素。插入 Deferred ShadingPass 自己能跑了还需要将其插入到合适的位置。目标是FDeferredShadingSceneRenderer::Render文件在Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cpp先 include 刚建的头文件#includeMustard/CustomScreenPassRendering.h然后在AddPostProcessingPasses之后加上if(ViewFamily.bResolveSceneViewFamilyTexture){for(int32 ViewIndex0;ViewIndexViews.Num();ViewIndex){constFViewInfoViewViews[ViewIndex];FRDGTextureRef InputCopyGraphBuilder.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、写ViewFamilyTextureRDG 据此安排复制和绘制的先后并插入需要的资源状态转换。不足之处UE 还提供了FScreenPassTexture、FScreenPassRenderTarget、FScreenPassTextureViewport和AddDrawScreenPass等更完整的抽象。FScreenPassTexture除了纹理还会把纹理和有效的 View Rect 绑在一起更适合在正式的后处理链里传递。验证新增 C Shader 类型后需要重新编译Renderer模块或整个编辑器。首次启动时引擎会发现新的 Global Shader 并编译CustomScreenPass.usf。后续只改 HLSL 的话打开 Shader 开发模式r.ShaderDevelopmentMode1用recompileshaders changed即可不必每次都重编 C。用 RenderDoc 截一帧能看到这次自定义的 screen pass