CPU 负责组织场景、资源和绘制命令,GPU 负责让同一套 Shader 在大量顶点与片元上并行执行。理解 Draw Call 的关键,不是背 API,而是看清不同数据以什么频率变化。
我追问的链
- 场景里只有 Node、GameObject 和组件,GPU 为什么会知道该画什么?
- Mesh 为什么不只是一些空间位置?
- 一个立方体只有 8 个角,为什么渲染时常有 24 个顶点?
- 索引缓冲解决了什么问题?
- 一次 Draw Call 到底提交了什么?
- 为什么很少的三角形也可能产生性能问题?
- 静态合批、动态合批和 GPU Instancing 分别在复用什么?
- 为什么两个对象使用同一个材质仍可能无法合批?
- Attribute 和 Uniform 的定义是什么?为什么必须区分它们?
- JavaScript / C# 对象为什么不能直接交给 GPU?
- Buffer 里到底放了什么,
stride和offset又是什么? - 上传数据和提交 Draw Call 是不是同一件事?
- CPU 提交以后,GPU 会立即画完吗?
先看整条链:GPU 最终看不到 Node,只看到数字和命令
以一个带图片的 Sprite 为例,引擎编辑器里看到的是:
1 | Node |
但真正进入图形 API 的内容更接近:
1 | 顶点 Buffer:每个顶点的位置、UV、颜色 |
中间发生了一次重要的“翻译”:
1 | 面向人和业务的对象 |
这一章要讲清的不是某个 API 名字,而是这次翻译为什么存在、每一步解决什么问题。
1. CPU 与 GPU 为什么要分工
CPU 擅长处理控制流和复杂业务:
1 | 游戏逻辑 |
GPU 擅长让同一段程序同时处理大量结构相似的数据:
1 | 大量顶点 |
因此一帧可以先近似理解为:
1 | CPU 更新游戏逻辑和 Transform |
CPU 不会逐像素告诉 GPU 应该是什么颜色。它提交数据和规则,让 GPU 批量计算。
场景对象为什么不能直接交给 GPU
JavaScript 或 C# 对象可能包含:
1 | 对象引用 |
GPU 并不知道这些语言对象的布局,也不能沿着 node.parent.children[0] 这样的引用链工作。它需要的是:
1 | 类型明确 |
所以 CPU 必须先把高级对象“拍平”为数字数组和资源句柄。可以类比餐厅:顾客说“来一份少辣的宫保鸡丁”,服务员要把它翻译成桌号、菜品编号、辣度和数量明确的后厨工单。
GPU 像吞吐量极高、但只认标准工单的后厨。Node 是业务语言,Buffer 和命令才是它认识的工单。
2. Mesh 提供的不是“物体”,而是可绘制数据
对 GPU 来说,Mesh 不是“角色”或“石头”,而是:
1 | 顶点属性数据 |
一个渲染顶点通常不只有位置,还可能携带:
1 | position 位置 |
所以“空间中的一个点”和“一个渲染顶点”不是同一个概念。
为什么立方体经常有 24 个顶点
立方体从几何上只有 8 个角,但同一个角同时属于三个面。
以右上前方的角为例:
1 | 在上表面:法线朝上 |
一个渲染顶点只能保存一套属性。位置虽然相同,法线却不同,因此这个角必须拆成三份顶点记录。
硬边立方体通常是:
1 | 6 个面 × 每面 4 个顶点 = 24 个渲染顶点 |
同理,UV 接缝、硬边法线和不同顶点色,也会让同一个空间位置拆成多个渲染顶点。
只有位置和全部顶点属性都相同的数据,才是真正可以共享的同一个渲染顶点。
3. 为什么还需要索引
GPU 通常以三角形作为基本图元。一个矩形面会拆成两个三角形:
1 | 0 ─── 1 |
顶点表只保存 4 份数据,索引表描述两个三角形:
1 | 0, 2, 1, |
数字不是坐标,而是“去顶点表中读取第几个顶点”。索引让相邻三角形复用相同的顶点记录,减少数据量,也有利于复用顶点 Shader 的计算结果。
一个顶点在内存里究竟是什么样
假设每个 2D 顶点携带:
1 | position:2 个 float = 8 字节 |
四个顶点可以交错存放:
1 | 字节 0 ─ 19:顶点 0 [position | uv | color] |
1 | stride:从一个顶点的开头走到下一个顶点开头,要跨多少字节 |
| 属性 | 类型 | offset | stride |
|---|---|---|---|
| position | 2 × float | 0 | 20 |
| uv | 2 × float | 8 | 20 |
| color | 4 × uint8 | 16 | 20 |
GPU 不靠变量名理解 Buffer。a_position 只是 Shader 里的入口,真正让它读对数据的是 CPU 提供的布局描述:
从 Buffer 的第 0 字节开始,把每 20 字节中的前 8 字节解释为两个浮点数。
如果把 stride 错写成 16,GPU 不会理解你的意图并报“第二个顶点读歪了”;它只会按错误规则继续读,画面于是出现拉伸、闪烁或乱码。
索引 Buffer 也只是规则明确的一段数字
矩形使用 6 个 uint16 索引:
1 | 0, 2, 1, 1, 2, 3 |
它只占 6 × 2 = 12 字节。绘制时 GPU 先读取索引,再用索引找到对应顶点。索引序列里顶点 1 和顶点 2 各出现两次,但顶点 Buffer 中仍只保存一份记录。
4. 一次 Draw Call 是什么
一次 Draw Call 可以理解为 CPU 对 GPU 说:
使用当前绑定的数据、程序和状态,绘制这一批图元。
| 类别 | 例子 | 回答的问题 |
|---|---|---|
| 几何数据 | 顶点缓冲、索引缓冲 | 画什么形状 |
| Shader / 材质 | 顶点与片元程序 | 怎样计算位置和颜色 |
| 资源与参数 | 矩阵、颜色、贴图、光照 | 计算时使用什么数据 |
| 渲染状态 | 深度、混合、剔除、渲染目标 | 怎样参与当前画面 |
一个 Sprite 可以近似为:
1 | Mesh = 四个顶点、两个三角形 |
Draw Call 不等于“一个 Node”或“一个 GameObject”:一个对象可能产生多次 Draw Call,多个对象也可能被合进一次或少数几次 Draw Call。
Draw Call 不是“把所有数据传过去”
通常存在两类不同操作:
1 | 资源操作:创建 Buffer、上传顶点、上传纹理、更新参数 |
静态 Mesh 可能在加载时上传一次,之后几千帧都复用同一份 GPU Buffer。每帧的 Draw Call 只是引用它,并不重新上传整个 Mesh。
1 | Upload ≠ Draw |
一次绘制命令可以近似写成什么
不同图形 API 名字不同,但逻辑大致类似:
1 | 绑定渲染目标:当前屏幕颜色缓冲和深度缓冲 |
最后一行才是狭义的 Draw Call。前面的绑定决定“当前”指的是什么,绘制命令消费这些已经设置好的上下文。
现代 API 往往把多项状态预先打包为 Pipeline State,把资源通过 Descriptor / Bind Group 等结构绑定;老式 API 看起来更像逐项设置。抽象方式不同,核心问题相同:
1 | 用哪套程序和状态? |
5. 完整走一遍:一个矩形怎样被画出来
先准备四个顶点:
| 顶点 | position | uv |
|---|---|---|
| 0 | (-0.5, 0.5) |
(0, 1) |
| 1 | ( 0.5, 0.5) |
(1, 1) |
| 2 | (-0.5, -0.5) |
(0, 0) |
| 3 | ( 0.5, -0.5) |
(1, 0) |
索引是:
1 | 0, 2, 1, 1, 2, 3 |
CPU 侧发生的事情:
1 | 1. 从 Sprite / Transform 计算模型矩阵 |
GPU 处理索引时可以近似想成:
1 | 读取索引 0 → 取顶点 0 → 执行一次顶点 Shader |
每个顶点的位置都会执行 clipPosition = MVP × localPosition。随后两个三角形经过裁剪、透视除法、视口变换和光栅化。每个片元得到插值后的 UV,再去纹理中采样颜色,最后经过深度与混合规则写入渲染目标。
1 | 顶点/索引 决定基础几何 |
6. 为什么状态变化会带来成本
CPU 与驱动准备下一批绘制通常需要:
1 | 绑定 Shader |
100 个 UI 图片都是矩形,但结果可能完全不同:
1 | 同图集、同材质、连续绘制 |
差别不在那 200 个三角形,而在绘制序列被切成了多少批。
Draw Call 多就一定慢吗
不一定。性能瓶颈至少可能落在:
1 | CPU:场景遍历、排序、状态准备、命令记录过重 |
“减少 Draw Call”主要针对 CPU 提交与状态切换成本。若 GPU 已经被大量像素计算压满,把 100 次 Draw Call 合成 10 次也未必解决主要问题。正确的问题是:当前帧到底卡在 CPU 提交、数据传输,还是 GPU 计算?
7. 合批究竟在合什么
在不改变最终画面的前提下,让更多图元共享一次状态设置和绘制提交。
| 方法 | 核心做法 | 典型场景 |
|---|---|---|
| 静态合批 | 预先把多个不动的网格合成大网格 | 建筑、地面、静态装饰 |
| 动态合批 | 每帧临时整理兼容的小网格 | UI、Sprite、简单粒子 |
| GPU Instancing | 复用同一份 Mesh,只提供每个实例的数据 | 草、树、相同模型、弹丸 |
1 | 静态合批 → 合并几何 |
为什么同一材质仍不一定能合批
绘制还可能因为以下差异断批:
1 | 纹理不同 |
例如顺序为 材质 X → 材质 Y → 材质 X,即使第一和第三个对象兼容,也可能因为透明顺序不能改变而形成三批。
合批也有代价
| 方法 | 节省了什么 | 可能付出的代价 |
|---|---|---|
| 静态合批 | 每帧提交和状态切换 | 合并后的内存、难以独立剔除或移动 |
| 动态合批 | Draw Call | CPU 每帧整理/拷贝顶点数据 |
| Instancing | 重复 Mesh 和多次提交 | Shader 与数据布局更复杂,仍受透明顺序等约束 |
如果为了合批把整座城市合成一个巨大 Mesh,只要相机看到其中一个角落,整个大 Mesh 都可能进入绘制候选。Draw Call 少了,但剔除粒度也变差了。
8. Shader 为什么需要不同种类的输入
顶点 Shader 会对不同顶点反复执行同一份程序:
1 | 输出位置 = MVP 矩阵 × 顶点位置 |
但两个输入的变化规律不同:
1 | 顶点位置 → 每次执行不同 |
GPU 必须知道哪些数据要根据顶点编号自动换下一份,哪些数据在当前 Draw Call 中保持相同。这就是 Attribute 与 Uniform 出现的原因。
9. Attribute 的定义、意义与来源
Attribute 是:
与单个顶点关联的数据。GPU 每执行一次顶点 Shader,会根据当前顶点编号和顶点布局,从顶点缓冲区中取出对应的一份。
旧版 GLSL / WebGL 1 常写成:
1 | attribute vec3 a_position; |
现代 GLSL 通常写成:
1 | in vec3 a_position; |
Attribute 让同一份顶点 Shader 程序,可以高效接收大量顶点各自不同的数据。
更精确一点,Attribute 描述的是顶点输入通道,它由三部分咬合:
1 | Shader 声明:我要 position、uv 等输入 |
只有 Shader 写了 a_position 并不够;只有一段顶点 Buffer 也不够。必须由 Pipeline / 顶点布局把两者对应起来。
Attribute 也不保证各顶点数值不同。“每顶点”描述的是关联关系和取值规则:执行顶点 0 就按顶点 0 的地址取一份,执行顶点 1 就按顶点 1 的地址取一份;两份值可以恰好相等。
实例化绘制中的输入流还可以按实例步进:普通 Attribute 每处理一个顶点前进一份,Instance Attribute 每切换一个实例才前进一份。
10. Uniform 的定义、意义与边界
从 Shader 语义看,Uniform 是:
不随当前图元中的顶点或片元编号自动变化的外部输入;在一次绘制所覆盖的相关 Shader 执行中,它们看到的是同一份绑定值。
1 | uniform mat4 u_mvp; |
CPU 可以近似执行:
1 | 设置 u_mvp = 当前物体的 MVP |
当前 Draw Call 的每个顶点读取同一个 u_mvp;产生的每个片元也读取同一个 u_tintColor。
“共同使用”不表示结果相同:
1 | 同一个矩阵 × 不同顶点位置 = 不同输出位置 |
它也不表示 Uniform 整帧都不变。CPU 可以在下一次 Draw Call 前修改它:
1 | 设置矩阵 A → Draw A |
但“Uniform = 每 Draw Call 更新一次”只是便于入门的说法,不是严格定义:
| 参数 | 常见变化频率 | 可能被谁复用 |
|---|---|---|
| 时间、全局环境光 | 每帧 | 这一帧的许多 Draw |
| View / Projection | 每相机 | 这个相机的许多 Draw |
| 粗糙度、材质颜色 | 每材质 | 使用该材质的许多 Draw |
| Model 矩阵 | 每对象或实例 | 常常只对应一个对象的 Draw |
只要绑定值没有变化,多次 Draw 可以继续复用同一份参数。现代 API 中,这些值还常被放进 Uniform Buffer / Constant Buffer,再通过 Descriptor 或 Bind Group 绑定;不一定表现为一次次调用 setUniform。
因此应分开两个问题:
1 | Shader 语义:当前这些执行实例是否共同看到同一值? |
为什么需要区分二者
如果 100 万个顶点都重复携带同一个 64 字节 mat4,会浪费大约 64 MB。作为 Uniform,当前绘制只需要一份。
反过来,如果顶点位置是普通 Uniform,同一 Draw Call 的所有顶点都会得到相同位置,三角形会缩成一个点。
| 概念 | 数据与谁关联 | GPU 如何取得 |
|---|---|---|
| Attribute | 一个顶点 | 按顶点编号从 Buffer 读取 |
| Uniform | 一组 Shader 执行共同使用 | 从当前绑定的参数区读取 |
11. Varying:顶点数据怎样来到片元
片元 Shader 通常不会直接读取原始 Attribute:
1 | 顶点 Attribute |
旧版 GLSL 称为 varying;现代 GLSL 使用顶点 Shader 的 out 与片元 Shader 的 in。
这也解释了为什么不能简单地说“所有每片元不同的数据都由 CPU 提供”。三角形内部可能覆盖几十万个片元,CPU 不会预先列出每个片元的 UV;GPU 根据三个顶点输出自动插值生成。
12. 用变化频率理解数据位置
1 | 每个顶点不同? |
Attribute 描述“当前顶点带来了什么”;Uniform 描述“这一批计算处于什么共同环境”。
13. Buffer 为什么是 CPU 与 GPU 之间的共同格式
Buffer 可以先通俗理解为:
一段有明确大小和用途、能被 GPU 按约定方式读取的连续字节存储。
它本身并不知道里面是“角色顶点”还是“MVP 矩阵”。含义来自创建用途和读取规则:
1 | 同样一串字节 |
为什么不直接让 GPU 读 CPU 数组
CPU 与 GPU 可能拥有不同物理内存和虚拟地址空间,通过总线连接,并有各自的缓存与访问规则。即使是统一内存设备,也仍需要图形 API 管理可见性、缓存一致性、资源状态和生命周期。共享内存不等于“任意语言对象都能直接安全读取”。
图形 API 让程序明确表达:
1 | 创建多大的资源 |
静态数据与动态数据
1 | 静态地形 Mesh:加载时上传,很多帧只读 |
如果每帧都重新上传从不变化的地形,就是浪费带宽;如果把频繁更新的数据放在不适合 CPU 写入的区域,又可能造成额外复制或等待。
引擎的 Buffer 管理,本质是在数据大小、更新频率、CPU 写入成本、GPU 读取效率和同步复杂度之间做选择。
14. 命令为什么还要“记录”再“提交”
在现代图形 API 中,CPU 常常不是每调用一次绘制函数就立刻让 GPU 停下并执行。更接近:
1 | CPU 创建/复用命令缓冲 |
类比快递仓库:记录命令是把一批包裹按路线装车,提交队列是让装好的车进入发车队列,GPU 执行才是车辆真正出发并完成配送。“提交成功”只表示 GPU 已经接到工作,不表示画面已经完成。
CPU 与 GPU 为什么要异步
如果每次 Draw 后 CPU 都等 GPU 完成,两边会轮流闲置。理想情况是流水线:
1 | 时间 ─────────────────────────→ |
这也是“frames in flight(同时在途的帧)”出现的原因。CPU 可以准备后续帧,但不能随便覆盖 GPU 仍在读取的 Buffer。
为什么需要同步
假设 GPU 还在读取帧 1 的顶点数据,CPU 已经把同一段内存改成帧 2,就会发生数据竞争。常见思路包括:
1 | 为多个在途帧准备多份动态 Buffer |
同步不是为了“让一切更安全所以到处等待”,而是只在存在真实数据依赖时建立先后关系。过度同步会把本来并行的流水线重新变成串行。
15. 把一次 Draw Call 拆成可检查的问题
| 层 | 要问的问题 | 常见现象 |
|---|---|---|
| 数据 | Buffer 里的字节正确吗 | 顶点飞散、UV 错乱 |
| 布局 | 类型、stride、offset 对吗 | 属性交叉读取、颜色异常 |
| 索引 | 数量、类型、顺序对吗 | 缺三角形、面翻转 |
| 参数 | 矩阵、颜色、纹理绑定对吗 | 位置错误、全黑、采样错图 |
| Pipeline | Shader 和状态兼容吗 | 不显示、深度/透明异常 |
| 顺序 | 命令与透明排序正确吗 | 遮挡和混合错误 |
| 同步 | 写入是否早于读取完成 | 偶发闪烁、帧间污染 |
性能问题也应拆开问:CPU 组织对象慢、命令和状态切换多、上传量太大、顶点计算重、片元和 Overdraw 重,还是发生了等待?这样就不会把所有问题都笼统归因于“Draw Call 太多”。
阶段锚点
1 | 场景对象 |
这一章的逻辑闭环是:
CPU 把面向业务的场景对象翻译成规则明确的 Buffer、资源、状态和命令;GPU 不理解“对象”,只按命令从指定位置取数,并在异步流水线上批量执行。Attribute、Uniform、合批和同步,都是为了说明数据由谁使用、多久变化、何时可以安全复用。
下一课将进入 GPU 内部,继续追问:
顶点 Shader 输出以后,图元装配、裁剪、透视除法和光栅化究竟怎样一步步产生片元?
关联
- 承接 坐标系与矩阵变换:上一课得到的 Model、View、Projection 矩阵,在这里成为绘制参数。
- 回看 顶点、三角形与插值:顶点属性在这里进一步落到 Attribute,插值数据则对应 Varying。
来源:与 Codex 的对话,2026-08。