ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

渲染管线的隐藏瓶颈:应用程序阶段如何决定帧率上限

渲染管线的隐藏瓶颈:应用程序阶段如何决定帧率上限

说实话,我第一次认真啃《实时渲染》第2章的时候,正好赶上《模拟项目X》在做渲染线程改造。主线程的帧时间被应用程序阶段吃掉了大半,Profile 曲线里一片深红,Draw Call 数量高得离谱。当时我看第2章第2.2节,脑子里只有一句话:原来我一直以为的“图形渲染”里,真正决定帧率上限的,往往是从这一小节开始的。这个阶段不产生像素,不画三角形,甚至很多时候看起来就是在处理一堆“跟渲染无关”的逻辑,但它决定了后面整个 GPU 阶段能不能跑得又快又稳。

如果你和我一样是做引擎、图形或者游戏客户端开发的,这一节最好放慢一点读。它解决的不是“怎么把某个特效调亮”的问题,而是“帧时间到底耗在哪、怎么从架构上降下来”的问题。对刚接触渲染管线的同学来说,搞清楚它也能少走很多弯路:很多所谓的性能问题,根本不是特效写得太烂,而是应用程序阶段输出的数据太烂。

1. 先把这一节放回整个管线里看

1.1 应用程序阶段到底算不算“渲染”

按书里的划分,图形渲染管线通常可以分成三个大阶段:应用程序阶段、几何阶段、光栅化阶段。后面两个阶段今天绝大多数情况下是在 GPU 上跑的,而应用程序阶段是在 CPU 上执行的,它的本质是一个软件阶段。所谓“软件阶段”,指的是这个阶段没有固定不变的硬件单元,几乎完全由开发者自己用代码控制。

很多人会下意识觉得应用程序阶段只是“准备一下数据”,那就窄了。它做的事情包括但不限于:读取输入、处理游戏逻辑、更新动画、跑物理碰撞、做场景管理、执行可见性剔除、最终生成渲染命令。换句话说,GPU 能画什么、画得对不对、画得快不快,第一步全看这个阶段的输出。如果这个阶段给 GPU 送去的是一个乱糟糟、充满冗余的物体列表,那几何阶段就算再快,也做了大量无用功。

我记得书里有个很关键的说法,虽然文字不多,但实际项目里特别有分量:应用程序阶段原则上可以和其他阶段并行执行。这也是为什么现代引擎会把渲染逻辑和游戏逻辑分离到不同线程、甚至不同进程中。这意味着它在性能模型上的地位,更像是“供给端”,后端 GPU 跑得再快,供给端一卡,整条流水线一样要停。我建议你把 2.2 节的标题换成另一个理解方式——它其实是在教你怎么当好 CPU 侧的“后勤调度”。

1.2 它和后面几个 GPU 阶段的关系

图形开发者通常会用“帧时间”来拆解性能瓶颈,而我踩过最多的坑,是把帧时间过高全部怪到 GPU 上。实际统计下来,尤其是在复杂场景、多人同屏或者开放世界项目里,CPU 侧的应用程序阶段经常才是第一瓶颈。

为什么?因为后面 GPU 阶段处理的数据,几乎全是从这个阶段“喂”过去的。几何阶段要做顶点变换,那顶点数据、索引缓冲、顶点缓冲得先准备好;光栅化阶段要决定哪些像素被覆盖,那材质、纹理、着色器状态也得先绑定好。这些准备动作,别看它们听起来琐碎,每一个都涉及内存分配、状态切换、缓冲区更新和驱动调用。等你把这些步骤全部串起来执行一遍,帧时间很容易就被吃掉了 3~5 毫秒甚至更多。

举个实际数字:60 FPS 的帧预算大约是 16.6 毫秒,很多移动端游戏能分配给 CPU 的时间通常只有 6~8 毫秒。如果你在这个阶段里做一次全场景无差别的物体遍历,或者每个物体都来一次状态切换,那后面 GPU 优化得再好也拉不回帧率。这也是为什么优化“应用程序阶段”,往往是渲染性能优化的第一优先级,而不是先去调 Shader。

2. 应用阶段在引擎里实际干的活

2.1 碰撞、物理和动画:看似不搭边,其实全在预算里

很多人学渲染时容易忽略一个事实:渲染循环不是从“开始绘制”才开始的,而是从场景“状态更新”就算起。比如一个角色要播放攻击动画,那么骨骼矩阵需要更新,蒙皮用到的顶点数据需要重新计算。这个计算如果放到渲染线程上执行,那它就是在跟绘制命令生成抢预算。

我记得在做某跨平台系统的时候,客户端里同时存在上千个动画实体。一开始想得很天真:动画采样丢给专门的动画线程不就完了?跑起来才发现,动画线程和渲染线程之间需要同步一份“骨格矩阵数组”,同步的成本比采样本身还高。后来参考了书里对 CPU 阶段任务划分的思路,把动画采样器和渲染数据提交放到同一个帧阶段里,用双缓冲避免竞争,CPU 侧的帧时间肉眼可见地降下去一截。

物理和碰撞也是同理。很多时候物体是在物理系统里算好位置,然后才传给渲染场景。如果你没有做时间步长上的统一,渲染拿到的位置可能和碰撞结果差了一帧,轻则画面抖动,重则角色穿墙。所以应用程序阶段的隐藏任务,其实是把“逻辑世界”和“视觉世界”的节奏对齐。对齐的前提是什么?是前期数据统一管理,而不是临时现算。

这里还容易碰到一个反直觉的点:为了追求“手感好”,把物理步长开到 120Hz。它们增加的不只是 CPU 压力,还有数据同步压力。我有一次在《模拟项目X》里做实验,把物理步长从 120Hz 降到 60Hz,结果手感几乎无变化,但 CPU 侧的物理线程开销明显减少,帧时间充裕了很多。说白了,应用程序阶段的每一毫秒都是抠出来的,不能舍不得砍。

2.2 要为“画什么”做的决策:可见性相关工作

应用程序阶段里最和渲染直接相关的任务,肯定是可见性判定。我的理解里,它主要分成两步:粗粒度剔除和细粒度剔除。粗粒度剔除通常用空间结构来加速,比如四叉树、八叉树、BVH 或者包围球层级;细粒度剔除则是拿物体的包围盒和视锥体做精确求交。一个经常被忽略的细节是,这种剔除不仅对主相机做,如果场景里有阴影贴图相机、反射相机、双人分屏相机,那每个相机都要有一套独立的视锥体判断。

我见过不少项目把可见性判定做成“每帧重新生成八叉树”,这是很大的浪费。空间结构应该是长时间维护的,每帧只是做增量更新,而不是推倒重建。原因很简单:重建结构本身要遍历所有物体,这就让算法的收益归零了。真正合适的做法是,把空间结构当成静态数据,每帧只更新“动态物体”所在节点,剔除时再结合上一帧的结果做有限搜索。

在做遮挡剔除的时候,最容易出问题的是“悬崖式闪烁”。比如一个高塔后面有个小怪,这帧小怪被判定完全遮挡,下一帧相机移动了一丁点,小怪突然出现,就会给人一种“噗”一下蹦出来的感觉。规避办法是加“遮挡延迟”:剔除结果不要立刻生效,而是多保留一帧,或者保存一个可见集合的历史队列。这个思路我在很多成熟引擎里都见过,本质跟书里讲的“应用程序阶段可以对后续反馈做缓冲”是一致的。

2.3 输入处理、摄像机与场景管理数据

除了那些听起来很高大上的剔除和动画,应用程序阶段还得处理大量接地气的内容:键盘鼠标输入、手柄输入、触摸事件、摄像机位置更新、场景节点层级变换。如果我一天没碰渲染,我可能会觉得这些都是“游戏逻辑”,不是“渲染管线”。但第2.2节把它放进渲染管线的第一部分,其实是在提醒我们:渲染不是一个孤立的“画圈”操作,它是在响应实时交互。

摄像机的数据尤其重要。视锥体的六个平面、投影矩阵、视图矩阵,全部由应用程序阶段计算出来,再传给顶点阶段。如果这里算错了一个矩阵,后面所有阶段都会跟着错。我还犯过一个经典错误:在 UI 相机和主相机之间混用了同一个投影矩阵,结果场景里某些特型物体显示位置全偏了。修了半天才发现是应用程序阶段把相机类型和数据搞混了。

场景管理数据也是这一阶段的重要产出物。我习惯把它理解成“最终渲染队列的雏形”:经过裁剪之后,需要把可见物体按照材质、网格、距离等信息组织好,方便后续生成绘制命令。这个过程如果做得好,后面阶段几乎就是线性遍历一个数组;做得不好,就是一个嵌套又重复的“ForEach”,程序和缓存全部被打爆。

3. 把“画什么”翻译成 GPU 能执行的绘制指令

3.1 绘制调用为什么贵:驱动翻译和状态切换

走到这里,应用程序阶段终于开始跟“绘制”有直接关系了。绘制调用,也就是我们常说的 Draw Call,本质上是一次 CPU 向 GPU 发起的绘制请求。很多人对它的成本误解很深,以为绘制本身很耗时,其实绘制调用的开销大头在“发起请求”本身:驱动要做参数校验、状态检查、命令翻译、资源绑定,最后才把命令塞进 GPU 能读到的队列里。

换句话说,一个正常的 Draw Call 在 CPU 侧开销通常在几微秒级别,听起来不大,但一个场景里塞几千个物体,那就轻松突破十毫秒。我还见过更夸张的情况:同一个材质反复切换 Render Target,每一次切换都可能让驱动重新配置整条渲染流水线,这种“状态突变”比单纯增加几个 Draw Call 更伤。

经验做法是在生成绘制命令之前,先对所有待绘制物体做一次排序。排序的原则很简单:相同的网格放一堆,相同的着色器放一堆,相同的纹理放一堆,让 GPU 尽量避免频繁换状态。这样做的收益,往往比少画几个物体还明显。我做过一次对比实验,场景里一万个物体,只优化排序不降低数量,CPU 侧帧时间直接从 4.5ms 降到了 2.1ms。

提示:判断一个场景是不是状态切换爆炸,最简单的办法是开着渲染调试工具,观察同一时刻被绑定的着色器数量。如果物体数量很多但着色器数量也奇多,那问题大概率不是模型复杂,而是应用程序阶段的状态管理太乱。

3.2 排序、批处理与实例化:怎么把命令变少

有了排序,下一步就是对同类物体做批处理。批处理的核心目的是把多个 Draw Call 合并成一个,让 CPU 少跑几次驱动路径。但要注意,批处理不是万能的。静态批处理会把多个网格合并成一个大网格,内存占用会上升,而且合批后移动其中一个物体就得重新合并;动态批处理每帧要做顶点变换和合并,CPU 开销时高时低,处理不当反而更慢。

我从项目里得到的经验是:优先做分层批处理。也就是说,把物体分成静态和动态两类,静态物体尽量用预合并或者 GPU 实例化,动态物体则只对“同样网格、同样材质”的批次合并。千万别把批处理做成“万物堆一锅”,那样做出来的画面没问题,运行效率却可能比不批还要低。

实例化则是我在实际项目中更推荐的方向。它不需要把网格真正合并,只需要在 GPU 侧一次性绘制多个实例,每实例通过额外数据区分各自的世界矩阵和材质参数。这样既保住了状态一致性,又没有对内存造成太大压力。比如草、树木、粒子这些数量大但结构简单的物体,特别适合做 GPU 实例化。前提是在应用程序阶段把实例数据整理到一个连续的 Buffer 里,这段整理工作本身也得算进 CPU 预算内。

3.3 现代 API 的命令缓冲:工作没消失,只是换了个位置

到了 DirectX 12 和 Vulkan 时代,很多初学者以为命令缓冲和多重录制能让 CPU 侧“不干话”。实际上这只是把绘制调用的“录制”动作提前了,CPU 侧的整理、校验、编译工作量依然存在,只不过被切分到不同的线程去执行了。

我见过一个优化案例,把整个场景的绘制命令录制分成四个线程,每个线程负责一个视锥区域或一个渲染层,最后做一次主提交。效果确实明显,但也踩了无数同步的坑:不同线程录制的命令缓冲不能引用互相释放的资源,提交时必须保证依赖排序正确。如果你还没有完全理解这些约束,贸然把命令生成改成多线程,结果往往是崩溃频率翻倍。第2.2节里提到的“可以和后续阶段并行”不等于“可以乱并行”,这是我在看片段时得到的最大警示。

所以我的建议是:在动手引入高端异步命令缓冲之前,先把单线程版的绘制队列整理干净。等队列本身变得规律了,再把它分发到多个线程身上。不要一上来就玩多线程,否则你连问题出在哪儿都定位不到。

4. CPU 端并行:应用程序阶段的工程化改造

4.1 多线程、帧数据与同步点

现代引擎普遍会让主线程、渲染线程、工作线程并行处理各自职责。应用程序阶段的并行工程化,本质上要做的是把“游戏逻辑更新、碰撞检测、动画采样、剔除、命令生成”拆成多个可以并发执行的任务。

任务拆开之后,最大的敌人是同步点。比如动画系统需要把骨骼矩阵写到渲染缓冲,而渲染线程正好在读取同一块缓冲,这时候如果同步方式写得太粗暴,整个管线就退化成了“排队执行”。我在项目里用过最常见的做法是“每帧多份缓冲”:每个线程或每个帧都有一份独立的数据缓冲,各个阶段写不同的帧号。这样读和写永远交错开,避免了竞争,效果非常稳。代价是内存占用会翻倍,但对现代设备来说完全可接受。

同步的优先级建议从低到高排列:先保证读到的数据是上一帧或者前两帧的延时数据,再逐步压缩延迟。压缩延迟的每一步都会带来新的同步风险。别小看这个权衡,很多所谓“渲染线程卡主线程”的问题,都发生在延迟压缩过头、双缓冲不够用的时候。

4.2 内存布局和 CPU 缓存:一个容易被忽略的隐形瓶颈

说到 CPU 侧的渲染优化,内存布局几乎是避不开的话题。应用程序阶段要遍历成千上万个渲染对象,如果这些对象是散落在内存各处的零散结构体,那 CPU 的缓存命中率会非常低,每访问一个对象都可能产生一次 Cache Miss,几万次下来就是满满的等待时间。

我用过一个非常土但有效的改造:把渲染对象按“渲染层”整理成连续数组,每个数组里只放最重要的顶点缓冲引用、索引范围、世界矩阵、材质 ID。这个数组很小、很连续,遍历起来特别舒服。光这一项改动,在《模拟项目X》的某些大场景里就让 CPU 开销降了接近 20%。

注意:在整理渲染用数据时,尽量避免到处用“链表”或者“节点指针跳转”的结构。这类结构在逻辑上很灵活,但在内存访问上非常不友好。用连续数组加索引代替,会让应用程序阶段的遍历速度快得多。这也是我在实际项目里反复验证过的结论。

5. 我对 2.2 的实际排障记录与常见误区

5.1 三个案例和排查套路

第一个案例发生在一次大场景性能分析里。帧时间的 CPU 部分几乎全在某个逻辑函数上,函数内部是一个全场景逐物体遍历。我最初的直觉是“遍历次数太多”,于是去优化每个物体的判断逻辑。结果没用。真正的问题是,那棵场景树的结构设计得太深,每一层遍历都在反复访问内存,缓存全被打乱了。后来我照着应用程序阶段的思路,把“渲染可见集合”单独抽成连续数组,只在需要的时候去查询场景树,CPU 侧一下就轻快了。

第二个案例是典型的遮挡剔除闪烁。场景里有个大型建筑,角色绕到建筑后面时经常会突然消失又出现。排查后发现是剔除结果用了上一帧的矩阵,和这帧的新位置产生偏差,导致判定结果反复跳变。解决方式是加了一个“可见性保持窗口”,让物体在一定帧数内保持可见或不可见状态,避免了高频跳变。这个问题如果你只盯着 GPU 或模型,很难想明白,但只要回到应用程序阶段的“数据一致性”角度,就很好理解。

第三个案例是 Draw Call 数量没有减少,但 CPU 帧时间却大规模下降。原因就是前面说的状态排序:绘制顺序从“按物体出现顺序”改成“按材质和着色器分类”后,驱动内部的切换成本大幅减少。这说明优化不一定非要“减少次数”,有时把顺序理理顺,照样能拿到巨大收益。

5.2 初学者最容易掉的三个坑

第一个坑:把渲染性能问题一律归罪于 GPU。看到帧率低就先调 Shader、降分辨率,实际上很可能是 CPU 侧的剔除和绘制命令生成没做好。先看 Profile,再动手,永远是对的。

第二个坑:过早引入复杂的空间结构或多线程机制。四叉树、八叉树、GPU 驱动生成、命令多线程录制,这些工具本身都有维护成本。如果场景就几百个物体,直接线性遍历可能比建树还快。先量化规模,再选择方案。

第三个坑:把应用程序阶段的辐射范围限定得太窄。以为它只是“逻辑更新”或“数据准备”,忽视了它对后续所有阶段的决定性影响。我前面提到的批处理、排序、状态切换、内存布局,几乎都跨在这条边界上。只有理解它是一个贯穿全链路的调度中枢,才能做好架构设计。下面用一张表把常见的症状和对应排查方向理一理:

症状可能原因优先排查方向
CPU 帧时间高,GPU 空闲遍历全场景物体、剔除效率低场景结构与可见集合整理
Draw Call 数量多但帧率正常状态顺序乱、驱动切换频繁绘制队列排序
角色进出遮挡时闪烁剔除结果使用旧帧数据双缓冲与可见性保持
多线程录制命令后崩溃资源依赖与同步不完整帧级缓冲与依赖排序
渲染线程等主线程延迟压缩过紧、共用数据竞争分帧缓冲,放宽同步

6. 合上这节之后,我自己做的三个现实改变

读第2.2节前,我做渲染优化时最爱抓“特效”“贴图”“Shader 复杂程度”。读完并且实践过一段时间之后,我的关注点彻底变了。现在接一个新需求,我第一件事是问:“应用程序阶段要产出哪些数据?这些数据怎么进内存?怎么传给后续阶段?”只要这一段理顺了,后面优化基本都是顺水推舟。

第一个现实改变:我会在项目里多做一个“渲染数据收集层”,专门负责把逻辑层的数据翻译成渲染数组。逻辑层可以继续用场景图、节点树,渲染层则只维护纯粹的数组结构,互不干扰。第二个现实改变:我会刻意控制“相机数量”和“视锥数量”,如果场景里阴影、反射、分屏相机特别多,需要在应用程序阶段标记清楚每份数据归属,避免重复生成相同几何数据。第三个改变:我会把剔除工作放到“动画和物理更新之后”,避免用旧位置做了剔除,再传一份新位置给 GPU,白白浪费一次剔除。

我印象最深的一句话是第2.2节里把应用程序阶段定义成“为了高效执行后续管线而进行的准备工作”。这句话读起来很普通,但只有被 CPU 侧的卡顿折磨过几次后,你才会明白其中的分量。最后再分享一个实际建议:遇到渲染性能问题,先别急着甩锅给 GPU,拿着 Profile 工具看看应用程序阶段占了多久。如果它已经超过一半,那就回头看数据是怎么收集、怎么整理、怎么提交的。这个切入点,是我认为这一节最值得每个人带走的经验。

RELATED READING

延伸阅读

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