Mesh 数据布局、GPU 资源与纹理绑定

Mesh 数据布局、GPU 资源与纹理绑定:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能说清一个**网格(Mesh)**由哪三样东西打包而成:一组顶点、一组索引、一组纹理,以及为什么一个模型要拆成多个 Mesh
  • 能看懂并独立写出 setupMesh():用 offsetof 定位每个顶点属性在结构体里的字节偏移、用 sizeof(Vertex) 当步长,把 VAO/VBO/EBO 一次性配好
  • 能用 .data() 安全上传空或非空容器,并解释 VAO 为什么会记住 EBO 与属性布局
  • 能实现 Mesh 的 GPU 资源释放与移动所有权,避免复制句柄造成重复删除
  • 能定位采样器命名、纹理单元上限或索引类型不匹配导致的黑模型与缺面问题

为什么一个模型不是一整块,而是一堆零件

上一章我们用工具把一个模型文件读进了内存,结果不是「一整个物体」,而是一堆零件:一辆车被拆成车身、轮胎、玻璃、车灯……每个零件有自己的一批顶点、自己该用哪张贴图。问题来了:拿到这一堆散件,怎么把它们一个个画到屏幕上?

直接的办法是写一大段流程,把所有零件的顶点、索引、贴图一股脑塞进去画。但零件一多,这段流程就乱成一团——哪批顶点配哪张贴图、哪段索引连哪个三角形,全靠人脑记,错一个就花屏。更好的办法是给每个零件配一个统一的「自装配说明书」:你把它该有的顶点、索引、贴图交给它,它自己就知道怎么把数据摆进显存、自己就能把自己画出来。

这一章就是造这本说明书——一个叫「网格」的小盒子。每个零件装进一个盒子,盒子自带「我怎么进显存」和「我怎么画自己」两套本领。配齐了,主程序只要对每个盒子喊一声「画」,整个模型就出来了;配不齐,你就得为每个零件手写一遍重复又易错的摆数据代码。

网格:一个能自己画自己的小盒子

我们把「一个能独立绘制的零件」叫做一个 (Mesh)。它装着三样东西:一组顶点(每个顶点带位置、法线、纹理坐标)、一组索引(用哪几个顶点、按什么顺序连成三角形)、一组纹理(这块零件该贴哪些图)。这三样凑齐,一个零件就能被完整地画出来。

为什么要拆成多个网格、而不是把整辆车塞进一个?因为不同零件用的材质与纹理集合不同:车身贴车漆、轮胎贴橡胶纹、玻璃贴反光图。一次 draw call 通常采用同一套材质绑定,所以「一套顶点 + 一套材质纹理」自然就是一个绘制单元。一个模型由多个网格组成——这正是下一章 Model 类要做的事:把上一章读出的那一堆零件,每个包成一个 Mesh,再统一管起来。本章只聚焦单个网格怎么自给自足。

顶点:把位置、法线、纹理坐标打包成一个结构体

一个顶点不止有位置,还带着法线(用于光照)和纹理坐标(用于采样贴图)。我们用一个结构体把它们打包:

关键在于这几样在内存里是 (交错排列)的:一个顶点的位置、法线、纹理坐标紧挨着,下一个顶点紧跟其后,全挤在一条连续内存上。这样只用一个缓冲就能装下所有顶点。代价是:告诉 GPU「位置从哪个字节开始、法线从哪个字节开始」时,得算出每个属性在结构体里的字节偏移。手算很容易错(位置占 12 字节、法线又占 12……),所以我们交给 offsetof 去算——这是 §6 的重点。

纹理:不光是图,还带着「类型」和「路径」

网格里的每张纹理,我们也用一个小结构体描述,它存三样:纹理对象的 id、一个 type 字符串(形如 "texture_diffuse""texture_specular",标明这是漫反射图还是镜面图)、以及它的文件 path

type 是后面 Draw 自动绑定纹理的关键——靠它才能把每张图接到着色器里对应的采样器上。path 暂时用不到,留着是为了下一章去重:同一张贴图可能被多个网格引用,记下路径就能「这张图已经加载过、直接复用」,避免重复占显存。这一章先把这个字段摆上,下一章 Model 再用。

代码逐段拆解

下面把这个 Mesh 类从头搭一遍。和前几章不同,这章没有 WebGL2 对照——Mesh 是组织模型的 C++ 类,不是某段可移植的着色器逻辑,所以我们专心把这一版 C++ 看透。先预测三个阶段出错时的现象:上传空容器处理错误可能直接崩溃;布局记录错误会让法线或 UV 错位;纹理命名或单元绑定错误则通常几何正常但表面发黑。

分步1 / 3

上传连续的顶点与索引字节

先生成 VAO/VBO/EBO,再用 vector.data() 与实际字节数上传。空容器应传 nullptr.data(),不能解引用不存在的第 0 个元素。

第一步:Vertex 结构体

一个顶点打包三样属性,全用 GLM 的向量类型,紧挨着声明就是 interleaved 排布:

struct Vertex {
  glm::vec3 Position;    // 位置(@0,占 12 字节)
  glm::vec3 Normal;      // 法线(@12,占 12 字节)
  glm::vec2 TexCoords;   // 纹理坐标(@24,占 8 字节)
  // 想做法线贴图时还能往下扩,比如:glm::vec3 Tangent;
};

在常见的默认 GLM 配置下,这三个字段通常紧挨着,sizeof(Vertex) 是 32 字节(12+12+8)。但若工程启用了对齐类型,编译器可能插入 padding;因此 32 只能用来理解,不能写死在 OpenGL 调用里。真正的步长与偏移必须分别取 sizeof(Vertex)offsetof,让当前编译配置给出权威布局。想支持法线贴图时,往结构体里再加一个 Tangent 字段即可,下游代码改动很小。

第二步:Texture 结构体

每张纹理记三样:

struct Texture {
  unsigned int id;       // OpenGL 纹理对象的句柄
  std::string type;      // 类型,如 "texture_diffuse" / "texture_specular"
  std::string path;      // 贴图文件路径(留作下一章去重缓存用)
};

id 是已经加载好、可以拿去绑定的纹理句柄;type 决定它在着色器里接到哪个采样器(第五步靠它拼名字);path 这章用不上,是给下一章 Model 做「同一张图只加载一次」的缓存键。

第三步:Mesh 类的成员和构造函数

类持有三组数据,外加一个公开VAO(让外部画的时候能绑定)。构造函数把传进来的三组数据存好,立刻调用 setupMesh() 把显存配置妥当:

class Mesh {
public:
  std::vector<Vertex>       vertices;   // 顶点
  std::vector<unsigned int> indices;    // 索引
  std::vector<Texture>      textures;   // 纹理
  unsigned int VAO;                     // 公开:Draw 时要绑它
 
  Mesh(std::vector<Vertex> vertices,
       std::vector<unsigned int> indices,
       std::vector<Texture> textures)
      : vertices(std::move(vertices)),
        indices(std::move(indices)),
        textures(std::move(textures)) {
    setupMesh();   // 一拿到数据就把 VAO/VBO/EBO 配好
  }
 
private:
  unsigned int VBO, EBO;   // 私有:外部不需要直接碰
  void setupMesh();
  // void Draw(Shader &shader);  // 见第五步,实际是 public
};

VAO 设为 public、VBO/EBO 设为 private,是因为绘制时外部只需要绑 VAO(它已经记住了 VBO/EBO 的配置),底下两个缓冲的句柄外部不必关心。构造里「一拿到数据就 setupMesh()」是这个类「自装配」的体现——使用者不用记得手动初始化。

但句柄不是普通整数:它们代表 Mesh 独占的 GPU 资源。若默认复制 Mesh,两个对象会拿着同一组句柄;一旦加入析构释放,就会重复删除。可复用实现至少应遵守 RAII:禁用复制、在移动时转交句柄、析构时释放,而且析构必须发生在 OpenGL context 仍有效的线程上。

#include <utility>
 
Mesh(const Mesh&) = delete;
Mesh& operator=(const Mesh&) = delete;
 
Mesh(Mesh&& other) noexcept
    : vertices(std::move(other.vertices)),
      indices(std::move(other.indices)),
      textures(std::move(other.textures)),
      VAO(std::exchange(other.VAO, 0)),
      VBO(std::exchange(other.VBO, 0)),
      EBO(std::exchange(other.EBO, 0)) {}
 
~Mesh() {
  if (EBO) glDeleteBuffers(1, &EBO);
  if (VBO) glDeleteBuffers(1, &VBO);
  if (VAO) glDeleteVertexArrays(1, &VAO);
}

生产代码还要实现移动赋值:先释放当前资源,再 std::exchange 接走对方句柄。若 std::vector<Mesh> 只通过构造与移动扩容,上面的移动构造已经覆盖主要路径;需要赋值时不能依赖编译器默认实现。

第四步:setupMesh()——offsetof 是主角

这是第一段核心代码:生成并绑定 VAO/VBO/EBO,把顶点和索引上传,然后告诉 GPU「每个顶点属性在那条 interleaved 内存里从哪个字节开始、隔多远是下一个顶点」。

void Mesh::setupMesh() {
  glGenVertexArrays(1, &VAO);
  glGenBuffers(1, &VBO);
  glGenBuffers(1, &EBO);
 
  glBindVertexArray(VAO);
 
  // 顶点数据:整个 vertices 一次性灌进 VBO(interleaved,连续一片)
  glBindBuffer(GL_ARRAY_BUFFER, VBO);
  glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),
               vertices.empty() ? nullptr : vertices.data(), GL_STATIC_DRAW);
 
  // 索引数据:灌进 EBO
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
  glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
               indices.empty() ? nullptr : indices.data(), GL_STATIC_DRAW);
  // …顶点属性指针见下方续 …
}

glBufferData 把整个 vertices 向量当成一片连续字节上传——因为它本来就是 interleaved 的,一次就够。这里用 .data() 而不是 &vertices[0]:空向量没有第 0 个元素,后者会触发未定义行为;字节数为 0 时传 nullptr 是明确且安全的。indices 同理灌进 。下面接着配顶点属性指针——这才是 offsetof 登场的地方:

  // 属性 0 = 位置:从结构体第 0 字节起,每跨 sizeof(Vertex) 是下一个顶点
  glEnableVertexAttribArray(0);
  glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
                        sizeof(Vertex), (void*)0);
 
  // 属性 1 = 法线:起点是 offsetof(Vertex, Normal)
  glEnableVertexAttribArray(1);
  glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
                        sizeof(Vertex), (void*)offsetof(Vertex, Normal));
 
  // 属性 2 = 纹理坐标:起点是 offsetof(Vertex, TexCoords)
  glEnableVertexAttribArray(2);
  glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE,
                        sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
 
  glBindVertexArray(0);   // 解绑,配置已记进 VAO
}

这里每调一次 glVertexAttribPointer,就是为一个 描述「它在内存里怎么取」,两个关键参数是步长(stride)和偏移(offset)。步长每个属性都填 sizeof(Vertex),意思是「从这个顶点的某属性跳到下一个顶点的同属性,要跨过整整一个结构体」——这正是 interleaved 排布的直接后果。偏移则用 自动计算,不依赖固定的 12 或 24。

为什么非用 offsetof 不可?因为结构体内存是连续 interleaved 的,每个属性的起点就是它前面所有字段的字节数之和。手算固然能填 1224,但一旦你往 Vertex 里加个字段、或调整顺序,所有手算的数字就全错了;offsetof 让编译器替你算,结构体怎么改都不会错。

第五步:Draw(shader)——按命名约定自动绑纹理

第二段核心代码。难点不在画三角形(那只是最后一行 glDrawElements),而在画之前怎么把这堆纹理接到着色器的采样器上。我们靠一套来自动化:

void Mesh::Draw(Shader &shader) {
  shader.use();
  GLint maxTextureUnits = 0;
  glGetIntegerv(GL_MAX_TEXTURE_IMAGE_UNITS, &maxTextureUnits);
  if (textures.size() > static_cast<size_t>(maxTextureUnits))
    throw std::runtime_error("Mesh exceeds fragment texture-unit limit");
 
  unsigned int diffuseN = 1, specularN = 1;   // 两类各自从 1 数
  for (unsigned int i = 0; i < textures.size(); i++) {
    glActiveTexture(GL_TEXTURE0 + i);          // 激活第 i 号纹理单元
 
    std::string number;
    std::string name = textures[i].type;       // "texture_diffuse" / "texture_specular"
    if (name == "texture_diffuse")
      number = std::to_string(diffuseN++);
    else if (name == "texture_specular")
      number = std::to_string(specularN++);
 
    // 拼出 "material.texture_diffuse1" 之类,设给对应 sampler
    shader.setInt(("material." + name + number).c_str(), i);
    glBindTexture(GL_TEXTURE_2D, textures[i].id);  // 把这张图绑到第 i 号单元
  }
  // …绘制见下方续…
}

setInt 修改当前 program 的 uniform,所以这里先 shader.use(),把激活着色器写成 Mesh 的明确契约。随后查询片段着色器可用的 GL_MAX_TEXTURE_IMAGE_UNITS;若模型纹理数超过硬件上限,继续做 GL_TEXTURE0 + i 不会得到更多可用插槽,应在导入阶段合并材质、减少贴图,或改成纹理数组等方案。

循环体每一轮干三件事:用 glActiveTexture(GL_TEXTURE0 + i) 激活第 i;按 type 给这一类的序号加一、拼出 material.texture_diffuse1 这样的字符串,用 setInt 把编号 i 设给同名的采样器 uniform;最后 glBindTexture 把这张图绑到刚激活的第 i 号单元。三步合起来,就把「第 i 张纹理」接到了「着色器里名字对得上的那个 sampler」。

注意序号 diffuseN/specularN 都从 1 起、且各自独立计数——所以两张漫反射图会是 texture_diffuse1texture_diffuse2,而镜面图是 texture_specular1。这必须和着色器里 sampler 的命名严格一致。绑完所有纹理,最后才真正画:

  // 真正绘制:绑 VAO,按 EBO 里的索引画三角形
  glActiveTexture(GL_TEXTURE0);             // 收尾,重置回 0 号
  glBindVertexArray(VAO);
  glDrawElements(GL_TRIANGLES, static_cast<GLsizei>(indices.size()),
                 GL_UNSIGNED_INT, 0);
  glBindVertexArray(0);
}

绑回 VAO(它记着第四步配好的所有属性指针和 EBO),调 glDrawElements——用 GL_TRIANGLES 模式、画 indices.size() 个索引、索引类型是 GL_UNSIGNED_INT。接口的数量参数是 GLsizei,因此构造时应拒绝超过 GLsizei 上限的超大索引数组,再做显式转换。注意这里是 glDrawElements不是 glDrawArrays,因为我们有索引、要按 EBO 复用顶点。着色器里与之配套的采样器声明长这样:

struct Material {
  sampler2D texture_diffuse1;
  sampler2D texture_specular1;
};
uniform Material material;
// 片段里就用 texture(material.texture_diffuse1, TexCoords) 取色

名字必须和 Draw 拼出来的一字不差——这就是「命名约定」的全部含义:两边约好同一套名字,绑定才能自动对上。

容易踩的坑

小结

  • Mesh 把顶点、索引、纹理与 VAO/VBO/EBO 封成一个绘制单元;CPU 容器可移动,GPU 句柄要有唯一所有者
  • 上传使用 .data() 与真实字节数,空容器不解引用第 0 个元素;析构则在有效 context 上释放三个 GPU 对象
  • 顶点属性的步长用 sizeof(Vertex)、偏移用 offsetof,不能把默认 GLM 布局下的 32/12/24 写死
  • Draw 先激活 shader、检查纹理单元容量,再按 type 拼出 texture_diffuseN 并绑定同名 sampler
  • 最后绑定 VAO,用 glDrawElements 按 EBO 索引绘制,并保证数量能安全表示成 GLsizei

练习

问题 1(扩代码型) 要给 Mesh 支持法线贴图,需要:① 在 Vertex 结构体里加一个切线字段 glm::vec3 Tangent;;② 在 setupMesh 里为它再配一个顶点属性指针(属性位置 3)。写出这第 3 个 glVertexAttribPointer 调用,并说明步长和偏移各填什么、为什么不用改前三个属性的偏移。

问题 2(问答 / 推导题) Draw 里如果把 diffuseN/specularN 的初值从 1 改成 0(于是拼出 texture_diffuse0),但着色器里 sampler 仍声明为 texture_diffuse1,会发生什么?为什么纹理单元的激活(glActiveTexture)不受这个改动影响、但纹理却照样贴不上?

问题 3(资源所有权排错题) 一个 std::vector<Mesh> 扩容后,旧 Mesh 被移动到新内存;随后旧对象析构。如果移动构造只是默认复制 VAO/VBO/EBO 句柄,会发生什么?写出移动时处理一个句柄的关键表达式,并说明为什么源对象必须归零。

名词解释

本章出现的专业名词,用大白话再讲一遍。

网格

一个能独立绘制的最小单元,由一组顶点 + 一组索引 + 一组纹理三样打包而成。一个模型(如一辆车)通常由多个网格组成(车身、轮胎、玻璃各是一个)。代码里它是一个 Mesh 类,自带「配显存」(setupMesh)和「画自己」(Draw)两个本领。详见本章「网格:一个能自己画自己的小盒子」。

interleaved

交错排列:把一个顶点的位置、法线、纹理坐标紧挨着连续存放,下一个顶点紧跟其后,全挤在一条连续内存上(而不是所有位置存一片、所有法线存另一片)。好处是上传一个 VBO 就够。详见本章「顶点:把位置、法线、纹理坐标打包成一个结构体」。

索引缓冲(EBO)

Element Buffer Object,索引缓冲。它存的不是顶点本身,而是一串「用第几个顶点」的编号。被多个三角形共用的顶点(如方块的角)只需在 VBO 里存一份,绘制时按编号重复引用,省显存。配 glDrawElements 使用。详见本章「第四步」。

offsetof

C/C++ 的一个宏,offsetof(结构体, 字段) 返回该字段从结构体开头算起的字节偏移。它会按当前编译器与对齐配置计算真实值,省得手算;改了结构体字段或 GLM 对齐选项,也不必同步维护魔法数字。详见本章「第四步」。

顶点属性

一个顶点身上携带的各项数据——位置、法线、纹理坐标等,每一项是一个「属性」。glVertexAttribPointer 告诉 GPU 每个属性在内存里从哪个字节起、隔多远是下一个顶点的同属性。详见本章「第四步」。

纹理单元

GPU 里编了号的纹理插槽(GL_TEXTURE0GL_TEXTURE1…)。先 glActiveTexture 选中第 i 号插槽,再 glBindTexture 把一张纹理插进去;着色器里把对应 sampler 的值设成 i,它就去第 i 号插槽取图。多张纹理同时用,靠不同编号区分。详见本章「第五步」。

采样器命名约定(texture_diffuseN)

网格纹理的 type 和着色器里 sampler 的 uniform 名遵守同一套起名规则:漫反射图叫 texture_diffuse 加序号、镜面图叫 texture_specular 加序号,序号都从 1 起。Draw 按这套规则机械地拼名、把第 i 张纹理接到同名 sampler,不必为每个网格手写绑定。详见本章「第五步」。

版本、来源与运行边界

本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。

正式概念与状态责任

  • mesh:在“Mesh 数据布局、GPU 资源与纹理绑定”中由Mesh 实例及其 VAO/VBO/EBO/texture handles负责解释其输入、受控状态和可观察结果;运行时以sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素定位它的第一处变化。
  • vertex:在“Mesh 数据布局、GPU 资源与纹理绑定”中由Mesh 实例及其 VAO/VBO/EBO/texture handles负责解释其输入、受控状态和可观察结果;运行时以sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素定位它的第一处变化。
  • index:在“Mesh 数据布局、GPU 资源与纹理绑定”中由Mesh 实例及其 VAO/VBO/EBO/texture handles负责解释其输入、受控状态和可观察结果;运行时以sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素定位它的第一处变化。
  • texture:在“Mesh 数据布局、GPU 资源与纹理绑定”中由Mesh 实例及其 VAO/VBO/EBO/texture handles负责解释其输入、受控状态和可观察结果;运行时以sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“setupMesh 上传数据并记录属性,Draw 绑定材质纹理后执行 indexed draw”发生后,Mesh 实例及其 VAO/VBO/EBO/texture handles应怎样改变Vertex 内存布局、索引、纹理语义/编号、attribute pointer 和资源寿命;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

Mesh 数据布局、GPU 资源与纹理绑定:状态合同

把 Vertex/Index/Texture 数据转成 Mesh 自有 VAO/VBO/EBO 与确定性 Draw 绑定

验证场景

官方教程正式概念

logl-16 · 基线帧

mesh固定 context、资源内容与输入事件,执行“setupMesh 上传数据并记录属性,Draw 绑定材质纹理后执行 indexed draw”

状态所有者Mesh 实例及其 VAO/VBO/EBO/texture handles
受控状态/资源Vertex 内存布局、索引、纹理语义/编号、attribute pointer 和资源寿命
触发命令setupMesh 上传数据并记录属性,Draw 绑定材质纹理后执行 indexed draw

冻结输入:mesh

Mesh 实例及其 VAO/VBO/EBO/texture handles记录Vertex 内存布局、索引、纹理语义/编号、attribute pointer 和资源寿命

结果:得到可重复的初始 GL 状态与资源身份

观测:sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素中的初始快照

预期:Mesh 实例及其 VAO/VBO/EBO/texture handles得到可复查结果,并持续满足“offsetof/stride 与 C++ Vertex 实际布局一致,EBO 绑定保存在该 VAO”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“setupMesh 上传数据并记录属性,Draw 绑定材质纹理后执行 indexed draw”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“offsetof/stride 与 C++ Vertex 实际布局一致,EBO 绑定保存在该 VAO”。

CPU command · GL state · GPU result

Mesh 数据布局、GPU 资源与纹理绑定:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素中的初始快照

不变量:offsetof/stride 与 C++ Vertex 实际布局一致,EBO 绑定保存在该 VAO

实验三:单故障与同输入恢复

注入“假定 glm::vec3 紧密无填充并手写 offset,法线/UV 从错误字节读取”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素一起恢复才算修复。

Single fault · first divergence · replay

Mesh 数据布局、GPU 资源与纹理绑定:反例与恢复

故障:假定 glm::vec3 紧密无填充并手写 offset,法线/UV 从错误字节读取

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“假定 glm::vec3 紧密无填充并手写 offset,法线/UV 从错误字节读取”

3. 定位首差一致

offsetof/stride 与 C++ Vertex 实际布局一致,EBO 绑定保存在该 VAO

4. 清理并重放一致

sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素

最小可重放检查

unit: logl-16
owner: Mesh 实例及其 VAO/VBO/EBO/texture handles
state_or_resource: Vertex 内存布局、索引、纹理语义/编号、attribute pointer 和资源寿命
command: setupMesh 上传数据并记录属性,Draw 绑定材质纹理后执行 indexed draw
pass_invariant: offsetof/stride 与 C++ Vertex 实际布局一致,EBO 绑定保存在该 VAO
single_fault: 假定 glm::vec3 紧密无填充并手写 offset,法线/UV 从错误字节读取
required_evidence: sizeof/offsetof、VAO 属性查询、buffer 大小、索引范围、纹理绑定与像素

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…