一个类比:GPU 是一台"多工位机床"
想象 OpenGL 上下文是一台大型加工机床,机床上有很多固定的工位(插槽)。
工位上的牌子(Target 名字)是刻死的,不能改:
┌───────────────────────────────────────────┐ │ GPU 加工机床 │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ 顶点数据工位 │ │ 索引数据工位 │ │ │ │ ARRAY_BUFFER│ │ELEMENT_ARRAY│ │ │ │ [ 空 ] │ │ [ 空 ] │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ 纹理工位0 │ │ 纹理工位1 │ │ │ │ TEXTURE0 │ │ TEXTURE1 │ │ │ │ [ 空 ] │ │ [ 空 ] │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ │ │ │ Shader工位 │ │ │ │ [ 空 ] │ │ │ └─────────────┘ │ │ │ │ [ 加工按钮:glDrawXXX ] │ │ │ └───────────────────────────────────────────┘现在你有一堆原料(GL 对象),放在旁边的仓库里:
仓库: 📦 顶点数据 A(id=1) 📦 顶点数据 B(id=2) 📦 索引数据(id=3) 🖼️ 纹理"墙面"(id=10) 🖼️ 纹理"法线图"(id=11) 📜 Shader "标准着色"(id=100)加工一个物体的流程
假设你要画一堵墙。整个流程是:
步骤 1:往工位上"摆原料"
// 把"顶点数据 A" 摆到"顶点数据工位"上glBindBuffer(GL_ARRAY_BUFFER,1);机床状态:
┌─────────────┐ │ 顶点数据工位 │ │ ARRAY_BUFFER│ │ 📦 id=1 │ ← 摆上了 └─────────────┘继续摆:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,3);// 索引数据摆到索引工位glActiveTexture(GL_TEXTURE0);// "我要操作 0 号纹理工位"glBindTexture(GL_TEXTURE_2D,10);// 摆"墙面纹理"到 0 号工位glActiveTexture(GL_TEXTURE1);// "我要操作 1 号纹理工位"glBindTexture(GL_TEXTURE_2D,11);// 摆"法线图"到 1 号工位glUseProgram(100);// 摆 Shader现在机床满员:
┌─────────────────────────────────────┐ │ GPU 加工机床 │ │ │ │ 顶点数据工位: 📦 id=1 │ │ 索引数据工位: 📦 id=3 │ │ 纹理工位 0 : 🖼️ 墙面(id=10) │ │ 纹理工位 1 : 🖼️ 法线(id=11) │ │ Shader 工位: 📜 id=100 │ │ │ │ [ 加工按钮 ] │ └─────────────────────────────────────┘步骤 2:按下"加工按钮"
glDrawElements(GL_TRIANGLES,...);机床不需要你告诉它用哪些原料。它就用当前所有工位上摆着的东西开始加工:
- 从"顶点数据工位"读顶点
- 从"索引数据工位"读索引顺序
- 用"Shader 工位"上的 shader 处理
- Shader 里的
sampler2D从"纹理工位"里取纹理 - 加工完成 → 一堵墙画好了 ✅
关键理解 1:工位牌子是固定的,不能同时摆两个
一个工位只能摆一个东西。你摆新的,旧的就下来了:
glBindBuffer(GL_ARRAY_BUFFER,1);// 工位摆着 id=1glBindBuffer(GL_ARRAY_BUFFER,2);// 现在工位是 id=2,id=1 被顶下去了这就是 OpenGL 状态机的核心。工位是全局共享的,谁最后 Bind 谁生效。
关键理解 2:同一个"原料"可以摆到不同工位
一个 Buffer 对象(比如 id=1),并不写死用途。它可以:
// 场景 A:当作顶点数据用glBindBuffer(GL_ARRAY_BUFFER,1);// 场景 B:当作 Uniform 数据用(虽然实际不会这么干,但语法允许)glBindBuffer(GL_UNIFORM_BUFFER,1);Buffer 对象本身只是一块内存,怎么用取决于你摆在哪个工位。这是 OpenGL 的灵活性所在。
关键理解 3:纹理工位很特殊,是"两层结构"
其他工位是"一级"的:一个牌子对应一个位置。
但纹理工位是多个编号 + 每个编号里还有子分类:
┌─────────────────────────────────────────┐ │ 纹理工位组 │ │ │ │ ┌── 工位 0 (GL_TEXTURE0) ──┐ │ │ │ ├ TEXTURE_2D: 🖼️ │ │ │ │ ├ TEXTURE_3D: [ 空 ] │ │ │ │ └ TEXTURE_CUBE: [ 空 ] │ │ │ └──────────────────────────┘ │ │ │ │ ┌── 工位 1 (GL_TEXTURE1) ──┐ │ │ │ ├ TEXTURE_2D: 🖼️ │ │ │ │ ├ TEXTURE_3D: [ 空 ] │ │ │ │ └ TEXTURE_CUBE: [ 空 ] │ │ │ └──────────────────────────┘ │ │ │ │ ┌── 工位 2 (GL_TEXTURE2) ──┐ │ │ ... │ └─────────────────────────────────────────┘ [当前操作指针] → 指向工位 0glActiveTexture(GL_TEXTURE1)的意思是:“我下一步操作的是 1 号工位”。它像一个指针,先指过去,再操作。
glActiveTexture(GL_TEXTURE1);// 把"操作指针"指向 1 号工位glBindTexture(GL_TEXTURE_2D,11);// 把纹理摆到"1 号工位的 2D 子格子"如果忘了glActiveTexture,操作指针停留在原处,新纹理就摆错工位了。这是超级常见的 bug。
关键理解 4:Shader 里的 sampler 是怎么找到纹理的?
这里是最绕的地方,我用图讲清楚。
Shader 里写:
uniform sampler2D u_diffuse; uniform sampler2D u_normal;这两个 sampler并不直接引用纹理对象。它们各自持有一个编号,指向"去哪个纹理工位取纹理"。
CPU 端设置这个编号:
glUniform1i(location_diffuse,0);// u_diffuse:去 0 号工位取glUniform1i(location_normal,1);// u_normal :去 1 号工位取流程图:
Shader 里: u_diffuse ──→ (整数 0) ──┐ │ u_normal ──→ (整数 1) ──┼──→ 去对应工位取纹理 │ ↓ ┌───────────────────────┐ │ 纹理工位 0: 🖼️墙面 │ ← u_diffuse 采样这里 │ 纹理工位 1: 🖼️法线 │ ← u_normal 采样这里 └───────────────────────┘关键理解:
- Shader 里的
sampler2D是"一个门牌号" - CPU 通过
glUniform1i告诉它"你的门牌号是 0" - 采样时,GPU 说:“哦,你门牌号 0,那我去 0 号工位取纹理”
一个完整场景演示:画两个物体
假设要连续画两个物体:一堵墙 + 一个桌子。
画墙:
// 摆原料glBindBuffer(GL_ARRAY_BUFFER,wall_vbo);glActiveTexture(GL_TEXTURE0);glBindTexture(GL_TEXTURE_2D,wall_tex);glUseProgram(shader_A);// 加工glDrawElements(...);机床状态:
顶点工位: 📦 wall_vbo 纹理工位0: 🖼️ wall_tex Shader: 📜 shader_A画桌子:
情况 1:换所有原料
glBindBuffer(GL_ARRAY_BUFFER,table_vbo);// 换顶点glBindTexture(GL_TEXTURE_2D,table_tex);// 换纹理(注意,还在 Unit 0)glUseProgram(shader_B);// 换 shaderglDrawElements(...);换的每一样都是一次"工位切换",都有开销。
情况 2:只换一点点(如果桌子用同样的 shader、纹理,只换顶点)
glBindBuffer(GL_ARRAY_BUFFER,table_vbo);// 只换顶点glDrawElements(...);// 直接画只切换一个工位,开销更小。
现在,你能推导出很多 Unity 的性能规律了
规律 1:相同材质的物体连续画,性能最好
为什么?因为不需要切换 Shader 工位 + 纹理工位。这就是 Unity 排序渲染队列的原因。
规律 2:合批的本质是"共用工位"
静态合批:把多个物体的顶点合并到一个大 VBO 里 → 只需摆一次"顶点工位"
GPU Instancing:所有实例用同一个 VBO + 同一个 Shader → 工位完全不切,只通过特殊机制传"每个实例不同的数据"
规律 3:SetPass Call 数量比 DrawCall 数量更能反映性能
为什么?因为 SetPass = “换 Shader / 换关键状态”。而"按加工按钮"(DrawCall)本身很便宜,贵的是换工位。
规律 4:切换 RenderTarget 特别贵
为什么?因为切换 Framebuffer 工位,在移动端(TBDR)会触发 Tile 数据的 Store/Load(还记得我之前讲的 TBDR 吗?)。这是跨越两层元知识的推导。
一句话总结
OpenGL 上下文就是一台带很多工位的机床。你的所有工作分两种:“往工位上摆原料” 和 “按加工按钮”。摆原料是慢的,加工按钮本身很快。所以性能优化的核心 = 少摆几次原料,多按几次按钮。
最后,给你一个"内心自检"
如果你能自己回答下面几个问题,说明彻底懂了:
- 为什么
glActiveTexture和glBindTexture要分两步调用,不能合成一个? - 一个 Buffer 对象 id=5,同时
glBindBuffer(GL_ARRAY_BUFFER, 5)和glBindBuffer(GL_UNIFORM_BUFFER, 5),会冲突吗? - Shader 里两个
sampler2D都通过glUniform1i设成了 0,会怎样? - 为什么 VAO 能"打包一堆状态"?它到底记录了哪些工位?