ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

游戏引擎渲染系统架构设计:从管线组织到RHI抽象与Shader管理

游戏引擎渲染系统架构设计:从管线组织到RHI抽象与Shader管理 1. 渲染系统在引擎里到底扮演什么角色聊游戏引擎架构渲染系统永远是那个最显眼、也最容易被误解的部分。很多人一提到渲染脑子里第一反应就是画东西觉得无非是把模型丢给显卡、跑个Shader、屏幕上出图就完事了。真做过引擎的人都知道渲染系统是整个引擎里耦合度最高、抽象层次最多、性能压力最集中的模块之一。它往上要对接场景管理、材质系统、动画系统、粒子特效往下要压榨GPU的每一分算力中间还得兼顾不同平台、不同硬件能力、不同画质档位的适配。一个渲染系统设计得好不好直接决定了这个引擎能不能撑起3A大作还是只能做做小体量项目。我这些年接触过不少自研引擎和商业引擎的渲染层从最早的固定管线一路看到现在的光追和Mesh Shader最大的感受是渲染系统的架构设计本质上是在抽象和性能之间反复做取舍。抽象层次越高上层写业务越舒服但中间层的开销和灵活性损失就越大抽象层次越低性能越可控但每加一个新特性都要动底层维护成本飙升。所以你看Unity、Unreal这些成熟引擎它们的渲染架构都不是一步到位设计出来的而是被一个个项目、一代代硬件倒逼着演化出来的。这篇文章我想把渲染系统的架构拆开来讲清楚从最顶层的渲染管线组织到中间的RHI抽象层再到最底层的Shader管理和平台适配。重点不是罗列API而是讲清楚每一层为什么这么设计、解决什么问题、有什么坑。适合已经有一定图形学基础、想往引擎方向深入的同学也适合正在自研引擎、需要做渲染架构决策的开发者。如果你只是想知道怎么写一个Shader那这篇可能偏重了但如果你想搞明白为什么引擎要这么组织渲染代码那咱们可以往下聊。2. 渲染管线的整体架构设计思路2.1 从能画出来到画得好还跑得快的演进逻辑早期引擎的渲染管线非常直接场景遍历一遍把可见物体挑出来逐个设置材质、绑定纹理、提交DrawCall完事。这种立即模式的思路在物体数量少、材质简单的年代完全够用。但一旦场景复杂起来问题就暴露了状态切换频繁、DrawCall爆炸、GPU大量时间在等CPU提交命令。于是引擎开始引入批处理和状态排序把相同材质的物体合并提交把渲染状态按代价排序尽量减少切换。再往后延迟渲染Deferred Rendering出现了。它的核心思路是把几何信息先写进G-Buffer光照计算推迟到屏幕空间做。这样做的好处是光照复杂度只和屏幕像素数相关和光源数量解耦能轻松支持几十上百个动态光源。但代价是显存带宽压力大、对透明物体和MSAA不友好。于是又有了前向Forward和混合渲染把光源做分块Tile或分簇Cluster剔除前向管线也能扛住大量光源。这个演进过程告诉我们一个道理渲染管线的架构不是拍脑袋定的而是被场景复杂度和硬件瓶颈两个变量推着走的。你在设计自己引擎的管线时第一步要问的不是我要用延迟还是前向而是我的目标场景是什么样、目标硬件是什么档次。做手游和做主机3A管线设计思路完全是两码事。2.2 管线阶段划分与数据流向一个完整的渲染管线从逻辑上可以切成这么几段可见性剔除视锥剔除、遮挡剔除、LOD选择目的是把不需要画的东西尽早扔掉渲染队列组织把剩下的物体按材质、渲染pass、深度等维度排序分组渲染Pass执行阴影Pass、深度Prepass、不透明Pass、透明Pass、后处理Pass依次执行提交与同步把命令提交给GPU处理CPU-GPU同步、多帧并行这里面的关键设计点是渲染队列。很多新手会忽略队列的重要性觉得排序嘛随便排排就行。实际上队列的组织方式直接决定了批处理效率。举个实际例子如果你把不透明物体和透明物体混在一个队列里那透明物体就得单独处理批处理基本失效。正确的做法是按渲染技术分队列每个队列内部再按材质排序材质相同的再按距离排序这样才能最大化合批。数据流向方面现代引擎普遍采用帧图Frame Graph或者叫渲染图Render Graph的架构。它的核心思想是不直接写先执行Pass A再执行Pass B而是声明每个Pass读哪些资源、写哪些资源由系统自动推导依赖关系、分配内存、插入同步。这样做的好处是资源复用率高、同步自动管理、管线调整灵活。Unreal的RDG、Frostbite的FrameGraph都是这个思路。自己实现一个简化版FrameGraph其实不难难的是处理好资源别名和跨队列同步。2.3 多线程渲染架构的取舍CPU端渲染提交曾经是最大的瓶颈之一。单线程遍历场景、提交命令主线程被渲染拖死游戏逻辑都跑不动。于是引擎开始做多线程渲染常见的有两种模式一种是渲染线程主线程的双线程模型主线程负责逻辑和场景更新渲染线程负责剔除、排序、提交。两者通过命令队列通信。这种模式实现相对简单但渲染线程仍然是单线程复杂场景下还是会卡。另一种是任务化渲染把剔除、排序、甚至部分命令生成拆成并行任务用Job System调度。这种模式能充分利用多核但同步和依赖管理复杂得多调试也困难。Unreal的渲染命令走的是RHI线程但场景剔除和可见性计算已经大量并行化了。我的经验是如果你的项目规模不大双线程模型足够用别一上来就搞全并行调试成本会让你怀疑人生。等真的遇到CPU瓶颈了再逐步把热点任务并行化这样风险可控。3. RHI抽象层的核心设计3.1 为什么需要RHI这层抽象RHI全称Render Hardware Interface直译就是渲染硬件接口。它的存在意义只有一个把上层渲染逻辑和底层图形API解耦。你想想如果引擎里到处直接调D3D12或者Vulkan的API那想加一个Metal后端、或者想从D3D11升级到D3D12得改多少代码RHI就是在这中间加一层上层只跟RHI打交道RHI再翻译成具体API调用。但RHI的设计有个永恒的难题抽象程度怎么定。抽象得太高比如只暴露画一个Mesh这种接口那上层想做个特殊效果就没法精细控制抽象得太低比如把D3D12的CommandList、PipelineState原样暴露那RHI就失去了跨平台的意义等于没抽象。成熟引擎的做法是核心接口保持中等抽象同时提供逃生舱允许上层在必要时直接访问底层API。3.2 资源抽象Buffer、Texture、PipelineStateRHI要抽象的资源类型主要有几类资源类型抽象要点常见坑Buffer顶点、索引、常量、结构化缓冲统一抽象不同API的Usage标志差异大要归一化Texture2D、3D、Cube、Array统一管理格式支持不一致要做能力查询PipelineState把一堆渲染状态打包成不可变对象D3D12的PSO编译慢要异步缓存Descriptor资源绑定方式的抽象Vulkan的DescriptorSet和D3D12的DescriptorHeap差异大这里重点说PipelineState。在D3D11时代渲染状态是散着设置的什么深度状态、混合状态、光栅化状态各设各的。到了D3D12和Vulkan这些状态被打包成一个不可变的PSO对象创建一次可以反复用。这个变化对引擎架构影响巨大PSO的创建和缓存成了渲染系统的核心问题之一。因为PSO编译很慢如果每帧都创建新的PSO帧率直接崩。所以引擎必须做PSO缓存把常用的状态组合预先创建好运行时只做查找。我踩过的一个坑是早期没做PSO缓存每次材质切换都新建PSO结果在低端机上帧率从60掉到15。后来加了缓存把PSO按Shader渲染状态组合做哈希查找问题才解决。这个经验告诉我RHI层的设计一定要考虑对象生命周期和缓存策略不能只想着接口好不好看。3.3 命令提交与同步机制命令提交这块不同API的模型差异很大。D3D11是立即模式调一个Draw就提交一个D3D12和Vulkan是命令列表模式先把命令录进CommandList再统一提交到队列执行。RHI要做的就是把这两种模型统一起来通常采用命令列表抽象上层录命令RHI负责提交。同步是另一个大坑。D3D12和Vulkan要求手动管理资源状态转换和屏障Barrier而D3D11是驱动自动处理。RHI层如果处理不好要么出现画面撕裂、闪烁要么性能大幅下降。常见的做法是引入资源状态跟踪RHI记录每个资源当前处于什么状态在提交时自动插入必要的屏障。但自动插入往往不够优化所以还要提供手动控制的接口让上层在关键路径上精细调优。提示屏障管理是RHI层最容易出bug的地方。建议在开发期开启验证层Validation Layer把所有状态转换都检查一遍别等到上线才发现画面随机闪烁。4. Shader系统的组织与管理4.1 Shader的编译、变体与缓存Shader系统是渲染架构里另一个复杂度爆炸的地方。一个Shader写出来往往要针对不同平台、不同画质、不同功能开关编译出几十上百个变体。这就是所谓的Shader变体爆炸问题。Unity的Shader变体问题被吐槽了这么多年根源就在这里。变体管理的核心思路是按需编译缓存。不是所有变体都要预编译而是根据实际使用情况动态编译编译好的缓存起来。但动态编译有卡顿风险所以又要在打包期做变体收集把可能用到的变体预先编译好。这个可能用到的判断就是难点收集多了包体大收集少了运行时卡顿。我的做法是先跑一遍完整的游戏流程用工具记录实际用到的变体组合生成一个变体列表打包时只编译这个列表里的变体。同时保留运行时动态编译的兜底逻辑防止漏网之鱼。这样能在包体和流畅度之间取得比较好的平衡。4.2 跨平台Shader编译方案跨平台Shader编译有几种主流方案手写多套每个平台写一套Shader性能最好但维护成本极高中间语言翻译写一套HLSL或GLSL用工具翻译成各平台代码比如SPIRV-Cross引擎自研语言像Unreal的HLSL变体、Unity的ShaderLab自己定义一套语法再翻译目前主流是第二种和第三种结合。写一套高层Shader编译成SPIR-V中间表示再翻译成各平台的原生代码。这样做的好处是逻辑统一坏处是翻译过程可能引入性能损失或者兼容性问题。特别是遇到平台特有的扩展功能时翻译层往往支持不好需要特殊处理。4.3 Shader参数绑定与常量缓冲Shader参数怎么传给GPU也是个有讲究的事。早期是逐个uniform设置效率低。现在普遍用常量缓冲Constant Buffer或者统一缓冲Uniform Buffer把一组参数打包成一块内存一次性传过去。常量缓冲的设计要点是对齐和布局。不同平台对常量缓冲的对齐要求不一样有的要求16字节对齐有的更宽松。如果布局没处理好会出现参数错位、数据读错的问题。我建议在RHI层统一做布局规范化把参数按16字节对齐打包这样跨平台最稳。另外常量缓冲的更新频率也要考虑。每帧都变的参数比如相机矩阵和很少变的参数比如材质常量应该分开放避免频繁更新整块缓冲。这个优化在移动平台上效果特别明显。5. 渲染系统的实操搭建与关键环节5.1 从零搭建一个最小渲染框架如果你要自己搭一个渲染框架我建议从最小可用版本开始别一上来就追求大而全。最小版本应该包含窗口和上下文创建能创建渲染表面拿到设备交换链管理能呈现画面处理窗口大小变化基础资源管理Buffer、Texture的创建和销毁一个简单的渲染Pass能画出一个三角形或者一个带纹理的方块命令提交和同步能正确提交命令并等待完成这个最小版本跑通之后再逐步加功能加深度测试、加相机、加场景管理、加材质系统、加光照。每加一个功能都要保证前面的部分不被破坏这样架构才能稳定演进。5.2 渲染Pass的组织与FrameGraph实践当Pass数量多起来之后手动管理依赖和资源会变得非常痛苦。这时候就该引入FrameGraph了。一个简化版FrameGraph的实现思路是每个Pass声明自己读哪些资源、写哪些资源系统根据读写关系构建依赖图拓扑排序确定执行顺序分析资源生命周期做内存别名复用自动插入必要的同步屏障我实际实现过一版最大的收获是资源别名复用能省下大量显存。比如阴影Pass用的深度图和后处理用的临时图如果生命周期不重叠可以共用同一块内存。在显存紧张的平台上这个优化能救命。5.3 性能分析与调优的实操方法渲染性能调优不能靠猜要靠工具。常用的分析手段有GPU抓帧工具看每个DrawCall的耗时、状态切换次数、带宽占用CPU端Profiler看渲染线程的耗时分布找出提交瓶颈带宽分析看显存读写量找出带宽热点调优的优先级一般是先减少DrawCall和状态切换再优化Shader复杂度最后才考虑微观层面的指令优化。很多人一上来就抠Shader指令结果发现瓶颈根本不在那白费功夫。注意不同平台的性能特征差异很大。移动平台对带宽和Overdraw极其敏感主机平台对DrawCall相对宽容但对GPU利用率要求高。调优一定要在目标平台上做别拿PC的结果去推断移动端。6. 常见问题与排查技巧实录6.1 画面异常类问题排查画面异常是最常见也最头疼的问题。我整理了一个速查表现象可能原因排查方向画面全黑相机矩阵错误、剔除过度、Shader编译失败检查相机参数、关闭剔除测试、看Shader日志画面闪烁同步问题、深度冲突、双缓冲问题检查屏障、检查深度精度、检查交换链颜色错误色彩空间问题、格式不匹配、混合状态错误检查sRGB设置、检查纹理格式、检查混合公式性能骤降PSO频繁创建、状态切换过多、带宽瓶颈抓帧看PSO创建次数、看状态切换、看带宽6.2 跨平台兼容性坑点跨平台开发最怕的就是在我机器上好好的到别人机器就崩。常见的兼容性坑有纹理格式支持不一致某些格式在部分平台不支持要做能力查询和降级Shader精度差异移动端GPU对浮点精度敏感half和float混用容易出问题常量缓冲对齐不同平台对齐要求不同布局要保守多线程行为差异某些API的线程模型和PC不同要特别小心我的经验是尽早做多平台测试别等到项目后期才移植。越早发现兼容性问题修复成本越低。6.3 内存与显存管理避坑显存管理在主机和移动平台上尤其重要。常见的坑包括资源泄漏创建了没释放跑久了显存爆掉碎片化频繁创建销毁不同大小的资源导致显存碎片峰值超标某个瞬间同时存在太多资源超过显存上限解决办法是引入资源池和引用计数配合FrameGraph的生命周期分析尽量复用资源。同时要监控显存峰值在加载场景时做预算控制。7. 渲染架构的扩展方向7.1 面向新硬件的架构演进硬件在变渲染架构也得跟着变。Mesh Shader、光线追踪、可变速率着色这些新特性都在改变渲染管线的组织方式。Mesh Shader把几何处理从固定管线解放出来让剔除和LOD更灵活光追改变了光照和阴影的计算方式可变速率着色让性能分配更精细。架构上要做的准备是保持RHI层的可扩展性新特性来了能快速接入保持管线组织的灵活性能根据硬件能力动态选择路径。别把架构写死留好扩展点。7.2 渲染与其它系统的协同渲染不是孤立的它和场景管理、动画、物理、音频都有交互。好的架构应该让这些交互清晰、低耦合。比如场景管理提供可见性信息渲染系统消费动画系统提供骨骼矩阵渲染系统绑定。接口要明确数据流要单向避免循环依赖。我在实际项目里见过渲染系统直接去查场景树的这种耦合在项目变大后会非常难维护。正确的做法是通过中间数据结构传递让渲染系统只依赖数据不依赖具体系统。7.3 工具链与调试支持渲染系统的可调试性非常重要。一个好的渲染架构应该配套Shader热重载改完Shader立刻看到效果不用重启渲染调试视图能单独查看G-Buffer、阴影图、光照结果性能计数器实时显示DrawCall、三角形数、带宽占用GPU抓帧集成一键抓帧方便分析这些工具的开发投入往往被低估但它们对团队效率的提升是巨大的。我个人的体会是在渲染工具上花的时间最终都会以数倍的效率回报回来。一个能热重载Shader的引擎迭代速度比不能热重载的快好几倍。最后分享一个小技巧在做渲染架构设计时先画数据流图把每个Pass的输入输出标清楚再动手写代码。很多架构问题在画图阶段就能发现比写完代码再改省事得多。渲染系统这东西想清楚了再写比边写边想靠谱得多。
返回列表