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 类,自带 setupMesh(配显存)和 Draw(画自己)两个本领。(Mesh)。它装着三样东西:一组顶点(每个顶点带位置、法线、纹理坐标)、一组索引(用哪几个顶点、按什么顺序连成三角形)、一组纹理(这块零件该贴哪些图)。这三样凑齐,一个零件就能被完整地画出来。
为什么要拆成多个网格、而不是把整辆车塞进一个?因为不同零件用的材质与纹理集合不同:车身贴车漆、轮胎贴橡胶纹、玻璃贴反光图。一次 draw call 通常采用同一套材质绑定,所以「一套顶点 + 一套材质纹理」自然就是一个绘制单元。一个模型由多个网格组成——这正是下一章 Model 类要做的事:把上一章读出的那一堆零件,每个包成一个 Mesh,再统一管起来。本章只聚焦单个网格怎么自给自足。
顶点:把位置、法线、纹理坐标打包成一个结构体
一个顶点不止有位置,还带着法线(用于光照)和纹理坐标(用于采样贴图)。我们用一个结构体把它们打包:
关键在于这几样在内存里是 ↡把一个顶点的各个属性(位置、法线、纹理坐标)紧挨着连续存放,而不是把所有位置存一片、所有法线存另一片。一个顶点的数据是一个连续的小块,下一个顶点紧跟其后。好处是上传一个 VBO 就够了,GPU 读一个顶点时数据就在一起。(交错排列)的:一个顶点的位置、法线、纹理坐标紧挨着,下一个顶点紧跟其后,全挤在一条连续内存上。这样只用一个缓冲就能装下所有顶点。代价是:告诉 GPU「位置从哪个字节开始、法线从哪个字节开始」时,得算出每个属性在结构体里的字节偏移。手算很容易错(位置占 12 字节、法线又占 12……),所以我们交给 offsetof 去算——这是 §6 的重点。
纹理:不光是图,还带着「类型」和「路径」
网格里的每张纹理,我们也用一个小结构体描述,它存三样:纹理对象的 id、一个 type 字符串(形如 "texture_diffuse"、"texture_specular",标明这是漫反射图还是镜面图)、以及它的文件 path。
type 是后面 Draw 自动绑定纹理的关键——靠它才能把每张图接到着色器里对应的采样器上。path 暂时用不到,留着是为了下一章去重:同一张贴图可能被多个网格引用,记下路径就能「这张图已经加载过、直接复用」,避免重复占显存。这一章先把这个字段摆上,下一章 Model 再用。
代码逐段拆解
下面把这个 Mesh 类从头搭一遍。和前几章不同,这章没有 WebGL2 对照——Mesh 是组织模型的 C++ 类,不是某段可移植的着色器逻辑,所以我们专心把这一版 C++ 看透。先预测三个阶段出错时的现象:上传空容器处理错误可能直接崩溃;布局记录错误会让法线或 UV 错位;纹理命名或单元绑定错误则通常几何正常但表面发黑。
上传连续的顶点与索引字节
先生成 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 同理灌进 ↡Element Buffer Object,索引缓冲对象。它存的不是顶点本身,而是一串「用第几个顶点」的编号。靠它,被多个三角形共用的顶点(比如方块四个角)只需在 VBO 里存一份,绘制时按索引重复引用,省显存也省带宽。。下面接着配顶点属性指针——这才是 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,就是为一个 ↡一个顶点身上携带的各项数据——位置、法线、纹理坐标等,每一项就是一个属性。glVertexAttribPointer 告诉 GPU 每个属性在那条 interleaved 内存里从哪个字节起、隔多远是下一个顶点的同名属性。 描述「它在内存里怎么取」,两个关键参数是步长(stride)和偏移(offset)。步长每个属性都填 sizeof(Vertex),意思是「从这个顶点的某属性跳到下一个顶点的同属性,要跨过整整一个结构体」——这正是 interleaved 排布的直接后果。偏移则用 ↡C/C++ 标准库的一个宏,offsetof(结构体, 字段) 返回该字段从结构体起始处算起的字节偏移。这里 offsetof(Vertex, Normal) 自动得到法线的真实偏移、offsetof(Vertex, TexCoords) 自动得到纹理坐标的真实偏移,省得手算,改了结构体或对齐配置也不易出错。 自动计算,不依赖固定的 12 或 24。
为什么非用 offsetof 不可?因为结构体内存是连续 interleaved 的,每个属性的起点就是它前面所有字段的字节数之和。手算固然能填 12、24,但一旦你往 Vertex 里加个字段、或调整顺序,所有手算的数字就全错了;offsetof 让编译器替你算,结构体怎么改都不会错。
第五步:Draw(shader)——按命名约定自动绑纹理
第二段核心代码。难点不在画三角形(那只是最后一行 glDrawElements),而在画之前怎么把这堆纹理接到着色器的采样器上。我们靠一套↡网格的纹理 type 和着色器里 sampler 的 uniform 名遵守同一套起名规则:漫反射图叫 texture_diffuse 加序号(texture_diffuse1、texture_diffuse2…),镜面图叫 texture_specular 加序号。Draw 按这套规则机械地拼出名字、把第 i 张纹理接到同名 sampler,不必为每个网格手写绑定。来自动化:
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 号↡GPU 里编了号的纹理插槽(GL_TEXTURE0、GL_TEXTURE1…)。先 glActiveTexture 选中第 i 号插槽,再 glBindTexture 把一张纹理插进去;着色器里则把对应 sampler 的值设成 i,它就去第 i 号插槽取图。多张纹理同时用,就靠不同编号的纹理单元区分。;按 type 给这一类的序号加一、拼出 material.texture_diffuse1 这样的字符串,用 setInt 把编号 i 设给同名的采样器 uniform;最后 glBindTexture 把这张图绑到刚激活的第 i 号单元。三步合起来,就把「第 i 张纹理」接到了「着色器里名字对得上的那个 sampler」。
注意序号 diffuseN/specularN 都从 1 起、且各自独立计数——所以两张漫反射图会是 texture_diffuse1、texture_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_TEXTURE0、GL_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
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 资源与纹理绑定:五段轨迹
当前观测: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 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“假定 glm::vec3 紧密无填充并手写 offset,法线/UV 从错误字节读取”
offsetof/stride 与 C++ Vertex 实际布局一致,EBO 绑定保存在该 VAO
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 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。