前阵子有兄弟拉我去救火,他们项目GPU占用看着也就一半,画面不算复杂,帧率却像过山车。查到最后,锅根本不在某个Pass,而是渲染器每帧都在创建临时缓冲,分配器没有复用,提交线程反复被唤醒。这种问题在游戏引擎里很典型——渲染系统架构的毛病,往往比单个特效更隐蔽,也更能决定一个项目是顺滑如丝还是卡成PPT。
这篇文章是《游戏引擎架构深度解析》系列的第二篇,聚焦渲染系统架构。我不打算讲某个图形API的某个接口怎么用,而是想以一位做过引擎底层、也搭过渲染器中层的人的身份,聊聊渲染系统通常由哪些部分组成、各部分之间的数据怎么流动、多线程和同步怎么设计、资源生命周期怎么管,以及遇到性能问题从哪个方向排查。适合做引擎或客户端开发的同学,也适合想深入理解渲染管线的游戏开发者。
1. 渲染器不管的那些事:从相机到像素的职责边界
1.1 渲染系统的输入、输出与内部模块
很多人把渲染系统理解成“从场景到屏幕”,但“场景”这个词太模糊了。游戏引擎里,跟最终画面强相关的模块一大堆:动画系统更新骨骼矩阵、物理系统同步刚体变换、粒子系统模拟成千上万的粒子、UI系统排版控件、光照系统维护阴影贴图……这些数据最终都要进入渲染器,但如果把它们全部塞进渲染器里,项目很快会失控。
在我看来,渲染系统的边界必须画清楚。它的输入是场景描述,包括网格引用、材质引用、实例变换矩阵、灯光列表、相机参数,再加上引擎其他模块产出的动态数据,比如骨骼矩阵、粒子位置、UI网格。它的输出是最终屏幕图像,可能还包括G-Buffer、深度缓冲、间接光照探针等供后续帧使用的中间结果。除此之外,不该它管的,它就应该马克·吐温式地拒绝。
一个典型的渲染系统内部会分出五个层次。第一层是API抽象层,通常叫RHI,把D3D12、Vulkan、Metal的差异藏起来,让上层用统一接口提交命令。第二层是场景数据层,负责把引擎侧传来的对象翻译成可提交的绘制参数,包括裁剪、排序、实例化数据整理。第三层是Pass调度层,决定这一帧先画什么后画什么,阴影、几何、光照、后处理各占哪一段。第四层是资源管理层,管几何、纹理、Shader、管线状态对象(PSO)、描述符堆。第五层是同步与提交层,负责Command Buffer的录制、Fence的等待与信号,以及最终的Present。这五层各司其职,RHI不关心业务,资源管理不关心画哪些物体,Pass调度只关心依赖关系,边界才会清爽。
1.2 边界模糊的代价:两个真实的失控现场
边界不清不是理论问题,我实际见过两个典型翻车案例。
案例A是模型加载被塞进了渲染线程。项目想要快速加载大模型,就直接在渲染线程里调用了IO解压接口。结果磁盘IO一慢,渲染线程整个阻塞,画面瞬间冻结,而且表现还不稳定,有时候卡一帧,有时候卡半秒。复盘时才发现,正确做法是让独立加载线程负责IO和解压,再把线程安全的网格数据引用通过队列交给渲染线程。渲染线程永远不应该等待这类业务性IO。
案例B是把UI合批逻辑写死在渲染器里。当时为了省一次全屏Pass,让场景渲染器顺带处理UI网格合并,结果UI样式一改,渲染器也要跟着改,还要小心不能影响场景Pass,测试成本直线上升。UI本质上是2D业务,应该有自己的渲染模块,只在最后的合成阶段和场景画面叠加。
这两个案例背后的教训是一条原则:渲染器应该只做数据处理与GPU提交,不做业务决策。它不应该等待IO、不应该发起网络请求、不应该做物理计算、不应该决定游戏逻辑能不能进某个Pass。它只负责把数据转换成GPU能消费的形式。一旦业务逻辑混进来,帧率问题的可复现性就崩了,性能优化也会变成玄学。
2. 场景数据怎么走进渲染器:剔除、Draw Call与GPU Driven
2.1 传统CPU驱动:每帧重建渲染列表
老一辈引擎的做法是一帧开始后,CPU遍历整个场景图,做视锥剔除、遮挡剔除、距离LOD,然后把可见物体的引用按某种顺序排好,生成渲染列表。这个列表一般会按这样几个维度整理。
按Pass分组是最基本的要求。阴影Pass、Base Pass、Lighting Pass、后处理Pass各拿各的列表,避免一个Pass里混入不该画的东西。按材质或PSO分组是为了减少状态切换。GPU的Pipeline State切换是昂贵操作,切换Shader、绑定纹理、改混合模式,一次可能几百纳秒到几微秒,乘上几万次Draw Call就是几毫秒级别的浪费。按深度排序则是为了配合渲染顺序,不透明物体通常前到后,透明物体后到前,保证深度测试和混合行为正确。
这套方案在小场景里完全够用,但场景物体从几百变成几十万之后,CPU侧的剔除和排序本身就变成了瓶颈。而且CPU和GPU是流水线合作关系,CPU慢下来,GPU就会在大部分时间里饿肚子,最终呈现出的帧率远低于GPU自身能力。开放世界项目之所以对引擎架构要求高,核心原因就在这里。
2.2 GPU Driven:把剔除决策也搬到GPU
于是行业里发展出了GPU Driven Rendering。思路是把场景数据打包成GPU能直接读的结构化缓冲,比如全部实例的变换矩阵、包围盒、材质ID,然后在GPU上执行视锥剔除和HZB遮挡剔除,再用Indirect Draw生成实际绘制参数。
这里有两个技术点需要展开。第一是Indirect Draw。传统的DrawCall是CPU告诉GPU“画这个网格,用这个材质”,而Indirect Draw的参数由GPU自己写入,CPU只需要提交一条“可能包含N万个实例”的间接绘制命令。具体画多少、画哪些,GPU会在执行时根据剔除结果决定。这一下把CPU每帧遍历、裁剪、排序的负担转移到了GPU的并行计算单元上,场景规模对CPU帧开销的影响被压到了极低。
第二是Mesh Shader。在新一代硬件上,Geometry处理从传统的VS/GS模式演进为Mesh Shader工作模式,顶点不再以单个线程方式进入管线,而是由工作组协作生成和剔除三角形。意义不只是省掉一些中间阶段,而是把几何处理、剔除、LOD选择全都并到同一个流水线阶段里,GPU的并行度利用得更为充分。
但别被趋势带偏。GPU Driven带来的CPU节省是实打实的,但移动端要考虑带宽和驱动成熟度。GPU端剔除本身要消耗额外算力和带宽,如果场景不够大,节省的CPU时间可能还抵不上GPU多付出的开销。想做这套,我建议先做一个技术验证POC,在桌面端跑通,再移植到目标移动设备上看实测曲线。
2.3 怎么选:全量GPU Driven还是混合路线
选型没有标准答案,但有一条经验可以分享。开放世界、海量对象、PC和主机平台,GPU Driven基本是必选项,否则CPU剔除会成为无法逾越的瓶颈。中小场景、UI和角色密集的场景,传统CPU清单反而更直观,调试也方便,毕竟你可以直接打断点看渲染列表内容。常见折中方案是:UI、角色、特效走传统CPU列表,地形、植被、城市资产走GPU Driven。这样既能压住规模最大的静态资产,又保留了角色和特效这个最容易出美术问题的区域的灵活性。
| 维度 | CPU驱动渲染 | GPU Driven渲染 |
|---|---|---|
| CPU开销 | 随对象数线性增长,对象一多立刻吃紧 | 基本固定,只提交少量间接命令 |
| GPU开销 | 常规绘制+CPU预处理,GPU负担较轻 | 额外执行GPU侧剔除,早期或小场景可能反而不划算 |
| 调试难度 | 低,渲染列表可在CPU侧检查 | 高,依赖GPU端逻辑,需要帧调试器支持 |
| 硬件要求 | 各平台通用 | 需要支持间接绘制和结构化缓冲,移动端需逐机型验证 |
| 适用场景 | 中小场景、UI、角色、原型 | 大规模开放世界、海量实例、植被与城市 |
很多团队纠结要不要全量改造,我的建议是先画出对象量级预测表,估算出未来最拥挤场景的单帧可见对象数。如果这个数字在几万以下,CPU清单完全够用;如果到了十万甚至百万级别,GPU Driven基本绕不开。架构选型永远是为最坏情况做准备的。
3. 一帧的流水线:Command Buffer、Barrier与资源生命周期
3.1 RHI层:把不同平台塞进同一套接口
现代引擎基本都有自己的RHI层。它的目标不只是接口一致,更是提交路径低开销。D3D12、Vulkan强调应用自己掌控同步,Metal也有类似机制,RHI层最关键的设计决策是避免每次提交都产生堆内存分配、每次换状态都做字符串查找。
我见过有引擎在RHI层做了很多OO封装,类层次套了好几层,结果DrawCall一多,CPU时间全浪费在C++虚函数调用和临时对象构造上。最佳实践是批量API:一次提交一组命令,命令里只带ID或Handle,不传对象。这类API设计看起来不酷,但它在高DrawCall场景下的稳定性是那些花哨设计比不了的。
资源绑定也同样。新API里切换描述符堆、更新绑定表开销很大,Bindless技术把资源放进一个大描述符堆,Shader通过索引直接访问,避免每帧重建绑定。引擎架构往数据驱动方向发展,很大程度上也是因为GPU资源访问模型变成了“一块大数组加索引”。
3.2 Barrier:平台同步税为什么难搞
Vulkan和DX12把图像布局转换、内存可见性、RenderPass依赖全部暴露给开发者。早年写D3D11或者Metal的人接手D3D12,第一反应通常是:这东西怎么这么多Barrier?一个简单的渲染Pass,从外部纹理读到写入、从渲染目标切换到采样输入,中间可能要插好几道同步。
手动管理Barrier最容易出现两类错误。一类是过度同步,为了图省事,随便插全屏障,所有Pass都变成串行,GPU并行度被卡死,性能腰斩。另一类是漏同步,出现诡异的白屏、黑屏、花屏,而且只在特定显卡上复现,排查极其痛苦。我自己的原则是:同步越少越好,但它必须出现在依赖真正存在的地方,而不是你想让它在的地方。这句话说起来简单,做起来需要对自己渲染流程的每一笔读写关系了如指掌,这也正是FrameGraph能解决痛点的原因。
3.3 FrameGraph:把一帧变成一张DAG
很多团队到这一步会引入FrameGraph,也就是帧图。它把一帧表示成若干Pass组成的有向无环图,每个Pass声明它读哪些资源、写哪些资源、需要创建哪些新资源。调度器拿到这些声明后,会自动完成三件事。
一是自动推导Pass之间的依赖,在正确的位置插入Barrier。二是在多个Pass之间复用瞬态资源,不需要每个Pass都分配独立RenderTarget,内存占用能降不少。三是识别没有依赖关系的Pass,把可并行的部分交给异步计算队列或不同优先级队列执行。
这相当于从根上消灭了一大批资源生命周期Bug。Unreal的RenderGraph、Frostbite的FrameGraph,以及不少自研引擎,都采用了类似思路。如果你的渲染器经常调整流程、增加Pass,改成FrameGraph是值得投入的。
但FrameGraph也不是零成本。它的抽象会让每帧调用链变深,调试时跳来跳去很费神。异步队列的时序很微妙,自动调度有时候反而不如手写来得精准。所以小项目、原型项目完全没必要上,方案选择要匹配项目复杂度,不要为了架构而架构。
3.4 Command Buffer录制策略
最后说录制。很多初版渲染器会在每帧重新创建并录制整个CommandBuffer,简单,但CPU开销很大,尤其在移动端。改进方向有三个。
分区录制:把渲染列表分成若干块,多个工作线程并行录制,每个线程维护自己的CommandBuffer,最后按序提交到主队列。缓存指令:对不变的部分,比如全屏三角形、固定UI背板,可以缓存录制结果,每帧复用。每帧复用CommandBuffer池:避免把内存分配变成性能热点。这里其实就牵出一个通用原则:渲染主路径上,所有内存分配都应该是可控的、可预期的,绝不能让系统堆分配器拖后腿。
4. 多线程渲染与帧同步:为什么帧率够高还是觉得卡
4.1 游戏线程与渲染线程:管线化与快照
今天几乎没有引擎还在单线程渲染。常见架构是游戏线程负责逻辑、动画、物理,渲染线程负责场景收集和命令录制,提交线程负责RHI提交。渲染线程通常会比游戏线程落后一到两帧,好处是逻辑和渲染互相不阻塞,坏处是输入延迟会变高。
两条线程之间的数据交换不要用锁,锁竞争会直接让帧时间变得不稳定。经典做法是帧级快照:游戏线程在帧开始拷贝一份场景状态,包括矩阵、参数、引用计数,渲染线程消费这份内存。这要求底层缓存系统支持双缓冲或环形缓冲,否则谁在写谁在读都分不清。我见过很多新手把共享指针直接扔到渲染线程,一帧跑完才释放,结果下一帧游戏线程已经开始改写了,偶尔出现半个场景用旧数据、半个场景用新数据的割裂画面,就是这样造成的。
4.2 提交、Present与“帧率高但卡”的秘密
帧率够高但感觉卡,是渲染架构设计最典型的隐性Bug。FPS只是平均值,帧时间波动才是玩家感知的关键。渲染线程一旦在等资源流送、等Fence、等垂直同步,都会让帧时间出现尖刺。平均帧率60,但每隔一两秒跳一个80毫秒的尖刺,玩家感受到的就不是流畅,而是明显卡顿。
Present与VSync的关系也值得琢磨。开着VSync时,GPU必须等显示器回扫信号,渲染线程提交再快也没用;如果用三缓冲,帧延迟和画面撕裂特性又会变化。我经常看到有人在测帧率时没关VSync、没固定测试场景,最后得出一堆毫无意义的曲线。真正做性能评估时,Present策略必须固定,否则你分辨不出瓶颈在渲染本身还是在交换链。
移动端还有更隐蔽的情况。某些设备上垂直同步的回调机制会让渲染线程处于一种“睡了又醒”的状态,帧时间忽高忽低,但平均帧率反而是满的。我们之前遇到一个iOS项目,画面看起来一跳一跳,帧率满,用性能分析器一看,渲染线程有大量时间卡在等待Present返回。后来调整了Present调用时机,把提交提前,并适当增加缓冲帧数,帧时间线立刻变得平滑。这类问题几乎不可能靠单个Pass优化解决,必须站在整个帧的时序层面去观察。
4.3 一帧的节奏要怎样设计
你可以把一帧拆成五个阶段来观察:游戏线程更新、渲染准备、命令录制、提交到队列、Present。理想状态是CPU在第N帧工作时,GPU正在执行第N-1帧,两边都不互相等待。
实际开发中要反复确认两件事。第一件是CPU会不会等GPU,典型表现是渲染线程在某个Fence上等待,等待期间GPU其实很闲。第二件是GPU会不会等CPU,典型表现是GPU使用率不高,但帧时间长,CPU侧在收集或录制阶段花了太多时间。先用帧调试器确认哪边慢,再去改架构,这才是正确的顺序。我见过太多人一上来就动Pass结构,最后发现瓶颈压根不在这里。
5. 资源与Shader管理:渲染器里最容易被忽略的暗坑
5.1 上传队列与资源生命周期
渲染器里很大一类Bug不是Pass写错,而是资源生命周期写错。GPU还在用某个缓冲,CPU已经把它释放了,或者反过来。结果就是花屏、卡死,或者最可怕的只在发布版偶现。
资源生命周期管理的基本模型是:资源创建时通过上传队列把数据从CPU内存拷贝到VRAM;使用期间不能被释放;释放时机由Fence决定,必须等GPU执行完最后一个引用它的帧之后再回收。简便可靠的做法是延迟回收队列:每个帧结束前,把上一帧标记为可释放的资源真正释放掉,这样至少保证资源存活到GPU执行完当前帧。如果一帧内一个资源被前一帧引用,就必须使用Fence做精确同步,不能只靠延迟一帧的土办法。
上传队列我建议做成环形缓冲。每帧映射一段内存、写入数据、提交上传命令,帧与帧之间用Fence避免读写冲突。最关键的是,永远不要在渲染主路径上调用无法预估耗时的内存分配函数。那些看起来人畜无害的new和malloc,在高频调用下会成为帧时间的隐形杀手。
5.2 Shader、PSO与变体爆炸
Shader管理是渲染架构另一大坑。游戏里上千个Shader文件很常见,每个文件又可能因宏定义组合出几十上百个变体,编译时间一眼望不到头。变体爆炸的危害很直接:编译时间暴涨,美术改个参数要等半天;运行时PSO编译导致卡顿,尤其在主机和移动端;内存占用暴涨,每个变体对应一份管线状态对象。
对策上我有一套比较实用的做法。一是对Shader变体做显式状态管理,只允许在配置面板里勾选真正需要的变体组合,禁止宏开关自由繁殖。二是使用PSO缓存,离线缓存可以烘焙进游戏包,运行时缓存则要保证命中率足够高。三是尽量用Uniform参数替代宏开关,材质参数走常量缓冲,而不是为每个参数组合生成新变体。
我刚做引擎时对Shader热重载不以为意,后来发现没有热重载,调一帧光照效果要重启整个App,效率低到令人绝望。后来用单独加载模块的方式实现了一套轻量热重载,即使不能全平台通用,开发期也值回票价。渲染架构的健壮性,很多时候不体现在线上帧率,而体现在团队迭代效率上。
5.3 流送与虚拟纹理
开放世界项目还得处理流送。贴图不能一口气全部加载进VRAM,得按mipmap级别渐进式加载。更成熟的做法是虚拟纹理,整个地图像一块巨大的虚拟纹理,渲染时只需要加载当前屏幕需要的物理页,内存压力大幅降低,贴图精度也不会被限制。
选择建议是:如果只是贴图量大的场景,mipmap streaming加异步加载就够用了。如果项目要做整个世界无缝加载,再考虑虚拟纹理。这个架构改动很大,要考虑内容管线的配合,贴图必须拆成页表友好的Tile格式,否则美术侧工作会有大量返工。
6. 渲染架构调试:实测一帧到底慢在哪
6.1 先用计数器定位,再用帧调试器看细节
渲染架构调优和普通逻辑调优不一样,不能只盯函数耗时。我推荐两层分析。
第一层看RHI计数器。DrawCall数、三角形数、状态切换次数、纹理切换、Overdraw、带宽占用,这些数据能告诉你这一帧在哪个维度超出了预算法。比如Overdraw过高,说明透明合批或光照范围可能有问题;状态切换次数高,说明渲染列表排序不行。第二层用帧调试器。单步回放当前帧,检查每个Pass的输入输出资源、Barrier位置、绘制调用内容,验证是不是有某一步在重复劳动。
很多人一上来就抓GPU时间戳,其实没必要。先把CPU侧的收集、录制、提交各阶段计时打开,再看GPU timeline,你会发现很多所谓的GPU瓶颈其实是CPU提交不行。CPU提交的节奏决定了GPU能不能吃饱,这个顺序千万不要搞反。
6.2 两个真实案例:临时缓冲和Present等待
案例1是每帧创建临时Vertex Buffer。某项目上线后移动端帧率直接腰斩,查了半天,原因是渲染器每次给动态网格上传数据都新建Buffer,分配器没有复用。修复方案很简单,改用一个帧级环形分配器,临时数据在帧内分配、帧末整体复位。这个改动看起来不起眼,但把提交线程的分配压力彻底清掉了,帧率曲线恢复平直。
案例2是Present等待导致的帧时间波动。某个引擎在移动设备上画面看起来一跳一跳,帧率却是满的。用性能分析器看帧时间,发现三成帧里渲染线程在等Present完成,要么是交换链缓冲数不够,要么是提交太晚和垂直同步撞在一起。调整Present时机和缓冲策略后,帧时间线立刻平滑下来。
这两个问题都不会在单个Pass里暴露,必须站在渲染架构层面看时序。这也是我一直强调可观测性的原因。
6.3 可观测性:渲染架构的隐形需求
任何渲染架构,如果你不能低成本地看到每一帧里发生了什么,它迟早会变成一团乱麻。命名每一类计数器、为每个Pass打上标签、自动保存一帧完整的Trace、支持把Trace回放到调试器里,这些工具不直接产生画面,但它们决定了你改架构时的安全边界。没有足够的观测手段,你怎么知道这次改动是真的更好了,还是只是把问题从左边挪到了右边?
我自己做引擎这些年,最深的体会是:渲染系统架构不是一组Pass的堆叠,而是一套数据流与时序的编排。边界分明、数据可控、同步交给工具推导、资源生命周期清晰,画面和性能只是这一切的自然结果。如果你正在搭渲染器,先别急着堆特效,把边界画好,把帧图建起来,把分配器做好,后面会省下无数个通宵查Bug的夜晚。