OpenGL 架构与状态机
从上下文、对象生命周期和精确绑定语义理解一次绘制如何读取完整状态
直觉引入
把 OpenGL 想成一间按“当前工位”工作的实验室。bindBuffer 不是把缓冲永久塞进某个全局盒子,而是先把对象放到一个工位;下一条命令再决定读取这个工位、修改对象内容,还是把工位上的对象关联记录到另一个对象中。只记住“先 bind 再操作”能写出样例,却解释不了为什么换一个 VAO 后 EBO 会变、而 ARRAY_BUFFER 看起来没有一起恢复。
一次 drawElements 也不是只消费“顶点 + 索引”。它读取当前程序、当前 VAO、VAO 中的属性槽和索引缓冲、纹理单元、uniform、视口、剔除、深度、混合、颜色掩码与目标帧缓冲。任何一个残留状态都能让几何消失或颜色异常;这正是 状态机 模型,也是学习状态所有权比背 API 更重要的原因。
上下文是命令执行边界
是 OpenGL 命令的前提。在原生 OpenGL 中,上下文必须由窗口系统接口创建,并在调用线程上成为 current;同一线程通常一次只有一个当前上下文。WebGL 则通过 canvas.getContext 返回上下文对象,JavaScript 调用明确落到该对象,不暴露原生的线程 current 操作。
上下文至少决定三类边界:
- 状态边界:当前程序、绑定点、开关、视口和帧缓冲绑定属于上下文状态。
- 对象命名空间:缓冲、纹理、程序等对象由上下文创建;原生平台可建立 share group 共享部分对象,但 VAO、默认帧缓冲等状态不能笼统视为可共享。
- 生命周期边界:上下文销毁或 WebGL context lost 后,旧 GPU 资源不能继续使用,恢复时必须重建程序、缓冲、纹理、VAO 和 FBO。
因此,“对象是全局的”是危险表述。更准确的模型是:命令总在某个上下文状态上执行,对象是否能跨上下文访问取决于对象类型和创建时的共享规则。
对象名不是对象内容
原生 OpenGL 的 glGen* 或 WebGL 的 create* 首先得到一个。对象经历“创建名字 → 绑定或附加 → 分配存储 → 填充数据 → 被其他状态引用 → 删除”的生命周期。
- VBO 是 buffer 对象的一种用途:当它被绑定到 ARRAY_BUFFER 后,bufferData 为它分配并上传顶点数据。
- EBO 仍是 buffer 对象;当它被绑定到 ELEMENT_ARRAY_BUFFER 时,drawElements 把它解释为索引数据。
- 同一个 buffer 对象的本质不是被名字永久标记成“VBO”或“EBO”,而是命令通过目标与关联决定如何解释它。
- deleteBuffer 表示应用不再需要该名字;若对象仍被有效引用,规范定义的删除与解绑规则决定何时真正释放。不要把 delete 等同于 CPU 语言里立即清空所有外部引用。
是命令的隐式参数。bufferData 没有 buffer 参数,因为它修改当前绑定到目标的 buffer;drawElements 也没有 EBO 参数,因为它从当前 VAO 的 element array binding 读取索引。
VAO 到底记录什么
解决的是“如何把若干缓冲解释成顶点输入”,而不是保存顶点数据本身。它至少包含:
- 每个属性槽是否启用;
- size、type、normalized、stride、offset 等属性格式;
- vertexAttribPointer 调用时 ARRAY_BUFFER 上的缓冲关联;
- WebGL2/OpenGL 对应的属性 divisor;
- ELEMENT_ARRAY_BUFFER 的当前绑定。
最容易混淆的差异是:ARRAY_BUFFER 绑定本身是上下文状态,不是整体 VAO 状态;但 vertexAttribPointer 会在调用时读取 ARRAY_BUFFER 绑定,并把那个缓冲关联记录进当前 VAO 的属性槽。相反,ELEMENT_ARRAY_BUFFER 绑定本身直接属于当前 VAO。
bindBuffer(ARRAY_BUFFER, vbo)上下文 ARRAY_BUFFER 绑定点暂时选择后续属性命令读取的缓冲vertexAttribPointer(...) 当前 VAO 的属性槽格式、步长、偏移,以及此刻的 VBO 关联bindBuffer(ELEMENT_ARRAY_BUFFER, ebo)当前 VAO索引缓冲绑定直接成为 VAO 状态因此,以下两句话只有第二句准确:
- 错误简化:“绑定 VAO 会恢复当时的 ARRAY_BUFFER。”
- 准确描述:“绑定 VAO 会恢复属性槽中已记录的缓冲关联和 EBO;后续 vertexAttribPointer 仍会读取调用当时的 ARRAY_BUFFER。”
一次绘制读取哪些状态
调用 drawElements 时,可把输入分成五组检查:
- 程序接口:当前 program 是否链接成功,顶点输入位置、uniform 与采样器是否匹配。
- 顶点获取:当前 VAO 是否启用正确属性,格式、步长、偏移和缓冲范围是否有效,EBO 的索引类型与数量是否正确。
- 光栅化状态:viewport、scissor、frontFace、cullFace 与 primitive 类型是否让图元实际覆盖屏幕。
- 逐片元状态:深度、模板、混合、颜色掩码和采样状态是否允许片元写入。
- 输出目标:当前 framebuffer 是否完整,draw buffers 和附件格式是否正确。
程序员看到的是一个 draw call,驱动看到的是一组完整状态快照。所谓,就是后一个绘制没有显式建立自己的前置条件,却读取了前一个绘制留下的状态。
分步配置可索引几何
先预测:配置 VAO 后,如果在 VAO 仍绑定时执行 bindBuffer(ELEMENT_ARRAY_BUFFER, null),后续再绑定这个 VAO,还能使用原来的索引缓冲吗?
可复现对象配置
下面的 WebGL2 示例用交错数组保存位置和颜色。顶点着色器应使用 layout(location=0) 的 vec2 位置和 layout(location=1) 的 vec3 颜色。
const vertices = new Float32Array([
// x, y, r, g, b
-0.7, -0.6, 1, 0, 0,
0.7, -0.6, 0, 1, 0,
0.0, 0.7, 0, 0, 1,
]);
const indices = new Uint16Array([0, 1, 2]);
const vao = gl.createVertexArray();
const vbo = gl.createBuffer();
const ebo = gl.createBuffer();
if (!vao || !vbo || !ebo) throw new Error("创建几何对象失败");
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const stride = 5 * Float32Array.BYTES_PER_ELEMENT;
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(
1,
3,
gl.FLOAT,
false,
stride,
2 * Float32Array.BYTES_PER_ELEMENT,
);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
// 不要在 vao 绑定时把 ELEMENT_ARRAY_BUFFER 设为 null。配置结束后解绑 ARRAY_BUFFER 不会破坏 VAO 属性槽中已经记录的 VBO 关联。反过来,如果在 vao 仍是当前 VAO 时解绑 ELEMENT_ARRAY_BUFFER,就会修改 vao 自己的索引缓冲状态。
显式绘制基线
下面的绘制函数不依赖上一个 pass 是否开启混合、是否关闭深度写入。对大型渲染器,更适合由状态缓存层比较差异后发命令,但该缓存层仍必须拥有一份明确的目标状态。
function drawOpaqueTriangle(gl, program, vao, indexCount) {
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
gl.depthMask(true);
gl.colorMask(true, true, true, true);
gl.disable(gl.CULL_FACE);
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);
const error = gl.getError();
if (error !== gl.NO_ERROR) {
throw new Error(`drawElements failed: 0x${error.toString(16)}`);
}
}getError 适合教学和开发期边界检查,不应无差别放进高频发布循环:它可能迫使实现同步或增加 CPU 开销。生产调试应结合调试回调、帧捕获和精确的 pass 标签。
上下文丢失与重建
WebGL 上下文可能因 GPU 重置、资源压力或浏览器策略丢失。旧对象在恢复后不可继续沿用;把 CPU 侧资源描述与 GPU 对象分离,才能可靠重建。
let running = true;
let resources = null;
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
running = false;
resources = null;
});
canvas.addEventListener("webglcontextrestored", () => {
resources = createAllGpuResources(gl); // 重新编译程序、上传缓冲与纹理
running = true;
requestAnimationFrame(render);
});
function render() {
if (!running || !resources) return;
drawFrame(gl, resources);
requestAnimationFrame(render);
}原生平台同样要把窗口表面、上下文和 GPU 对象生命周期分层。窗口 resize 可能只要求更新 viewport 和附件;上下文真正重建则要求重新创建其拥有或引用的 GPU 状态。
常见误区
练习
小结
- 上下文拥有当前状态,对象能否共享取决于平台、对象类型和创建规则,不能视为进程全局资源。
- 对象名只是句柄;绑定点为命令提供隐式对象参数,存储分配和数据上传才建立对象内容。
- VAO 保存属性格式、属性缓冲关联和 EBO 绑定,但不整体保存上下文的 ARRAY_BUFFER 绑定。
- drawElements 消费程序、顶点获取、光栅化、逐片元状态和输出目标,状态泄漏必须按这五组定位。
- CPU 资源描述与 GPU 句柄分离,才能在上下文丢失或重建时恢复完整渲染状态。