入门
环境搭建和第一个 Demo
环境搭建分为两步:
安装 GLFW:这一步比较简单,略过
安装 GLAD:
前往 在线服务 语言设置为 C/C++, gl=version 4.6, profile=Core。勾选 Generate a loader
点击 GENERATE 并下载其中的 zip 。
将 zip 解压到 project/extern
在 CMakeLists.txt 中声明静态库:
add_library( glad STATIC "${CMAKE_CURRENT_SOURCE_DIR}/extern/glad/src/glad.c" ) target_include_directories(glad PRIVATE glfw3::glfw3 "${CMAKE_CURRENT_SOURCE_DIR}/extern/glad/include") target_link_libraries(glad PRIVATE dl)
在不进行链接 dl 库时,会报两个错:
|
经过上面的步骤,我们就创建了一个 OpenGL 开发环境和一个名为 glad 的静态库,接下来在项目中链接这些库:
add_executable(main main.cpp)
find_package(glfw3 REQUIRED)
target_include_directories(main PRIVATE glfw3::glfw3 "${CMAKE_CURRENT_SOURCE_DIR}/extern/glad/include")
target_link_libraries(main PRIVATE glfw glad)
接下来写代码:
// clang-format off
extern "C" {
#include <glad/glad.h>
#include <GLFW/glfw3.h>
}
// clang-format on
#include <iostream>
int main() {
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", nullptr, nullptr);
if(!window) {
std::cout << "Fail" << std::endl;
return -1;
}
glfwMakeContextCurrent(window);
if(!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
std::cout << "Fail" << std::endl;
return -1;
}
glViewport(0, 0, 800, 600);
glfwSetFramebufferSizeCallback(window, [](GLFWwindow* window, int width, int height) {
glViewport(0, 0, width, height);
});
while(!glfwWindowShouldClose(window)) {
// 按下 ESC 后退出窗口
if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) glfwSetWindowShouldClose(window, true);
// 开始渲染
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 检查事件
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwTerminate();
return 0;
}
在头两行之所以需要禁用 clang-format 是因为其会将头文件排序,并将 glad.h 放在 glfw3.h 后面,但是这样是不行的。
同时,根据上面的代码可以看出来构建一个窗体可分为以下几步:
初始化 glfw
创建窗口
初始化窗口上下文
加载 glad
初始化窗口的 ViewPort
设置窗口帧缓冲区的回调函数
进入事件循环
代码比较简单,而且注释很全面,不再进行多余的解释。
需要注意的是函数 glfwSwapBuffers 。此函数是双缓冲的,这里的双缓冲与 Qt 有所不同。由于 OpenGL 的像素是从上到下、从左向右一个一个像素进行绘制的,因此可能存在闪烁,这时最好的方式就是在后台缓冲区内将窗口绘制完毕后直接交换到前台,这时就不再有闪烁了。
此函数用于将缓冲区交换到 ViewPort 。如果不调用,程序启动时显示的画面就是窗口启动时屏幕上显示的画面,这样也能理解为什么程序卡顿时屏幕不再刷新(因为没有走到刷新函数的地方)。应该还有同类的非双缓冲函数,但是现在还没学到,等学到再更新吧。 |
绘图初步
在绘图前,需要先对 OpenGL 绘图有初步了解:
OpenGL 的绘图坐标是数学中的笛卡尔坐标系,而不是左上角为 (0,0) 的坐标系
OpenGL 的坐标范围为 [-1, 1],超过此范围的点不会进行显示
OpenGL 中的点都是三维的点。但是可以通过将 z 轴设置为零化为二维
OpenGL 中的颜色是 rgba
需要区分一下坐标和像素点,坐标是一个连续的实数,而像素在微观上是一个个矩形,因此将像素点的坐标是量子的,而且只有整数。OpenGL 在光栅化的过程中会将坐标点映射到像素点上。 这里可以参考一下 Qt 中的绘图,其总是将坐标映射到矩形的左下角 |
OpenGL 在将顶点转换为数据时一共有六个步骤:
顶点着色器用来进行坐标的映射(例如三维到二维)
图元装配 (Primitive Assembly)
将顶点装配成形状,对形状进行轮廓描述几何着色器绘制形状内部的点
着色器用于光栅化图元
片段着色器用于对像素进行上色。此阶段的上色会综合考虑像素的原色、阴影和光照等
混成阶段会考虑形状的叠加问题,通过检测 z 轴和 alpha 色得到像素的最终颜色。在此阶段中一些不需要的点会被丢弃
上述的每个阶段的输入是上个阶段的输出。每个阶段是高度独立、可并行的。
创建着色器
着色器用于对顶点进行处理。着色器使用 OpenGL 着色器语言 (OpenGL Shading Language)
写成。既可以将其独立编译亦可以将其硬编码在代码中在运行时进行编译
通过一个个着色器并将这些不同类型的着色器链接到一起,就形成了一个着色程序。着色程序在绘制图形前进行指定。
unsigned int createShader() {
// 顶点着色器
constexpr const char* vertexShaderSource =
"#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
auto vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr);
glCompileShader(vertexShader);
// 片段着色器
constexpr const char* fragmentShaderSource =
"#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
auto fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr);
glCompileShader(fragmentShader);
// 链接着色器
auto shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
// 删除已用的着色器
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
return shaderProgram;
}
开辟顶点缓冲区
顶点缓冲区 (Vertex Buffer Object)
用于在 GPU 中开辟一段缓冲区用于储存顶点。使用缓冲区可以一次性发送大量数据,而无需经由 CPU 一个顶点一个顶点地发送。
原教程将 Vertex Buffer Object 译作“顶点缓冲对象”,但是我认为关键不在于 Object 而在 Buffer,故译作顶点缓冲区 |
uint VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBindBuffer 允许创建并绑定 多种 缓冲区。缓冲对象除了 GL_ARRAY_BUFFER 还有用于储存其它类型缓存对象的缓冲区
复制顶点数据
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f , -0.5f, 0.0f,
0.0f , 0.5f , 0.0f
};
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBufferData 会根据第一个参数将数据复制到对应的缓冲区中,最后一个参数用来暗示数据的类型,有以下几种情况:
GL_STATIC_DRAW | 数据不会或几乎不会改变 |
GL_DYNAMIC_DRAW | 数据会被改变很多 |
GL_STREAM_DRAW | 数据每次绘制时都会改变 |
指定顶点属性
顶点属性表示了顶点在内存中的排列方式。本次顶点的排列方式为四字节对齐的顶点,中间不存在空隙,因此一个向量的大小为 4x3=32 字节
// 设置顶点数组
uint VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
// 指定顶点属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
这里的 顶点数组对象 ((Vertex Array Object)
是用来储存顶点的属性的。当我们有多种顶点属性时,我们无需每次都指定一次顶点属性,只需要将顶点与其相应的顶点数组绑定就好。与其叫做顶点数组对象,我看倒不如叫做顶点属性对象。
渲染顶点
渲染顶点需要在事件循环中,总共需要三个步骤:
// 指定着色器程序
glUseProgram(createShader());
// 指定顶点属性
glBindVertexArray(VAO);
// 渲染顶点
glDrawArrays(GL_TRIANGLES, 0, 3);
顶点索引
OpenGL 主要处理三角形,如果我们需要更加复杂的图形就需要使用三角形进行组合。例如画一个矩形:
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, 0.5f, 0.0f, // 左上角
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
可以看到有些顶点被重复指定了,当形状更加复杂时就会产生非常可观的开销。一种优化的方式是使用索引缓冲,在使用索引缓冲时我们可以定义顶点数组和形状的顶点索引来减小开销
float vertices[] = {
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
unsigned int indices[] = { // 注意索引从0开始!
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
自然地,缓冲区也要切换为索引缓冲区:
unsigned int EBO;
glGenBuffers(1, &EBO);
// 复制顶点数据
// ...
// 复制索引数据
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
当目标是 GL_ELEMENT_ARRAY_BUFFER 的时候,VAO 会储存 glBindBuffer 的函数调用。这也意味着它也会储存解绑调用,所以确保你没有在解绑 VAO 之前解绑索引缓冲区,否则它就没有这个 EBO 配置了。 |
绘制方式也要改变:
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glDrawElements 函数从当前绑定到 GL_ELEMENT_ARRAY_BUFFER 目标的 EBO 中获取索引。这意味着我们必须在每次要用索引渲染一个物体时绑定相应的 EBO,这还是有点麻烦。不过顶点数组对象同样可以保存索引缓冲对象的绑定状态。VAO 绑定时正在绑定的索引缓冲对象会被保存为 VAO 的元素缓冲对象。绑定 VAO 的同时也会自动绑定 EBO。 |