WRITING · 文章

OpenGL study1:创建一个三角形

ZRonin

板块1:OpenGL初始化与窗口创建

先看代码

#include <glad/gl.h>
#include <GLFW/glfw3.h>
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, "Triangle", nullptr, nullptr);
    glfwMakeContextCurrent(window);
    gladLoadGL(glfwGetProcAddress);
    glViewport(0, 0, 800, 600);

这一整个板块干的事情只有一句话:创建一个窗口,并让 OpenGL 准备好在这个窗口里工作。

  1. GLFW 和 GLAD 分别负责什么

前两行的头文件

#include <glad/gl.h>
#include <GLFW/glfw3.h>

其中的

GLFW负责创建窗口,鼠标键盘输入,OpenGL Context,窗口事件。

GLAD负责加载OpenGL函数。

所以GLFW其实就是负责帮助OpenGL创建一个工作场地,而GLAD是负责帮我们拿到OpenGL的工具。

  1. glfwInit()

这个这一句的意思就是初始化GLFW,也就是启动GLFW。在写出这一段之前其余的glfw...()函数都不应该直接使用。

  1. glfwWindowHint
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE,GLFW_OPENGL_CORE_PROFILE);

这三行代码是在告诉GLFW我接下来想要创建什么样的OpenGL环境。

注意前两行括号末尾的两个3,这两个组合起来就是OpenGL3.3,也就是Major = 3,Minor = 3 → OpenGL 3.3

第三句里面的GLFW_OPENGL_PROFILE表示我要设置这个OpenGL Profile这个属性,然后GLFW_OPENGL_CORE_PROFILE就是表示将这个属性设置为Core Profile,Core Profile表示使用现代OpenGL的核心功能,这个写法可以很直观的表示为设置(音量,8)。除了Core Profile以外还有...COMPAT_PROFILE,这个是Compatibility Profile,兼容模式,它和Core的区别是Core是只保留现代 OpenGL 的核心用法,而Compatibility是现代 OpenGL+保留一些老 OpenGL 功能。

所以综上三句话合起来就是:请给我创建一个 OpenGL 3.3 Core 环境。

  1. 创建窗口
GLFWwindow* window =
    glfwCreateWindow(800,600,"Triangle",nullptr,nullptr);

拆开来看

glfwCreateWindow(...表示创建一个窗口,前面的GLFWwindow* 表示它返回一个指向GLFWwindow的指针,我们把这个指针保存进window,所以就有了GLFWwindow* window,所以可以理解成window是我们以后控制这个窗口的“句柄”。

在glfwCreateWindow( 下面的五个参数分别表示

800 → 窗口宽度

600 → 窗口高度

"Triangle" → 窗口标题

nullptr → 不使用全屏显示器

nullptr → 不与其他 OpenGL Context 共享资源

在这个函数里面的参数位置和类型都是固定的,我们按照位置填写上去,对于指针参数,如果我们填写nullptr就是表示不提供对应对象,所以上面第四个参数和第五个参数为什么都是填写nullptr,并不是这个nullptr有什么特殊功能。

  1. OpenGL Context

这一句很重要:

glfwMakeContextCurrent(window);

Context 这个词在图形学里面会经常遇到,可以理解OpenGL当前的工作环境。

现在代码里面有一个window,因为OpenGL还不知道“我接下来的绘制命令应该针对哪个窗口?”,所以glfwMakeContextCurrent(window);意思就是把window的OpenGL Context 设置成当前Context,之后的glClear(...),glDrawArrays(...),glCreateShader(...)这些 OpenGL 指令才知道自己在哪个环境执行。

  1. GLAD
gladLoadGL(glfwGetProcAddress);

这一句话翻译成人话就是:GLAD,通过 GLFW 提供的方法,把我需要的 OpenGL 函数全部加载进来。

现代OpenGL 有很多函数,比如:glGenBuffers(),glBindBuffer(),glCreateShader(),glUseProgram()

这些函数的实际实现来自显卡驱动,所以 GLAD 要去显卡驱动里找到它们。GLFW 给 GLAD 一个函数:glfwGetProcAddress,它的作用就是根据 OpenGL 函数名字找到对应函数地址。

所以实际流程就是:GLAD → glfwGetProcAddress → 显卡驱动 → 找到 OpenGL 函数地址

  1. glviewport
glViewport(0, 0, 800, 600);

这句话是在告诉 OpenGL:你最终把画面映射到窗口的哪一块区域。

四个参数值的意思表示为左下角 x = 0,左下角 y = 0,宽 = 800,高 = 600,所以就是使用整个800*600的窗口作为绘制区域。

板块2:顶点数据vertices

这个板块的对应代码

float vertices[] =
{
     0.0f,  0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f
};
  1. float vertices[]是什么

这个其实只是一个普通的cpp数组,float还是表示这个数组里面存的是浮点数,里面参数里面的0.0f后面的f就表示为这个小数按照float类型处理,而如果不写f就默认0.0这个值按照double处理

  1. 其中的参数表示什么意思
float vertices[] =
{
    // x        y       z
     0.0f,  0.5f, 0.0f,   // 顶点 1
    -0.5f, -0.5f, 0.0f,   // 顶点 2
     0.5f, -0.5f, 0.0f    // 顶点 3
};

这样子写就很直观了,每一行代表一个顶点,而一个顶点包含三个参数值,就是XYZ三个参数。

所以顶点1 = ( 0.0,0.5,0.0)

  1. 这些坐标到底是什么坐标

现在写的0.5f,0.0f这些不是像素,不是说x=0.5像素,而是OpenGL最后使用的一种标准化坐标:NDC:Normalized Device Coordinates,中文一般叫:标准化设备坐标。

NDC 仍然使用 x、y、z 三个坐标轴,所以形式上和数学中的三维直角坐标系类似,但它是 OpenGL 渲染管线中的一种标准化坐标空间。在 OpenGL 中,NDC 的 x、y、z 可见范围通常为 -1 到 1。

  1. 如何形成一个三角形

虽然顶点仍然使用三维坐标(x,y,z)表示,但当前三个顶点的 z 都为 0,因此它们位于同一个平面中。此时主要观察 x 、y 两个方向,就可以把它近似理解为二维坐标系中的三角形。

然后我们再按照普通的二维坐标把顶点都画上去就形成了一个三角形。

  1. 现在CPU还只是有了数据

目前我们输入的float vertices[]还只是CPU里面的一串数字,GPU 还不知道这些数字存在,所以后面会说如何把这些数据送进GPU,这就是VBO。

板块3:Vertex Shader 顶点着色器

const char* vertexShaderSource = R"(
    #version 330 core
    layout(location = 0) in vec3 aPos;
    void main()
    {
        gl_Position = vec4(aPos, 1.0);
    }
)";
  1. 先注意:这时候它还只是字符串
const char* vertexShaderSource = R"( ... )";

其中的函数const char* 表示指向一串不可修改的字符的指针,也就是说vertexShaderSource里面存的是一段文本。

而R"( ... )" 叫 Raw String Literal,原始字符串,它的好处是可以直接写多行字符串。

普通字符串可能要这样:

const char* text =
    "#version 330 core\n"
    "void main()\n"
    "{\n"
    "}\n";

用R"( ... )"就可以直接:

const char* text = R"(
    #version 330 core

    void main()
    {
    }
)";

这样就更容易看。

这一句目前还只是一句cpp代码,所以目前vertexShaderSource CPU → 内存中的字符串 → 里面恰好写着 GLSL 代码 这一套流程还没有正式运行。
2. Shader 用的不是 C++,而是 GLSL

#version 330 core

layout(location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos, 1.0);
}

这一段不是cpp,是GLSL(OpenGL Shading Language),OpenGL 的着色器语言。

C++负责程序逻辑、创建窗口、传数据,而GLSL负责 GPU 上的顶点和片元处理。

  1. version 330 core
#version 330 core

意思是这份 Shader 按照 GLSL 3.30 Core 规范编译。这里的330对应的是 GLSL 3.30 ,它和我们之前请求的:OpenGL 3.3 Core是相对应的,core则表示:使用 Core Profile 对应的现代 GLSL 规则。

  1. layout(location = 0)
layout(location = 0) in vec3 aPos;

这一行很重要

先拆成

layout(location = 0)

意思是这个输入数据使用 0 号顶点属性位置。

以后会看到 CPU/OpenGL 这边:

glVertexAttribPointer(
    0,
    ...
);

这里的0

C++:
glVertexAttribPointer(0, ...)

               ↕ 对应

GLSL:
layout(location = 0)

所以可以理解成:CPU 端的 0 号顶点属性,会送到这里。

剩下的

in vec3 aPos;

其中的in表示这是 Vertex Shader 的输入。也就是外部顶点数据→Vertex Shader,vec3是GLSL的三维向量类型,意思就是vec3可以存三个float,vec2可以存两个float,vec4可以存四个float,所以vec3里面可以装XYZ三个数值。

后面的aPos只是一个变量名,可以随意更改。

  1. Vertex Shader 是“每个顶点执行一次”
    我们创建一个三角形一共需要3个顶点。所以Vertex Shader需要执行三次,每一个顶点都需要执行一次Vertex Shader ,所以shader需要转换到GPU运行,GPU最擅长的就是这些并行任务处理。

  2. void main()

这个void main()和cpp里面的int main()概念很相似,它就是shader的入口函数,GPU 执行这个 Vertex Shader 时,就从main()开始。

  1. gl_Position
gl_Position = vec4(aPos, 1.0);

gl_Position 是GLSL 预定义的特殊变量。Vertex Shader 最核心的任务之一就是计算当前顶点最终应该处于什么位置,这个位置必须写进gl_Position。所以可以把 Vertex Shader 理解成:

输入:顶点位置

↓ 计算

输出:gl_Position

  1. 为什么aPos里面的vec3,而到了gl_Position却是vec4

我们输入aPos是(x,y,z)就是vex3,但是OpenGL要求gl_Position这个变量储存的是(x,y,z,w)也就是vec4,所以上面写的是vec4(aPos, 1.0),后面这个1.0就是补充的w分量

  1. 最后面这个1.0是什么
    这个第四个数叫w分量,它属于齐次坐标Homogeneous Coordinates

只需要先理解普通的3D位置:(x, y, z),在图形学里面通常表示为:(x, y, z, 1),也就是vec4(aPos, 1.0)

  1. 现在这个 Vertex Shader 实际什么都没变

我们现在有

gl_Position = vec4(aPos, 1.0);

相当于:输入在哪里,就让顶点继续在哪里。

就比如输入:

(0.0, 0.5, 0.0)

↓

Vertex Shader

↓

输出:

(0.0, 0.5, 0.0, 1.0)

没有 Model Matrix,没有 Camera,没有 Perspective。所以直接改变z的数值也看不见实际的变化,因为目前 Vertex Shader 没做真正的 3D 投影。

板块 4:Fragment Shader 片元着色器

  1. Fragment Shader 在整个流程的什么位置?
    上一块 Vertex Shader 处理的是顶点在哪里。比如我们构建一个三角形,Vertex Shader 实际只处理这 3 个顶点,但我们最后看到的不是三个点,而是一个填满颜色的三角形。中间发生了一个很重要的步骤——光栅化。整个流程就是:三个顶点 → Vertex Shader → 三角形 → Rasterization → 光栅化 → 产生大量 Fragment → Fragment Shader → 计算颜色。
    所以 Fragment Shader 并不是直接处理最开始那三个顶点。
  2. Fragment 到底是什么?
    Fragment 中文通常翻译成片元,可以把它粗略理解成:“一个可能最终成为屏幕像素的候选数据。”
    例如三角形覆盖了屏幕上的一大片区域,光栅化阶段会生成很多 Fragment:
                   ×

          × × ×

     × × × × ×

× × × × × × ×
每个X都可以粗略理解成一个 Fragment。然后 Fragment Shader 会对这些 Fragment 分别运行。
所以Fragment 和 Pixel 是一回事吗?严格来说不是,Fragment 是 OpenGL 渲染过程中产生的数据,Pixel 是最终显示器 framebuffer 中真正保存下来的像素。Fragment Shader 后面还可能经历:Depth Test(深度测试), Stencil Test(模板测试), Blending (混合),有些 Fragment 最后可能被丢弃。现在可以暂时理解为:Fragment Shader 主要负责计算最终像素颜色。

  1. #version 330 core
    和 Vertex Shader 一样使用 GLSL 3.30 Core。

  2. out vec4 color;
    这里和 Vertex Shader 的 in vec3 aPos; 有点关系,之前的in是输入,现在的out是输出,所以out vec4 color;的意思就是Fragment Shader 最终输出一个vec4类型的颜色,并把变量取名叫color。

  3. 为什么颜色是vec4
    因为颜色由四个分量组成:R G B A,分别是:R = Red ,G = Green ,B = Blue ,A = Alpha 。所以里面实际上是(R, G, B, A)

  4. 现在我们输出为什么是橙色
    现在是color = vec4(1.0, 0.5, 0.2, 1.0); 所以R = 1.0,G = 0.5,B = 0.2,A = 1.0 ,混合出来就是橙色,Alpha现在是最大值所以完全不透明,理论上将Alpha调整到0会变成透明,0.5会变成半透,但是现在不一定马上看到透明效果,因为 OpenGL 还需要开启Blending(混合),例如后面会涉及:glEnable(GL_BLEND);

  5. Vertex Shader 和 Fragment Shader 对比
    Vertex Shader

负责:

“这个顶点在哪里?”

         ↓

Rasterization

把三角形变成大量 Fragment

         ↓

Fragment Shader

负责:

“这个 Fragment 是什么颜色?”
对应代码

// Vertex Shader
gl_Position = vec4(aPos, 1.0);
// Fragment Shader
color = vec4(1.0, 0.5, 0.2, 1.0);

最简单的对应就是:Vertex Shader 管位置,Fragment Shader 管颜色。

板块 5:Shader 的编译与链接

前面写的 Vertex Shader 和 Fragment Shader,到目前为止都只是C++ 里的字符串,GPU 现在还不能直接执行。所以接下来要做两步:

Shader 源码字符串

↓

编译 Compile

↓

Shader 对象

↓

链接 Link

↓

Shader Program

  1. 先创建 Vertex Shader 对象
unsigned int vertexShader =
    glCreateShader(GL_VERTEX_SHADER);

这里的

glCreateShader(GL_VERTEX_SHADER)

是在告诉 OpenGL创建一个“顶点着色器对象”。GL_VERTEX_SHADER就是在说明:这个 Shader 是 Vertex Shader。

返回值是一个 ID

unsigned int vertexShader;

可以把它理解成OpenGL 给这个 Shader 对象分配的编号。

  1. 把源码交给 Shader
glShaderSource(
    vertexShader,
    1,
    &vertexShaderSource,
    nullptr
);

这一句是把刚才那段 GLSL 源码交给这个 Vertex Shader 对象。

拆开来看

第一个参数:vertexShader,表示这份源码属于哪个 Shader。

第二个参数:1,表示这里提供了 1 段源码字符串。

第三个参数:&vertexShaderSource,表示GLSL 源码字符串在哪里。

第四个参数:nullptr,表示不额外提供每段字符串长度,让 OpenGL 按字符串结尾判断。

  1. 编译 Vertex Shader
glCompileShader(vertexShader);

这句就是真正开始编译 GLSL Shader,编译成功以后,这个vertexShader对象就不只是字符串了,而是一个已经编译好的 Shader 对象。

  1. Fragment Shader 完全一样

片元着色器

unsigned int fragmentShader =
    glCreateShader(GL_FRAGMENT_SHADER);

glShaderSource(
    fragmentShader,
    1,
    &fragmentShaderSource,
    nullptr
);

glCompileShader(fragmentShader);

和 Vertex Shader 的流程几乎一样。只是将 GL_VERTEX_SHADER 换到 GL_FRAGMENT_SHADER ,

  1. 为什么编译完还不能直接用?

因为Vertex Shader 和 Fragment Shader 现在还是两个独立的 Shader 对象,需要把它们组合起来。

所以创建

unsigned int shaderProgram =
    glCreateProgram();

这里的program可以理解成一整套可以真正用于绘制的 Shader 程序。

  1. Attach Shader
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);

就是把两个 Shader 装进 Program

shaderProgram

├─ Vertex Shader

└─ Fragment Shader

其实就是把两个已经编译好的 Shader 模块加入同一个程序。

  1. Link:链接 Shader Program
glLinkProgram(shaderProgram);

这一步叫Link,链接。和 C++ 工程里的链接概念很像,具体流程是Vertex Shader + Fragment Shader → Link → 完整 Shader Program。

那为什么需要 Link?因为 OpenGL 要检查不同 Shader 阶段之间是否能正确连接。

例如 Vertex Shader 如果输出:out vec3 color; Fragment Shader 如果想接:in vec2 color;,类型就不匹配,链接阶段就可能报错。

所以 Link 不只是简单“拼起来”,还会检查各 Shader 阶段接口是否一致。

  1. 编译和链接的区别

Compile

glCompileShader(...)

检查的是单个 Shader 自己写得对不对。例如vec3 a = vec4(...);,这种语法或类型问题可能在编译阶段报错。

Link

glLinkProgram(...)

检查的是多个 Shader 阶段拼在一起能不能正常工作。

所以可以记成:

Compile → 检查单个 Shader

Link → 检查 Shader 之间是否能组成一个完整程序

  1. 最终真正使用的是shaderProgram

后面渲染循环里会写

glUseProgram(shaderProgram);

意思是后面的绘制使用这套 Shader Program。也就是说glUseProgram(shaderProgram) → Vertex Shader 生效 + Fragment Shader 生效

之后

glDrawArrays(...)

GPU 才会按照这套 Shader 来处理数据。

  1. 整个流程串起来

Vertex Shader 源码字符串

↓

glCreateShader()

↓

glShaderSource()

↓

glCompileShader()

↓

Vertex Shader 对象

Fragment Shader 源码字符串

↓

glCreateShader()

↓

glShaderSource()

↓

glCompileShader()

↓

Fragment Shader 对象

两个 Shader

↓

glCreateProgram()

↓

glAttachShader()

↓

glLinkProgram()

↓

Shader Program

到最后

glUseProgram(shaderProgram);

真正开始使用。

板块 6:VAO、VBO 与顶点属性布局

对应的代码是

unsigned int VAO, VBO;

glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);

glBindVertexArray(VAO);

glBindBuffer(GL_ARRAY_BUFFER, VBO);

glBufferData(
    GL_ARRAY_BUFFER,
    sizeof(vertices),
    vertices,
    GL_STATIC_DRAW
);

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

glEnableVertexAttribArray(0);

这一整块的核心任务是把 CPU 中的顶点数据传给 OpenGL,并告诉 OpenGL这些数据该怎么读。

  1. 先分清 VBO 和 VAO
    VBO

VBO:Vertex Buffer Object,顶点缓冲对象。

它主要解决:顶点数据放在哪里。

例如

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

一开始在 CPU 内存里,通过 VBO 后,大致变成:

CPU

vertices[]

   ↓

   ↓ 上传

   ↓

VBO

OpenGL / GPU 可使用的数据

VAO

VAO:Vertex Array Object,顶点数组对象。

它主要解决这些顶点数据应该怎么解释。

所以简单记   VBO = 数据   VAO = 数据读取规则

  1. 创建 VAO 和 VBO
unsigned int VAO, VBO;

这里先定义两个 ID。

然后

glGenVertexArrays(1, &VAO);

创建一个 VAO。

glGenBuffers(1, &VBO);

创建一个 Buffer,也就是我们的 VBO。

OpenGL 会给这些对象分配编号。

例如可能是:VAO = 1 VBO = 2,这些数字只是 OpenGL 对象的 ID。

  1. 为什么先glBindVertexArray(VAO)
glBindVertexArray(VAO);

意思是:把这个 VAO 设置为当前正在配置的 VAO,后面做的顶点属性设置,都会被记录到当前 VAO 中。

  1. glBindBuffer

接下来

glBindBuffer(GL_ARRAY_BUFFER, VBO);

这里是在告诉 OpenGL当前GL_ARRAY_BUFFER对应的是这个 VBO。

之后调用

glBufferData(...)
  1. glBufferData
glBufferData(
    GL_ARRAY_BUFFER,
    sizeof(vertices),
    vertices,
    GL_STATIC_DRAW
);

这一句才是真正上传顶点数据。

四个参数可以这样理解:

GL_ARRAY_BUFFER

→ 把数据传给当前绑定的顶点 Buffer

sizeof(vertices)

→ 数据一共有多大

vertices

→ 数据从哪里来

GL_STATIC_DRAW

→ 这份数据之后基本不会频繁修改

所以执行之后:

CPU

vertices[]

  1. 0   0.5   0.0

-0.5 -0.5   0.0

  1. 5  -0.5   0.0

        ↓

   glBufferData

        ↓

VBO

  1. 0   0.5   0.0

-0.5 -0.5   0.0

  1. 5  -0.5   0.0

现在 OpenGL 已经拿到数据了。

  1. 但 GPU 还是不知道数据怎么读

VBO 里面目前只是:0.0 0.5 0.0 -0.5 -0.5 0.0 0.5 -0.5 0.0

OpenGL不知道这是:xyz xyz xyz,还是rgb rgb rgb

甚至不知道每几个数字组成一个顶点?

所以需要

glVertexAttribPointer(...)
  1. glVertexAttribPointer
glVertexAttribPointer(
    0,
    3,
    GL_FLOAT,
    GL_FALSE,
    3 * sizeof(float),
    nullptr
);

先翻译成人话:0号顶点属性,每次读取3个 float,每个顶点之间相隔3个 float,从数据开头开始读取。

然后我们逐个参数看。

第一个参数0

这个就是前面的0号顶点属性位置,它会对应 Vertex Shader:

layout(location = 0) in vec3 aPos;

所以

glVertexAttribPointer(0, ...)

            ↓

location = 0

            ↓

         aPos

这两个0是相对应的

第二个参数3

这个表示这个顶点属性包含 3 个分量,因为我们的位置是xyz,正好三个 float,所以3对应的就是(x, y, z)。

如果以后 UV 是:u v,就有可能变成2

第三个参数GL_FLOAT

GL_FLOAT表示数据类型是 float。因为前面写的数组就是:

float vertices[]

所以这里就写GL_FLOAT

第四个参数GL_FALSE

GL_FALSE这个参数表示:是否需要把整数数据进行标准化。我们现在的数据本身就是 float,所以使用GL_FALSE就可以了。

第五个3 * sizeof(float)

3 * sizeof(float)这个叫做stride,步长,意思是从当前顶点的这个属性开始,到下一个顶点同一属性开始,需要跨多少字节。

现在的数据:x y z | x y z | x y z,每一个顶点都有3个float,所以从第一个x到下一个x要跨3个float,所以是3 * sizeof(float)

如果一个 float 是 4 字节:3 × 4 = 12 bytes,所以每隔 12 字节读取下一个顶点。

第六个参数nullptr

表示第一个属性从 Buffer 的第 0 字节开始读取。因为数据一开始就是xyz,位置属性没有前面的其他数据。

所以offset = 0,就用nullptr表示

  1. glEnableVertexAttribArray(0)

评论