参考文章: LearnOpenGL CN

介绍

OpenGL本身并不是一个API,它仅仅是一个由 Khronos组织 制定并维护的规范(Specification)

OpenGL自身是一个巨大的状态机(State Machine), 通常通过设置选项,操作缓冲来更改OpenGL的状态

什么是状态机

OpenGL只是一个标准/规范

开始

首先要做的就是创建一个OpenGL上下文(Context)和一个用于显示的窗口

流行的库:

  • GLUT
  • SDL
  • SFML
  • GLFW: 一个专门针对OpenGL的C语言库, 它提供了一些渲染物体所需的最低限度的接口. 它允许用户创建OpenGL上下文, 定义窗口参数以及处理用户输入

OpenGL动态链接库在 Windows 上的路径: C:\Windows\System32\opengl32.dll


需要用到的库

  • GLFW: 一个专门针对OpenGL的C语言库, 它提供了一些渲染物体所需的最低限度的接口. 它允许用户创建OpenGL上下文, 定义窗口参数以及处理用户输入
  • OpenGL
  • GLAD: 运行时获取函数地址的库 (管理OpenGL的函数指针)
库的使用与编译暂不记录

窗口

while(!glfwWindowShouldClose(window))
{
    glfwSwapBuffers(window);
    glfwPollEvents();  
}

glfwSwapBuffers 交换缓冲有什么用?

交换缓冲实际上是双缓冲的交换, 前缓冲保存着最终输出的图像,它会在屏幕上显示;而所有的的渲染指令都会在后缓冲上绘制. 当所有的渲染指令执行完毕后,我们交换(Swap)前缓冲和后缓冲,这样图像就能立即呈显出来.

那什么是单缓冲?

单缓冲是指直接在屏幕上按照从左到右,由上而下逐像素地绘制图像, 也就是说使用单缓冲, 你会感觉到画面的撕裂感


三角形 - 顶点与片段

参考文章: 你好, 三角形

记住三个单词:

  • 顶点数组对象:Vertex Array Object,VAO
  • 顶点缓冲对象:Vertex Buffer Object,VBO
  • 元素缓冲对象:Element Buffer Object,EBO

    • 或 索引缓冲对象 Index Buffer Object,IBO

3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线管理的

图形渲染管线可以被划分为两个主要部分:

  1. 3D坐标转为2D坐标
  2. 2D坐标转变为实际的有颜色的像素
图形渲染管线 (Graphics Pipeline) 大多译为管线, 实际上指的是一堆原始图形数据途经一个输送管道, 期间经过各种变化处理最终出现在屏幕的过程

图形渲染管线指的是一个过程, 自然会有多个阶段, 每个阶段将会把前一个阶段的输出作为输入

当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个阶段 (渲染管线) 运行各自的小程序,从而在图形渲染管线中快速处理你的数据. 这些小程序叫做着色器 (Shader)

着色器是一个小程序, 可由自己编写代码控制

OpenGL 着色器是用 OpenGL 着色器语言 (OpenGL Shading Language, GLSL) 写成的

pipeline.png


我们以数组的形式传递3个3D坐标作为图形渲染管线的输入, 这个数组称为顶点数据 (Vertex Data)

OpenGL 光知道了顶点数据还不够, 我们还需提示 OpenGL 这些顶点数据是用来渲染成什么的, 比如点, 线, 三角形

这种提示被称之为图元 (Primitive)

  1. 顶点着色器: 把一个单独的顶点作为输入, 将3D坐标转为另一种3D坐标
  2. 图元装配: 把所有的点装配成指定图元的形状
  3. 几何着色器: 通过产生新顶点构造出新的(或是其它的)图元来生成其他形状
  4. 片段着色器: 把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)
OpenGL中的一个片段是OpenGL渲染一个像素所需的所有数据。

几何着色器的输出会被传入光栅化阶段 (Rasterization Stage)

光栅化会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping). 裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率.

片段着色器主要用于计算像素的最终颜色

最后一步, Alpha测试和混合(Blending)阶段, 这个阶段会检测片段的对应深度, 混合 alpha (透明度)

不必慌张, 如果只是绘制一个简单的三角形, 不需要上面所说的所有着色器, 但必须要定义至少一个顶点着色器和一个片段着色器

GPU中没有默认的顶点/片段着色器

绘制三角形

数据准备

OpenGL 只在标准化设备坐标里绘制图形, x, y, z 坐标范围均为 (-1 ~ +1)

因此, 先定义一串浮点数组, 每个点的坐标范围在 -1 到 1 之间:

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

坐标如图:

ndc.png


创建顶点缓存对象 VBO, 用于管理显存中的顶点:

  1. 生成一个带有缓冲ID的VBO对象
  2. 指定缓冲对象类型
  3. 最后, 将顶点数据复制到缓冲的内存中
// 1. 生成带有缓冲 ID 的 VBO 对象
// glGenBuffers(VBO 对象数量, VBO对象)
unsigned int VBO;
glGenBuffers(1, &VBO);

// 2. 绑定缓冲对象类型 GL_ARRAY_BUFFER
glBindBuffer(GL_ARRAY_BUFFER, VBO);

// 3. 复制顶点数据到显存中
// glBufferData(目标缓冲的类型, 数据的大小, 实际数据, 显卡管理给定数据的方式)
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

显卡管理数据的方式有 3 种:

  • GL_STATIC_DRAW 数据不会或几乎不会改变
  • GL_DYNAMIC_DRAW 数据会频繁改变
  • GL_STREAM_DRAW 数据每次绘制时都会改变

顶点着色器

TODO: 这里的 GLSL 写法还可以再改进

编写顶点着色器, 使用的是 GLSL,

基础 GLSL 顶点着色器源码:

#version 330 core
// 此为注释

// (location = 0) 定义顶点属性位置
// vec3 声明三维向量变量
// in 关键字, 获取输入
// aPos 为变量名
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

#version 330 core 声明 OpenGL 版本

编译着色器:

// GLSL 源码
// 必须动态编译着色器
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";

// 创建着色器对象, 这里创建的是顶点着色器
// glCreateShader(着色器类型)
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);

// 编译着色器
// glShaderSource(着色器对象, 源码数量, 源码, 未知)
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

我们还可以通过以下代码来判断着色器是否编译成功:

int  success;
char 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;
}

片段着色器

片段着色器器用于计算像素颜色:

此处输出的是橘色

#version 330 core
// out 输出变量
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

编译片段着色器:

unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

着色器程序

两个着色器现在都编译了,剩下的事情是把两个着色器对象链接到一个用来渲染的着色器程序 (Shader Program) 中

就如同 C/C++ 的编译一样, 针对多个编译后的对象, 需要用链接器将它们组合成一个完整的程序

将每个着色器的输出链接到下个着色器的输入, 也就是说, 代码顺序很重要:

// 创建着色器程序对象
unsigned int shaderProgram = glCreateProgram();

// 附加着色器并链接
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

检查链接是否发送错误的代码同理:

glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    ...
}

待着色器程序链接成功, 会得到一个着色器程序对象, 还需要一步, 激活该程序对象

glUseProgram(shaderProgram);

// 之前生成着色器已经没用了
// 可以通过 glDeleteShader 来删除着色器对象
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

但是还没结束

虽然我们向 GPU 发送了显存数据, 编译了着色器程序

注意我们向 GPU 发送的数据是浮点数组, OpenGL 根本不知道数组里面的数据究竟描述的是什么

我们需要告诉 OpenGL, 任何解释数组中的数据, 即 链接顶点属性


链接顶点属性

vertex_attribute_pointer.png

告诉OpenGL该如何解析顶点数据:

glVertexAttribPointer(顶点属性, 顶点属性大小, 数据类型, 是否数据标准化, 步长, 偏移量)

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);

// 启用
glEnableVertexAttribArray(0);

函数 glVertexAttribPointer 参数的进一步解释:

  • 顶点属性大小: 我们使用的三维向量来表示坐标
  • 数据类型: 浮点构成的数组, 所以是 GL_FLOAT
  • 顶点属性: 在之前的顶点着色源码中, layout(location = 0) 定义了position顶点属性的位置
  • 是否标准化: 将所有数据映射到范围 0 ~ 1 之间, (如果数据是有符号的, 则映射到 -1 ~ 1 之间)
  • 步长: 每组顶点属性的间隔
  • 偏移量: 这里做了强制类型转换, 数据在数组的开头, 所以是 0

当你链接顶点属性成功后, 你就可以绘制图形了, 但是, 每绘制一次就要重新链接一次顶点属性, 会会非常麻烦

为避免这种麻烦, 我们还需要将其存放至顶点数组对象中, 方便重复使用


顶点数组对象

顶点数组对象 (Vertex Array Object, VAO) 可以像顶点缓冲对象那样被绑定, 顶点属性调用都会储存在这个VAO中

一个顶点数组对象会储存以下这些内容:

  • glEnableVertexAttribArrayglDisableVertexAttribArray 的调用
  • 通过 glVertexAttribPointer 设置的顶点属性配置。
  • 通过 glVertexAttribPointer 调用与顶点属性关联的顶点缓冲对象。

vertex_array_objects.png

创建 VAO 对象:

// 创建 VAO 对象
unsigned int VAO;
glGenVertexArrays(1, &VAO);

绑定 VAO 对象:

在这里我们只需把在 glBindVertexArray(VAO) 放在之前操作的最前面, 就可以完成绑定 VAO 的绑定

注意: 这里的代码只运行一次, 是一次初始化操作 ( 除非你的物体频繁改变 )
glBindVertexArray(VAO);

// 绑定 VBO 的代码 
// 链接顶点属性的代码

// 停止绑定, 防止数据混淆
// 不过一般很少用解绑
glBindVertexArray(0); 

// **************************************************
// 渲染循环:
glBindVertexArray(VAO); // 使用之前绑定的 VAO
// 一些绘制图形的代码...
OpenGL的核心模式要求我们使用VAO,好让它知道如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西

我们还可以同时绑定两个 VBO 或 VAO

像这样:

unsigned int VBOs[2], VAOs[2];
glGenVertexArrays(2, VAOs);
glGenBuffers(2, VBOs);

glBindVertexArray(VAOs[0]);
// 需要绑定的代码 1

glBindVertexArray(VAOs[1]);
// 需要绑定的代码 1

绘制

做完之前所有的操作, 现在到了最后一步, 绘制三角形

// 在渲染循环中:
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

绘制两个三角形

现在, 我们想用两个三角形来绘制一个矩形

我们可以再定义一个三角形, 也就是说再添加三个顶点, 这样算下来, 就有 6 个顶点, 显然顶点太多了 (一个矩形才四个点), 重复了两个点, 而且会导致不必要的性能损耗

这个时候, 就要用到 EBO 了, 它存储 OpenGL 用来决定要绘制哪些顶点的索引。我们只需要定义 4 个顶点位置, 然后说明绘制顺序即可, 这称之为 索引绘制(Indexed Drawing)

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,2,3)就是顶点数组vertices的下标,
    // 这样可以由下标代表顶点组合成矩形

    0, 1, 3, // 第一个三角形
    1, 2, 3  // 第二个三角形
};

创建 EBO:

unsigned int EBO;
glGenBuffers(1, &EBO);

// 绑定 VBO 并复制到缓冲中
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

// 渲染循环
...

// glDrawElements(绘制模式, 绘制数量, 索引类型, EBO 偏移量)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
VAO 也可以跟踪 VBO 绑定

glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 线框模式

glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 默认的填充模式


总结

绘制一个三角形至少需要一个顶点着色器和一个片段着色器

除此之外, 还需要链接顶点属性和绑定顶点数组对象才能绘制三角形

要记住, OpenGL 是一个状态机

问题

GLAD初始化

让我搞了半天的问题, GLAD 的初始化位置

代码成功编译了, 但一旦开始运行就立马退出, 我硬是没搞明白哪儿出问题了, 后来仔细读了下 GLFW 的上下文指导, 才发现问题:

Loading extension with a loader library

我在程序的最开始就尝试初始化 GLAD, 这样显然是不行的

当 GLFW 创建完 OpenGL 上下文后, 才能初始化 GLAD

window = glfwCreateWindow(640, 480, "My Window", NULL, NULL);
if (!window)
{
    ...
}
 
glfwMakeContextCurrent(window);

// 初始化 GLAD
gladLoadGLLoader((GLADloadproc) glfwGetProcAddress);

窗口问题

问题1: 窗口有个难看的边框

一开始我是直接复制 GLFW 的实例代码来创建窗口的, 由于我的开发环境是 Hyprland, 运行出的窗口会出现一个很不和谐的标题栏

我们需要配置 GLFW 来取消窗口边框

// 在创建窗口前设置
glfwWindowHint(GLFW_DECORATED, GL_FALSE);

问题2: 绘制的三角形在右下角

这个问题其实是自己阅读不仔细导致的, 我没有设置 OpenGL 的视口 viewport (即 OpenGL 渲染窗口的尺寸大小), 导致渲染的的范围固定

其绘制结果就是: 绘制的三角形在右下角, 且不会随窗口变化而变化

解决方法就是使用回调函数, 动态设置 OpenGL 的渲染视口大小

// 注册回调函数
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

...

// 回调函数
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    glViewport(0, 0, width, height);
}

渲染视口大小并不是窗口大小, 这点要注意

在高分辨率的屏幕上, OpenGL 的渲染视口高宽通常大于窗口高宽


编译配置

先自行下载依赖:

GLFW: https://www.glfw.org/

GLAD: https://glad.dav1d.de/


Visual Studio

项目属性配置:

  • 输出目录: $(SolutionDir)$(Platform)\$(Configuration)\
  • C/C++ -> 常规 -> 附加包含目录:$(SolutionDir)Dependencies\GLFW\include;$(SolutionDir)Dependencies\GLAD\include;$(SolutionDir)Dependencies\GLM\include
  • 链接器 -> 输入 -> 附加依赖项: opengl32.lib;glfw3.lib;User32.lib;Gdi32.lib;Shell32.lib
  • 链接器 -> 附加库目录 -> $(SolutionDir)Dependencies\GLFW\lib-vc2022
记得把 glad.cpp 包含进 "源文件" 中

Linux

Cmake 的教程: https://cmake.org/cmake/help/latest/guide/tutorial/index.html

我的目录结构:

open-gl
|-main.cpp
|-CMakeLists.txt
|-build
|-GLAD
   |-include
   |    |-glad
   |       |-glad.h
   |-src
      |-glad.c

使用 CMake 编译项目:

cmake_minimum_required(VERSION 3.2...3.8)

project(GLStudy
    DESCRIPTION "OpenGL Study")

# 设置GL库偏好
set(OpenGL_GL_PREFERENCE LEGACY)
# set(OpenGL_GL_PREFERENCE GLVND)
find_package(OpenGL REQUIRED)
find_package(glfw3  REQUIRED)

# 设置包含文件夹
include_directories(GLAD/include)

add_executable(main main.cpp GLAD/src/glad.c)


# 链接
target_link_libraries(main glfw)
target_link_libraries(main OpenGL::GL)
最后修改:2024 年 06 月 10 日
如果觉得我的文章对你有用,请随意赞赏