本文描述如何使用OpenGL API 绘制一个纯色三角形,环境是 Windows + Visual Studio 2017,具体配置可以参考[OpenGL]Visual Studio 环境搭建
1. 整体步骤
我们的目标是在窗口中渲染出一个三角形,我们暂时先不考虑渲染管线的各种细节,思考一下在窗口中渲染出一个三角形需要做哪些事情呢?大概是以下三个问题。
- 用什么数据来表示三角形?
我们都知道三角形是由三个点的位置数据组成,当我们需要绘制三角形时,必须要提供三个点的坐标信息,也就是顶点数据,在示例中,我们使用一个静态结构数组来提供三角形的顶点数据。 - 三角形数据如何从 CPU 传输给 GPU?
通常绘制工作是由 GPU 来完成,而我们提交的顶点数据通常是由 CPU 从内存中读取。在我们的例子里,是程序代码中写死的一个结构数组,也就是运行时在内存中分配的一部分空间,而通常情况下数据是读取模型文件得到的,总之这部分数据是在内存中,而 GPU 并不能够直接访问内存,需要 CPU 将这部分数据复制到显存供 GPU 访问。(现代移动端 SoC 芯片,CPU 和 GPU 共享存储,暂不在此讨论)。 - 具体如何把三角形显示出来?
我们的显示屏幕是由一个一个方块,也就是像素组成,当 GPU 拿到三角形数据以后,它需要确定屏幕上哪些像素属于这个三角形的一部分,从而把这些像素绘制成三角形的颜色,从而得到一个三角形的形状,如何确定哪些像素被三角形覆盖的过程叫光栅化,本示例不展开讨论,可以参考详解渲染管线这篇文章,本示例将会讨论怎么确定三角形所覆盖的每个像素是什么颜色,这是由着色器来确定的。
下面我们来解释真个绘制过程中的几个关键步骤。
2. 第一步,准备顶点数据
- 声明顶点数据
示例中我们用一个数组来表示三角形的位置信息
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f // Top
};
我们这里提供的是归一化设备坐标,xyz 都在 [-1, 1] 区间内取值,关于什么是归一化设备坐标,可以参考详解渲染管线中关于坐标变换的部分,在后面你将知道,这里并非一定要提供归一化设备坐标,你可以提供任何坐标系下的坐标,只要你的着色器做对应的处理就就可以。
- 将数据提交到显存
// 分配一个 VBO id
GLuint VBO;
glGenBuffers(1, &VBO);
// VBO 绑定到 GL_ARRAY_BUFFER 缓冲
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 复制数据到缓冲区
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
OpenGL 中使用**顶点缓冲对象(Vertex Buffer Object, VBO)来管理显存中的顶点数据,上面的代码表示将顶点数据复制到了显存中的 GL_ARRAY_BUFFER 标识的这块缓冲区。
3. 第二步,准备着色器
3.1 什么是着色器
着色器是运行在 GPU 上的一段代码,它的作用是将输入的顶点数据(本示例中只有位置,大多情况下还包括颜色、法线等等)进行处理,得到每个像素的颜色,从而达到绘制的目的。着色器通常包括顶点着色器和片段着色器,着色器需要进行编译和链接才能使用。
3.2 顶点着色器
顶点着色器是一段 GPU 程序,它对每个顶点执行一次,当渲染三角形时,会将三角形的每一个顶点数据作为输入参数去调用一次顶点着色器,顶点着色器的输出作为输入参数来调用片段着色器。
#version 330 core
layout (location = 0) in vec3 position;
void main()
{
gl_Position = vec4(position.x, position.y, position.z, 1.0);
};
这是最简单的一段顶点着色器代码,第一行是着色器版本相关信息,可以不用关心,第二行
layout (location = 0) in vec3 position;
in
关键字表示着色器的输入参数时一个 vec3 类型(3个 float)的值,也就是位置信息,location = 0
表示从缓冲区取数据是的标识为0,也就是"我要取0号属性的数据“的意思。这个值是用来确保着色器正确的取到缓冲区数据的。
gl_Position = vec4(position.x, position.y, position.z, 1.0);
这一行表示了顶点着色器的处理逻辑:将输入的3维坐标转换为4维坐标作为输出。在真实的场景,我们提供给顶点着色器的一般不会是标准化设备坐标,在顶点着色器内可能会涉及一系列的坐标变换。
3.3 片段着色器
什么是片段(fragement)?片段可以理解为像素的内存模型,每一个像素对应一个片段,片段着色器针对每个像素执行一次,如果你的窗口分辨率为 800*600,那么每一帧将执行 800 * 600 次片段着色器
#version 330 core
out vec4 color;
void main()
{
color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
看看这段最简单的片段着色器代码,片段着色器的返回结果是该像素的最终颜色,需要一个4分量的向量来表示,可以用 out
关键字声明输出变量,这里我们命名为color。
color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
表示像素将被绘制成这个颜色。这里我们可能会有一个很大的疑问:顶点着色器的输出作为片段着色器的输入,顶点着色器只执行了3次,而片段着色器针对每个像素执行一次,3个顶点着色器的输出值是怎么对应那么多个片段着色器的输入的?这个问题是光栅化渲染的一个关键阶段,可以参考渲染管线详解这个文章,简单来说就是
遍历三角形覆盖到的所有像素,根据像素中心距离三个顶点的距离,对顶点着色器输出的数据进行插值,得到该像素对应的片段着色输入数据。
3.4 编译着色器
为了能够让OpenGL使用着色器,我们必须在运行时动态编译着色器的代码,包括顶点着色器和片段着色器,着色器动态编译代码如下:
GLuint shaderProgram;
GLFWwindow* window;
// Shaders
const GLchar* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 position;\n"
"void main()\n"
"{\n"
"gl_Position = vec4(position.x, position.y, position.z, 1.0);\n"
"}\0";
const GLchar* fragmentShaderSource = "#version 330 core\n"
"out vec4 color;\n"
"void main()\n"
"{\n"
"color = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
void compile_shader()
{
// 创建顶点着色器
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
// 指定着色器代码
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
// 编译顶点着色器,检查并提示编译结果
glCompileShader(vertexShader);
GLint success;
GLchar infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// 创建片段着色器
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
// 指定片段着色器代码
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
// 编译片段着色器,检查并提示编译结果
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// 创建着色器程序
shaderProgram = glCreateProgram();
// 绑定着色器
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
// 链接着色器程序,检查并提示链接结果
glLinkProgram(shaderProgram);
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
// 链接完成后移除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
}
这段代码我们创建、编译并连接得到了一个着色器程序对象 shaderProgram
,我们需要告诉 GPU 使用这个着色器程序对象来进行绘制,这个作用需要使用 APIglUseProgram
来完成。
glUseProgram(shaderProgram);
到现在,我们已经把输入顶点数据复制到了显存中对应的缓冲区,并通过编译连接着色器来告诉 GPU 将使用什么方式来处理顶点数据。现在还需要做的就是将缓冲区中的顶点数据进行解析,并与顶点着色器的输入属性对应起来。
4. 第三步,解析顶点属性
这一步骤的目的其实是要告诉 GPU,缓冲区中的数据是如何布局的,如何将缓冲区中的数据和顶点着色器的输入参数对应起来,通常使用 API glVertexAttribPointer
来实现,例如
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
这个 API 的参数意义如下:
- 第一个参数指定需要配置的顶点属性,对应顶点着色器中的
layout(location=0) in vec3 position;
,假如我们还使用了顶点颜色数据,在顶点着色器中定义了
layout(location=1) in vec4 color;
那么第一个参数需要传 1 来绑定缓冲区中的颜色数据和着色器中的变量
- 第二个参数表示顶点属性的尺寸,这里的顶点位置是一个vec3,它由3个 float 值组成,所以大小是3,这里的单位是第三个参数,而不是字节数
- 第三个参数表示属性的数据类型,着色器中 vec3 的分量都是 float 类型,这里参数值是 GL_FLOAT
- 第四个参数表示是否要将属性数据进行标准化。如果我们设置为GL_TRUE,所有数据都会被映射到[-1, 1] 或[0, 0] 之间
第五个参数是步长,表示连续的顶点属性组之间的间隔,也就是第二个顶点位置和第一个顶点位置之间间隔了3个 GL_FLOAT
最后一个参数的类型是 GLvoid* ,表示顶点属性数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0
整体来说,glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
这句代码调用的意义大概是:我现在需要从缓冲区中取数据,数据是给顶点着色器当做定义的layout=0
的参数用的,从缓冲区开头第一个字节(第五个参数值为0)开始取数据,连续读3个GL_FLOAT 的值组合起来作为一个顶点数据传递给顶点着色器,每隔 3 * sizeof(GLfloat) 个字节,是下一个顶点数据
最后,我们需要启用顶点属性,所有的顶点属性默认都是禁用的,需要调用 API glEnableVertexAttribArray
来进行启用
glEnableVertexAttribArray(0);
这句代码告诉 GPU:我要启用 0 号顶点属性,你去根据 glVertexAttribPointer
第一个参数为 0 的调用来解析缓冲区数据,取出对应的数据,传递给顶点着色中中 location=0
的参数吧
5. 使用顶点数组对象VAO进行绘制
到目前为止,其实我们已经完成了绘制三角形的所有流程:
- 将三角形位置信息复制到缓冲区中
- 定义顶点着色器和片段着色器来确定如何绘制三角形
- 链接顶点属性,告诉 GPU 如何解析缓冲区数据和如何将缓冲区中的数据与着色器中的属性对应起来
理论上来说我们已经可以完成三角形的绘制了。然而
OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。
那么什么是 VAO?已经有了 VBO 为什么还需要 VAO?
5.1 VBO
Vertex Buffer Object,顶点缓存对象,就是显存中的一块纯数据,由内存中复制过来的顶点数据
5.2 VAO
VAO 可以理解成一套解析规则或者一个状态,它记录了 glVertexAttribPointer
这个方法的调用结果,记录了每个顶点属性的指针,绘制时可以根据 VAO 中的指针去取顶点数据,而不需要每次绘制都重新进行 VBO的 解析,可以看看下面关于 VAO 的代码
// 申请缓冲区
GLuint VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// 绑定VAO,表示
glBindVertexArray(VAO);
// 提交数据
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 使用Buffer中的数据在 VAO 生成 0 号顶点属性的指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
// 启用 0 号顶点属性
glEnableVertexAttribArray(0);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
上面这段代码可以这么理解:申请了一片存储区域 VAO,在解析 VBO 的过程中,将每个顶点的 0 号属性(位置) 的起始位置指针和属性的大小记录在 VAO 的一个元素中,后面每一帧可以直接使用 VAO 的元素访问缓冲区的数据,传递给着色器,而不需要每一帧都解析了:
while (!glfwWindowShouldClose(window))
{
// 处理事件
glfwPollEvents();
// 清屏
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 指定着色器程序
glUseProgram(shaderProgram);
// 绑定VAO
glBindVertexArray(VAO);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
// 解绑 VAO
glBindVertexArray(0);
// 双缓冲交换
glfwSwapBuffers(window);
}
关于 VAO 更详细的内容,可以参考 [OpenGL]VBO,VAO和EBO详解 这篇文章
6. 完整代码
我们已经完成了使用 OpenGL 来绘制一个纯色三角形的所有代码,这里分为四个文件,分别为
common.h // 通用方法定义
shader_common.h // 着色器相关的通用定义
draw_triangle.h // 三角形绘制逻辑
main.cpp // 入口方法
现在将这些文件的代码分别列出
6.1 通用方法定义
// common.h
#pragma once
#include
#define GLEW_STATIC
#include
#include
const GLuint WIDTH = 800, HEIGHT = 600;
GLFWwindow* window;
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode);
void init_opengl()
{
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);
window = glfwCreateWindow(WIDTH, HEIGHT, "LearnOpenGL", nullptr, nullptr);
glfwMakeContextCurrent(window);
gladLoadGL();
glfwSetKeyCallback(window, key_callback);
int width, height;
glfwGetFramebufferSize(window, &width, &height);
glViewport(0, 0, width, height);
}
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode)
{
if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
glfwSetWindowShouldClose(window, GL_TRUE);
}
6.2 着色器相关的通用方法
// shader_common.h
#pragma once
#include "common.h"
GLuint compile_shader(const GLchar* vertexShaderSource, const GLchar* fragmentShaderSource)
{
// 创建顶点着色器
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
// 指定着色器代码
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
// 编译顶点着色器,检查并提示编译结果
glCompileShader(vertexShader);
GLint success;
GLchar infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// 创建片段着色器
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
// 指定片段着色器代码
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
// 编译片段着色器,检查并提示编译结果
glCompileShader(fragmentShader);
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}
// 创建着色器程序
GLuint shaderProgram = glCreateProgram();
// 绑定着色器
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
// 链接着色器程序,检查并提示链接结果
glLinkProgram(shaderProgram);
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
// 链接完成后移除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
return shaderProgram;
}
6.3 绘制三角形
// draw_triangle.h
#include "shader_common.h"
const GLchar* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 position;\n"
"void main()\n"
"{\n"
"gl_Position = vec4(position.x, position.y, position.z, 1.0);\n"
"}\0";
const GLchar* fragmentShaderSource = "#version 330 core\n"
"out vec4 color;\n"
"void main()\n"
"{\n"
"color = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
int draw_trangle()
{
// 初始化 OpenGL
init_opengl();
// 编译和链接着色器
GLuint shaderProgram = compile_shader(vertexShaderSource, fragmentShaderSource);
// 顶点数据
GLfloat vertices[] = {
-0.5f, -0.5f, 0.0f, // Left
0.5f, -0.5f, 0.0f, // Right
0.0f, 0.5f, 0.0f // Top
};
// 申请缓冲区
GLuint VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// 绑定VAO,表示
glBindVertexArray(VAO);
// 提交数据
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 使用Buffer中的数据在 VAO 生成 0 号顶点属性的指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0);
// 启用 0 号顶点属性
glEnableVertexAttribArray(0);
// 解绑VBO
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 解绑VAO
glBindVertexArray(0);
while (!glfwWindowShouldClose(window))
{
// 处理事件
glfwPollEvents();
// 清屏
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 指定着色器程序
glUseProgram(shaderProgram);
// 绑定VAO
glBindVertexArray(VAO);
// 绘制指令
glDrawArrays(GL_TRIANGLES, 0, 3);
// 解绑 VAO
glBindVertexArray(0);
// 双缓冲交换
glfwSwapBuffers(window);
}
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glfwTerminate();
return 0;
}
6.4 入口程序
#include "draw_triangle.h"
int main(void)
{
return draw_trangle();
}