ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UE4自定义Shader与RHI实战:从编译到渲染管线插入

UE4自定义Shader与RHI实战:从编译到渲染管线插入

1. 项目缘起与整体设计思路

1.1 为什么要在UE4里折腾自定义Shader和RHI

很多做UE4项目的人,一开始都会觉得材质编辑器够用了,连线连到天荒地老也能凑合出效果。但真到了要做特殊效果、做后处理、做性能极致优化的时候,材质编辑器那套节点系统就开始捉襟见肘了。比如你想做一个完全自定义的延迟光照模型,或者想在渲染管线里插入一个自己的Pass,又或者想直接操作GPU资源做Compute Shader计算,这时候就绕不开两个东西:自定义Shader和RHI。

我自己第一次接触这块是因为一个后处理需求:需要在场景渲染完成后,对深度缓冲和法线缓冲做一次自定义的滤波处理,材质编辑器里的后处理材质虽然能做,但性能和灵活性都受限,尤其是需要多Pass迭代的时候,节点连线根本没法优雅地表达。于是硬着头皮去啃了UE4的Shader编译流程和RHI抽象层,踩了不少坑,也总结了一些能直接复用的经验。

这篇文章面向的是有一定UE4使用基础、想往渲染底层走的开发者。不需要你精通图形学,但至少得知道顶点着色器、像素着色器大概在干什么,知道RenderTarget是什么东西。我会从整体设计思路讲起,然后拆解核心细节,再给出一套可操作的实现流程,最后把常见问题和排查技巧整理出来。

1.2 整体架构:Shader文件、模块注册与RHI抽象

UE4的渲染体系大致可以分成三层:最上层是材质编辑器产出的材质资源,中间层是Shader编译系统和渲染线程的Pass调度,最底层是RHI(Render Hardware Interface)。RHI的作用是把不同图形API(比如D3D11、D3D12、OpenGL、Vulkan)的差异屏蔽掉,让上层代码用统一的接口去操作GPU。

自定义Shader的流程,本质上就是绕过材质编辑器,直接写HLSL代码,然后通过UE4的Shader编译管线把它编译成对应平台的字节码,最后在渲染线程里通过RHI接口把Shader绑定到管线状态上执行。这里面有几个关键文件类型:

  • .usf文件:UE4的Shader源文件,本质是HLSL加上一些UE4的宏和include。
  • .ush文件:Shader头文件,放公共函数和结构体定义。
  • Shader编译入口:通常在某个模块的.cpp文件里,通过IMPLEMENT_SHADER_TYPE宏注册。
  • RHI调用层:在渲染线程的某个Pass里,通过FRHICommandList来设置Shader参数、绑定资源和绘制。

整体设计思路是:把自定义Shader做成一个独立的Shader类型,注册到UE4的Shader编译系统里,然后在自定义的SceneViewExtension或RenderPass里通过RHI调用它。这样做的好处是Shader的编译、缓存、平台适配都由UE4的管线接管,你只需要关注Shader逻辑本身和RHI参数绑定。

为什么不用全局Shader或者直接改引擎源码?因为全局Shader会影响所有材质,风险太大;改引擎源码则会导致升级困难。独立Shader类型加SceneViewExtension的方式,既能保持引擎的完整性,又能灵活插入自定义渲染逻辑,是目前社区里比较推荐的做法。

2. 核心细节解析与实操要点

2.1 Shader文件编写:HLSL与UE4宏的配合

写自定义Shader的第一步是创建.usf文件。UE4的Shader文件不是纯HLSL,它有一套自己的宏系统,比如#include "/Engine/Public/Platform.ush"来引入平台相关的定义,用IMPLEMENT_MATERIAL_SHADER_TYPE或IMPLEMENT_SHADER_TYPE来注册。一个典型的自定义全局Shader大概长这样:

// MyCustomShader.usf #include "/Engine/Public/Platform.ush" void MainVS( in float4 InPosition : ATTRIBUTE0, in float2 InUV : ATTRIBUTE1, out float4 OutPosition : SV_POSITION, out float2 OutUV : TEXCOORD0) { OutPosition = InPosition; OutUV = InUV; } void MainPS( in float4 InPosition : SV_POSITION, in float2 InUV : TEXCOORD0, out float4 OutColor : SV_Target0) { OutColor = float4(InUV, 0.0f, 1.0f); }

这里有几个细节需要注意。第一,#include "/Engine/Public/Platform.ush"是必须的,它定义了UE4在各个平台上的基础类型和宏。第二,顶点着色器的输入语义ATTRIBUTE0、ATTRIBUTE1是UE4约定的,对应顶点缓冲里的位置和UV。第三,像素着色器的输出SV_Target0对应第一个RenderTarget。

注意:UE4的Shader编译是离线+运行时结合的,修改.usf文件后需要重新编译Shader,有时候编辑器不会自动检测到变化,需要手动触发重新编译或者重启编辑器。

2.2 Shader参数绑定:Uniform Buffer与RHI资源

Shader写好了,怎么把数据传进去?UE4里主要通过Uniform Buffer(统一缓冲)来传递常量参数。你需要定义一个C++结构体,继承自FUniformBufferStruct或者直接用BEGIN_UNIFORM_BUFFER_STRUCT宏,然后在Shader里用对应的cbuffer来接收。

// MyCustomUniformBuffer.h BEGIN_UNIFORM_BUFFER_STRUCT(FMyCustomUniforms, ) SHADER_PARAMETER(FVector4, MyColor) SHADER_PARAMETER(float, MyIntensity) END_UNIFORM_BUFFER_STRUCT()

在Shader里对应:

cbuffer MyCustomUniforms { float4 MyColor; float MyIntensity; }

然后在渲染线程里,通过FMyCustomUniforms的TUniformBufferRef来创建和更新Uniform Buffer,再通过RHI命令列表绑定到Shader。这里的关键是Uniform Buffer的布局必须和Shader里的cbuffer完全一致,包括对齐规则。UE4的SHADER_PARAMETER宏会自动处理对齐,但如果你手动写结构体,就要小心float4的对齐问题。

另一个重要的资源是纹理和采样器。在Shader里声明Texture2D和SamplerState,然后在C++侧通过FShaderResourceParameter和FSamplerStateRHIRef来绑定。UE4的RHI层用FRHITexture和FRHISamplerState来抽象,绑定的时候通过FRHICommandList::SetShaderTexture和SetShaderSampler。

2.3 RHI调用:在渲染线程里插入自定义Pass

自定义Shader最终要在渲染线程里执行。UE4的渲染线程和游戏线程是分离的,所有RHI调用都必须在渲染线程里进行。常见的做法是继承FSceneViewExtensionBase,在PrePostProcessPass_RenderThread或者SubscribeToPostProcessingPass里插入自己的Pass。

class FMyCustomViewExtension : public FSceneViewExtensionBase { public: virtual void SubscribeToPostProcessingPass( EPostProcessingPass Pass, FAfterPassCallbackDelegateArray& InOutPassCallbacks, bool bIsPassEnabled) override { if (Pass == EPostProcessingPass::MotionBlur) { InOutPassCallbacks.Add( FAfterPassCallbackDelegate::CreateRaw( this, &FMyCustomViewExtension::MyCustomPass_RenderThread)); } } void MyCustomPass_RenderThread( FRHICommandListImmediate& RHICmdList, const FSceneView& View, const FPostProcessMaterialInputs& Inputs) { // 在这里通过RHICmdList调用自定义Shader } };

在MyCustomPass_RenderThread里,你需要获取Shader的引用、设置管线状态、绑定Uniform Buffer和纹理、然后调用DrawPrimitive或者DrawIndexedPrimitive。这里有个坑:管线状态对象(PSO)的创建和缓存。UE4的RHI里,PSO是通过FPipelineStateInitializer来创建的,创建过程比较耗时,所以通常要缓存起来,避免每帧都创建。

提示:在渲染线程里访问游戏线程的数据要特别小心,必须通过渲染线程安全的代理或者拷贝。直接访问UObject是危险的,因为游戏线程可能同时在修改它。

3. 实操过程与核心环节实现

3.1 创建Shader模块与注册Shader类型

第一步是创建一个独立的Shader模块。在UE4项目里,通常是在Source目录下新建一个模块,比如MyCustomShaderModule,然后在Build.cs里添加必要的依赖:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "RenderCore", "RHI", "Renderer" });

然后在模块的.cpp文件里注册Shader类型:

IMPLEMENT_SHADER_TYPE(, FMyCustomVS, TEXT("/MyPlugin/MyCustomShader.usf"), TEXT("MainVS"), SF_Vertex); IMPLEMENT_SHADER_TYPE(, FMyCustomPS, TEXT("/MyPlugin/MyCustomShader.usf"), TEXT("MainPS"), SF_Pixel);

这里的FMyCustomVS和FMyCustomPS是继承自FGlobalShader的类,需要声明DECLARE_GLOBAL_SHADER宏。Shader文件的路径是虚拟路径,需要在模块的ShaderDirectory里配置映射。

class FMyCustomVS : public FGlobalShader { DECLARE_GLOBAL_SHADER(FMyCustomVS); SHADER_USE_PARAMETER_STRUCT(FMyCustomVS, FGlobalShader); using FParameters = FMyCustomUniforms; };

这一步的关键是Shader虚拟路径的映射。UE4通过AddShaderSourceDirectoryMapping来把物理路径映射到虚拟路径,通常在模块启动时调用:

void FMyCustomShaderModule::StartupModule() { FString ShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders")); AddShaderSourceDirectoryMapping(TEXT("/MyPlugin"), ShaderDir); }

3.2 编译Shader与处理平台差异

Shader写好后,编译过程由UE4的Shader编译系统自动处理。在编辑器里,你可以通过Recompile Shaders菜单手动触发,或者在打包时自动编译。编译过程中,UE4会把.usf文件预处理,展开所有宏和include,然后调用平台对应的Shader编译器(比如D3D的fxc、Vulkan的glslang)生成字节码。

平台差异是这里最头疼的问题。不同图形API对Shader的语法和语义支持不一样,比如D3D11和Vulkan在纹理采样、原子操作、内存布局上都有差异。UE4的Platform.ush里定义了很多宏来屏蔽这些差异,比如PLATFORM_D3D11、PLATFORM_VULKAN,你可以用条件编译来处理:

#if PLATFORM_D3D11 // D3D11特定代码 #elif PLATFORM_VULKAN // Vulkan特定代码 #endif

注意:在写跨平台Shader时,尽量避免使用平台特有的功能,比如D3D的Texture2DArray在某些移动平台上支持不好。如果必须用,一定要用条件编译隔离。

编译失败时,UE4会在输出日志里打印详细的错误信息,包括行号和错误原因。常见的编译错误包括:类型不匹配、语义重复、cbuffer对齐错误、include路径错误。排查时先看日志里的第一行错误,往往后面的错误都是连锁反应。

3.3 在渲染管线中插入自定义Pass并绑定资源

Shader编译通过后,下一步是在渲染管线里插入自定义Pass。以SceneViewExtension为例,你需要在SubscribeToPostProcessingPass里注册回调,然后在回调里执行RHI调用。完整的RHI调用流程大概是这样:

void FMyCustomViewExtension::MyCustomPass_RenderThread( FRHICommandListImmediate& RHICmdList, const FSceneView& View, const FPostProcessMaterialInputs& Inputs) { // 1. 获取Shader引用 TShaderMapRef<FMyCustomVS> VertexShader(GetGlobalShaderMap(View.GetFeatureLevel())); TShaderMapRef<FMyCustomPS> PixelShader(GetGlobalShaderMap(View.GetFeatureLevel())); // 2. 设置渲染目标 FRHIRenderPassInfo RPInfo(Inputs.SceneColor.Texture->GetRenderTargetItem().TargetableTexture, ERenderTargetActions::Load_Store); RHICmdList.BeginRenderPass(RPInfo, TEXT("MyCustomPass")); // 3. 设置管线状态 FGraphicsPipelineStateInitializer GraphicsPSOInit; RHICmdList.ApplyCachedRenderTargets(GraphicsPSOInit); GraphicsPSOInit.BlendState = TStaticBlendState<>::GetRHI(); GraphicsPSOInit.RasterizerState = TStaticRasterizerState<>::GetRHI(); GraphicsPSOInit.DepthStencilState = TStaticDepthStencilState<false, CF_Always>::GetRHI(); GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI = GFilterVertexDeclaration.VertexDeclarationRHI; GraphicsPSOInit.BoundShaderState.VertexShaderRHI = VertexShader.GetVertexShader(); GraphicsPSOInit.BoundShaderState.PixelShaderRHI = PixelShader.GetPixelShader(); GraphicsPSOInit.PrimitiveType = PT_TriangleList; SetGraphicsPipelineState(RHICmdList, GraphicsPSOInit); // 4. 绑定Uniform Buffer FMyCustomUniforms Uniforms; Uniforms.MyColor = FVector4(1.0f, 0.0f, 0.0f, 1.0f); Uniforms.MyIntensity = 0.5f; TUniformBufferRef<FMyCustomUniforms> UniformBuffer = TUniformBufferRef<FMyCustomUniforms>::CreateUniformBufferImmediate(Uniforms, UniformBuffer_SingleFrame); VertexShader->SetParameters(RHICmdList, UniformBuffer); PixelShader->SetParameters(RHICmdList, UniformBuffer); // 5. 绑定纹理和采样器 PixelShader->SetTextureParameter(RHICmdList, TEXT("InputTexture"), Inputs.SceneColor.Texture); // 6. 绘制 RHICmdList.SetStreamSource(0, GFilterVertexBuffer.VertexBufferRHI, 0); RHICmdList.DrawIndexedPrimitive( GFilterIndexBuffer.IndexBufferRHI, 0, 0, 4, 0, 2, 1); RHICmdList.EndRenderPass(); }

这段代码里每一步都有讲究。BeginRenderPass和EndRenderPass是必须成对的,中间的所有绘制都会渲染到指定的RenderTarget上。SetGraphicsPipelineState会设置整个管线状态,包括混合、光栅化、深度模板、Shader绑定。SetParameters会把Uniform Buffer绑定到Shader的对应槽位。DrawIndexedPrimitive的参数要仔细核对,索引数、顶点数、实例数都不能错。

提示:TUniformBufferRef::CreateUniformBufferImmediate创建的Uniform Buffer是单帧有效的,下一帧需要重新创建。如果需要持久化的Uniform Buffer,要用CreateUniformBuffer并手动管理生命周期。

4. 常见问题与排查技巧实录

4.1 Shader编译失败:从日志到定位

Shader编译失败是最常见的问题,表现是编辑器里材质变紫、后处理失效、或者直接崩溃。排查的第一步是看输出日志里的Shader编译错误。UE4的日志会打印出具体的文件名、行号和错误描述,比如:

Error: MyCustomShader.usf(15,23): error X3000: syntax error: unexpected token 'float4'

这种错误通常是语法问题,比如少了分号、括号不匹配、类型名拼写错误。但有时候错误信息会误导人,比如cbuffer对齐错误可能报的是“无法识别的类型”,实际上是结构体布局不对。

另一个常见原因是include路径错误。UE4的Shader虚拟路径和物理路径的映射关系容易搞混,如果AddShaderSourceDirectoryMapping没调用或者路径写错,编译时会报“找不到文件”。这时候要检查模块的StartupModule是否执行了,以及路径拼接是否正确。

还有一种情况是平台特定的编译错误。比如在D3D11上编译通过,在Vulkan上失败,通常是因为用了平台特有的语法或者功能。这时候要用条件编译隔离,或者查UE4的Platform.ush里有没有对应的宏。

4.2 RHI调用崩溃:资源绑定与线程安全

RHI调用崩溃通常比Shader编译失败更难排查,因为崩溃点可能在渲染线程的任意位置。常见的崩溃原因有几种:

第一种是资源未绑定。比如Shader里声明了Texture2D InputTexture,但C++侧没有调用SetTextureParameter,运行时就会崩溃或者渲染出黑屏。排查方法是检查Shader里所有声明的资源,确保C++侧都绑定了。

第二种是Uniform Buffer布局不匹配。如果C++结构体和Shader里的cbuffer布局不一致,绑定后Shader读到的数据就是乱的,可能导致除零、越界访问等崩溃。排查方法是仔细核对每个参数的类型和顺序,注意float4的对齐规则。

第三种是线程安全问题。在渲染线程里访问游戏线程的UObject,如果游戏线程同时在修改它,就会崩溃。排查方法是检查所有在渲染线程里访问的数据,确保要么是渲染线程安全的,要么是通过代理拷贝的。

注意:RHI调用崩溃时,堆栈信息往往不完整,因为渲染线程的调用栈可能被优化掉了。这时候可以用-d3ddebug或者-vulkanvalidation启动参数来开启图形API的调试层,获取更详细的错误信息。

4.3 性能问题:Draw Call与状态切换

自定义Shader的性能问题通常表现为帧率下降、GPU占用高。常见的原因有:

  • Draw Call过多:每个自定义Pass至少一次Draw Call,如果Pass数量多或者绘制次数多,Draw Call就会爆炸。优化方法是合并Pass、减少绘制次数、使用Instancing。
  • 状态切换频繁:每次SetGraphicsPipelineState都会触发状态切换,如果每帧切换几百次,CPU开销会很大。优化方法是按状态排序绘制,减少切换次数。
  • Uniform Buffer更新频繁:每帧创建和更新Uniform Buffer会有CPU开销,如果数据不变,可以缓存起来复用。
  • 纹理采样过多:像素着色器里采样次数过多会导致GPU带宽瓶颈,优化方法是减少采样、使用Mipmap、压缩纹理格式。

排查性能问题可以用UE4的ProfileGPU命令,它会显示每个Pass的GPU耗时。也可以用stat rhi查看Draw Call数量和状态切换次数。如果某个Pass耗时特别高,就重点优化它。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
材质变紫Shader编译失败查看输出日志修复语法错误或include路径
渲染黑屏资源未绑定检查SetTextureParameter调用补全资源绑定
崩溃在渲染线程Uniform Buffer布局不匹配核对C++结构体和cbuffer调整结构体对齐
帧率下降Draw Call过多用stat rhi查看合并Pass或使用Instancing
平台间效果不一致平台特定语法检查条件编译用Platform.ush宏隔离
Shader修改不生效编译缓存未更新手动Recompile Shaders重启编辑器或清理缓存

这张表是我自己踩坑后整理的,基本上覆盖了80%的常见问题。遇到新问题时,先对照这张表排查,往往能快速定位。

5. 进阶技巧与个人经验分享

5.1 Shader调试:Printf与RenderDoc

Shader调试比CPU调试麻烦得多,因为不能直接打断点。UE4提供了一些调试手段,比如在Shader里用#if DEBUG_SHADER条件编译插入调试输出,或者用printf在像素着色器里输出中间值(需要平台支持)。但最常用的还是RenderDoc或者PIX这类GPU调试工具。

RenderDoc可以捕获一帧的完整渲染过程,查看每个Draw Call的输入输出、Shader代码、资源绑定、管线状态。用RenderDoc排查问题时,先找到对应的Draw Call,然后检查Shader的输入参数是否正确、纹理是否绑定、输出结果是否符合预期。如果输出不对,再逐步检查Shader逻辑。

提示:用RenderDoc捕获UE4时,需要在启动参数里加-renderdoc或者用RenderDoc的注入功能。捕获后,可以在RenderDoc里查看每个Pass的详细信息和Shader的汇编代码。

5.2 Shader缓存与热重载

UE4的Shader编译比较慢,尤其是大项目里Shader数量多的时候。为了加快迭代速度,可以开启Shader的热重载功能。在编辑器里,修改.usf文件后,UE4会自动检测变化并重新编译,但有时候不会触发,需要手动点Recompile Shaders。

另一个技巧是Shader缓存。UE4会把编译好的Shader字节码缓存到DerivedDataCache目录里,下次启动时直接加载,不用重新编译。如果缓存损坏或者过期,可以删除DerivedDataCache目录强制重新编译。但注意,删除缓存后第一次启动会很慢,因为要重新编译所有Shader。

5.3 跨平台适配的坑

跨平台适配是自定义Shader最麻烦的部分。不同平台的图形API差异很大,比如:

  • D3D11:支持Texture2DArray、RWTexture2D,但对原子操作支持有限。
  • D3D12:支持更多高级功能,但需要手动管理资源屏障和描述符堆。
  • Vulkan:支持Compute Shader和原子操作,但内存布局和同步规则更严格。
  • Metal:苹果平台专用,语法和D3D差异较大。

适配时,尽量用UE4的Platform.ush里定义的宏来隔离平台差异。如果某个功能在某个平台上不支持,就用条件编译降级处理。比如RWTexture2D在D3D11上不支持,可以用Texture2D加Compute Shader替代。

注意:跨平台适配时,一定要在每个目标平台上实际测试,不能只在Windows上编译通过就认为没问题。不同平台的Shader编译器对语法的容忍度不一样,有些在D3D上能过的代码在Vulkan上会报错。

5.4 个人经验:从踩坑到形成套路

我自己做自定义Shader和RHI这块,最大的体会是:先跑通最小闭环,再逐步加功能。一开始不要想着一步到位做出完整效果,而是先写一个最简单的Shader,能编译、能绑定、能绘制出东西,然后再一点点加Uniform Buffer、加纹理、加多Pass。这样每加一个功能都能快速验证,出问题也容易定位。

另一个经验是多写注释和文档。Shader代码和RHI调用代码的可读性往往很差,过几个月回头看可能自己都看不懂。所以在关键步骤上写清楚注释,比如每个Uniform Buffer参数的用途、每个RHI调用的目的、每个平台条件编译的原因。这样后续维护和扩展会轻松很多。

最后,善用社区资源。UE4的渲染体系很复杂,官方文档有时候不够详细,但社区里有大量优秀的开源项目和博客文章可以参考。遇到问题时,先搜索一下有没有人遇到过类似的问题,往往能节省大量时间。但要注意,参考别人的代码时要理解原理,不能直接复制粘贴,否则出了问题都不知道怎么排查。

这个内容后续还可以这样扩展:把自定义Shader封装成插件,支持在材质编辑器里通过自定义节点调用;或者把RHI调用层抽象成通用的渲染Pass框架,支持动态注册和排序。这些方向我还在探索中,有机会再分享。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进