从顶点数据到第一个三角形

用 shader program、VBO、VAO 与可选 EBO 建立顶点输入契约,跑通 draw call 到 framebuffer 的最短链路。

学习目标

  • 能解释 shader compile、program link 与 draw-time use 的对象生命周期
  • 能区分 VBO 存储、VAO 状态捕获和 EBO 索引复用的职责
  • 能计算 interleaved attributes 的 stride、offset、buffer bytes 与 primitive count
  • 能实现并调试从 vertex data、attribute layout 到 draw call 的完整三角形
  • 能比较 drawArraysdrawElements,改出不同形状和颜色并验证结果

为什么画一个三角形这么费劲

把一个三角形画到屏幕上,听起来像「给三个点连线填色」那么简单。但显卡不是这么工作的——它更像一条工厂流水线:原料从一头进去,经过一道道工位加工,成品从另一头出来。

你要做的不是「画」,而是备料 + 排产:先把三个角的坐标当原料送进车间,再告诉流水线每道工位怎么处理这批原料,最后按下开工按钮。

没有这套流程会怎样?显卡收到一堆数字却不知道它们是坐标、该连成什么、用什么颜色填——于是什么都不会出现。这一章就是把这条流水线一道道走通。

第一个三角形是一组对象契约

author inputs → create → capture → draw and verify

1. Source + dataGLSL · vertices · indices

CPU 侧权威输入

2. Create objectsprogram · VBO · VAO · EBO

合法 GPU objects

3. Capture contractattrib pointer + element buffer

VAO 保存读取规则

4. Draw + verifyuse · bind · draw · log

三角形和诊断证据

VBO 存 bytes,VAO 捕获 attribute 与 element-buffer 读取状态,program 定义阶段逻辑;绘制时 program 和 VAO 都必须有效。

第一个角色:顶点,与它携带的「属性」

流水线的原料是 ——一个数据点。三角形由三个顶点定义。但顶点不只是「一个坐标」:它身上可以挂多种信息,每一种叫一个

本章最简单的情形:每个顶点只带一个属性——位置,用 3 个 float(x、y、z)表示。三个顶点连起来就是 9 个 float,在内存里挨着排成一条直线:

顶点0          顶点1          顶点2
x0 y0 z0   |   x1 y1 z1   |   x2 y2 z2     ← 内存里就是连续的 9 个 float

显卡拿到的其实只是这 9 个裸数字,它并不天然知道「每 3 个是一个顶点的位置」——这一点稍后由 VAO 来交代。

为什么坐标只能写在某个范围里?因为顶点位置用的是 ——一个 x/y/zx/y/z 都落在 [1,1][-1, 1] 的标准空间,正中间是原点。不管屏幕多大,先用这套统一坐标写顶点,落在框外的部分会被裁掉。本章三个顶点 (0, 0.5)(-0.5, -0.5)(0.5, -0.5) 就是这套坐标里的一个三角形。

第二个角色:VBO,把原料一次性搬进显存

顶点数据现在还在内存(CPU 那边)。但绘制发生在显卡(GPU)那边,每帧都从内存现搬数据既慢又浪费带宽。于是先把它搬进 ——显存里一块专门的「料仓」。

它解决的问题:把顶点数据一次性从内存搬进显存,之后每帧绘制时显卡就近取料。没有它,每帧都要把这堆数字重新上传一遍,慢且浪费带宽。VBO 处在流水线最前端,是顶点数据进显卡的第一站。

若每个顶点有 kk 个 attributes,第 ii 个 attribute 含 cic_i 个、每个分量占 sis_i bytes,interleaved layout 的步长与 NvN_v 个顶点的最小 buffer bytes 为:

stride=i=1kcisi,Bvertex=Nvstridestride=\sum_{i=1}^{k}c_i s_i, \qquad B_{vertex}=N_v\cdot stride

本章只有 position:c=3c=3GL_FLOAT 为 4 bytes,所以 stride 是 12 bytes,三个顶点需要 36 bytes。代码中 sizeof(vertices)Float32Array.byteLength 应与这个手算结果一致。

第三个角色:VAO 与属性指针,告诉显卡怎么读这堆字节

数据进了显存,可显卡看到的还是一串裸字节,它不知道「每个顶点占几个字节、位置从第几字节开始读、一次读几个 float」。补上这份说明的,是 ,它通过 glVertexAttribPointer / gl.vertexAttribPointer 配置:

  • location:这是第几号属性(位置属性用 0 号,着色器里 layout(location = 0) 对应)
  • size:每次读几个值(位置是 3 个 float,所以填 3)
  • stride(步长):相邻两个顶点的起点间隔多少字节(每顶点 3 个 float = 12 字节)
  • offset(偏移):这个属性从顶点的第几字节开始读(位置在最前,填 0)

而这套「怎么读」的配置本身,得有个东西记下来——这就是 。VAO 不存顶点数据(数据在 VBO 里),它只存「解读方式」。绑定 VAO 之后再调 vertexAttribPointer,配置就被这张说明书捕获;之后绘制时只要绑回 VAO,显卡就知道怎么解读那堆数字。它处在流水线最前端的 环节。

一句话记住三者关系:VBO 是料(一串字节),属性指针是「怎么切这串字节」的规则,VAO 是把这套规则记下来的说明书。 下面这张图把这条链路和「属性指针怎么切字节」画在一起:

VAO 捕获的是读取状态,不是顶点 bytes

bind VAO → bind VBO → pointer/enable → optional EBO

1. Bind VAOglBindVertexArray

选择状态容器

2. Bind VBOGL_ARRAY_BUFFER

pointer 调用读取当前 binding

3. Define attributepointer + enable

format/stride/offset/buffer

4. Bind EBOELEMENT_ARRAY_BUFFER

index buffer 属于 VAO state

`glVertexAttribPointer` 把当时的 ARRAY_BUFFER 关联写进 attribute state;ELEMENT_ARRAY_BUFFER binding 本身属于当前 VAO。

glVertexAttribPointer 不会把 bytes 复制进 VAO;它把 format、stride、offset 和调用当时绑定的 VBO记录进当前 attribute state。另一个容易漏掉的规则是 GL_ELEMENT_ARRAY_BUFFER binding 属于 VAO state,因此创建 indexed mesh 时应在目标 VAO 绑定期间绑定 EBO。

EBO:矩形为什么不必重复两个顶点

两个三角形组成矩形时,drawArrays 可提交 6 个 vertices,但其中两个角重复。[0,1,2,0,2,3],让四个 unique vertices 组成两个 triangles。

GL_TRIANGLES 下,非索引与索引绘制产生的 primitive counts 分别为:

Ntriarrays=Nv3,Ntriindexed=Ni3N_{tri}^{arrays}=\left\lfloor\frac{N_v}{3}\right\rfloor, \qquad N_{tri}^{indexed}=\left\lfloor\frac{N_i}{3}\right\rfloor

是否节省 memory 取决于 vertex stride 与 index width。粗略 bytes 为:

Barrays=Nvstride,Bindexed=Nustride+NisindexB_{arrays}=N_v\cdot stride, \qquad B_{indexed}=N_u\cdot stride+N_i\cdot s_{index}

矩形中 Nu=4N_u=4Ni=6N_i=6;真实 mesh 的 vertex attributes 越大、共享越多,索引复用越有价值。EBO 的 index type 必须与 glDrawElements 的 type 匹配,最大 index 也必须小于 unique vertex count。

一点点坐标数学

上一节说过,顶点坐标用的是 NDC,范围 [1,1][-1, 1]。屏幕像素却是从左上角 (0,0)(0,0) 到右下角 (w,h)(w, h)。流水线靠 把前者映射到后者:

xscreen=xndc+12w,yscreen=1yndc2hx_{\text{screen}} = \frac{x_{\text{ndc}} + 1}{2} \cdot w, \qquad y_{\text{screen}} = \frac{1 - y_{\text{ndc}}}{2} \cdot h

这个式子在说:把 [1,1][-1, 1] 先平移、缩放到 [0,1][0, 1],再乘以屏幕宽/高。yy1yndc1 - y_{\text{ndc}} 是因为屏幕的 yy 轴朝下,而 NDC 的 yy 轴朝上,需要翻转。

这一步由显卡按你设的视口自动完成,代码里通常只调一次 glViewport / gl.viewport,无需手算——理解它能帮你想明白「为什么图形上下没颠倒」。

本章只用到上面这个轻量映射。但顶点在进 NDC 之前,往往还要被一连串矩阵变换「掰」到位(平移、旋转、缩放、投影)——那是后续「变换 / 坐标系统」章的主角。这里先给一个坐标变换预告:下面这个可视化里,拖动输入向量、调 2×2 矩阵的四个数,看变换后的网格和基向量 î / ĵ 怎么扭曲——先建立「矩阵 = 把整个空间掰一下」的手感。

可交互

交互可视化加载中…

流水线的五道工位

渲染管线把顶点变成像素,中间这五道工位是固定流程。其中第三道 会把矢量三角形切成一个个屏幕像素——每个被覆盖的像素产生一个 ,交给后面的工位上色。

下面这个分步可视化把同一份三角形数据走一遍:从三个孤立顶点,到连成三角形、铺到像素网格、逐片段上色、最后写入帧缓冲。可暂停、可单步、可拖进度:

光栅化把连续图元离散成覆盖样本

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。
渲染管线1 / 5

顶点着色器

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 互为镜像。

第一步:写两段着色器源码

显卡画图靠两段小程序: 处理每个顶点的位置, 算出每个片段的颜色。本章最简版:顶点着色器把传进来的位置原样输出,片段着色器输出固定的红色。

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); // 红
})";

两段逻辑完全相同,只有版本声明这一行不同——这是第一个要记住的差异点:

第二步:编译、链接成一个着色器程序

源码只是字符串,要先编译成显卡能跑的东西,再把两段链接成一个 。编译/链接失败时一定要查日志,否则后面只会得到一片黑。

Compile 与 Link 是两道独立门

shader objects are temporary; linked program is the draw-time executable

1. Create shaderglCreateShader(type)

阶段 object

2. Compilesource + status + log

每阶段独立通过

3. Link programattach + link + log

接口匹配的 executable

4. Detach/deleteprogram owns executable

临时 shader 可释放

Compile status 只证明单个 shader 合法;link status 还要验证阶段接口。Program 成功链接后可 detach/delete 临时 shader objects。
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 查日志

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);

两边逻辑完全对应,只是申请缓冲的 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);

对照 §3 那把尺子看这几个参数:第一个 0 是 location(对上着色器里 layout(location = 0));3 是每次读 3 个值;3 * sizeof(float) / 3 * 4 是步长(每顶点 12 字节);最后的 0 是偏移(位置从第 0 字节起读)。

第五步:下达绘制命令

万事俱备——启用程序、绑回 VAO、发一条 ,三角形就出现了:

glUseProgram(program);     // 启用着色器程序
glBindVertexArray(VAO);    // 绑回说明书
glDrawArrays(GL_TRIANGLES, 0, 3); // 从第 0 个顶点起画 3 个 → 一个三角形

drawArrays(TRIANGLES, 0, 3) 的意思是「从第 0 个顶点开始,取 3 个顶点,每 3 个组成一个三角形」。这一条命令一发,§5 那条「点 → 三角形 → 像素 → 上色」的流水线就被完整跑了一遍。完整可运行源码将在章末提供(M5)。

容易踩的坑

小结

  • 显卡像工厂流水线:你负责备料(顶点)与排产(配置),不直接「画」
  • VBO 是显存料仓,VAO 是料仓说明书,两者配合才能正确取数
  • 渲染管线五道工位:顶点 → 图元装配 → 光栅化 → 片段 → 测试混合
  • 片段着色器就能改出不同颜色/渐变;改顶点数据就能改形状
  • 顶点坐标用 NDC([1,1][-1,1]),由视口变换映射到屏幕像素

练习

问题 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。

讨论

评论区加载中…