渲染管线入门:CPU与GPU的分工,从Draw Call到像素填充

做动画优化的朋友,迟早要面对一个问题:掉帧到底掉在哪了?

很多人一上来就盯着GPU看,觉得掉帧就是显卡不行。其实我见过太多案例——CPU那边早就堵死了,GPU在那干等着。所以今天咱们把渲染管线从头捋一遍,看看每一帧到底经历了什么。

CPU与GPU:一对不太默契的搭档

先说个最直观的比喻。CPU像个项目经理,啥都能干,但一次只能处理几件事。GPU像个流水线工人,只会干一件事——画像素,但一次能画几百万个。

做动画时,CPU负责“准备画什么”,GPU负责“怎么画出来”。这两者之间有个关键接口,叫Draw Call。

核心概念:Draw Call是CPU发给GPU的一条绘图指令。每次Draw Call都包含“用什么材质、用什么顶点、画成什么样子”这些信息。一帧里Draw Call越多,CPU的负担就越重。

我记得有一次优化一个UI列表动画,帧率死活上不去。查了半天发现一帧发了400多个Draw Call。CPU光忙着发指令了,根本没时间处理动画逻辑。后来合批到80个,帧率直接翻倍。

从Draw Call到像素:完整流程拆解

一帧画面从CPU到屏幕,大致走这么几步。我画了张图,你先看个全貌:

渲染管线完整流程 渲染管线完整流程 CPU 阶段 应用逻辑 动画更新、物理、输入 裁剪/合批 视锥裁剪、合批计算 提交Draw Call 命令缓冲区 命令 GPU 阶段 顶点处理 顶点着色器 光栅化 三角形→像素 像素处理 像素着色器 输出合并 帧缓冲 关键瓶颈:CPU侧Draw Call过多 → 命令缓冲区拥堵 → GPU空闲等待 → 掉帧 GPU侧:Overdraw过多 → 像素着色器过载 → 填充率不足 → 掉帧

好,咱们一步步拆开看。

第一步:CPU上的应用逻辑

每一帧开始,CPU先跑你的游戏逻辑——动画更新、物理模拟、输入处理。这部分如果太重,帧率直接崩。我见过有人在一帧里做了几百次碰撞检测,CPU直接跑满16ms,GPU连活都接不到。

个人经验:我习惯用Profiler先看CPU耗时。如果CPU单帧超过10ms,那掉帧大概率是CPU瓶颈。先把逻辑优化了再谈GPU的事。

第二步:裁剪与合批

逻辑跑完后,CPU开始决定“哪些东西需要画”。这里有两个关键操作:

  • 视锥裁剪:不在相机视野里的物体直接扔掉,不浪费Draw Call。
  • 合批:把能用同一材质、同一网格的物体合并成一个Draw Call。

说白了,合批就是让CPU少跑几趟。你想想看,100个同样的箱子,如果分开画就是100个Draw Call。合批之后,1个就够了。

避坑指南:我曾经为了合批把不同材质的物体硬塞到一起,结果GPU那边反而更慢了。合批的前提是材质相同、顶点格式一致。强行合批只会让CPU做更多无用功。

第三步:提交Draw Call

CPU把裁剪和合批后的结果打包成命令,塞进一个叫命令缓冲区的地方。这个缓冲区是CPU和GPU之间的桥梁。

嗯,这里有个坑。命令缓冲区是有大小的。如果一帧的Draw Call太多,缓冲区满了,CPU就得等着。这就是所谓的CPU端瓶颈。

指标 健康范围 警告范围 危险范围
Draw Call数量 < 100 100 - 300 > 300
CPU单帧耗时 < 8ms 8 - 14ms > 14ms
命令缓冲区等待 0ms 1 - 3ms > 3ms

第四步:GPU顶点处理

GPU拿到命令后,先处理顶点。顶点着色器会把每个顶点的位置从模型空间转换到屏幕空间。这里有个关键点——顶点数量。

一个角色模型如果面数太高,顶点数几十万,GPU光算顶点位置就累得够呛。我优化过一个角色展示动画,模型面数从10万降到3万,画质几乎没变,帧率从25飙到55。

第五步:光栅化

顶点处理完后,GPU把三角形转换成像素。这一步叫光栅化。说白了就是决定“这个三角形覆盖了屏幕上的哪些像素点”。

光栅化的效率跟三角形大小有关。很小的三角形(比如远处物体的细节)会导致GPU花大量时间在光栅化上,但实际覆盖的像素很少。这就是所谓的微三角形瓶颈。

第六步:像素处理与输出合并

最后一步,像素着色器给每个像素算颜色。这里最容易出问题的是Overdraw——同一个像素被画了好几次。

举个例子:一个半透明的UI面板叠在角色上面。GPU先画角色,再画UI面板,同一个像素算了两次。如果叠了五六层,那就是五六倍的像素计算量。

关键数据:Overdraw每增加1倍,像素着色器的负载就翻一倍。在移动设备上,Overdraw超过3倍基本就稳不住60帧了。

输出合并阶段,GPU把最终颜色写入帧缓冲。这里还有个带宽的问题。4K分辨率下,一帧要写入约830万个像素。如果帧缓冲还带深度、模板这些附件,带宽消耗更大。

CPU与GPU的并行与等待

理论上CPU和GPU可以并行工作——CPU处理第N+1帧的逻辑,GPU渲染第N帧的画面。但现实往往没那么美好。

我遇到过一种情况:CPU提交Draw Call太快,GPU跟不上,命令缓冲区越堆越多。结果CPU发现缓冲区满了,只能停下来等GPU消费。这就是CPU Stall。

反过来,如果GPU太快,CPU还没准备好下一帧的命令,GPU就空闲了。这叫GPU Stall。

优化思路:我一般用RenderDoc抓帧,看GPU的占用率。如果GPU占用率低于80%,说明CPU在拖后腿。如果GPU占用率长期100%,那就是GPU瓶颈。

一张表总结:掉帧的根因在哪

现象 可能原因 排查方向
CPU占用高,GPU占用低 Draw Call过多、逻辑太重 合批、减少Update中的计算
CPU和GPU占用都高 场景太复杂、分辨率太高 LOD、降低分辨率、减少Overdraw
CPU占用低,GPU占用高 像素着色器太重、Overdraw严重 简化Shader、减少半透明层叠
帧率波动大 内存带宽不足、纹理采样瓶颈 压缩纹理、减少大纹理使用

好了,渲染管线的完整流程就是这样。从CPU的逻辑处理,到Draw Call的提交,再到GPU的顶点处理、光栅化、像素填充——每一步都可能成为掉帧的元凶。