首页 / 资讯中心 / 文章详情

游戏引擎渲染系统架构设计:RHI抽象、渲染管线与多线程渲染实践

游戏引擎渲染系统架构设计:RHI抽象、渲染管线与多线程渲染实践 ★ FEATURED ARTICLE
1. 渲染系统在游戏引擎中的定位与整体设计聊渲染系统之前得先把它在引擎里的位置摆正。很多人一提到渲染就想到画三角形、写Shader但实际上渲染系统是引擎里最庞大也最复杂的子系统之一它要同时对接上层游戏逻辑、下层图形API、中间还要管理资源、调度任务、处理多平台差异。我做了这么多年引擎相关的工作一个很深的体会是渲染系统的架构设计好坏直接决定了这个引擎能不能支撑起从手机小游戏到3A大作的跨度。1.1 渲染系统到底解决什么问题从最朴素的角度看渲染系统要回答三个问题画什么、怎么画、什么时候画。画什么涉及场景管理、可见性剔除、资源加载怎么画涉及材质系统、Shader管理、渲染管线组织什么时候画涉及帧调度、多线程同步、GPU与CPU的并行。这三个问题看起来简单但每一个展开都是一部血泪史。我见过不少自研引擎一开始把渲染写成一个大函数所有东西塞在一起跑Demo没问题一旦场景复杂起来就彻底失控。所以架构设计的第一步就是分层。通常我会把渲染系统分成这么几层最上层是场景表示层负责维护渲染相关的场景数据中间是渲染管线层负责组织渲染流程和Pass下面是RHI层也就是Render Hardware Interface负责屏蔽不同图形API的差异最底下是平台后端对接具体的D3D、Vulkan、Metal、OpenGL等。这个分层不是拍脑袋定的每一层都有明确的职责边界。场景表示层不关心用什么APIRHI层不关心场景里有什么物体这样当你要换图形API或者加新平台时改动被限制在最小的范围内。我试过把一个原本只支持D3D11的引擎扩展到Vulkan因为RHI抽象做得还行大概两周就把基础渲染跑通了如果当初没分层这个工作量至少翻五倍。1.2 为什么RHI抽象是架构的核心RHI这个词听起来很唬人说白了就是一层接口把不同图形API的相似功能用统一的接口暴露出来。比如创建纹理D3D11叫CreateTexture2DVulkan叫vkCreateImageMetal叫newTextureWithDescriptorRHI就定义一个CreateTexture接口内部根据当前平台调用对应的API。但RHI的设计远不止是改个名字这么简单。不同API的资源管理模型差异巨大。D3D11是驱动帮你管理内存你创建资源就行Vulkan和D3D12要求你自己管理内存分配、自己处理同步Metal又有一套自己的模型。RHI要做的是在这些差异之上提供一个相对统一的抽象同时不能损失太多性能。我的经验是RHI抽象要遵循最小公倍数原则只抽象所有API都有的功能平台特有的功能通过扩展接口暴露。比如Mesh ShaderD3D12和Vulkan都支持但支持的细节不一样RHI可以提供一个基础的Mesh Shader接口具体参数通过扩展结构体传递。这样既保证了通用性又不至于把高级功能阉割掉。注意RHI抽象最容易犯的错误是抽象过度。我见过有人把RHI设计得无比优雅结果每个后端实现都要写几千行适配代码性能还掉了20%。抽象的目的是减少重复不是追求设计模式的美感。1.3 渲染管线的组织方式渲染管线这个词在不同语境下含义不同。有时候指的是GPU硬件管线顶点处理、光栅化、像素处理有时候指的是引擎层面的渲染流程阴影Pass、GBuffer Pass、光照Pass、后处理Pass。这里说的是后者。引擎层面的渲染管线核心问题是如何组织一系列渲染Pass。最简单的做法是硬编码主函数里按顺序调用各个Pass。这种做法在管线固定的时候没问题但一旦要支持不同的渲染路径前向、延迟、移动端简化路径硬编码就变成了灾难。更好的做法是管线可配置。把每个Pass封装成独立的对象管线就是Pass的列表通过配置文件或者代码来组装。这样加一个新Pass只需要写Pass本身不用改管线框架。我现在的项目里渲染管线是通过一个JSON配置驱动的美术和TA也能参与调整效率提升很明显。但可配置也有代价就是调试变难了。Pass之间的依赖关系如果没管理好很容易出现这个Pass依赖的资源还没准备好的问题。所以管线框架必须要有依赖声明和自动排序机制每个Pass声明自己读什么、写什么框架自动算出执行顺序这样既灵活又不容易出错。2. 渲染管线的核心细节与实操要点理解了整体架构接下来要深入管线内部的细节。这部分是渲染系统最核心也最容易踩坑的地方我尽量把关键点和经验都讲透。2.1 前向渲染与延迟渲染的取舍这是渲染管线设计里第一个大决策。前向渲染是每个物体在绘制时直接计算光照延迟渲染是先把几何信息位置、法线、材质写到GBuffer然后再统一计算光照。前向渲染的优点是简单、透明物体处理好、内存占用低缺点是光源多了之后性能急剧下降因为每个物体都要遍历所有光源。延迟渲染的优点是光源数量几乎不影响性能缺点是GBuffer内存占用大、透明物体需要单独处理、MSAA支持麻烦。我的建议是移动端优先前向PC和主机优先延迟。移动端GPU的带宽和内存都紧张GBuffer的开销扛不住PC和主机带宽充足延迟渲染能支撑大量动态光源。当然也有折中方案比如Clustered Forward把光源按空间分簇前向渲染也能支持大量光源现在很多引擎都在用。具体选型的时候还要考虑项目的实际需求。如果游戏是户外大场景、光源主要是太阳光加少量点光源前向渲染完全够用如果是室内场景、大量动态光源延迟渲染优势明显。我做过一个项目一开始用延迟渲染后来发现场景里光源其实很少切回前向渲染后性能反而更好内存也省了不少。2.2 Shader管理系统的设计Shader是渲染系统的灵魂但Shader管理也是最容易乱的地方。一个中等规模的游戏Shader变体数量轻松上千如果管理不好编译时间、内存占用、运行时切换开销都会成为问题。Shader变体的来源主要有几个平台差异D3D、Vulkan、Metal、质量等级低、中、高、功能开关是否开启阴影、是否开启雾效、材质参数不同的贴图组合。这些组合起来就是指数级增长。我的做法是按需编译加缓存。不在打包时编译所有变体而是运行时根据实际使用情况编译编译好的缓存起来。这样首次加载会慢一点但后续就快了。同时要有一个变体剔除机制把实际用不到的变体提前排除掉。比如某个材质从来不用某张贴图那相关的变体就不用编译。还有一个坑是Shader编译的线程安全。Shader编译很耗时必须放到后台线程但很多图形API的Shader编译不是线程安全的。解决办法是在后台线程做预处理比如解析、优化真正的API调用放到渲染线程。这个细节不注意很容易出现随机崩溃。2.3 渲染资源的生命周期管理渲染资源包括纹理、Buffer、RenderTarget、PipelineState等这些东西的创建和销毁都很耗时管理不好会导致卡顿和内存泄漏。核心原则是延迟销毁。GPU是异步执行的你这一帧提交了使用某个资源的命令可能几帧之后GPU才真正执行完。如果这时候你把资源销毁了GPU就会读到无效内存。所以资源销毁必须延迟到GPU确认不再使用之后。具体实现上可以用帧同步或者Fence机制。帧同步是简单粗暴地延迟N帧销毁N通常取2到3Fence是更精确的方式GPU执行完某个Fence后通知CPUCPU再销毁相关资源。前者简单但浪费内存后者精确但实现复杂。我一般先用帧同步等性能优化阶段再换成Fence。资源池化也很重要。频繁创建销毁RenderTarget会导致内存碎片和性能抖动用一个池子管理起来需要的时候从池子里拿不用的时候还回去能显著提升稳定性。我见过一个项目每帧创建销毁几十个RenderTarget帧率波动特别大改成池化之后帧率曲线平滑了很多。3. 实操过程与核心环节实现理论讲完了这一部分讲具体怎么落地。我会用一个简化的渲染管线作为例子把关键环节的实现思路和代码结构讲清楚。3.1 RHI层的接口设计先看RHI层的接口设计。核心接口大概有这么几类设备接口、资源接口、命令接口、同步接口。// 设备接口负责创建资源和查询能力 class IRHIDevice { public: virtual IRHITexture* CreateTexture(const TextureDesc desc) 0; virtual IRHIBuffer* CreateBuffer(const BufferDesc desc) 0; virtual IRHIPipelineState* CreatePipelineState(const PipelineStateDesc desc) 0; virtual IRHICommandList* CreateCommandList() 0; virtual void SubmitCommandList(IRHICommandList* cmdList) 0; virtual void Present() 0; }; // 命令接口负责录制渲染命令 class IRHICommandList { public: virtual void BeginRenderPass(const RenderPassDesc desc) 0; virtual void EndRenderPass() 0; virtual void SetPipelineState(IRHIPipelineState* pso) 0; virtual void SetVertexBuffer(IRHIBuffer* buffer, uint32_t slot) 0; virtual void SetIndexBuffer(IRHIBuffer* buffer) 0; virtual void DrawIndexed(uint32_t indexCount, uint32_t startIndex) 0; virtual void Dispatch(uint32_t x, uint32_t y, uint32_t z) 0; };这个接口设计的关键点是命令列表。D3D12和Vulkan都是命令列表模型D3D11和OpenGL是立即模式。RHI统一成命令列表模型D3D11后端内部把命令缓存起来在Submit的时候一次性执行。这样上层代码不用关心底层是哪种模式。命令列表还有一个好处是支持多线程录制。不同的线程可以同时录制不同的命令列表最后在主线程提交。这对CPU性能提升很明显尤其是场景复杂的时候。我实测过一个场景单线程录制CPU耗时8ms改成4线程录制后降到2.5ms效果很显著。3.2 渲染管线的组装有了RHI接下来组装渲染管线。我用一个简化的延迟渲染管线作为例子包含这几个Pass阴影Pass、GBuffer Pass、光照Pass、透明Pass、后处理Pass。class RenderPipeline { public: void Execute(IRHICommandList* cmdList, Scene* scene, Camera* camera) { // 1. 阴影Pass渲染阴影贴图 mShadowPass-Execute(cmdList, scene, camera); // 2. GBuffer Pass渲染几何信息 mGBufferPass-Execute(cmdList, scene, camera); // 3. 光照Pass计算光照 mLightingPass-Execute(cmdList, scene, camera, mGBufferPass-GetGBuffer()); // 4. 透明Pass渲染透明物体 mTransparentPass-Execute(cmdList, scene, camera, mLightingPass-GetOutput()); // 5. 后处理Pass色调映射、抗锯齿等 mPostProcessPass-Execute(cmdList, mTransparentPass-GetOutput()); } };每个Pass内部的结构类似设置RenderTarget、设置PipelineState、遍历可见物体、提交DrawCall。这里的关键是可见性剔除不能把所有物体都提交给GPU要先在CPU端剔除掉不可见的。剔除分几级视锥剔除、遮挡剔除、距离剔除。视锥剔除最简单判断物体的包围盒是否在相机视锥内遮挡剔除复杂一些判断物体是否被其他物体挡住距离剔除是根据距离和LOD决定是否渲染。我一般先做视锥剔除再做距离剔除遮挡剔除看场景复杂度决定要不要上。3.3 多线程渲染的实现多线程渲染是提升CPU性能的关键。基本思路是把渲染工作分成几块不同线程并行处理。常见的分法有按物体分、按Pass分、按命令列表分。按物体分是把场景物体分给不同线程每个线程负责一部分物体的剔除和DrawCall准备按Pass分是不同Pass在不同线程录制按命令列表分是把一个Pass的命令分到多个命令列表。我推荐按命令列表分因为这种方式最灵活也最容易实现负载均衡。具体做法是主线程负责组织Pass每个Pass内部把物体分成N组每组录制一个命令列表N个线程并行录制最后主线程按顺序提交。void GBufferPass::Execute(IRHICommandList* cmdList, Scene* scene, Camera* camera) { // 剔除 auto visibleObjects CullObjects(scene, camera); // 分组 const int numThreads 4; auto groups SplitIntoGroups(visibleObjects, numThreads); // 并行录制 std::vectorIRHICommandList* cmdLists(numThreads); ParallelFor(numThreads, [](int i) { cmdLists[i] mDevice-CreateCommandList(); RecordGBuffer(cmdLists[i], groups[i]); }); // 提交 for (auto* list : cmdLists) { cmdList-ExecuteCommands(list); } }这里有个坑是资源竞争。多个线程同时访问同一个资源比如材质、纹理时需要加锁或者用无锁数据结构。我的经验是尽量在剔除阶段就把资源准备好录制阶段只读不写这样就不需要加锁。提示多线程渲染的调试很痛苦因为问题是随机的。建议在开发阶段加一个单线程模式开关出问题的时候切到单线程复现能省很多时间。4. 常见问题与排查技巧实录渲染系统的问题排查是最考验经验的因为很多问题现象相似但原因完全不同。这一部分我整理了一些典型问题和排查思路。4.1 画面异常问题速查现象可能原因排查方法画面全黑相机矩阵错误、RenderTarget未绑定、Shader编译失败先检查相机矩阵再检查RenderTarget绑定最后看Shader日志画面闪烁深度冲突、双缓冲同步问题、资源竞争检查深度测试设置检查Present时机检查多线程同步颜色异常颜色空间错误、纹理格式不匹配、混合模式错误检查sRGB设置检查纹理格式检查混合状态物体消失剔除错误、LOD切换问题、资源未加载关闭剔除看是否出现检查LOD阈值检查资源加载状态性能骤降DrawCall过多、状态切换频繁、带宽瓶颈用GPU Profiler看各阶段耗时检查DrawCall数量这个表是我这些年排查问题的经验总结大部分画面问题都能在里面找到方向。但要注意现象相同原因可能不同比如画面全黑可能是相机问题也可能是Shader问题还可能是RenderTarget问题要一步步排查。4.2 Shader编译问题的排查Shader编译问题是最让人头疼的因为错误信息往往很晦涩。我总结了几类常见问题语法错误最好排查编译器会直接告诉你哪一行有问题。但要注意不同平台的Shader编译器对语法的宽容度不同D3D的编译器比较严格OpenGL的编译器比较宽松所以要在最严格的平台上测试。链接错误通常是输入输出不匹配。比如顶点Shader输出的是float3像素Shader输入的是float4就会链接失败。这种问题要看清楚每个阶段的输入输出签名。运行时错误最难排查Shader编译通过了但运行结果不对。常见原因有常量缓冲区布局不匹配、纹理采样器状态错误、分支预测失败。这种问题要用RenderDoc之类的工具抓帧分析看每个DrawCall的实际输入输出。我踩过的一个坑是常量缓冲区对齐。D3D11要求常量缓冲区按16字节对齐我有个结构体是float3加float总共16字节看起来没问题但实际上float3在常量缓冲区里占16字节因为对齐所以整个结构体是32字节。这种问题编译器不会报错但数据会错位排查了很久才发现。4.3 性能问题的定位方法性能问题分CPU瓶颈和GPU瓶颈定位方法不同。CPU瓶颈的表现是GPU利用率低CPU某个线程跑满。用Profiler看各线程耗时找到热点函数。常见的CPU瓶颈有DrawCall提交、剔除计算、资源加载、Shader参数设置。GPU瓶颈的表现是CPU等待GPUGPU利用率高。用GPU Profiler看各Pass耗时找到最耗时的Pass。常见的GPU瓶颈有Overdraw、带宽、Shader复杂度、纹理采样。我一般用二分法定位先关掉一半的Pass看性能是否恢复如果恢复了说明问题在被关掉的Pass里再继续二分。这个方法简单粗暴但很有效尤其是对复杂的渲染管线。还有一个技巧是看GPU计数器。现代GPU都有硬件计数器能告诉你ALU利用率、带宽利用率、纹理单元利用率等。如果ALU利用率高说明Shader计算量大如果带宽利用率高说明纹理采样或RenderTarget读写多。根据计数器调整优化方向比盲目优化有效得多。4.4 跨平台适配的坑跨平台是渲染系统最烦人的部分每个平台都有自己的脾气。D3D11相对简单但要注意Feature Level的差异。Feature Level 11.0和11.1支持的功能不同比如11.1才支持UAV在像素Shader里的读写。如果目标平台是11.0就要避免用11.1的特性。D3D12和Vulkan复杂得多内存管理、同步、描述符都要自己处理。最常见的坑是同步错误资源还在被GPU使用就被CPU修改了。这种问题往往表现为随机崩溃或者画面异常很难复现。解决办法是用Validation Layer它能在开发阶段捕获大部分同步错误。Metal的坑主要在API设计上比如Metal的RenderPassDescriptor和D3D的RenderTarget概念不太一样需要仔细适配。还有Metal的Shader语言是MSL和HLSL、GLSL都不一样需要单独维护。移动平台的坑主要是精度问题。移动GPU对float精度支持不如桌面有些在桌面上没问题的Shader在移动上就会出现精度错误。解决办法是尽量用mediump关键计算用highp并且在不同设备上测试。注意跨平台适配一定要尽早开始不要等到PC版本做完再移植。我见过太多项目PC版本做完再移植到主机结果发现架构上就不支持只能大改。正确的做法是从第一天就考虑跨平台RHI抽象做好平台相关代码隔离好。5. 渲染系统的进阶方向与个人经验前面讲的都是基础架构这一部分聊聊进阶方向和我在实际项目中的一些体会。5.1 Mesh Shader与GPU驱动渲染Mesh Shader是近几年GPU渲染的热点它把传统的顶点处理管线换成了更灵活的任务Shader加网格Shader组合。好处是可以在GPU端做剔除和LOD减少CPU负担。但Mesh Shader的普及还需要时间。目前支持Mesh Shader的主要是较新的GPU主机平台上PS5和Xbox Series X都支持PC上需要D3D12 Ultimate或者Vulkan的扩展。如果项目要覆盖老设备还是得保留传统管线。我的建议是新项目可以尝试但要有回退方案。把Mesh Shader作为可选路径不支持的时候回退到传统管线。这样既能用上新特性又不会丢失兼容性。5.2 光线追踪的集成光追是另一个热点但目前主要还是用在反射、阴影、全局光照这些特定效果上完全光追的游戏还很少。集成光追的关键是和传统管线的融合不能完全替换而是作为增强。比如反射可以用光追做精确反射也可以用SSR做近似反射根据场景和性能预算动态选择。这种混合方案是目前比较务实的做法。光追的另一个问题是性能开销大。即使用硬件加速光追的开销也比传统渲染高不少。所以要用降噪技术用较低采样数加降噪达到可接受的画质。降噪算法本身也有开销需要仔细调优。5.3 我在实际项目中的几点体会做了这么多年渲染有几个体会特别深。第一架构比优化重要。我见过太多项目一开始不重视架构后面性能问题一大堆想优化都无从下手。好的架构能让优化事半功倍坏的架构会让优化事倍功半。第二工具比代码重要。渲染系统的调试离不开工具RenderDoc、PIX、GPU Profiler这些工具能帮你快速定位问题。我花在写调试工具上的时间最后都加倍赚回来了。第三测试要尽早。渲染问题往往在特定场景下才出现所以要尽早做压力测试用复杂的场景、大量的光源、各种材质去测试。等到项目后期才发现问题修复成本会高很多。第四文档要跟上。渲染系统复杂人员流动也大没有文档的话新人上手很慢老人走了知识就丢了。我现在的习惯是每做一个模块就写文档记录设计思路、关键决策、已知问题后面省了很多事。最后分享一个小技巧渲染系统的日志要分级。Info级别记录关键流程Warning级别记录潜在问题Error级别记录实际错误。开发阶段开Info发布版本只开Warning和Error。这样既能排查问题又不会影响性能。我见过有人把所有日志都开着结果日志本身成了性能瓶颈这就本末倒置了。渲染系统这个话题太大了一篇文章很难讲完。我尽量把架构层面的东西讲清楚具体的实现细节每个引擎都不一样需要根据项目实际情况调整。如果你在做渲染相关的工作欢迎交流踩过的坑越多经验越值钱。
阅读完成 · 觉得有帮助?
咨询建站