OpenGL 架构与状态机

从上下文、对象生命周期和精确绑定语义理解一次绘制如何读取完整状态

直觉引入

把 OpenGL 想成一间按“当前工位”工作的实验室。bindBuffer 不是把缓冲永久塞进某个全局盒子,而是先把对象放到一个工位;下一条命令再决定读取这个工位、修改对象内容,还是把工位上的对象关联记录到另一个对象中。只记住“先 bind 再操作”能写出样例,却解释不了为什么换一个 VAO 后 EBO 会变、而 ARRAY_BUFFER 看起来没有一起恢复。

一次 drawElements 也不是只消费“顶点 + 索引”。它读取当前程序、当前 VAO、VAO 中的属性槽和索引缓冲、纹理单元、uniform、视口、剔除、深度、混合、颜色掩码与目标帧缓冲。任何一个残留状态都能让几何消失或颜色异常;这正是 状态机 模型,也是学习状态所有权比背 API 更重要的原因。

上下文是命令执行边界

是 OpenGL 命令的前提。在原生 OpenGL 中,上下文必须由窗口系统接口创建,并在调用线程上成为 current;同一线程通常一次只有一个当前上下文。WebGL 则通过 canvas.getContext 返回上下文对象,JavaScript 调用明确落到该对象,不暴露原生的线程 current 操作。

上下文至少决定三类边界:

  1. 状态边界:当前程序、绑定点、开关、视口和帧缓冲绑定属于上下文状态。
  2. 对象命名空间:缓冲、纹理、程序等对象由上下文创建;原生平台可建立 share group 共享部分对象,但 VAO、默认帧缓冲等状态不能笼统视为可共享。
  3. 生命周期边界:上下文销毁或 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。

“当前绑定”只是命令输入;真正持久化的位置取决于具体命令

因此,以下两句话只有第二句准确:

  • 错误简化:“绑定 VAO 会恢复当时的 ARRAY_BUFFER。”
  • 准确描述:“绑定 VAO 会恢复属性槽中已记录的缓冲关联和 EBO;后续 vertexAttribPointer 仍会读取调用当时的 ARRAY_BUFFER。”

一次绘制读取哪些状态

状态机:绑对象 → 拨开关 → 发绘制渲染上下文 Context(拥有当前状态;对象可按平台规则共享)VAO记录属性配置属性缓冲关联 + EBO 绑定VBO顶点属性数据位置/法线/UVEBO索引数组复用顶点配置时记录关联当前状态开关DEPTH_TESTBLENDCULL_FACEuseProgramdrawElements()绘制命令按当前 VAO + VBO + EBO + 程序 + 开关状态跑一遍管线
VAO 保存属性格式、属性缓冲关联与 EBO 绑定;drawElements 再读取程序和测试状态

调用 drawElements 时,可把输入分成五组检查:

  1. 程序接口:当前 program 是否链接成功,顶点输入位置、uniform 与采样器是否匹配。
  2. 顶点获取:当前 VAO 是否启用正确属性,格式、步长、偏移和缓冲范围是否有效,EBO 的索引类型与数量是否正确。
  3. 光栅化状态:viewport、scissor、frontFace、cullFace 与 primitive 类型是否让图元实际覆盖屏幕。
  4. 逐片元状态:深度、模板、混合、颜色掩码和采样状态是否允许片元写入。
  5. 输出目标:当前 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 句柄分离,才能在上下文丢失或重建时恢复完整渲染状态。

讨论

评论区加载中…