Instancing 实例化
LearnOpenGL 顺序:Advanced OpenGL → Instancing · 原教程
这一节在渲染管线中的位置
实例化解决的不是“GPU 不会画很多三角形”,而是另一件事:
当很多物体共享同一份 Mesh 时,不要让 CPU 为每个物体重复发一次 Draw Call。
普通绘制大量相同物体时,逻辑通常像:
for (int i = 0; i < amount; ++i)
{
shader.setMat4("model", models[i]);
glDrawElements(...);
}如果有 10,000 个石头,就可能有 10,000 次 Draw Call。每个石头本身也许很简单,但 CPU 必须不断告诉 OpenGL“再画一次”,这部分命令提交和状态准备会形成瓶颈。
实例化把它变成:
glDrawElementsInstanced(..., amount);于是数据流变成:
CPU
│
├─ 上传一份 Mesh
├─ 上传一批 per-instance 数据
│
└─ 只发 1 次 Draw Call
↓
GPU
重复执行 Vertex Shader
instance 0
instance 1
instance 2
...
instance N-1同一份顶点数据,为什么还能画到不同位置
如果只重复同一个 Mesh,没有额外数据:
instance 0 ─┐
instance 1 ─┼─ 同样的位置
instance 2 ─┤
... ┘它们会完全重叠。
所以实例化的关键不是“重复画”,而是:
哪些数据是 per-vertex,哪些数据是 per-instance。
一个石头 Mesh 的局部顶点属于 per-vertex:
position
normal
texCoord而每个石头自己的:
position / offset
rotation
scale
model matrix
color属于 per-instance。
最简单的区分方式是 gl_InstanceID:
void main()
{
// instanced draw 时:
// 0, 1, 2, 3 ...
int id = gl_InstanceID;
}一次实例化 Draw Call 里,每个实例都会得到自己的 ID。
Instanced Array:属性不是每个 Vertex 都前进一次
教程前半先用 100 个四边形说明实例化。更实用的版本是把每个实例的 offset 放进一个 VBO:
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER,
sizeof(glm::vec2) * 100,
translations,
GL_STATIC_DRAW);它看上去仍然是 Vertex Attribute:
layout (location = 2) in vec2 aOffset;区别发生在:
glVertexAttribDivisor(2, 1);普通顶点属性默认可以理解成:
divisor = 0
vertex 0 → 读下一份 attribute
vertex 1 → 读下一份 attribute
vertex 2 → 读下一份 attribute
...而:
divisor = 1
instance 0 的所有 vertices → 使用 offset[0]
instance 1 的所有 vertices → 使用 offset[1]
instance 2 的所有 vertices → 使用 offset[2]
...所以同一次 Vertex Shader invocation 同时拿到了两种节奏的数据:
Mesh VBO
position / normal / uv
↓ 每个 vertex 前进
Instance VBO
offset / color / model matrix
↓ 每个 instance 前进
↓
Vertex Shader这就是 Instanced Array。
为什么 mat4 会占 4 个 Attribute Location
小行星带真正需要的不是一个 vec2 offset,而是每个石头自己的完整 Model Matrix:
layout (location = 3) in mat4 aInstanceMatrix;但硬件的 vertex attribute slot 本质上最多承载一个四分量向量,所以一个 mat4 会拆成四列:
location 3 → column 0
location 4 → column 1
location 5 → column 2
location 6 → column 3因此 CPU 端会为四个 location 都设置 pointer:
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)0);
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)(sizeof(glm::vec4)));
glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)(2 * sizeof(glm::vec4)));
glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)(3 * sizeof(glm::vec4)));然后四列都设:
glVertexAttribDivisor(3, 1);
glVertexAttribDivisor(4, 1);
glVertexAttribDivisor(5, 1);
glVertexAttribDivisor(6, 1);这样 Vertex Shader 对同一个 instance 的所有顶点都会读到同一张 Model Matrix:
gl_Position =
projection *
view *
aInstanceMatrix *
vec4(aPos, 1.0);小行星带:实例化真正省掉的是什么
教程最后的小行星场景里,大量岩石共享同一份 Rock Mesh,但每颗岩石有不同的:
translation
rotation
scale普通方式:
rock 0 → model uniform → Draw Call
rock 1 → model uniform → Draw Call
rock 2 → model uniform → Draw Call
...实例化:
一次上传很多 model matrices
↓
Instance Matrix Buffer
↓ divisor = 1
同一份 Rock VAO
↓
glDrawElementsInstanced(..., amount)
↓
很多颗不同位置 / 角度 / 大小的岩石所以实例化节省的核心是:
重复物体不再重复提交 Draw Call;Mesh 仍然只存一份,变化的数据单独按 instance 提供。
它并不会让 GPU 少处理那些最终真的要画的顶点。100,000 个实例依然意味着大量 Vertex Shader 和 Fragment Shader 工作。它优化的是 CPU → GPU 的命令提交和状态切换成本。
和已经学过的 Buffer / VAO 怎么接起来
实例化没有发明一个完全陌生的新资源。它还是以前学过的东西:
Mesh VBO
↓ glVertexAttribPointer
VAO
│
├─ per-vertex attribute
│
Instance VBO
↓ glVertexAttribPointer
↓ glVertexAttribDivisor(..., 1)
VAO
│
└─ per-instance attribute
↓
glDraw*Instanced
↓
Vertex Shader所以可以把它理解成:
同一个 VAO 里,允许存在两种不同“前进频率”的 Attribute。
一个跟着 Vertex 走,一个跟着 Instance 走。
Interactive Lab
Lab 使用 WebGL2 的真实实例化 API:drawArraysInstanced、vertexAttribDivisor 和 gl_InstanceID。其中最后一个实验会把同一份低模岩石 Mesh 实例化成小行星带,用同一个 Draw Call 绘制大量不同 Model Matrix 的实例。