上一篇《OpenGL环境搭建》给接下来的内容创造了基础,我们拥有了glfw提供的窗口,也有了ImGui提供的GUI,接下来就磨刀霍霍向猪羊,准备大干一场。 不过,最开始,我们还只能绘制简单的东西:点、线、三角形和四边形。

目标

各种华丽的二维、三维图形,虚拟现实,都是由点线面基本图元构成的。如果接触过Maya、Blender等建模工具,会发现,模型的基础就是点线面,建模就是构建点线面,然后挤出立体,从而形成三维。

然而最简单的面就是拥有三个顶点的面:三角形。所以目标就是:

  1. 绘制点。
  2. 绘制线。
  3. 绘制三角形。
  4. 绘制四边形。
  5. 最后介绍一个调试程序RenderDoc,方便图形程序调试,也可以可视化了解渲染管线流程。

渲染管线

上代码之前,先了解了解渲染流程。

图形从坐标到显示在屏幕上,需要一系列流程,跟产品流水线一样,需要经过不同工序,最后屏幕计算出每个像素该是什么颜色(光栅化),然后显示出来。这条流水线叫做渲染管线。

GPU发展初期,使用的是固定渲染管道,中间工序无法手动编程修改。到后来,GPU渲染管线中,三个工序可编程定制。下图标为蓝色的就是可编程流程。

图片来自 learnopengl-cn

渲染管线:

  1. 顶点数据准备,包括顶点坐标、顶点颜色、顶点法线和顶点uv坐标等信息。
  2. 顶点着色器,每个顶点执行一次,主要目的就是顶点变换,也允许我们对顶点属性做一些处理,比如颜色、法线、uv坐标等。
  3. 形状(图元)装配,将输入的顶点装配为指定图元,假如我们指定图元是点,那就是一个个点。如果是线,则两个顶点之间绘制线,如果是三角形,会将输入顶点每三个一组装配为三角形。
  4. 几何着色器,根据输入的顶点产生新顶点,改变其形状。比如游戏中的车损、破碎等效果。
  5. 光栅化,就是将前面流程装配的图元映射到屏幕中的像素。图元是连续的,但屏幕像素是有限的,离散的。假如窗口分辨率是1920*1080,就是在1920*1080长度的RGB数组中,计算每个RGB的值。在进行片段着色器之前,会进行裁剪,丢弃视图之外的像色,提升执行效率。
  6. 片段着色器(片元着色器),每个像素执行一次。计算出像素最终颜色,也是所有OpenGL产生高级效果的地方,比如光照、阴影、反射等等。
  7. 测试与混合,这里检测几何的深度,那个物体在那个物体的后面、前面。如果被遮挡,就丢弃后谜案的像素。但有些情况下,如果物体时半透明、透明的,则需要混合,以此计算颜色。

更多可以在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. 初始化

    1) 编译着色器并获得着色器程序;

    2) 准备顶点属性数据,坐标、颜色、UV坐标和法线等;

    3) 初始化顶点数组对象、顶点缓冲对象和顶点索引缓冲对象(顶点索引缓冲对象可选),并分别设置值;

    4) 开启顶点属性并映射值规则。

  2. 绘制

    1) 获得着色器程序并应用;

    2) 绑定顶点缓冲对象或顶点索引缓冲对象;

    3) 执行绘制;

  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. 编译着色器
    1. 创建着色器对象
    2. 编译着色器源码
    3. 如果编译成功返回,编译失败程序终止
  2. 创建着色器程序对象
  3. 着色器程序连接顶点着色器和片元着色器
// 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

如上图所示,VBO的初始化有以下几个步骤:

  1. 创建顶点属性缓冲对象;
  2. 设置值;
  3. 开启属性
  4. 设置值的规则;

代码:

//... 准备顶点属性数据的地方省略,就是上边的两个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()方法,方法就是主循环调起,之前实现是空的。现在我们就将三个点放入场景然后渲染。

渲染步骤:

  1. 应用着色器程序;
  2. 绑定顶点数组对象 VAO 或 EBO;
  3. 绘制。

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)

只需要把渲染模式改为下列其中一个:

  1. GL_LINES ,独立的线段,绘制时因为p3后面没有顶点了,所以只绘制p1-p2的线段;
  2. GL_LINE_STRIP,连续连段,p1-p2,p2-p3两条线段;
  3. GL_LINE_LOOP,p1-p2, p3-p1,首尾相连。

为了动态更新观察变换,ImGui 就派上用场了,我决定加一个选择控件,动态更新绘制模式。【ImGui】的代码基本上可以用demo窗口的,拿过来改改就可以。

  1. 声明一个变量
// 定义当前绘制模式
static GLuint currentMode = GL_POINTS;//默认点模式绘制
  1. 画控件
 // 列出所有的绘制模式,因为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();
 }
  1. 渲染时使用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,顶点数据经过顶点着色器之后变成了多少,还可以预览输入顶点的模型。

下载

官网: https://renderdoc.org/

使用

使用比较简单,首先,在【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