从顶点数据到第一个三角形
用 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 的完整三角形
- 能比较
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。