OpenGL渲染器04_绘制点线面
上一篇《OpenGL环境搭建》给接下来的内容创造了基础,我们拥有了glfw提供的窗口,也有了ImGui提供的GUI,接下来就磨刀霍霍向猪羊,准备大干一场。 不过,最开始,我们还只能绘制简单的东西:点、线、三角形和四边形。
目标
各种华丽的二维、三维图形,虚拟现实,都是由点线面基本图元构成的。如果接触过Maya、Blender等建模工具,会发现,模型的基础就是点线面,建模就是构建点线面,然后挤出立体,从而形成三维。
然而最简单的面就是拥有三个顶点的面:三角形。所以目标就是:
- 绘制点。
- 绘制线。
- 绘制三角形。
- 绘制四边形。
- 最后介绍一个调试程序RenderDoc,方便图形程序调试,也可以可视化了解渲染管线流程。
渲染管线
上代码之前,先了解了解渲染流程。
图形从坐标到显示在屏幕上,需要一系列流程,跟产品流水线一样,需要经过不同工序,最后屏幕计算出每个像素该是什么颜色(光栅化),然后显示出来。这条流水线叫做渲染管线。
GPU发展初期,使用的是固定渲染管道,中间工序无法手动编程修改。到后来,GPU渲染管线中,三个工序可编程定制。下图标为蓝色的就是可编程流程。
图片来自 learnopengl-cn

渲染管线:
- 顶点数据准备,包括顶点坐标、顶点颜色、顶点法线和顶点uv坐标等信息。
- 顶点着色器,每个顶点执行一次,主要目的就是顶点变换,也允许我们对顶点属性做一些处理,比如颜色、法线、uv坐标等。
- 形状(图元)装配,将输入的顶点装配为指定图元,假如我们指定图元是点,那就是一个个点。如果是线,则两个顶点之间绘制线,如果是三角形,会将输入顶点每三个一组装配为三角形。
- 几何着色器,根据输入的顶点产生新顶点,改变其形状。比如游戏中的车损、破碎等效果。
- 光栅化,就是将前面流程装配的图元映射到屏幕中的像素。图元是连续的,但屏幕像素是有限的,离散的。假如窗口分辨率是1920*1080,就是在1920*1080长度的RGB数组中,计算每个RGB的值。在进行片段着色器之前,会进行裁剪,丢弃视图之外的像色,提升执行效率。
- 片段着色器(片元着色器),每个像素执行一次。计算出像素最终颜色,也是所有OpenGL产生高级效果的地方,比如光照、阴影、反射等等。
- 测试与混合,这里检测几何的深度,那个物体在那个物体的后面、前面。如果被遮挡,就丢弃后谜案的像素。但有些情况下,如果物体时半透明、透明的,则需要混合,以此计算颜色。
更多可以在learnopengl-cn中查看。
其中,顶点着色器和片元着色器不可省略,因此,我们需要从着色器编写开始。
OpenGL着色器程序采用一种类似C的语言,叫做GLSL。
顶点着色器
下边是最基础的一个顶点着色器:
// 版本申明
#version 330 core
// 输入 a_position,类型是vec3,location是指定变量的位置,每个变量的location不能相同。
// vec3 拥有3个分量的向量;同理,vec4就是拥有4个分量 ...
layout (location = 0) in vec3 a_position;
void main()
{
// gl_Position 是OpenGL内部变量,就是输入该顶点的位置
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
上文提到过,顶点着色器是每个顶点都会执行一次,伪代码就是:
for(Vertex v : vetices){
// 顶点处理
v.postion = ?;
v.color=?;
v.textcoord=?;
//... 都是对 v 的属性的一些更新
}
片元着色器
片元,其实可以理解为像素,比如我们窗口分辨率下是400*600,那就是 400*600=240000个片元。
// 版本申明
#version 330 core
// 输出变量,片元颜色值
out vec4 FragColor;
void main()
{
// RGBA 颜色空间颜色值
FragColor = vec4(1.0f, 0.0f, 0.0f, 1.0f);
}
这里就引出另一个点,如何表示颜色?
我们应该都知道三原色,光的三原色是红Red绿Green蓝Blue,通过三个分量不同比例可以合成各种颜色,就是RGB颜色空间。RGB也有一部分颜色是缺失的。所以还有HSV颜色空间、HLS颜色空间等也很常用,这部分不细述了。
RGB颜色值,可能见的比较多的是0-255,但在OpenGL中,颜色值是0-1的。比如:
r1.0, g:0.0, b:0.0 就是红色;
r:1.0, g:1.0, b:0.0 就是黄色;
…
另外的一个分量Alpha,表示透明度,通常写作a。
绘制一个点
渲染任何物体的步骤基本是这样:
-
初始化
1) 编译着色器并获得着色器程序;
2) 准备顶点属性数据,坐标、颜色、UV坐标和法线等;
3) 初始化顶点数组对象、顶点缓冲对象和顶点索引缓冲对象(顶点索引缓冲对象可选),并分别设置值;
4) 开启顶点属性并映射值规则。
-
绘制
1) 获得着色器程序并应用;
2) 绑定顶点缓冲对象或顶点索引缓冲对象;
3) 执行绘制;
-
回收资源
我们会发现,初始化的部分比较复杂,流程繁多,因此我一个个步骤来写。
编译着色器
编写顶点着色器
#version 330 core
// 定义了两个输入型变量
layout (location = 0) in vec2 a_position; // 位置
layout (location = 1) in vec4 a_color; // 颜色 ...
out vec4 v_color;
void main(){
gl_Position = vec4(a_position, 0, 1);
v_color = a_color;
}
个人习惯:
受WebGL影响,输入型的变量称为attribute,所以我一般将变量名命名为: a_xxx;
另外,顶点着色器传给片元着色器的变量名,在WebGL中成为varying,所以我一般将变量命名为: v_xxx
编写片元着色器
#version 330 core
in vec4 v_color; // 注意这个变量名跟顶点着色器的输出变量一致,这样就可以拿到传输过来的值
out vec4 FragColor;
void main(){
FragColor = v_color; //将顶点的颜色值作为像素颜色值
}
链接着色器程序
这个流程有三个步骤:
- 编译着色器
- 创建着色器对象
- 编译着色器源码
- 如果编译成功返回,编译失败程序终止
- 创建着色器程序对象
- 着色器程序连接顶点着色器和片元着色器
// 1. 初始化着色器, 目前添加两个,一个顶点着色器,一个是片元着色器
const char* vsSource = "#version 330 core\n"
"layout (location = 0) in vec2 a_position;\n" // 位置
"layout (location = 1) in vec4 a_color;\n" // 颜色 ...
"out vec4 v_color;\n"
"void main(){\n"
" gl_Position = vec4(a_position, 0, 1);\n"
" v_color = a_color;\n"
"}";
const char* fsSource = "#version 330 core\n"
"in vec4 v_color;\n"
"out vec4 FragColor;\n"
"void main(){\n"
" FragColor = v_color;\n"
"}";
/**
* 通过顶点着色器和片元着色器获得着色器程序program
* @param vsSource 顶点着色器源码
* @param fsSource 片元着色器源码
*/
GLuint getProgramFromShaderSource(const char* vsSource, const char* fsSource) {
GLuint vs = compileShader(vsSource, GL_VERTEX_SHADER);
GLuint fs = compileShader(fsSource, GL_FRAGMENT_SHADER);
GLuint program = glCreateProgram();
int bLinkSuc;
int logLen = 512;
char infoLog[512];
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &bLinkSuc);
if(!bLinkSuc){
glGetProgramInfoLog(program, logLen, NULL, infoLog);
std::cout << infoLog << std::endl;
assert(0);
}
glDeleteShader(vs);
glDeleteShader(fs);
return program;
}
/**
* 编译着色器
* @param src 着色器源码
* @param type GL_VERTEX_SHADER,GL_FRAGMENT_SHADER,GL_GEOMETRY_SHADER
*/
GLuint compileShader(const char* src, GLenum type) {
int bCompileSuc;
int logLen = 512;
char infoLog[512];
GLuint vs = glCreateShader(type);
glShaderSource(vs, 1, &src, NULL);
glCompileShader(vs);
glGetShaderiv(vs, GL_COMPILE_STATUS, &bCompileSuc);
if(!bCompileSuc){
glGetShaderInfoLog(vs, logLen, NULL, infoLog);
std::cout << infoLog << std::endl;
assert(0);
}
return vs;
}
准备顶点属性数据
什么是顶点:顶点是空间中的一个点,一般使用坐标表示。那么在笛卡尔坐标系中二维可以表示为(x,y),三维可以表示为(x,y,z)…
两个点可以确定一条直线,三个点可以确定一个平面。我们先绘制三个点。
//2. 准备顶点属性数据; (1) 位置、 (2) 颜色
// 如何表示一个顶点数据? 在计算机层面如何存储。 Point(x, y), 笛卡尔坐标系方式,来确认出一个点的位置。存储使用数据存储
float vertexPos[] = {
0.0f, 0.5f, //第一个顶点
0.0f, 0.0f, //第二个顶点
0.5f, 0.0f, //第三个顶点
};
// 如何表示颜色数据:RGB色彩空间、HSV色彩空间... 这里采用RGB,然后加一个A表示透明度
float vertexColor[] = {
1.0f, 0.0f, 0.0f, 1.0f, //红色
0.0f, 1.0f, 0.0f, 1.0f, //绿色
1.0f, 1.0f, 0.0f, 1.0f, //黄色
};
我这里定义的顶点数据都在 -1.0 ~ 1.0 的范围内,这是**标准设备坐标(Normalized Device Coordinates, NDC)**范围,是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在屏幕上。与通常的屏幕坐标不同,y轴正方向为向上,(0, 0)坐标是这个图像的中心,而不是左上角。顶点着色器之后,所有(变换过的)坐标都在这个坐标空间中,否则它们就不可见了。

接下来是需要重点理解三个对象:
VAO:Vertex Array Object 顶点数组对象
VBO:Vertex Buffer Object 顶点缓冲对象
EBO:Element Buffer Object 索引缓冲对象
VBO
顶点缓冲对象。我们准备好顶点属性数据之后,需要将这些数据放到缓冲对象中。
那上边准备好的顶点属性数据作为例子:
顶点有两个属性(attribute),分别是坐标(a_position)和颜色(a_color),那么需要创建两个缓冲对象,然后把数据放进去。放进缓冲对象之后,还要告诉GPU通过什么样的规则处理这些数据。

如上图所示,VBO的初始化有以下几个步骤:
- 创建顶点属性缓冲对象;
- 设置值;
- 开启属性
- 设置值的规则;
代码:
//... 准备顶点属性数据的地方省略,就是上边的两个float数组
//1. 创建顶点属性缓冲对象:两个缓冲对象,坐标和颜色
GLuint vbo[2];
glGenBuffers(2, vbo);
//2.设置值
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]); //GL_ARRAY_BUFFER 缓冲区类型,使用缓冲之前一定要先绑定
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexPos), vertexPos, GL_STATIC_DRAW); //将坐标数据放进缓冲
// 拿到着色器中申明的变量地址location. glsl中的:layout (location = 0) in vec2 a_position;重点就是这里
GLuint loc = glGetAttribLocation(program, "a_position");
//3.开启属性
glEnableVertexAttribArray(loc);
//4.设置属性值的规则:
// GLuint index 属性的location
// GLint size 构成每个属性的分量个数:比如我们定的坐标只有x,y两个分量,这里就是2。到了三维阶段,我们需要定义x,y,z,值就是3
// GLenum type 构成每个属性的分量数据类型,我们定义了float数组,对应OpenGL中则是GL_FLOAT枚举
// GLboolean normalized 是否标准化,如果为true, 向量的标准化。详细后边写向量的地方会涉及这个点
// GLsizei stride 步长,加入我们在一个数组中放很多属性,步长就是顶点属性组之间的间隔。 learn-opengl.io网站描述的比较详细。因为我们把每个属性都分开VBO了,这里都是0
// const void *pointer 起始偏移值
glVertexAttribPointer(loc, 2, GL_FLOAT, false, 0, (void*) 0);
//颜色VBO同上
glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexColor), vertexColor, GL_STATIC_DRAW);
loc = glGetAttribLocation(program, "a_color");
glEnableVertexAttribArray(loc);
glVertexAttribPointer(loc, 4, GL_FLOAT, false, 0, (void*) 0);
VAO
顶点数组对象,可以理解为,VAO就是存储上边两个VBO的指针,以后用到两个VBO的地方,只需要绑定这个VAO就可以了。

不恰当的比喻:
VBO存了一堆数组的指针,也存了读取规则。
VAO则是存了一堆VBO的指针。
后边要介绍的EBO,则是存储了VAO的索引。
代码:
// 创建顶点数组
GLuint vao;
glGenVertexArrays(1, &vao);
//绑定顶点数组
glBindVertexArray(vao);
// ...在此处进行VBO的创建、设置值和规则配置。
// GLuint vbo[2];
// glGenBuffers(2, vbo);
// ...
// ...
// glVertexAttribPointer(loc, 2, GL_FLOAT, false, 0, (void*) 0);
//接触绑定,一般在上边Bind,结束就解除,绑定到0就是解除,因为每个VAO都是大于0的
glBindVertexArray(0);
渲染,调用draw call
上边的初始化步骤全是CPU在执行,这部分的代码,除非是物体顶点属性经常变化,一般都是执行一次,不放在渲染循环中去的。尽量保证渲染循环就拿数据渲染,少一些对象的创建和逻辑判断,减轻CPU的压力。
准备完Program、VAO、VBO之后。就可以执行绘制了。不过,我们希望在循环内绘制,只要窗口不关闭,我们要绘制的对象都不要消失,因此,下一步代码应该在主循环中写了。
上次我们定义了 renderScene()方法,方法就是主循环调起,之前实现是空的。现在我们就将三个点放入场景然后渲染。

渲染步骤:
- 应用着色器程序;
- 绑定顶点数组对象 VAO 或 EBO;
- 绘制。
EBO的绘制下文再说
实现:
// 应用着色器程序
glUseProgram(program);
// 绑定顶点数组对象 VAO
glBindVertexArray(vao);
glPointSize(5.0f); //点的像素大小,只有绘制点时候有用。
//绘制
// GLenum mode 模式 GL_POINTS点
// GLint first 起始索引,三个顶点都要绘制,所以起始是0
// GLsizei count 绘制总数,三个顶点,所以是3
glDrawArrays(GL_POINTS, 0, 3);
glBindVertexArray(0);
使用VAO绘制结果:

绘制线、面
绘制线,现有顶点 p1(0.0f, 0.5f)、p2(0.0f, 0.0f)、p3(0.5f, 0.0f)
只需要把渲染模式改为下列其中一个:
- GL_LINES ,独立的线段,绘制时因为p3后面没有顶点了,所以只绘制p1-p2的线段;
- GL_LINE_STRIP,连续连段,p1-p2,p2-p3两条线段;
- GL_LINE_LOOP,p1-p2, p3-p1,首尾相连。
为了动态更新观察变换,ImGui 就派上用场了,我决定加一个选择控件,动态更新绘制模式。【ImGui】的代码基本上可以用demo窗口的,拿过来改改就可以。
- 声明一个变量
// 定义当前绘制模式
static GLuint currentMode = GL_POINTS;//默认点模式绘制
- 画控件
// 列出所有的绘制模式,因为ImGui的选择空间Combo只支持字符串作为预览值,所以这里额外顶一个数组。
const GLuint modelItems[] = {GL_POINTS, GL_LINES, GL_LINE_STRIP, GL_LINE_LOOP, GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN};
// 展示选择框的文字,比如 GL_POINTS可以写作“点”(注意,这里中文可能会乱码,需要选择正确的字体格式才可以,后续会写)
const char* previewText[] = {"GL_POINTS", "GL_LINES", "GL_LINE_STRIP", "GL_LINE_LOOP", "GL_TRIANGLES", "GL_TRIANGLE_STRIP", "GL_TRIANGLE_FAN" };
static int item_current_idx = currentMode;
const char* combo_preview_value = previewText[item_current_idx];
if (ImGui::BeginCombo("Draw Mode", combo_preview_value))
{
// 循环定义选择框选项
for (int n = 0; n < IM_ARRAYSIZE(previewText); n++)
{
const bool is_selected = (item_current_idx == n);
if (ImGui::Selectable(previewText[n], is_selected))
item_current_idx = n;
if (is_selected)
ImGui::SetItemDefaultFocus();
// 赋值
currentMode = modelItems[item_current_idx];
}
ImGui::EndCombo();
}
- 渲染时使用currentMode变量
//... BindVertexArray(vao)..
//...
//绘制
glDrawArrays(currentMode, 0, 3);
//...glBindVertexArray(0);
//...
结果:

使用EBO
在正式使用EBO之前,我们先绘制一个四边形。
因为OpenGL最基本的面图元是三角形,我们没办法直接使用四个顶点来绘制出四边形,而是需要两个三角形连在一起绘制为四边形。
顶点数据准备:
float vertexPos[] = {
0.0f, 0.5f, //第一个顶点
0.0f, 0.0f, //第二个顶点
0.5f, 0.0f, //第三个顶点
0.0f, 0.5f, //第一个顶点
0.5f, 0.0f, //第二个顶点
0.5f, 0.5f, //第三个顶点
};
float vertexColor[] = {
1.0f, 0.0f, 0.0f, 1.0f, //红色
0.0f, 1.0f, 0.0f, 1.0f, //绿色
1.0f, 1.0f, 0.0f, 1.0f, //黄色
1.0f, 0.0f, 0.0f, 1.0f, //红色
1.0f, 1.0f, 0.0f, 1.0f, //黄色
0.0f, 1.0f, 1.0f, 1.0f, //
};
然后在渲染场景的地方,修改顶点数量:
glDrawArrays(currentMode, 0, 6); //3->6;currentMode选择GL_TRIANGLES
结果:

结果正是我们想要的,但仔细观察会发现,有两个顶点是重合的。
//第一个三角形
0.0f, 0.5f, //第一个顶点
0.5f, 0.0f, //第三个顶点
//第二个三角形
0.0f, 0.5f, //第一个顶点
0.5f, 0.0f, //第二个顶点
那么怎么避免这种事情发生呢,就需要EBO了。
EBO是顶点数组索引,可以在定义顶点后,使用索引在到需要使用的地方。那么四边形的绘制就变成了:
// 顶点
float vertexPos[] = {
0.0f, 0.5f, //第一个顶点
0.0f, 0.0f, //第二个顶点
0.5f, 0.0f, //第三个顶点
0.5f, 0.5f, //第四个顶点
};
// 索引
int indices[] = {
0, 1, 2,
0, 2, 3
};
//... VAO...
//... VBO...
GLuint ebo;
glGenBuffers(1, &ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 5.解除绑定
glBindVertexArray(0);
渲染时:
/**
* 渲染场景
*/
void renderScene() {
// 应用着色器程序
glUseProgram(program);
// 绑定顶点数组对象 VAO
glBindVertexArray(vao);
// 绑定EBO
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glPointSize(5.0f);
//绘制
// 6:索引数量
glDrawElements(currentMode, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
运行后会发现结果是一样的。
面的朝向
在定义顶点数据的时候,我们是逆时针顺序定义的。

所以,当我们要定义背面的顶点时候,其实就是过来顺时针定义。这个在三维的顶点定义时候会用到。
// 顶点
float vertexPos[] = {
0.0f, 0.5f,
0.5f, 0.5f,
0.5f, 0.0f,
0.0f, 0.0f,
};
// ...
RenderDoc
图形程序调试过程,会发现比较困难,首先是要保证渲染主循环要在30帧以上,像游戏,甚至要求在60帧以上,所以效率是最重要的。针对我们刚创建的VAO、VBO和EBO数据,同样也是调试比较困难。所以介绍一个图形化界面调试程序:RenderDoc。
RenderDoc 可以看到当前帧的执行情况,包括执行了多少次draw call,顶点数据经过顶点着色器之后变成了多少,还可以预览输入顶点的模型。
下载
使用
使用比较简单,首先,在【Launch Application】选项卡中,选择并运行我们的图形程序,然后再运行过程中 F12 快捷键生成若干份 Capture。打开某个Capture后,可以看到当前帧的执行流程。包括在渲染管线中,每个流程执行后的顶点坐标变化。



结语
绘制最基础的点、线和三角形需要了解VAO、VBO和EBO三大对象,就需要很多代码量,也是基础,也是最重要的。通过绘制简单的点、线和面,了解如何把数据发送到GPU中去,并经过一系列图形渲染管线,然后得到我们想要的图形。
笔记中的过程都经过反复验证,但也可能存在一些问题。我放了源码在这里,可以交流交流。
源码地址: 01_render_simple_graphic
https://gitee.com/xingchen0085/m-render_demo/tree/master/01_render_simple_graphic
- Author: xingchen
- Link: http://www.adiosy.com/posts/opengl_renderer/04_%E7%BB%98%E5%88%B6%E7%82%B9%E7%BA%BF%E9%9D%A2.html
- License: This work is under a 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议. Kindly fulfill the requirements of the aforementioned License when adapting or creating a derivative of this work.