ARTICLE DETAIL

资讯详情

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

游戏引擎渲染系统架构分层设计:从数据组织到RHI与Shader管理

游戏引擎渲染系统架构分层设计:从数据组织到RHI与Shader管理 1. 渲染系统在引擎里到底扮演什么角色很多人第一次翻《游戏引擎架构》这类资料时会把渲染系统等同于画图的模块。这个理解不算错但太浅了。渲染系统真正的位置是引擎里唯一一个直接对帧率、画质、功耗、内存带宽同时负责的子系统。物理算错一帧玩家可能看不出来渲染慢两毫秒手感立刻变差。所以架构设计上渲染系统往往是最早被定死、也最难被推翻重来的部分。我在实际项目里见过太多先随便写个渲染器后面再重构的团队最后基本都卡在同一个地方上层逻辑和底层图形接口耦合太深想换一个后端或者加一套新的光照模型牵一发动全身。这就是为什么渲染系统的架构讨论核心从来不是怎么写一个 Shader而是怎么把变化隔离在可控的边界内。渲染系统要解决的核心矛盾其实就三组第一平台差异——不同硬件、不同图形接口的能力和限制完全不同第二画质与性能的取舍——同一个场景你可以用前向渲染跑 200 帧也可以用延迟渲染跑 60 帧但支持几十盏动态光第三内容与代码的解耦——美术希望改材质不用等程序重新编译程序希望换管线不用让美术重做资源。这三组矛盾决定了渲染系统必须分层。从架构视角看一个成熟的渲染系统通常被切成四层最上面是场景与渲染数据组织层负责把游戏世界翻译成要画什么中间是渲染管线层决定按什么顺序、用什么方式画下面是RHIRender Hardware Interface渲染硬件接口层屏蔽用什么 API 画最底下是驱动与硬件。这篇文章就沿着这四层往下拆重点讲清楚每一层的职责边界、常见设计取舍以及那些文档里不会写、但踩过才知道的坑。如果你正在做引擎、做自研渲染框架或者只是想把 Unity/Unreal 的渲染流程真正搞明白这套分层思路都能直接套用。下面我按数据怎么组织 → 管线怎么选 → RHI 怎么抽象 → Shader 怎么管理的顺序展开中间会穿插大量实际项目里的判断依据。2. 渲染数据组织层从游戏世界到绘制列表2.1 场景图不是必须的但可见性管理是新手容易把场景图Scene Graph当成渲染系统的标配其实不是。场景图解决的是变换继承和空间查询它属于场景管理层渲染系统真正需要的是从场景里提取出来的可见性结果和绘制数据。很多高性能引擎干脆不用树状场景图而是用扁平的实体数组加空间加速结构BVH、八叉树、网格划分因为树遍历的缓存局部性在现代 CPU 上并不好。真正必须的是可见性管理。一个开放世界场景可能有几十万个可渲染对象每帧不可能全部提交。剔除Culling分三步走视锥剔除、遮挡剔除、距离/重要性剔除。视锥剔除最简单用包围盒和六个平面做测试成本极低遮挡剔除最贵通常用上一帧的深度缓冲做硬件遮挡查询Hardware Occlusion Query或者用软件光栅化的 Hi-Z 方案。这里有个经验遮挡查询的结果有一到两帧延迟所以不能等结果再决定画不画而是先假设可见、查询结果下帧生效否则会出现物体闪烁。2.2 绘制列表的组织方式直接决定 CPU 开销剔除完之后剩下的对象要组织成绘制列表Draw List。这一步的架构选择影响巨大。最朴素的做法是每个对象一次 Draw Call但 Draw Call 的 CPU 开销在几千次以上就会成为瓶颈。于是有了几种主流组织方式组织方式适用场景优点代价逐对象提交对象少、材质差异大实现简单、灵活Draw Call 多CPU 压力大按材质排序合批大量相同材质的小物件显著减少状态切换需要排序动态对象合批难实例化Instancing同网格同材质的大量对象一次提交画很多个需要硬件支持数据布局受限间接绘制Indirect DrawGPU 驱动剔除的场景CPU 几乎不参与需要 GPU 侧缓冲管理调试难我个人的判断标准是先看瓶颈在 CPU 还是 GPU。如果 CPU 的提交线程已经跑满而 GPU 还有余量优先上合批和实例化如果 GPU 是瓶颈那减少 Draw Call 意义不大应该去优化 Overdraw 和 Shader 复杂度。这个判断顺序很多人搞反花大力气合批结果帧率没变。2.3 材质与网格的资源句柄设计渲染数据层还有一个容易被忽视的设计点资源和句柄的分离。渲染线程不应该直接持有材质对象指针而应该持有句柄Handle/ID通过句柄去查表。原因很简单资源加载和卸载是异步的渲染线程如果直接解引用指针很容易在资源被卸载后访问到野指针。句柄加引用计数或者延迟释放队列是更稳的做法。具体实现上常见的是把材质、网格、纹理都放进各自的资源池句柄就是一个索引加版本号。版本号的作用是防止句柄复用导致的错误——资源 A 释放后索引被资源 B 复用旧句柄就会错误地指向 B。加上版本号旧句柄查表时版本对不上直接判定失效。这个细节在小型项目里经常被省略等到线上出现偶发的画面错乱才追悔莫及。3. 渲染管线层前向、延迟与混合方案的真实取舍3.1 前向渲染为什么至今没被淘汰前向渲染Forward Rendering是最直观的管线对每个物体遍历所有影响它的光源算完光照直接输出。它的优点是带宽友好、支持 MSAA、透明物体处理自然。缺点是光源一多每个物体都要重复计算所有光源复杂度是物体数乘以光源数。很多人以为延迟渲染全面碾压前向其实不然。移动端至今大量使用前向渲染原因就是延迟渲染的 G-Buffer 写入读出会吃掉大量带宽而移动设备的带宽极其宝贵。另外前向渲染对 MSAA 的支持是天然的延迟渲染要做 MSAA 就得把 G-Buffer 也做多采样成本翻倍。所以如果你的场景光源不多、但对边缘抗锯齿要求高前向反而是更优解。3.2 延迟渲染的 G-Buffer 布局是门学问延迟渲染Deferred Rendering的核心是把几何信息先写进 G-Buffer光照阶段再统一计算。G-Buffer 的布局直接决定了内存占用和带宽。常见的布局有多渲染目标MRT分离位置、法线、反照率、粗糙度各占一张纹理。可读性好但纹理数量多带宽高。打包压缩把法线用八面体编码压到两个通道粗糙度和金属度打包进一个通道。省带宽但解码有额外 ALU 开销。深度重建位置不存位置只存深度光照阶段用深度和相机矩阵反推世界坐标。省一张纹理但每次采样都要做矩阵运算。我在项目里的经验是移动端优先用深度重建加打包法线桌面端可以适当放宽。因为移动端带宽是硬约束多算点 ALU 无所谓桌面端 ALU 和带宽都相对充裕可读性更重要方便调试。延迟渲染还有个绕不开的问题透明物体。G-Buffer 只能存一个深度层透明物体没法直接写进去。标准做法是延迟渲染画完不透明物体后再切回前向渲染画透明物体。这意味着引擎必须同时维护两套管线架构上要预留这个切换能力。3.3 混合管线与可见性缓冲的兴起近几年一个明显趋势是混合管线不透明部分用延迟或可见性缓冲Visibility Buffer透明部分用前向。可见性缓冲更进一步G-Buffer 里只存三角形 ID 和深度光照阶段再根据 ID 去取顶点属性。这样 G-Buffer 极小带宽大幅下降代价是光照阶段要做一次几何属性获取对缓存不友好。选哪种管线我的建议是列一张表把项目的硬约束写清楚再决定约束条件倾向方案光源数量少8前向光源数量多且动态延迟移动端、带宽紧张前向或可见性缓冲需要大量 MSAA前向需要复杂材质分层延迟更灵活团队规模小、调试时间少前向先跑起来这张表不是绝对的但它能帮你在架构评审时快速对齐认知避免我觉得延迟更高级这种没有依据的争论。4. RHI 层把图形 API 关进笼子里4.1 RHI 存在的唯一理由是隔离变化RHIRender Hardware Interface的本质是一个抽象层把不同图形 API 的差异封装起来让上层管线代码只依赖一套统一接口。它的价值不在于支持多平台这个口号而在于当某个平台出问题时你能把问题定位在 RHI 实现里而不是散落在整个渲染代码中。设计 RHI 时最大的诱惑是做得太厚。有些引擎的 RHI 抽象得过于彻底连资源创建、状态转换、命令录制都重新定义了一套模型结果就是上层代码写起来别扭底层优化又做不进去。我的观点是RHI 应该薄只封装真正有差异的部分比如资源绑定方式、管线状态对象、同步原语。至于渲染逻辑本身不该进 RHI。4.2 命令缓冲与多线程录制现代图形 API 都支持命令缓冲Command Buffer和多线程录制。这是 RHI 设计里最影响性能的部分。核心思路是把渲染工作拆成多个命令列表在多个线程并行录制最后按顺序提交。这样能充分利用多核 CPU把提交开销摊薄。但多线程录制有个大坑资源状态的同步。如果两个线程同时录制对同一张纹理的读写就会产生竞争。解决方案通常是让渲染图Render Graph在录制前做一次依赖分析自动插入屏障和同步点。自己手写同步几乎必然出错我强烈建议用渲染图来管理。4.3 资源生命周期与延迟销毁RHI 层还要管资源的生命周期。GPU 是异步执行的CPU 提交完命令后资源不能立刻销毁因为 GPU 可能还在用。标准做法是延迟销毁队列资源标记为待销毁后等 GPU 执行到某个围栏Fence之后再真正释放。围栏的粒度可以是每帧一个也可以是每个命令列表一个。粒度越细内存回收越及时但管理成本越高。这里有个实测经验围栏不要设得太频繁。我曾经为了及时回收给每个 Pass 都加围栏结果同步开销反而拖慢了整体帧率。后来改成每帧一个围栏内存峰值虽然高一点但帧率稳定多了。这个取舍要看项目的内存预算没有标准答案。5. Shader 管理从写死到可组合5.1 Shader 变体爆炸是必然的关键是怎么管任何做过实际项目的人都知道Shader 变体Variant数量会失控。一个基础材质加上不同的光照模式、阴影开关、雾效开关、骨骼动画开关组合起来轻松上百个变体。如果每个变体都单独编译编译时间和包体都会爆炸。管理变体的核心手段是宏定义加按需编译。把可变部分抽成宏运行时根据材质和场景需求组合出需要的变体只编译用到的。更进一步可以用超级着色器Uber Shader把所有分支写在一个 Shader 里用动态分支或宏切换。超级着色器的好处是变体少坏处是寄存器压力大可能影响占用率。我的实际做法是折中高频组合用宏预编译低频组合用动态分支。比如阴影开关这种影响大的用宏雾效这种影响小的用分支。判断标准是看这个分支在目标硬件上的开销以及它出现的频率。5.2 Shader 编译的异步化与缓存Shader 编译慢是行业顽疾。架构上能做的是异步编译加持久化缓存。首次遇到新变体时先用一个占位 Shader 顶上后台线程编译真正的变体编译完再替换。同时把编译结果缓存到磁盘下次启动直接加载。缓存的关键是版本校验。Shader 源码、编译选项、驱动版本任何一个变了缓存都要失效。我见过因为没做版本校验玩家更新驱动后画面错乱的案例。缓存键至少要包含源码哈希、编译宏集合、目标平台和驱动版本。5.3 材质系统与 Shader 的绑定关系材质系统是 Shader 管理的上层。一个好的材质系统应该让美术通过参数调整外观而不是改代码。实现上材质就是一组参数加一个 Shader 引用。参数的类型和默认值由 Shader 的反射信息决定这样美术改 Shader 参数时不需要程序介入。这里有个设计细节参数应该分组和命名。把基础色金属度粗糙度这些常用参数放在显眼位置把高级调试类参数折叠起来。这不是技术问题但直接影响美术的使用效率。我在项目里会强制要求 Shader 参数按功能分组否则材质面板会变成一锅粥。6. 那些文档不会写的踩坑记录6.1 精度问题half 和 float 的边界移动端 GPU 对 half16 位浮点的支持很好用 half 能省带宽和寄存器。但 half 的精度只有大约三位十进制有效数字用在世界坐标、大范围 UV 上会出问题。我踩过的坑是用 half 存世界坐标远处物体出现明显抖动。后来改成位置用 float、颜色和法线用 half问题消失。判断标准很简单这个值的变化范围是否超过 half 能精确表示的范围。颜色 0 到 1、法线 -1 到 1half 完全够用世界坐标动辄几百上千必须 float。6.2 渲染线程与逻辑线程的数据同步渲染线程和逻辑线程分离是性能优化的常见手段但同步做不好会出各种诡异问题。最常见的是逻辑线程改了变换矩阵渲染线程读到一半。解决方案是双缓冲逻辑线程写一份渲染线程读另一份每帧交换。交换点要放在明确的同步屏障处不能随便找个地方就换。还有个隐蔽的坑资源句柄的跨线程传递。逻辑线程创建的资源句柄传给渲染线程时如果资源还没加载完渲染线程会拿到无效句柄。正确做法是句柄带上状态标记渲染线程遇到未就绪的句柄就跳过或用占位资源。6.3 调试工具要早做不要等出问题才做渲染 bug 是最难调的 bug 之一因为涉及 GPU 异步执行断点基本没用。我的经验是项目一开始就要做渲染调试工具能可视化 G-Buffer 的每个通道、能查看每个 Pass 的耗时、能抓取单帧的完整状态。这些工具前期投入几天后期能省下几十天。具体来说至少要有一个帧调试器能暂停在某一帧查看当时的绘制列表、绑定的资源和管线状态。市面上的图形调试工具很强但引擎内部的调试视图比如把法线、粗糙度直接画到屏幕上更轻量日常开发中用的频率更高。7. 从架构视角看几个热词背后的真实问题最近社区里讨论比较多的几个话题其实都能对应到上面讲的架构层面。PS5 支持 Mesh Shader 吗这类问题本质是在问几何管线能不能从固定功能走向可编程。Mesh Shader 把传统的顶点、曲面细分、几何着色器合并成一个可编程阶段让剔除和 LOD 可以在 GPU 上更灵活地做。这对渲染架构的影响是可见性管理的重心从 CPU 往 GPU 转移绘制列表的组织方式也要跟着变。Unity 二次元 Shader和NPR 卡通渲染这类需求考验的是材质系统的表达能力。卡通渲染需要描边、色阶、光照分层这些都不是标准 PBR 能直接覆盖的。架构上要么提供可编程的着色模型接口要么允许自定义光照 Pass。如果材质系统只能调参数不能换模型这类需求就会做得很别扭。《The Book of Shader》习题这种学习路径说明很多人是从 Shader 入手理解渲染的。这没错但我要提醒一句Shader 是渲染系统的末端不是全部。理解了 Shader 怎么写不等于理解了渲染系统怎么组织。真正决定引擎渲染能力的是上面讲的数据组织、管线选择和 RHI 抽象。Shader 写得再花哨架构不对一样跑不动。8. 我在实际项目里的几条判断准则做了这么多年渲染我总结出几条自己一直在用的判断准则分享出来供参考。第一条先测量再优化。渲染优化最忌讳凭直觉。GPU 耗时、带宽占用、Draw Call 数量这些都要有数据支撑。我见过太多团队花几周优化了一个根本不是瓶颈的地方。第二条架构的复杂度要和团队规模匹配。小团队不要一上来就搞渲染图、多线程录制、可见性缓冲这些都需要专人维护。先用最简单的管线跑通等真的遇到瓶颈再逐步引入。过度设计比设计不足更常见。第三条把变化点隔离出来。渲染系统里变化最快的是 Shader 和材质变化最慢的是 RHI 和资源管理。架构设计要让变化快的部分容易改变化慢的部分足够稳。这个原则说起来简单做起来需要克制——不要因为某个新特性就把底层改得面目全非。第四条调试能力是架构的一部分。一个没有调试视图、没有帧分析、没有资源查看器的渲染系统不管设计得多优雅实际开发效率都会很低。把调试工具当成一等公民来设计这是我踩了无数坑之后最深的体会。渲染系统的架构没有银弹前向和延迟各有适用场景RHI 的厚度要拿捏Shader 变体要控制。真正重要的是理解每一层在解决什么问题然后根据项目的实际约束做取舍。希望这篇拆解能帮你在下次架构评审时把讨论从哪个更高级拉回到哪个更适合。
返回列表