Bind()是否要在constructor中调用?

[!Question] 我注意到Cherno在indexBuffer和vertexBuffer的构造函数中中都自动调用了Bind(),但是vertexArray和后面的Shader类都没有……这么做是正确的吗,或者有什么思考?

你又提出了一个观察极其细致、直击设计核心的绝佳问题!这背后确实有非常深刻和重要的设计考量。

答案是:Cherno 的做法是完全正确且经过深思熟虑的。VertexBufferIndexBuffer 的构造函数中自动 Bind(),而在 VertexArrayShader 中不这么做,是基于这些对象在 OpenGL 渲染管线中扮演的不同角色和它们的典型使用模式

让我们来分析这背后的“为什么”。


Group 1: VertexBufferIndexBuffer - “数据容器”

这两个类的核心职责是:成为一个特定数据的容器,并负责将这些数据上传到 GPU

为什么在构造函数中 Bind() 是合理的?

  1. 紧密的操作流程: 创建一个 VBO/IBO 的典型流程是:glGenBuffers -> glBindBuffer -> glBufferData。这三个操作几乎总是连续、成组出现的。将 BindBufferData 封装在构造函数里,完美地匹配了这个原子性的操作流程。

  2. 创建即填充 (Creation is Population): 当你创建一个 VertexBuffer 对象时,你的意图几乎总是“创建一个 VBO 并立即用某些数据填充它”。构造函数是执行这种“创建并初始化”逻辑的完美场所。

  3. 临时性的绑定: 对 VBO/IBO 的绑定通常是短暂的。我们绑定它,是为了填充数据,或者是为了将它关联到 VAO。完成这些操作后,我们常常会解绑它(glBindBuffer(..., 0)),或者绑定下一个 VBO。因此,在构造函数中执行这次“一次性”的绑定,并不会对后续状态造成不良影响。

1
2
3
4
5
6
// 这种模式非常自然
// 构造函数内部:
// glGenBuffers(...)
// glBindBuffer(...) <-- 绑定,准备填充
// glBufferData(...) <-- 填充
VertexBuffer vb(data, size);

Group 2: VertexArrayShader - “状态切换器”

这两个类的核心职责与 VBO/IBO 完全不同。它们不是数据的容器,而是状态的管理者。它们的作用是在渲染循环中被频繁地激活(绑定)和停用(解绑),以切换 GPU 的整体渲染状态。

为什么在构造函数中 Bind() 是不合理/危险的?

  1. 创建和使用是分离的:

    • 创建 (new Shader(...)) 通常发生在程序的初始化阶段。你可能会一次性加载所有需要的着色器。
    • 使用 (shader.Bind()) 发生在渲染循环内部,并且是在需要用这个特定着色器绘制某个物体时才调用。
    • 如果你在构造函数中就 Bind() 了着色器,那么在初始化阶段加载完所有着色器后,最后一个被创建的着色器将保持激活状态,这很可能不是你想要的,并且会污染后续的 OpenGL 状态。
  2. 破坏了状态管理的确定性:

    • 想象一下你的初始化代码:
      1
      2
      3
      4
      // 初始化阶段
      Shader shaderA("shaderA.glsl"); // 构造时自动Bind,现在shaderA是激活的
      Shader shaderB("shaderB.glsl"); // 构造时自动Bind,现在shaderB是激活的
      Shader shaderC("shaderC.glsl"); // 构造时自动Bind,现在shaderC是激活的
    • 在进入渲染循环之前,哪个着色器是激活的?是 shaderC。这完全是由对象的创建顺序决定的,非常隐晦且危险。如果有人调整了这几行代码的顺序,程序的行为就会在不经意间改变。
  3. 违反了使用者的预期:

    • 一个程序员在使用 ShaderVertexArray 类时,他的心智模型是:
      • Shader myShader(...) -> “我准备好了一个着色器。”
      • myShader.Bind() -> “我现在要使用这个着色器。”
    • 如果构造函数自动 Bind(),就打破了这种“准备”和“使用”的清晰分离。它让对象的创建产生了**副作用 (Side Effect)**——即改变了全局的 OpenGL 状态,这是非常糟糕的设计。
  4. VertexArray 同理:

    • VAO 的创建和配置通常在初始化时完成。
      1
      2
      3
      4
      5
      // 初始化
      VertexArray va;
      va.Bind(); // 显式绑定,准备配置
      va.AddBuffer(vb, layout);
      va.Unbind(); // 配置完成,解绑
    • 在渲染循环中,你再根据需要 va.Bind() 来切换到这个 VAO 的状态。如果在构造时就自动绑定,同样会产生上面提到的所有问题。

总结:角色决定行为

角色 核心操作模式 在构造函数中 Bind() 是否合理?
VertexBuffer / IndexBuffer 数据容器 创建 -> 绑定 -> 填充 (一次性设置) ,这符合其原子性的创建流程。
VertexArray / Shader 状态切换器 创建 (准备) -> 在需要时绑定 (激活) -> 使用 -> 解绑 ,这会产生意外的副作用,破坏状态管理的确定性。

Cherno 的设计完美地体现了“最小意外原则 (Principle of Least Astonishment)”

  • 创建一个 VertexBuffer 时,你期望它已经被数据填充好了。
  • 创建一个 Shader 时,你不期望它会擅自改变当前激活的着色器。

这个看似微小的设计差异,实际上是区分一个仅仅“能用”的封装和一个“好用、安全、可维护”的封装的关键所在。它反映了设计者对 OpenGL 工作流程和良好软件工程实践的深刻理解。