一个类比:GPU 是一台多工位机床想象 OpenGL 上下文是一台大型加工机床,机床上有很多固定的工位(插槽)。工位上的牌子(Target 名字)是刻死的,不能改:┌───────────────────────────────────────────┐ │ GPU 加工机床 │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ 顶点数据工位 │ │ 索引数据工位 │ │ │ │ ARRAY_BUFFER│ │ELEMENT_ARRAY│ │ │ │ [ 空 ] │ │ [ 空 ] │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ 纹理工位0 │ │ 纹理工位1 │ │ │ │ TEXTURE0 │ │ TEXTURE1 │ │ │ │ [ 空 ] │ │ [ 空 ] │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────────────┐ │ │ │ Shader工位 │ │ │ │ [ 空 ] │ │ │ └─────────────┘ │ │ │ │ [ 加工按钮:glDrawXXX ] │ │ │ └───────────────────────────────────────────┘现在你有一堆原料(GL 对象),放在旁边的仓库里:仓库: 顶点数据 A(id1) 顶点数据 B(id2) 索引数据(id3) ️ 纹理墙面(id10) ️ 纹理法线图(id11) Shader 标准着色(id100)加工一个物体的流程假设你要画一堵墙。整个流程是:步骤 1:往工位上摆原料// 把顶点数据 A 摆到顶点数据工位上glBindBuffer(GL_ARRAY_BUFFER,1);机床状态:┌─────────────┐ │ 顶点数据工位 │ │ ARRAY_BUFFER│ │ id1 │ ← 摆上了 └─────────────┘继续摆: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 加工机床 │ │ │ │ 顶点数据工位: id1 │ │ 索引数据工位: id3 │ │ 纹理工位 0 : ️ 墙面(id10) │ │ 纹理工位 1 : ️ 法线(id11) │ │ Shader 工位: id100 │ │ │ │ [ 加工按钮 ] │ └─────────────────────────────────────┘步骤 2:按下加工按钮glDrawElements(GL_TRIANGLES,...);机床不需要你告诉它用哪些原料。它就用当前所有工位上摆着的东西开始加工:从顶点数据工位读顶点从索引数据工位读索引顺序用Shader 工位上的 shader 处理Shader 里的sampler2D从纹理工位里取纹理加工完成 → 一堵墙画好了 ✅关键理解 1:工位牌子是固定的,不能同时摆两个一个工位只能摆一个东西。你摆新的,旧的就下来了:glBindBuffer(GL_ARRAY_BUFFER,1);// 工位摆着 id1glBindBuffer(GL_ARRAY_BUFFER,2);// 现在工位是 id2,id1 被顶下去了这就是 OpenGL 状态机的核心。工位是全局共享的,谁最后 Bind 谁生效。关键理解 2:同一个原料可以摆到不同工位一个 Buffer 对象(比如 id1),并不写死用途。它可以:// 场景 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 对象 id5,同时glBindBuffer(GL_ARRAY_BUFFER, 5)和glBindBuffer(GL_UNIFORM_BUFFER, 5),会冲突吗?Shader 里两个sampler2D都通过glUniform1i设成了 0,会怎样?为什么 VAO 能打包一堆状态?它到底记录了哪些工位?