ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

游戏引擎渲染系统架构:从分层设计到性能优化实战

游戏引擎渲染系统架构:从分层设计到性能优化实战 1. 从一次帧率骤降说起渲染系统到底在解决什么问题很多人第一次接触游戏引擎的渲染系统都是从“为什么我的场景一复杂就掉帧”这个问题开始的。我印象很深的一次经历是帮一个做独立项目的朋友排查一个场景静态画面跑满帧一旦镜头转向某个方向帧率立刻从稳定状态掉到个位数。他以为是模型面数太高把模型减面减到几乎看不出细节问题依旧。最后定位下来真正的原因是他把大量不同材质的小物件堆在了同一个可见区域渲染系统每帧都在做大量的状态切换和重复提交GPU 大部分时间在等 CPU 把绘制指令喂过来而不是在真正画东西。这件事很典型它说明一个道理渲染系统的核心矛盾从来不是“能不能画出画面”而是“如何在有限的每帧预算内把该画的画面高效、正确地画出来”。游戏引擎的渲染架构本质上就是围绕这个矛盾搭建起来的一整套组织方式。它要处理的事情包括场景里成百上千个物体怎么筛选出真正要画的、每个物体的材质和网格怎么绑定、绘制指令怎么排序才能减少状态切换、光照和阴影怎么算、后处理怎么叠加、以及最终怎么把一张图交给显示设备。如果你正在做引擎开发、图形程序或者只是想让自己的项目跑得更流畅理解渲染系统的架构分层是非常有必要的。因为绝大多数性能问题根源都不在“某个 API 调用慢”而在于架构层面的组织方式不合理。这篇文章我会从渲染系统的整体分层讲起一路拆到渲染管线、可见性剔除、批次合并、材质系统、光照与阴影、后处理最后落到实际项目里怎么排查渲染性能问题。内容会偏工程实践尽量把“为什么这么设计”讲清楚而不是只罗列概念。需要先说明一点不同引擎的具体实现差异很大商业引擎和自研引擎的取舍也完全不同。我下面讲的是一套在业界被反复验证过的通用架构思路具体到某个引擎时细节会有出入但核心逻辑是相通的。你在对照自己项目的时候重点看的是“分层思想”和“取舍逻辑”而不是照搬某个具体实现。2. 渲染系统的分层结构谁在指挥谁在执行2.1 从场景数据到屏幕像素的完整链路要理解渲染架构先得把整条链路捋一遍。一个物体从“存在于场景里”到“出现在屏幕上”中间要经过好几个阶段的处理每个阶段由不同的模块负责。我习惯把它分成四层来看第一层是场景层。这一层描述的是“世界里有什么”包括物体的变换、网格引用、材质引用、光照信息等。它不关心怎么画只关心数据本身。很多引擎会把这一层做成和渲染解耦的结构好处是逻辑层可以独立更新渲染层可以按自己的节奏读取。第二层是可见性与组织层。这一层负责回答“这一帧到底要画哪些东西”。它通过视锥剔除、遮挡剔除、距离剔除等手段把场景里大量物体筛成一个小得多的可见集合。同时它还要做排序和分组为后面的批次合并做准备。这一层是渲染性能的关键战场很多优化都发生在这里。第三层是渲染管线层。这一层是真正和图形 API 打交道的地方负责把可见集合转换成绘制调用管理渲染目标、渲染状态、着色器绑定等。它通常会被组织成一个个“渲染通道”Pass比如阴影 Pass、主几何 Pass、透明 Pass、后处理 Pass 等。第四层是后端抽象层。这一层封装具体的图形 API把上层统一的指令翻译成对应平台的调用。它的存在是为了让引擎能同时支持多种图形接口而不需要在上层写一堆条件分支。这四层之间的关系是单向依赖上层描述需求下层负责执行。理解这个分层你在排查问题时就能快速定位是场景数据本身有问题还是可见性剔除没筛干净还是管线里某个 Pass 开销过大还是后端抽象层有额外开销。2.2 为什么渲染要和逻辑解耦新手写渲染代码时最常见的做法是在逻辑更新里直接调用绘制。物体移动一下就顺手画一下。小项目里这样写没问题但一旦场景复杂起来就会出大问题。原因在于逻辑更新和渲染的节奏往往不一致。逻辑可能固定步长更新渲染却要跟着显示刷新率走。如果两者耦合在一起要么逻辑被渲染拖慢要么渲染被逻辑阻塞。更麻烦的是逻辑层的数据在渲染过程中可能被修改导致画面出现撕裂或状态不一致。所以成熟的引擎都会做逻辑与渲染的解耦。逻辑层更新完场景数据后渲染层在合适的时机读取一份相对稳定的数据快照。这份快照可以是双缓冲的也可以是某种同步机制保证的。解耦带来的好处是逻辑可以按自己的节奏跑渲染也可以按自己的节奏跑两者通过数据边界交互而不是通过函数调用直接耦合。我在实际项目里踩过的一个坑是早期为了图省事让渲染直接读逻辑层的对象指针结果逻辑层在渲染过程中销毁了一个对象渲染层访问到了已经释放的内存画面直接花屏。后来改成渲染层持有自己的数据副本问题才彻底解决。这个教训让我明白解耦不只是架构美观问题更是稳定性问题。2.3 渲染线程与主线程的分工现代引擎里渲染通常会独立成一个或多个线程。主线程负责逻辑更新和场景数据准备渲染线程负责实际的绘制指令提交。两者之间通过命令队列或者数据快照来通信。这样设计的原因是图形 API 的调用本身是有开销的尤其是状态切换和绘制提交。如果这些操作都在主线程做主线程就会被渲染拖住逻辑帧率上不去。把渲染放到独立线程后主线程可以继续准备下一帧的数据渲染线程则专心把当前帧画出来两者并行整体吞吐量就上去了。但多线程渲染也带来了新的复杂度。最典型的问题是数据竞争主线程在改场景数据渲染线程同时在读读到的可能是改了一半的数据。解决办法通常是双缓冲或者三缓冲主线程写一份渲染线程读另一份写完再交换。交换的时机要卡在帧边界上保证渲染线程读到的始终是一份完整的数据。还有一个问题是命令提交的时机。渲染线程不能随便往 GPU 提交命令因为 GPU 有自己的执行节奏。如果提交太快命令队列会堆积提交太慢GPU 又会空闲。所以引擎通常会用某种同步机制比如围栏或者信号量来协调 CPU 和 GPU 的节奏。这部分内容展开会很长这里先点到为止你只需要知道渲染线程不是简单地“多开一个线程画图”就行。3. 渲染管线的组织方式Pass 是怎么串起来的3.1 前向渲染与延迟渲染的取舍渲染管线最核心的一个架构决策就是选前向渲染还是延迟渲染。这两条路线决定了后面几乎所有 Pass 的组织方式。前向渲染的思路很直接对每个物体用它的材质着色器算一遍光照直接输出到渲染目标。它的优点是简单、透明物体处理自然、支持多种材质模型。缺点是光照计算和物体数量强相关如果场景里有很多光源和很多物体每个物体都要对每个光源算一遍开销会爆炸。而且被遮挡的像素也会被着色造成大量无效计算。延迟渲染换了个思路先把所有物体的几何信息位置、法线、材质参数写进一组 G-Buffer然后再用一个全屏 Pass 统一算光照。这样光照计算只和屏幕像素数相关和物体数量无关而且被遮挡的像素在光照阶段会被深度测试剔除不会浪费。缺点是 G-Buffer 占用大量显存带宽透明物体不好处理而且对材质模型的灵活性有约束。实际项目里怎么选我的经验是光源数量少、材质种类多、透明物体多的场景前向渲染更合适光源数量多、场景以不透明为主、对带宽不敏感的场景延迟渲染更合适。很多现代引擎会做混合方案比如不透明部分走延迟透明部分走前向取两者之长。这里有个容易被忽略的点延迟渲染的 G-Buffer 格式设计非常关键。每个通道存什么、用什么精度直接决定了带宽开销和后续光照计算的精度。我见过一个项目为了省显存把法线压到很低的精度结果光照出现明显的块状瑕疵。后来把法线通道精度提上去画面才正常。所以 G-Buffer 的设计要在带宽和精度之间找平衡不能一味省。3.2 渲染通道的排序逻辑确定了前向还是延迟之后接下来要决定各个 Pass 的执行顺序。这个顺序不是随便排的它要满足几个约束。首先是依赖关系。阴影 Pass 必须在主几何 Pass 之前因为主几何要用阴影贴图。后处理 Pass 必须在所有几何 Pass 之后因为它要读最终画面。依赖关系决定了 Pass 的先后不能颠倒。其次是状态切换成本。相邻的 Pass 如果渲染状态接近切换成本就低。所以引擎通常会把状态相似的 Pass 排在一起减少切换。比如所有写深度的 Pass 放一起所有写颜色的 Pass 放一起。再次是带宽和缓存的利用。有些 Pass 的输出可以留在片上缓存里下一个 Pass 直接读不用写回显存再读回来。这种“Pass 合并”能显著降低带宽开销。但合并的前提是两个 Pass 的渲染目标兼容而且中间不需要 CPU 介入。我在实际项目里做过一个优化把原本分开的“写 G-Buffer”和“算直接光照”两个 Pass 合并成一个中间结果留在片上带宽直接降了一大截帧率提升很明显。但这个优化有前提就是光照计算不需要 CPU 参与而且 G-Buffer 的格式要能塞进片上缓存。如果格式太大塞不下合并反而会更慢。所以 Pass 合并要看具体硬件和格式不能盲目做。3.3 渲染图把 Pass 依赖显式化当 Pass 数量多起来之后靠人工维护执行顺序很容易出错。于是有些引擎引入了渲染图的概念把每个 Pass 的输入输出声明成资源节点引擎自动分析依赖关系推导出执行顺序并做资源复用和屏障插入。渲染图的好处是你只需要声明“这个 Pass 读什么、写什么”不用关心它什么时候执行。引擎会自动帮你排好序还能发现资源可以复用减少显存分配。对于复杂的管线这能省下大量手工维护的成本。但渲染图也不是银弹。它的自动分析有开销对于简单管线可能得不偿失。而且自动推导的顺序未必是最优的有时候你需要手动干预。我的建议是管线简单时手工排管线复杂到手工排容易出错时再上渲染图。不要为了用而用。4. 可见性剔除把不该画的东西挡在门外4.1 视锥剔除的基本原理与边界情况视锥剔除是最基础也是最有效的剔除手段。它的逻辑很简单只有落在摄像机视锥体内的物体才需要画视锥外的直接跳过。实现上通常是把物体的包围盒和视锥的六个平面做相交测试。如果包围盒完全在某个平面外侧就剔除如果完全在内侧就保留如果相交也保留保守策略。这个测试很快因为只需要几次点积和比较。但视锥剔除有几个容易踩的坑。第一个是包围盒的精度。如果包围盒比实际物体大很多就会有很多物体明明不在视锥里却被保留下来剔除效果大打折扣。所以包围盒要尽量贴合物体必要时可以用多个包围盒或者更精确的包围体。第二个是动态物体的更新。静态物体的包围盒可以预计算动态物体的包围盒每帧都要重算。如果重算开销太大反而会拖慢帧率。这时候可以考虑用更简单的包围体或者降低更新频率。第三个是视锥的构建。视锥的六个平面要从摄像机的投影矩阵反推出来如果矩阵有特殊变换比如斜投影反推出来的平面可能不准确。这时候要特别小心宁可保守一点也不要错误剔除。4.2 遮挡剔除的工程实现难点视锥剔除只能剔除视锥外的物体视锥内但被挡住的物体它管不了。遮挡剔除就是来解决这个问题的如果一个物体被前面的东西完全挡住了那它就不需要画。遮挡剔除的实现方式有很多种。最简单的是硬件遮挡查询先画一遍遮挡物然后查询某个物体的可见像素数如果为零就剔除。但硬件查询有延迟查询结果要等几帧才能拿到这期间物体可能已经移动了导致剔除错误。所以硬件查询通常要配合保守策略宁可多画一点也不要错误剔除造成画面缺失。另一种是软件遮挡剔除用 CPU 做射线或者深度缓冲的软件光栅化判断物体是否可见。这种方式没有延迟但 CPU 开销大而且精度不如硬件。还有一种思路是基于层级的剔除把场景组织成层次结构先剔除大块再剔除小块。这样能快速排除大量物体减少后续测试的数量。我在项目里用过硬件遮挡查询最大的体会是查询的粒度很关键。如果每个物体都查一次查询次数太多开销反而上去了。通常的做法是把多个物体合并成一个查询组一次查询判断一组是否可见。组的划分要合理既不能太大导致剔除效果差也不能太小导致查询次数多。4.3 剔除的保守性与正确性的平衡剔除这件事本质上是在“多画一点”和“少画一点”之间找平衡。剔除得太激进可能把该画的剔掉了画面出现缺失剔除得太保守性能又上不去。我的经验是宁可保守不要激进。因为画面缺失是用户直接能看到的 bug而性能差一点用户未必察觉。尤其是在遮挡剔除这种容易出错的环节保守策略能避免很多诡异问题。但保守不等于不优化。你可以通过提高包围盒精度、优化层级结构、合理划分查询组等方式在保证正确性的前提下尽量提升剔除率。这需要针对具体场景调没有万能参数。5. 批次合并与状态管理减少 CPU 到 GPU 的无效沟通5.1 绘制调用的开销到底在哪很多人以为绘制调用的开销在 GPU 端其实大部分开销在 CPU 端。每次绘制调用CPU 都要做一系列准备工作验证渲染状态、绑定着色器、绑定纹理、绑定顶点缓冲、设置常量、最后才提交绘制命令。这些操作单个看起来不贵但数量一多累积起来就很可观。更麻烦的是这些操作很多是状态切换。如果相邻两次绘制用的状态不同就要切换切换本身有开销而且可能导致 GPU 管线刷新进一步拖慢。所以渲染优化的一个核心目标就是减少绘制调用数量减少状态切换次数。5.2 静态合批与动态合批的适用场景合批的思路是把多个小绘制合并成一个大绘制。这样绘制调用数量少了状态切换也少了。静态合批针对的是不动的物体。在预处理阶段把多个静态物体的网格合并成一个大的网格运行时一次画出来。它的优点是运行时开销极低缺点是合并后的网格不能单独移动而且占用显存更多。适合场景里大量不动的小物件比如建筑、植被、道具。动态合批针对的是会动但顶点数很少的物体。运行时把多个小物体的顶点数据临时拼在一起一次提交。它的优点是不需要预处理缺点是每帧都要重新拼CPU 开销不小而且只适合顶点数很少的物体。如果物体顶点数多拼的开销可能超过合批省下的开销。我在项目里做过对比一个场景里有几百个动态小物件用动态合批后绘制调用从几百降到几十帧率提升明显。但另一个场景里物件顶点数较多动态合批反而让帧率下降了因为拼数据的开销太大。所以动态合批要看顶点数不是所有动态物体都适合。5.3 实例化渲染的威力与限制实例化渲染是合批的进阶版同一个网格画多次每次用不同的变换和材质参数。GPU 一次绘制调用就能画出成百上千个实例效率极高。实例化的限制在于所有实例必须共享同一个网格和同一个着色器。如果实例之间网格不同或者着色器不同就不能用实例化。所以它适合大量重复的物体比如草、树、粒子、子弹。实例化还有一个坑是实例数据的更新。如果每帧都要更新所有实例的变换更新开销可能不小。这时候可以考虑把实例数据放在 GPU 端用计算着色器更新减少 CPU 到 GPU 的传输。5.4 材质与着色器变体的管理状态切换里着色器切换是开销比较大的一种。如果每个物体用不同的着色器切换次数就会很多。解决办法是着色器变体管理把功能相近的着色器合并成一个超级着色器用宏或者分支来控制不同功能。这样切换时只需要改宏或者改分支不用换整个着色器。但超级着色器也有代价分支会带来额外开销而且编译时间变长。所以要在切换开销和分支开销之间找平衡。通常的做法是把最常用的功能组合编译成专门的变体不常用的走通用分支。材质管理也是类似思路把材质参数组织成常量缓冲批量更新减少单独设置常量的次数。材质和着色器的绑定关系要尽量稳定避免频繁切换。6. 光照与阴影渲染系统里最贵的部分6.1 实时光照的计算模型选择光照计算是渲染里最耗时的部分之一。实时光照通常用简化的光照模型比如 Lambert 漫反射加 Blinn-Phong 高光或者基于物理的 BRDF。模型越复杂效果越好但开销越大。选择光照模型时要考虑目标平台的算力和画面的需求。移动端通常用简化模型桌面端可以用更复杂的。但也不是越复杂越好有时候简化模型配合好的美术资源效果反而更讨喜。光照计算的组织方式也很关键。前向渲染里光照在物体着色时算延迟渲染里光照在全屏 Pass 里算。前者和物体数量相关后者和像素数量相关。要根据场景特点选。6.2 阴影贴图的分辨率与级联策略阴影是光照里开销最大的部分。阴影贴图的分辨率直接决定阴影质量也直接决定开销。分辨率越高阴影越清晰但显存和带宽开销越大。级联阴影是常用的优化手段把视锥按距离分成几段近处用高分辨率阴影贴图远处用低分辨率。这样近处阴影清晰远处虽然模糊但用户不容易察觉整体开销可控。级联的划分要合理。如果近处级联太小近处阴影会不够清晰如果远处级联太大远处阴影会太模糊。通常要根据摄像机参数和场景尺度来调。我在项目里调级联参数时会先确定近处需要多清晰再反推级联的划分和分辨率。6.3 阴影的软硬与性能权衡硬阴影边缘锐利软阴影边缘柔和更接近真实。但软阴影需要更多采样开销更大。常见的软阴影实现是 PCF百分比渐近过滤在阴影贴图上采多个点取平均。采样数越多阴影越软开销越大。通常会在质量和性能之间选一个折中比如采 4 到 16 个点。还有一种思路是基于距离的软化近处阴影硬一点远处阴影软一点。这样既保证了近处细节又让远处看起来自然整体开销也可控。7. 后处理与输出最后一道工序的取舍7.1 后处理链的组织与顺序后处理是在几何和光照都算完之后对最终画面做的一系列处理比如色调映射、 bloom、景深、抗锯齿、颜色分级等。这些处理通常串成一条链每个环节读上一个环节的输出写自己的输出。后处理链的顺序很重要。比如色调映射通常要在 bloom 之后因为 bloom 是在高动态范围下算的色调映射是把高动态范围压到显示范围。如果顺序反了bloom 的效果就不对。后处理的开销主要来自全屏采样和带宽。每个后处理环节都要读一遍画面、写一遍画面环节越多带宽开销越大。所以后处理链要精简能合并的环节尽量合并。7.2 抗锯齿方案的选择抗锯齿是后处理里比较重要的一环。常见的方案有 MSAA、FXAA、TAA 等。MSAA 在几何阶段做效果好但开销大而且对延迟渲染不友好。FXAA 在后处理做开销小但会模糊细节。TAA 在时间维度上做效果好但需要运动向量而且可能产生鬼影。选择哪种方案要看画面需求和性能预算。如果性能充足且用前向渲染MSAA 是不错的选择。如果性能紧张FXAA 能用较低开销换来可接受的抗锯齿效果。TAA 适合对画质要求高且能接受一定复杂度的项目。7.3 输出与显示同步最后一步是把渲染结果输出到显示设备。这一步要考虑同步问题如果渲染帧率和显示刷新率不一致可能出现撕裂或者卡顿。常见的同步方式是垂直同步渲染完一帧后等显示刷新再输出下一帧。这样不会撕裂但可能引入延迟。另一种是自适应同步根据显示设备的刷新节奏动态调整兼顾流畅和延迟。输出环节还有一个容易忽略的点是颜色空间。渲染通常在某个颜色空间做输出到显示设备时要转换到显示设备的颜色空间。如果转换不对画面会偏色。这个坑我在项目里踩过调了半天才发现是颜色空间没对上。8. 渲染性能问题的排查思路从现象到根因8.1 先分清是 CPU 瓶颈还是 GPU 瓶颈排查渲染性能问题第一步是分清瓶颈在 CPU 还是 GPU。方法很简单把分辨率降到很低如果帧率明显上升说明是 GPU 瓶颈如果帧率没怎么变说明是 CPU 瓶颈。CPU 瓶颈通常是绘制调用太多、状态切换太频繁、逻辑更新太重。GPU 瓶颈通常是像素着色太复杂、带宽不够、光照计算太重。两者的优化方向完全不同所以先分清瓶颈在哪很重要。8.2 用帧调试工具定位具体 Pass分清瓶颈后下一步是定位具体是哪个 Pass 慢。这时候要用帧调试工具把一帧里每个 Pass 的耗时都抓出来。看的时候要注意有些 Pass 的耗时是显性的有些是隐性的。比如某个 Pass 本身很快但它触发了管线刷新导致后续 Pass 变慢这种隐性问题要靠对比才能发现。我的习惯是先看总耗时再看各 Pass 占比然后针对占比大的 Pass 深入分析。8.3 常见性能陷阱与修复案例我整理了几个常见的性能陷阱以及对应的修复思路现象可能原因修复思路绘制调用数量极高没有合批每个物体单独画静态合批、动态合批、实例化状态切换频繁材质和着色器种类太多合并材质、超级着色器、排序GPU 等待 CPU绘制提交太慢多线程渲染、命令缓冲带宽瓶颈G-Buffer 太大、后处理环节太多压缩格式、合并 Pass阴影开销大阴影贴图分辨率太高、级联不合理降分辨率、调级联、软硬折中透明物体排序错误排序算法不对按深度排序、用深度剥离这些陷阱我在不同项目里都遇到过修复思路也验证过。但要注意每个项目的具体情况不同不能照搬。关键是理解背后的原理然后针对自己的场景调。8.4 优化要讲优先级别过早优化最后说一个心态问题不要过早优化。很多新手一上来就想把渲染做到极致结果花大量时间优化了不重要的部分真正瓶颈却没解决。正确的做法是先让画面正确再让画面流畅最后才追求极致性能。优化要有数据支撑先测量再优化优化完再测量确认真的有效。没有测量的优化都是瞎猜。我在项目里见过太多“优化了半天反而更慢”的案例根源都是没搞清楚瓶颈在哪就动手。所以排查性能问题第一步永远是测量第二步是分析第三步才是动手改。这个顺序不能乱。渲染系统的架构设计说到底是在一堆约束里找平衡画质和性能的平衡、灵活性和复杂度的平衡、通用性和针对性的平衡。没有完美的架构只有适合当前项目的架构。理解这些取舍背后的逻辑比记住某个具体实现更重要。
RELATED READING

延伸阅读

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