页面

CPU 怎样组织数据并命令 GPU 绘制

← 返回兔子洞总览 · rendering 主题地图

CPU 负责组织场景、资源和绘制命令,GPU 负责让同一套 Shader 在大量顶点与片元上并行执行。理解 Draw Call 的关键,不是背 API,而是看清不同数据以什么频率变化。

我追问的链

  • 场景里只有 Node、GameObject 和组件,GPU 为什么会知道该画什么?
  • Mesh 为什么不只是一些空间位置?
  • 一个立方体只有 8 个角,为什么渲染时常有 24 个顶点?
  • 索引缓冲解决了什么问题?
  • 一次 Draw Call 到底提交了什么?
  • 为什么很少的三角形也可能产生性能问题?
  • 静态合批、动态合批和 GPU Instancing 分别在复用什么?
  • 为什么两个对象使用同一个材质仍可能无法合批?
  • Attribute 和 Uniform 的定义是什么?为什么必须区分它们?
  • JavaScript / C# 对象为什么不能直接交给 GPU?
  • Buffer 里到底放了什么,strideoffset 又是什么?
  • 上传数据和提交 Draw Call 是不是同一件事?
  • CPU 提交以后,GPU 会立即画完吗?

先看整条链:GPU 最终看不到 Node,只看到数字和命令

以一个带图片的 Sprite 为例,引擎编辑器里看到的是:

1
2
3
Node
├── Transform:位置、旋转、缩放
└── Sprite:图片、颜色、材质

但真正进入图形 API 的内容更接近:

1
2
3
4
5
6
7
顶点 Buffer:每个顶点的位置、UV、颜色
索引 Buffer:0, 1, 2, 2, 1, 3
纹理资源:图片解码后得到的像素
Shader:怎样变换顶点、怎样计算片元颜色
参数:MVP 矩阵、透明度、染色
状态:混合、深度、剔除、渲染目标
命令:使用以上内容绘制 6 个索引

中间发生了一次重要的“翻译”:

1
2
3
4
5
面向人和业务的对象
Node、Component、Material、SpriteFrame
↓ CPU / 引擎提取、排序、打包
面向 GPU 的扁平数据
Buffer、Texture、Pipeline、Descriptor、Draw Command

这一章要讲清的不是某个 API 名字,而是这次翻译为什么存在、每一步解决什么问题。

1. CPU 与 GPU 为什么要分工

CPU 擅长处理控制流和复杂业务:

1
2
3
4
5
6
游戏逻辑
节点层级
动画状态
可见性判断
资源管理
绘制顺序

GPU 擅长让同一段程序同时处理大量结构相似的数据:

1
2
3
4
大量顶点
大量三角形
大量片元
大量纹理采样

因此一帧可以先近似理解为:

1
2
3
4
5
6
7
8
9
CPU 更新游戏逻辑和 Transform

CPU 找出相机可见的物体

CPU 准备 Mesh、材质、纹理、矩阵与渲染状态

CPU 通过图形 API 记录并提交绘制命令

GPU 执行顶点处理、图元装配、光栅化和片元处理

CPU 不会逐像素告诉 GPU 应该是什么颜色。它提交数据和规则,让 GPU 批量计算。

场景对象为什么不能直接交给 GPU

JavaScript 或 C# 对象可能包含:

1
2
3
4
5
6
7
对象引用
字符串
函数
动态数组
垃圾回收信息
父子节点关系
引擎私有状态

GPU 并不知道这些语言对象的布局,也不能沿着 node.parent.children[0] 这样的引用链工作。它需要的是:

1
2
3
4
5
类型明确
长度明确
排列连续或规则
地址与用途明确
能被图形 API 和驱动描述

所以 CPU 必须先把高级对象“拍平”为数字数组和资源句柄。可以类比餐厅:顾客说“来一份少辣的宫保鸡丁”,服务员要把它翻译成桌号、菜品编号、辣度和数量明确的后厨工单。

GPU 像吞吐量极高、但只认标准工单的后厨。Node 是业务语言,Buffer 和命令才是它认识的工单。

2. Mesh 提供的不是“物体”,而是可绘制数据

对 GPU 来说,Mesh 不是“角色”或“石头”,而是:

1
2
3
顶点属性数据
+
描述怎样组成图元的索引

一个渲染顶点通常不只有位置,还可能携带:

1
2
3
4
5
6
7
position  位置
normal 法线
uv 纹理坐标
color 顶点颜色
tangent 切线
weights 骨骼权重
joints 骨骼编号

所以“空间中的一个点”和“一个渲染顶点”不是同一个概念。

为什么立方体经常有 24 个顶点

立方体从几何上只有 8 个角,但同一个角同时属于三个面。

以右上前方的角为例:

1
2
3
在上表面:法线朝上
在右表面:法线朝右
在前表面:法线朝前

一个渲染顶点只能保存一套属性。位置虽然相同,法线却不同,因此这个角必须拆成三份顶点记录。

硬边立方体通常是:

1
6 个面 × 每面 4 个顶点 = 24 个渲染顶点

同理,UV 接缝、硬边法线和不同顶点色,也会让同一个空间位置拆成多个渲染顶点。

只有位置和全部顶点属性都相同的数据,才是真正可以共享的同一个渲染顶点。

3. 为什么还需要索引

GPU 通常以三角形作为基本图元。一个矩形面会拆成两个三角形:

1
2
3
4
0 ─── 1
│ / │
│ / │
2 ─── 3

顶点表只保存 4 份数据,索引表描述两个三角形:

1
2
0, 2, 1,
1, 2, 3

数字不是坐标,而是“去顶点表中读取第几个顶点”。索引让相邻三角形复用相同的顶点记录,减少数据量,也有利于复用顶点 Shader 的计算结果。

一个顶点在内存里究竟是什么样

假设每个 2D 顶点携带:

1
2
3
4
position:2 个 float = 8 字节
uv: 2 个 float = 8 字节
color: 4 个 byte = 4 字节
合计: 20 字节

四个顶点可以交错存放:

1
2
3
4
字节  0 ─ 19:顶点 0 [position | uv | color]
字节 20 ─ 39:顶点 1 [position | uv | color]
字节 40 ─ 59:顶点 2 [position | uv | color]
字节 60 ─ 79:顶点 3 [position | uv | color]
1
2
stride:从一个顶点的开头走到下一个顶点开头,要跨多少字节
offset:某个属性相对这个顶点开头,要跳过多少字节
属性 类型 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
2
3
4
5
6
Mesh      = 四个顶点、两个三角形
Texture = 精灵图片
Material = 采样纹理并处理透明度的 Shader 与参数
Matrix = Sprite 的位置、旋转和缩放
State = 透明混合等状态
Draw Call = 使用以上内容执行绘制

Draw Call 不等于“一个 Node”或“一个 GameObject”:一个对象可能产生多次 Draw Call,多个对象也可能被合进一次或少数几次 Draw Call。

Draw Call 不是“把所有数据传过去”

通常存在两类不同操作:

1
2
资源操作:创建 Buffer、上传顶点、上传纹理、更新参数
绘制操作:绑定已有资源和状态,命令 GPU 绘制

静态 Mesh 可能在加载时上传一次,之后几千帧都复用同一份 GPU Buffer。每帧的 Draw Call 只是引用它,并不重新上传整个 Mesh。

1
2
Upload ≠ Draw
数据在 GPU 可访问的位置准备好 ≠ GPU 已经开始绘制

一次绘制命令可以近似写成什么

不同图形 API 名字不同,但逻辑大致类似:

1
2
3
4
5
6
绑定渲染目标:当前屏幕颜色缓冲和深度缓冲
绑定 Pipeline:使用哪个 Shader,以及混合/深度/剔除规则
绑定顶点 Buffer:四个顶点在哪里、布局是什么
绑定索引 Buffer:六个索引在哪里、类型是 uint16
绑定参数和纹理:MVP、颜色、Sprite 图片
drawIndexed(indexCount = 6)

最后一行才是狭义的 Draw Call。前面的绑定决定“当前”指的是什么,绘制命令消费这些已经设置好的上下文。

现代 API 往往把多项状态预先打包为 Pipeline State,把资源通过 Descriptor / Bind Group 等结构绑定;老式 API 看起来更像逐项设置。抽象方式不同,核心问题相同:

1
2
3
4
用哪套程序和状态?
从哪里取数据?
取多少?
把结果写到哪里?

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
2
3
4
5
6
7
1. 从 Sprite / Transform 计算模型矩阵
2. 与相机 View、Projection 组合出 MVP
3. 确认顶点与索引 Buffer 已创建并包含正确数据
4. 确认纹理已上传,Shader / Pipeline 已准备好
5. 把当前 MVP、颜色等参数写入参数区
6. 记录 drawIndexed(6)
7. 把命令提交到图形队列

GPU 处理索引时可以近似想成:

1
2
3
4
5
6
7
读取索引 0 → 取顶点 0 → 执行一次顶点 Shader
读取索引 2 → 取顶点 2 → 执行或复用结果
读取索引 1 → 取顶点 1 → 执行或复用结果
组成第一个三角形

读取索引 1、2、3
组成第二个三角形

每个顶点的位置都会执行 clipPosition = MVP × localPosition。随后两个三角形经过裁剪、透视除法、视口变换和光栅化。每个片元得到插值后的 UV,再去纹理中采样颜色,最后经过深度与混合规则写入渲染目标。

1
2
3
4
5
6
顶点/索引     决定基础几何
MVP 决定它出现在什么位置
Shader 决定怎样计算
Texture 提供可查询的图像数据
Pipeline 状态 决定怎样参与已有画面
Draw Command 决定现在处理哪一段数据

6. 为什么状态变化会带来成本

CPU 与驱动准备下一批绘制通常需要:

1
2
3
4
5
绑定 Shader
绑定纹理和 Buffer
更新材质参数
设置深度、混合与剔除状态
记录绘制命令

100 个 UI 图片都是矩形,但结果可能完全不同:

1
2
3
4
5
同图集、同材质、连续绘制
→ 可能合成很少的 Draw Call

每张图使用独立纹理
→ 可能接近 100 次 Draw Call

差别不在那 200 个三角形,而在绘制序列被切成了多少批。

Draw Call 多就一定慢吗

不一定。性能瓶颈至少可能落在:

1
2
3
4
5
CPU:场景遍历、排序、状态准备、命令记录过重
带宽:频繁上传大量动态数据
顶点阶段:顶点很多或顶点 Shader 很重
片元阶段:分辨率高、Overdraw 多、Shader/采样很重
同步:CPU 被迫等待 GPU,或 GPU 等待资源

“减少 Draw Call”主要针对 CPU 提交与状态切换成本。若 GPU 已经被大量像素计算压满,把 100 次 Draw Call 合成 10 次也未必解决主要问题。正确的问题是:当前帧到底卡在 CPU 提交、数据传输,还是 GPU 计算?

7. 合批究竟在合什么

在不改变最终画面的前提下,让更多图元共享一次状态设置和绘制提交。

方法 核心做法 典型场景
静态合批 预先把多个不动的网格合成大网格 建筑、地面、静态装饰
动态合批 每帧临时整理兼容的小网格 UI、Sprite、简单粒子
GPU Instancing 复用同一份 Mesh,只提供每个实例的数据 草、树、相同模型、弹丸
1
2
3
静态合批       → 合并几何
动态合批 → 临时拼几何
GPU Instancing → 复用几何,只更换实例参数

为什么同一材质仍不一定能合批

绘制还可能因为以下差异断批:

1
2
3
4
5
纹理不同
材质参数不同
混合、深度、剔除或 Stencil 状态不同
渲染队列不同
透明物体的顺序不能交换

例如顺序为 材质 X → 材质 Y → 材质 X,即使第一和第三个对象兼容,也可能因为透明顺序不能改变而形成三批。

合批也有代价

方法 节省了什么 可能付出的代价
静态合批 每帧提交和状态切换 合并后的内存、难以独立剔除或移动
动态合批 Draw Call CPU 每帧整理/拷贝顶点数据
Instancing 重复 Mesh 和多次提交 Shader 与数据布局更复杂,仍受透明顺序等约束

如果为了合批把整座城市合成一个巨大 Mesh,只要相机看到其中一个角落,整个大 Mesh 都可能进入绘制候选。Draw Call 少了,但剔除粒度也变差了。

8. Shader 为什么需要不同种类的输入

顶点 Shader 会对不同顶点反复执行同一份程序:

1
输出位置 = MVP 矩阵 × 顶点位置

但两个输入的变化规律不同:

1
2
顶点位置 → 每次执行不同
MVP 矩阵 → 当前这一批共同使用

GPU 必须知道哪些数据要根据顶点编号自动换下一份,哪些数据在当前 Draw Call 中保持相同。这就是 Attribute 与 Uniform 出现的原因。

9. Attribute 的定义、意义与来源

Attribute 是:

与单个顶点关联的数据。GPU 每执行一次顶点 Shader,会根据当前顶点编号和顶点布局,从顶点缓冲区中取出对应的一份。

旧版 GLSL / WebGL 1 常写成:

1
2
attribute vec3 a_position;
attribute vec2 a_uv;

现代 GLSL 通常写成:

1
2
in vec3 a_position;
in vec2 a_uv;

Attribute 让同一份顶点 Shader 程序,可以高效接收大量顶点各自不同的数据。

更精确一点,Attribute 描述的是顶点输入通道,它由三部分咬合:

1
2
3
Shader 声明:我要 position、uv 等输入
顶点布局:这些输入分别是什么类型、offset 和 stride
Buffer:真正存放字节的地方

只有 Shader 写了 a_position 并不够;只有一段顶点 Buffer 也不够。必须由 Pipeline / 顶点布局把两者对应起来。

Attribute 也不保证各顶点数值不同。“每顶点”描述的是关联关系和取值规则:执行顶点 0 就按顶点 0 的地址取一份,执行顶点 1 就按顶点 1 的地址取一份;两份值可以恰好相等。

实例化绘制中的输入流还可以按实例步进:普通 Attribute 每处理一个顶点前进一份,Instance Attribute 每切换一个实例才前进一份。

10. Uniform 的定义、意义与边界

从 Shader 语义看,Uniform 是:

不随当前图元中的顶点或片元编号自动变化的外部输入;在一次绘制所覆盖的相关 Shader 执行中,它们看到的是同一份绑定值。

1
2
uniform mat4 u_mvp;
uniform vec4 u_tintColor;

CPU 可以近似执行:

1
2
3
设置 u_mvp = 当前物体的 MVP
设置 u_tintColor = 红色
提交 Draw Call

当前 Draw Call 的每个顶点读取同一个 u_mvp;产生的每个片元也读取同一个 u_tintColor

“共同使用”不表示结果相同:

1
同一个矩阵 × 不同顶点位置 = 不同输出位置

它也不表示 Uniform 整帧都不变。CPU 可以在下一次 Draw Call 前修改它:

1
2
设置矩阵 A → Draw A
设置矩阵 B → Draw B

但“Uniform = 每 Draw Call 更新一次”只是便于入门的说法,不是严格定义:

参数 常见变化频率 可能被谁复用
时间、全局环境光 每帧 这一帧的许多 Draw
View / Projection 每相机 这个相机的许多 Draw
粗糙度、材质颜色 每材质 使用该材质的许多 Draw
Model 矩阵 每对象或实例 常常只对应一个对象的 Draw

只要绑定值没有变化,多次 Draw 可以继续复用同一份参数。现代 API 中,这些值还常被放进 Uniform Buffer / Constant Buffer,再通过 Descriptor 或 Bind Group 绑定;不一定表现为一次次调用 setUniform

因此应分开两个问题:

1
2
Shader 语义:当前这些执行实例是否共同看到同一值?
CPU 更新频率:这份值多久重写或重新绑定一次?

为什么需要区分二者

如果 100 万个顶点都重复携带同一个 64 字节 mat4,会浪费大约 64 MB。作为 Uniform,当前绘制只需要一份。

反过来,如果顶点位置是普通 Uniform,同一 Draw Call 的所有顶点都会得到相同位置,三角形会缩成一个点。

概念 数据与谁关联 GPU 如何取得
Attribute 一个顶点 按顶点编号从 Buffer 读取
Uniform 一组 Shader 执行共同使用 从当前绑定的参数区读取

11. Varying:顶点数据怎样来到片元

片元 Shader 通常不会直接读取原始 Attribute:

1
2
3
4
5
6
7
顶点 Attribute

顶点 Shader 输出

光栅化阶段在三角形内部插值

片元 Shader 输入

旧版 GLSL 称为 varying;现代 GLSL 使用顶点 Shader 的 out 与片元 Shader 的 in

这也解释了为什么不能简单地说“所有每片元不同的数据都由 CPU 提供”。三角形内部可能覆盖几十万个片元,CPU 不会预先列出每个片元的 UV;GPU 根据三个顶点输出自动插值生成。

12. 用变化频率理解数据位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
每个顶点不同?
→ Attribute

同一次 Draw Call 共同使用?
→ Uniform

从顶点输出并在三角形内部插值?
→ Varying / 顶点输出与片元输入

同一次实例化绘制中,每个对象不同?
→ Instance 数据

Shader 需要按坐标查询大量数据?
→ Texture / Buffer 等资源

Attribute 描述“当前顶点带来了什么”;Uniform 描述“这一批计算处于什么共同环境”。

13. Buffer 为什么是 CPU 与 GPU 之间的共同格式

Buffer 可以先通俗理解为:

一段有明确大小和用途、能被 GPU 按约定方式读取的连续字节存储。

它本身并不知道里面是“角色顶点”还是“MVP 矩阵”。含义来自创建用途和读取规则:

1
2
3
4
同样一串字节
+ 顶点布局 → 可以解释为顶点属性
+ 索引类型 → 可以解释为顶点编号
+ Shader 声明 → 可以解释为参数或结构化数据

为什么不直接让 GPU 读 CPU 数组

CPU 与 GPU 可能拥有不同物理内存和虚拟地址空间,通过总线连接,并有各自的缓存与访问规则。即使是统一内存设备,也仍需要图形 API 管理可见性、缓存一致性、资源状态和生命周期。共享内存不等于“任意语言对象都能直接安全读取”。

图形 API 让程序明确表达:

1
2
3
4
5
创建多大的资源
它将用于顶点、索引、参数还是复制
什么时候由 CPU 写
什么时候由 GPU 读
不同阶段之间怎样保证可见

静态数据与动态数据

1
2
3
4
静态地形 Mesh:加载时上传,很多帧只读
角色骨骼矩阵:每帧更新
粒子顶点:可能每帧大量变化
相机矩阵:每相机或每帧更新

如果每帧都重新上传从不变化的地形,就是浪费带宽;如果把频繁更新的数据放在不适合 CPU 写入的区域,又可能造成额外复制或等待。

引擎的 Buffer 管理,本质是在数据大小、更新频率、CPU 写入成本、GPU 读取效率和同步复杂度之间做选择。

14. 命令为什么还要“记录”再“提交”

在现代图形 API 中,CPU 常常不是每调用一次绘制函数就立刻让 GPU 停下并执行。更接近:

1
2
3
4
5
6
7
8
9
CPU 创建/复用命令缓冲

依次记录:切换渲染目标、绑定 Pipeline、绑定资源、Draw

结束记录

提交到 GPU 队列

GPU 在轮到这批命令时顺序消费

类比快递仓库:记录命令是把一批包裹按路线装车,提交队列是让装好的车进入发车队列,GPU 执行才是车辆真正出发并完成配送。“提交成功”只表示 GPU 已经接到工作,不表示画面已经完成。

CPU 与 GPU 为什么要异步

如果每次 Draw 后 CPU 都等 GPU 完成,两边会轮流闲置。理想情况是流水线:

1
2
3
4
时间 ─────────────────────────→

CPU:准备帧 1 | 准备帧 2 | 准备帧 3
GPU: 执行帧 1 | 执行帧 2 | 执行帧 3

这也是“frames in flight(同时在途的帧)”出现的原因。CPU 可以准备后续帧,但不能随便覆盖 GPU 仍在读取的 Buffer。

为什么需要同步

假设 GPU 还在读取帧 1 的顶点数据,CPU 已经把同一段内存改成帧 2,就会发生数据竞争。常见思路包括:

1
2
3
4
为多个在途帧准备多份动态 Buffer
使用 Fence 判断 GPU 是否完成
使用 Barrier / 资源状态转换约束 GPU 阶段间的读写顺序
避免无必要地让 CPU 等待 GPU

同步不是为了“让一切更安全所以到处等待”,而是只在存在真实数据依赖时建立先后关系。过度同步会把本来并行的流水线重新变成串行。

15. 把一次 Draw Call 拆成可检查的问题

要问的问题 常见现象
数据 Buffer 里的字节正确吗 顶点飞散、UV 错乱
布局 类型、stride、offset 对吗 属性交叉读取、颜色异常
索引 数量、类型、顺序对吗 缺三角形、面翻转
参数 矩阵、颜色、纹理绑定对吗 位置错误、全黑、采样错图
Pipeline Shader 和状态兼容吗 不显示、深度/透明异常
顺序 命令与透明排序正确吗 遮挡和混合错误
同步 写入是否早于读取完成 偶发闪烁、帧间污染

性能问题也应拆开问:CPU 组织对象慢、命令和状态切换多、上传量太大、顶点计算重、片元和 Overdraw 重,还是发生了等待?这样就不会把所有问题都笼统归因于“Draw Call 太多”。

阶段锚点

1
2
3
4
5
6
7
8
9
10
11
12
场景对象
↓ CPU 提取
Mesh、材质、纹理、矩阵、渲染状态

Attribute:每顶点数据
Uniform:每 Draw Call 共同参数

Draw Call:使用当前绑定内容绘制一批图元
↓ CPU 记录并提交命令队列
GPU 在异步流水线上读取 Buffer、执行 Shader

必要的同步保证数据不会被提前覆盖

这一章的逻辑闭环是:

CPU 把面向业务的场景对象翻译成规则明确的 Buffer、资源、状态和命令;GPU 不理解“对象”,只按命令从指定位置取数,并在异步流水线上批量执行。Attribute、Uniform、合批和同步,都是为了说明数据由谁使用、多久变化、何时可以安全复用。

下一课将进入 GPU 内部,继续追问:

顶点 Shader 输出以后,图元装配、裁剪、透视除法和光栅化究竟怎样一步步产生片元?

关联


来源:与 Codex 的对话,2026-08。