从顶点数据到第一个三角形
从顶点数据到第一个三角形:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。
学习目标
- 能解释 shader compile、program link 与 draw-time use 的对象生命周期
- 能区分 VBO 存储、VAO 状态捕获和 EBO 索引复用的职责
- 能计算 interleaved attributes 的 stride、offset、buffer bytes 与 primitive count
- 能实现并调试从 vertex data、attribute layout 到 draw call 的完整三角形
- 能比较
drawArrays与drawElements,改出不同形状和颜色并验证结果
为什么画一个三角形这么费劲
把一个三角形画到屏幕上,听起来像「给三个点连线填色」那么简单。但显卡不是这么工作的——它更像一条工厂流水线:原料从一头进去,经过一道道工位加工,成品从另一头出来。
你要做的不是「画」,而是备料 + 排产:先把三个角的坐标当原料送进车间,再告诉流水线每道工位怎么处理这批原料,最后按下开工按钮。
没有这套流程会怎样?显卡收到一堆数字却不知道它们是坐标、该连成什么、用什么颜色填——于是什么都不会出现。这一章就是把这条流水线一道道走通。
第一个三角形是一组对象契约
author inputs → create → capture → draw and verify
CPU 侧权威输入
合法 GPU objects
VAO 保存读取规则
三角形和诊断证据
第一个角色:顶点,与它携带的「属性」
流水线的原料是 ↡渲染里最小的数据单元:一个点,至少携带位置,还可附带颜色、法线、纹理坐标等。三角形由 3 个顶点定义。——一个数据点。三角形由三个顶点定义。但顶点不只是「一个坐标」:它身上可以挂多种信息,每一种叫一个 ↡顶点身上的一项数据,如位置、颜色、法线、纹理坐标。一个顶点可同时带多个属性,着色器按编号(location)读取它们。。
本章最简单的情形:每个顶点只带一个属性——位置,用 3 个 float(x、y、z)表示。三个顶点连起来就是 9 个 float,在内存里挨着排成一条直线:
顶点0 顶点1 顶点2
x0 y0 z0 | x1 y1 z1 | x2 y2 z2 ← 内存里就是连续的 9 个 float显卡拿到的其实只是这 9 个裸数字,它并不天然知道「每 3 个是一个顶点的位置」——这一点稍后由 VAO 来交代。
为什么坐标只能写在某个范围里?因为顶点位置用的是 ↡Normalized Device Coordinates,标准化设备坐标:x/y/z 都落在 [-1,1] 的标准空间,正中间是 0,框外的会被裁掉。——一个 都落在 的标准空间,正中间是原点。不管屏幕多大,先用这套统一坐标写顶点,落在框外的部分会被裁掉。本章三个顶点 (0, 0.5)、(-0.5, -0.5)、(0.5, -0.5) 就是这套坐标里的一个三角形。
第二个角色:VBO,把原料一次性搬进显存
顶点数据现在还在内存(CPU 那边)。但绘制发生在显卡(GPU)那边,每帧都从内存现搬数据既慢又浪费带宽。于是先把它搬进 ↡Vertex Buffer Object,显存里一块专门存顶点数据的缓冲区;把顶点一次性搬进去,绘制时显卡就近取料,不必每帧从内存重传。——显存里一块专门的「料仓」。
它解决的问题:把顶点数据一次性从内存搬进显存,之后每帧绘制时显卡就近取料。没有它,每帧都要把这堆数字重新上传一遍,慢且浪费带宽。VBO 处在流水线最前端,是顶点数据进显卡的第一站。
若每个顶点有 个 attributes,第 个 attribute 含 个、每个分量占 bytes,interleaved layout 的步长与 个顶点的最小 buffer bytes 为:
本章只有 position:、GL_FLOAT 为 4 bytes,所以 stride 是 12 bytes,三个顶点需要 36 bytes。代码中 sizeof(vertices) 或 Float32Array.byteLength 应与这个手算结果一致。
第三个角色:VAO 与属性指针,告诉显卡怎么读这堆字节
数据进了显存,可显卡看到的还是一串裸字节,它不知道「每个顶点占几个字节、位置从第几字节开始读、一次读几个 float」。补上这份说明的,是 ↡一个告诉显卡怎么从 VBO 里切出顶点的设置:第几号属性、每次读几个值、什么类型、相邻两个顶点间隔多少字节(步长)、从第几字节起读(偏移)。,它通过 glVertexAttribPointer / gl.vertexAttribPointer 配置:
- location:这是第几号属性(位置属性用 0 号,着色器里
layout(location = 0)对应) - size:每次读几个值(位置是 3 个
float,所以填 3) - stride(步长):相邻两个顶点的起点间隔多少字节(每顶点 3 个
float= 12 字节) - offset(偏移):这个属性从顶点的第几字节开始读(位置在最前,填 0)
而这套「怎么读」的配置本身,得有个东西记下来——这就是 ↡Vertex Array Object,记录「顶点数据该怎么解读」的说明书对象:哪些属性开了、各自的步长/偏移/类型。绑回它,显卡就知道怎么读 VBO。。VAO 不存顶点数据(数据在 VBO 里),它只存「解读方式」。绑定 VAO 之后再调 vertexAttribPointer,配置就被这张说明书捕获;之后绘制时只要绑回 VAO,显卡就知道怎么解读那堆数字。它处在流水线最前端的 ↡渲染管线第一道工位,把顶点数据按 VAO 说明书取出、组织好交给后续阶段。 环节。
一句话记住三者关系:VBO 是料(一串字节),属性指针是「怎么切这串字节」的规则,VAO 是把这套规则记下来的说明书。 下面这张图把这条链路和「属性指针怎么切字节」画在一起:
VAO 捕获的是读取状态,不是顶点 bytes
bind VAO → bind VBO → pointer/enable → optional EBO
选择状态容器
pointer 调用读取当前 binding
format/stride/offset/buffer
index buffer 属于 VAO state
glVertexAttribPointer 不会把 bytes 复制进 VAO;它把 format、stride、offset 和调用当时绑定的 VBO记录进当前 attribute state。另一个容易漏掉的规则是 GL_ELEMENT_ARRAY_BUFFER binding 属于 VAO state,因此创建 indexed mesh 时应在目标 VAO 绑定期间绑定 EBO。
EBO:矩形为什么不必重复两个顶点
两个三角形组成矩形时,drawArrays 可提交 6 个 vertices,但其中两个角重复。↡Element Buffer Object,保存顶点索引并由drawElements按索引复用VBO顶点的缓冲对象存 [0,1,2,0,2,3],让四个 unique vertices 组成两个 triangles。
GL_TRIANGLES 下,非索引与索引绘制产生的 primitive counts 分别为:
是否节省 memory 取决于 vertex stride 与 index width。粗略 bytes 为:
矩形中 、;真实 mesh 的 vertex attributes 越大、共享越多,索引复用越有价值。EBO 的 index type 必须与 glDrawElements 的 type 匹配,最大 index 也必须小于 unique vertex count。
一点点坐标数学
上一节说过,顶点坐标用的是 NDC,范围 。屏幕像素却是从左上角 到右下角 。流水线靠 ↡把 [-1,1] 的 NDC 坐标按视口大小映射成屏幕像素坐标的那一步变换。 把前者映射到后者:
这个式子在说:把 先平移、缩放到 ,再乘以屏幕宽/高。 用 是因为屏幕的 轴朝下,而 NDC 的 轴朝上,需要翻转。
这一步由显卡按你设的视口自动完成,代码里通常只调一次 glViewport / gl.viewport,无需手算——理解它能帮你想明白「为什么图形上下没颠倒」。
本章只用到上面这个轻量映射。但顶点在进 NDC 之前,往往还要被一连串矩阵变换「掰」到位(平移、旋转、缩放、投影)——那是后续「变换 / 坐标系统」章的主角。这里先给一个坐标变换预告:下面这个可视化里,拖动输入向量、调 2×2 矩阵的四个数,看变换后的网格和基向量 î / ĵ 怎么扭曲——先建立「矩阵 = 把整个空间掰一下」的手感。
交互可视化加载中…
流水线的五道工位
渲染管线把顶点变成像素,中间这五道工位是固定流程。其中第三道 ↡把矢量图元(如三角形)切成一个个屏幕像素格子的过程,决定它盖住了哪些像素。 会把矢量三角形切成一个个屏幕像素——每个被覆盖的像素产生一个 ↡光栅化后产生的、对应一个像素的待着色数据点,片段着色器会逐个处理它。,交给后面的工位上色。
下面这个分步可视化把同一份三角形数据走一遍:从三个孤立顶点,到连成三角形、铺到像素网格、逐片段上色、最后写入帧缓冲。可暂停、可单步、可拖进度:
光栅化把连续图元离散成覆盖样本
primitive · sample grid · coverage · fragment output
连续边界与内部
离散 sample locations
产生 covered fragments
测试后成为像素
顶点着色器
GPU 拿到 3 个顶点,逐个算出它们在裁剪空间的位置(这里只是 3 个孤立的点)。
走通流程后,下面是本章主 Demo——一个真·三角形:顶点着色器吐出本章那三个 NDC 顶点 (0, 0.5)、(-0.5, -0.5)、(0.5, -0.5),片段着色器只给三角形覆盖到的片段上色,背景留空(透明)。先别看代码——
猜一猜:右侧片段着色器现在输出的是「随位置渐变 + 基色」。如果把
FragColor那行整段改成固定红色vec4(1.0, 0.0, 0.0, 1.0),画面会变成什么样?三角形的形状会变吗?动手改一下再看答案。
实时演示加载中…
代码逐段拆解
画一个三角形的完整代码就五步:写两段着色器 → 编译链接成程序 → 把顶点搬进 VBO → 用 VAO 记录怎么读 → 下达绘制命令。下面逐步对照,C++/OpenGL 与 WebGL2/TS 互为镜像。
第一步:写两段着色器源码
显卡画图靠两段小程序:↡渲染管线里第一段可编程小程序,对每个顶点跑一次,主要把顶点位置摆到 NDC 里(输出 gl_Position)。 处理每个顶点的位置,↡光栅化后对每个片段跑一次的小程序,算出这个片段最终是什么颜色(输出到 FragColor)。 算出每个片段的颜色。本章最简版:顶点着色器把传进来的位置原样输出,片段着色器输出固定的红色。
const char *vertexSrc = R"(#version 330 core
layout (location = 0) in vec3 aPos;
void main() {
gl_Position = vec4(aPos, 1.0);
})";
const char *fragmentSrc = R"(#version 330 core
out vec4 FragColor;
void main() {
FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红
})";const vertexSrc = `#version 300 es
layout (location = 0) in vec3 aPos;
void main() {
gl_Position = vec4(aPos, 1.0);
}`;
const fragmentSrc = `#version 300 es
precision highp float;
out vec4 FragColor;
void main() {
FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红
}`;两段逻辑完全相同,只有版本声明这一行不同——这是第一个要记住的差异点:
第二步:编译、链接成一个着色器程序
源码只是字符串,要先编译成显卡能跑的东西,再把两段链接成一个 ↡把编译好的顶点着色器和片段着色器链接到一起、可被显卡执行的整体;绘制前用 useProgram 启用它。。编译/链接失败时一定要查日志,否则后面只会得到一片黑。
Compile 与 Link 是两道独立门
shader objects are temporary; linked program is the draw-time executable
阶段 object
每阶段独立通过
接口匹配的 executable
临时 shader 可释放
unsigned int vs = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vs, 1, &vertexSrc, NULL);
glCompileShader(vs);
unsigned int fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fs, 1, &fragmentSrc, NULL);
glCompileShader(fs);
unsigned int program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program); // 失败用 glGetProgramInfoLog 查日志function compile(type: number, src: string) {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(s) ?? "编译失败");
return s;
}
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl.VERTEX_SHADER, vertexSrc));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSrc));
gl.linkProgram(program); // 失败用 gl.getProgramInfoLog 查日志createShader → shaderSource → compileShader 编译每一段,createProgram → attachShader → linkProgram 把两段焊成一个程序。绘制前再用 glUseProgram(program) / gl.useProgram(program) 把它启用。接着才轮到准备数据。
第三步:把顶点数据搬进 VBO
三角形的三个顶点先写成一个扁平数组,再创建一块缓冲、绑定、上传到显存:
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f,
};
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);const vertices = new Float32Array([
-0.5, -0.5, 0.0, 0.5, -0.5, 0.0, 0.0, 0.5, 0.0,
]);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);两边逻辑完全对应,只是申请缓冲的 API 命名风格不同:
第四步:用 VAO 记录属性布局(属性指针)
数据进了显存,还要告诉显卡「每个顶点 3 个 float、从第 0 字节开始」。绑定 VAO 后调用 vertexAttribPointer,这段配置就被这张「说明书」记下来:
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
glVertexAttribPointer(
0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 3 * 4, 0);
gl.enableVertexAttribArray(0);对照 §3 那把尺子看这几个参数:第一个 0 是 location(对上着色器里 layout(location = 0));3 是每次读 3 个值;3 * sizeof(float) / 3 * 4 是步长(每顶点 12 字节);最后的 0 是偏移(位置从第 0 字节起读)。
第五步:下达绘制命令
万事俱备——启用程序、绑回 VAO、发一条 ↡按 VAO 说明书从 VBO 取顶点、走完整条管线画出图元的绘制命令;glDrawArrays 从第 0 个顶点起画 3 个顶点、组成一个三角形。,三角形就出现了:
glUseProgram(program); // 启用着色器程序
glBindVertexArray(VAO); // 绑回说明书
glDrawArrays(GL_TRIANGLES, 0, 3); // 从第 0 个顶点起画 3 个 → 一个三角形gl.useProgram(program); // 启用着色器程序
gl.bindVertexArray(vao); // 绑回说明书
gl.drawArrays(gl.TRIANGLES, 0, 3); // 从第 0 个顶点起画 3 个 → 一个三角形drawArrays(TRIANGLES, 0, 3) 的意思是「从第 0 个顶点开始,取 3 个顶点,每 3 个组成一个三角形」。这一条命令一发,§5 那条「点 → 三角形 → 像素 → 上色」的流水线就被完整跑了一遍。完整可运行源码将在章末提供(M5)。
容易踩的坑
小结
- 显卡像工厂流水线:你负责备料(顶点)与排产(配置),不直接「画」
- VBO 是显存料仓,VAO 是料仓说明书,两者配合才能正确取数
- 渲染管线五道工位:顶点 → 图元装配 → 光栅化 → 片段 → 测试混合
- 改片段着色器就能改出不同颜色/渐变;改顶点数据就能改形状
- 顶点坐标用 NDC(),由视口变换映射到屏幕像素
练习
问题 1: 在上面可编辑 Demo 里做两件事:①把片段着色器 FragColor 那行改成固定红色 vec4(1.0, 0.0, 0.0, 1.0),看三角形变成纯红;②再把顶点着色器里 vec2( 0.0, 0.5) 这个顶点改成 vec2( 0.0, 0.9),看形状怎么变。解释为什么改 frag 只换颜色、改 vert 才换形状。
问题 2: 不看上面的代码,自己从零写出「创建 VBO + VAO + EBO + 配置位置属性」四步,画出一个矩形(目标效果:屏幕中央一个正方形)。
问题 3: 为什么要先绑定 VAO 再配置顶点属性,而不是反过来?EBO binding 又为何应在目标 VAO 绑定期间设置?
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 顶点(Vertex)
渲染里最小的数据单元——一个点。它至少带「位置」,还可以再挂颜色、法线、纹理坐标等。三角形就是由 3 个顶点定义的。详见本章「第一个角色」。
- 顶点属性(attribute)
挂在一个顶点身上的某一项数据,比如位置、颜色。一个顶点可以同时带好几个属性,着色器按编号(location)去读它们。本章每个顶点只带一个属性:位置。
- 顶点缓冲对象(VBO)
显存里一块专门存顶点数据的「料仓」。先把三角形三个角的坐标一次性搬进去,显卡画的时候就近取,不用每帧从内存重新搬——快、省。详见本章「第二个角色」。
- 属性指针
一份「怎么从 VBO 这串字节里切出一个个顶点」的说明:第几号属性、每次读几个值、相邻顶点隔多少字节(步长)、从第几字节起读(偏移)。靠
vertexAttribPointer配置。详见本章「第三个角色」。- 顶点数组对象(VAO)
料仓的「说明书」。它不存数据,只记「这堆数字里每个顶点占几个字节、坐标从第几字节开始读」(也就是上面的属性指针)。绑回这张说明书,显卡才知道怎么解读那堆数字。详见本章「第三个角色」。
- 输入装配
渲染管线的第一道工位:按 VAO 说明书把顶点数据取出、组织好,再交给后面的工位。可以理解成流水线的「上料口」。
- 标准化设备坐标(NDC)
一套「标准坐标尺」:不管屏幕多大,坐标都用 [-1, 1] 这个固定范围来表示,正中间是 0。落在这个范围外的东西会被裁掉。这样写顶点坐标时就不用关心屏幕到底多少像素。
- 视口变换
把上面那套 [-1, 1] 的标准坐标,按屏幕实际宽高换算成「第几个像素」的那一步。比如把 x=0(正中)算成屏幕宽度的一半处。显卡按你设的视口自动做,无需手算。
- 光栅化
把一个矢量图形(如三角形)切成一个个屏幕像素格子的过程。三角形本是「三个角 + 三条边」的数学描述,屏幕却只能点亮一个个方块像素——光栅化就是决定「哪些方块在三角形里、该被点亮」。下面这步分解可单步、可拖进度:
分步1 / 4① 拿到三角形
光栅化把连续图元离散成覆盖样本
primitive · sample grid · coverage · fragment output
1. Primitive3 vertices → triangle连续边界与内部
2. Pixel gridviewport samples离散 sample locations
3. Coverageinside / outside产生 covered fragments
4. Fragment outputFragColor → framebuffer测试后成为像素
片段不是最终像素;它还需通过深度、模板与混合等 per-fragment operations,才可能修改 framebuffer。 输入是三个顶点连成的矢量三角形,此刻它还没有任何「像素」概念,只是三个坐标。
- 片段
光栅化后产生的、对应一个像素的「待上色数据点」。它还不是最终像素——要等片段着色器算出颜色、再通过深度/混合测试后,才真正写进屏幕。可以理解成「准像素」。
- 顶点着色器
管线里第一段你能写的小程序,对每个顶点跑一次,主要任务是把顶点位置摆到正确的地方(输出
gl_Position)。本章里它只是把传进来的坐标原样输出。详见本章「第一步」。- 片段着色器
光栅化后对每个片段跑一次的小程序,决定这个片段最终是什么颜色(输出
FragColor)。改它就能改出不同颜色或渐变——这正是本章主 Demo 在玩的东西。- 着色器程序
把编译好的顶点着色器和片段着色器链接到一起、能被显卡执行的整体。绘制前用
useProgram启用它,显卡画图时才知道该跑哪两段着色器。详见本章「第二步」。- 绘制调用
按 VAO 说明书从 VBO 取顶点、走完整条管线画出图形的那条命令。本章用
drawArrays(TRIANGLES, 0, 3):从第 0 个顶点起取 3 个、组成一个三角形。详见本章「第五步」。- 元素缓冲对象(EBO)
保存 vertex indices 的缓冲。
drawElements按这些 indices 重用 VBO 中的 unique vertices;它的 binding 属于当前 VAO state。
版本、来源与运行边界
本章以 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 结果的验证。
正式概念与状态责任
- vertex buffer object:在“从顶点数据到第一个三角形”中由当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program负责解释其输入、受控状态和可观察结果;运行时以编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素定位它的第一处变化。
- vertex array object:在“从顶点数据到第一个三角形”中由当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program负责解释其输入、受控状态和可观察结果;运行时以编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素定位它的第一处变化。
- shader program:在“从顶点数据到第一个三角形”中由当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program负责解释其输入、受控状态和可观察结果;运行时以编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素定位它的第一处变化。
- triangle:在“从顶点数据到第一个三角形”中由当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program负责解释其输入、受控状态和可观察结果;运行时以编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素定位它的第一处变化。
章专属 OpenGL 状态实验
先预测“上传 VBO,记录 VAO 属性合同,绑定 program 后调用 glDrawArrays/glDrawElements”发生后,当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program应怎样改变顶点字节、stride/offset、属性启用位、program 链接状态和图元输入;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。
实验一:Context—资源—结果合同
选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。
Context · resource · observable result
从顶点数据到第一个三角形:状态合同
把顶点字节、VAO 属性解释和已链接 program 接成一次可解释的三角形 draw call
验证场景
官方教程正式概念
logl-03 · 基线帧
vertex buffer object:固定 context、资源内容与输入事件,执行“上传 VBO,记录 VAO 属性合同,绑定 program 后调用 glDrawArrays/glDrawElements”
冻结输入:vertex buffer object
当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program记录顶点字节、stride/offset、属性启用位、program 链接状态和图元输入
结果:得到可重复的初始 GL 状态与资源身份
观测:编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素中的初始快照
预期:当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program得到可复查结果,并持续满足“shader 读取的每个 location 都由当前 VAO 以同类型、同宽度和正确步长供给”
实验二:CPU 命令到 GPU 结果的五段轨迹
逐段执行“上传 VBO,记录 VAO 属性合同,绑定 program 后调用 glDrawArrays/glDrawElements”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“shader 读取的每个 location 都由当前 VAO 以同类型、同宽度和正确步长供给”。
CPU command · GL state · GPU result
从顶点数据到第一个三角形:五段轨迹
当前观测:编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素中的初始快照
不变量:shader 读取的每个 location 都由当前 VAO 以同类型、同宽度和正确步长供给
实验三:单故障与同输入恢复
注入“position 实际是 3 个 float,却把 stride 写成 5 个 float,第二个顶点起始地址错位”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素一起恢复才算修复。
Single fault · first divergence · replay
从顶点数据到第一个三角形:反例与恢复
故障:position 实际是 3 个 float,却把 stride 写成 5 个 float,第二个顶点起始地址错位
第 1 次沿用同一 context、资源、uniform 与 draw 输入
保持其余输入不变,仅注入“position 实际是 3 个 float,却把 stride 写成 5 个 float,第二个顶点起始地址错位”
shader 读取的每个 location 都由当前 VAO 以同类型、同宽度和正确步长供给
编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素
最小可重放检查
unit: logl-03
owner: 当前 VAO、GL_ARRAY_BUFFER/GL_ELEMENT_ARRAY_BUFFER 绑定与 shader program
state_or_resource: 顶点字节、stride/offset、属性启用位、program 链接状态和图元输入
command: 上传 VBO,记录 VAO 属性合同,绑定 program 后调用 glDrawArrays/glDrawElements
pass_invariant: shader 读取的每个 location 都由当前 VAO 以同类型、同宽度和正确步长供给
single_fault: position 实际是 3 个 float,却把 stride 写成 5 个 float,第二个顶点起始地址错位
required_evidence: 编译/链接日志、buffer 字节、VAO 属性查询、GL 错误与 framebuffer 像素复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。