一文走进现代OpenGL

作为前言,这是一篇非常长非常长的文章——在文末,你会得到一个纯色三角形,这将经历非常漫长的努力,甚至你还会觉得气馁,因为花费一整天画了个平面三角形。本文呢包括如何准备VS2019,如何开始第一个HelloWorld,渲染管线的简介.......你需要自行学习完毕C语言,全文的代码是C++编译通过的,因此,你或许还需要一丁点的C++知识,但我保证不多。最后,你需要理解Win32开发里面的东西——资源文件、预编译头、按键消息,这些有助于你对代码的领悟,请务必熟悉基本的Win32开发,接下来将使用它的知识。最后的最后,请不要在乎最终程序的效率,我知道你会抱怨它那个while看着开销大上天,总而言之我们才刚开始,好了,开始吧。

对了一点点约定,我用粉色表示我认为关键的地方,用蓝色表示一些无所谓的强调,用绿色表示一些关键概念。如果有加粗,那当然是更重要啦~API名称或者宏定义会使用紫色或者是超链接(超链接意味着它指向一篇文档)。如果你讨厌这长透顶的篇幅,或者你准备好了某些东西,可以直接跳转:

目录

0. OpenGL?

1. 准备你的Visual Studio,创建项目

2. 添加glfw。

3. 添加glad

4. 创建窗口

5. 纯色背景

6. 准备渲染

6.1 渲染管线

6.2 顶点输入与标准化设备坐标

6.3 准备顶点数组

6.4 准备顶点着色器

6.5 准备片段着色器

6.6 链接着色器程序

6.7 对接顶点数据到顶点着色器

6.8 顶点数组对象

7. 绘制三角形

8. 结语


0. OpenGL?

有点好奇各位是怎么知道它的呢。

它其实是一个API规范,它仅仅是一个由Khronos组织制定并维护的规范(Specification)。这套规范描述了每个函数的具体任务、返回值等等,负责编写OpenGL库的人将会按照它实现。我们将使用的4.0版本也有着规范文档,可以点开来看看。OpenGL可以渲染3D图形,我们也会用它来渲染3D图形。

这些简介不再说了。

1. 准备你的Visual Studio,创建项目

虽然这是VS2019的例子,但是它依然通用于其它的IDE。在此之前,请新建一个空项目,注意什么也不要包含。在VS2019里面,你需要选择这个,注意在后续步骤中一定不要包含任何代码:

创建完成之后,项目中应该是没有任何代码的。

接下来的这步可以省略——如果看不懂或者之类的......设置预编译头,我最开始学习C/C++的时候就爱上了预编译头这玩意,总而言之看着很舒服就对了。添加pch.h和pch.cpp,把项目(注意有Debug和Release两个配置,都要设置,下面的子系统选择也是一样的)的预编译头设置为"使用",选择pch.cpp,把它的预编译头选择为"创建"。

关键一步,我想你不会爱那个黑色的控制台窗口,因此,我们要更改子系统:在项目上右键单击,选择属性 -> 链接器 -> 所有选项,找到子系统,把它改为窗口:

子系统配置

这样,我们的入口点函数就从main变成了winMain。在配置属性 -> 高级里面,你可以看到你的项目用的字符集,一般是Unicode。这意味着我们的入口点函数名称叫做wWinMain。更改Debug和Release的配置都为这个,然后添加一个main.cpp文件,开始编写你的代码。需要注意的是,因为我喜欢预编译头,所以我#incldue的是pch.h,如果不会配置它或者不想使用它,把它更换成你自己需要包含的头文件就是:

#include "pch.h"
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    return 0;
}

编译试试?如果编译通过了,那么上面的所有步骤表示正确。如果你收到了无法重定位函数、无法找到符号"_XXXXX"这类的链接器错误,说明你没有编写正确的入口点函数。请检查你的步骤,如果实在无法,那么你可以丢弃掉上面的步骤——你依然可以使用传统的方式,用main函数作为入口点函数。这没什么大不了的,除了那个黑窗口。

到此,最开始的配置就完成了。

2. 添加glfw。

在继续之前,我想说一个无奈的事实,微软一直很推荐自家的DirectX,因此OpenGL总是被当作捡来的一样对待——不过OpenGL有着强硬的地位,因此各大显卡厂家都支持它。在Windows下,OpenGL的版本是1.0(还是1.1,我忘记了),这意味着哪怕你在用着Windows 10,上面可访问的OpenGL版本依然是20年前的玩意。因此,我们需要glad——用它们去用显卡厂商给的新版本API。

首先,你需要准备glfw——在这里去下载glfw。为了省略构建的烦恼,请直接下载编译好的版本。也就是这个:

GLfw的选择

可能你在猜测我想要64位的可以吗?这当然是可以的,不过,要注意你在VS里面的配置,也要用64位。不过用作学习,32位也差不多够了,因此我们选择32位。

把它放在你的VS的包含目录下。或者把它扔到你的项目的源代码目录下去,那里默认是一个包含目录。具体的来说,你需要下面这些东西,点开压缩包:

glfw目录下的东西

然后,把include目录下的内容丢到你的项目中去,选择合适自己的版本,这里是vs2019——按照自己的需求选择。在lib-vc2019下有三个文件,一个是glfw3.lib, 一个是glfw3dll.lib,和一个glfw3.dll。glfw3.lib是静态库版本,另外的一个lib是dll所带的那个。在这里我选择了dll,你可以选择静态库版本,这没什么。把对应的lib添加到你的项目中,这样就完成了。作为测试,你可以试试编译下面的代码:

#include "pch.h"
#include "GLFW/glfw3.h"
#pragma comment(lib, "glfw3dll.lib")
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    return 0;
}

我强调了glfw和glfw3dll.lib,在这之后,你不会看见它们,不过要记住,那个pch.h中是一直有着这两个的。如果编译通过,说明上面的步骤全部正确,如果没有通过(比如出现找不到什么什么的错误之类),请检查你的VC++目录,保证编译器和链接器可以找到那几个文件。自此,glfw添加完成了。

3. 添加glad

同样的,我们在网上下载glad。glad的底层依然是调用Windows的API去取得对应函数的地址。前面说过,这些API由显卡厂家负责,而且OpenGL常年在Windows上处于捡来的地位,因此,我们无法直接访问它们。去这里下载GLad。glad可以方便的帮我们搞定这个取得API的步骤。如果你好奇——在Windows上,这可以通过wglGetProcAddress完成。点开那个页面后,你会看到下面的配置选项,按照截图所示的配置,修改红框中的内容,其余都不动:

GLad配置

然后点击"GENERATE",它在页面最下面。对了,有个"Generate a loader"选项,一定保证它勾选上。

在这之后,有个zip文件,选择它即可。

glad.zip

解压之后,你会看到两个文件夹:include和src。把src目录下的所有文件添加到你的项目中去,把include中的所有东西添加到你的项目中。在这之后,你的项目中应该有两个源文件:main.cpp和glad.c,你可以选择把glad.c的名字改成glad.cpp,这没有任何影响。如果你有预编译头,你还应该有一个pch.cpp,不过这无关紧要。作为测试,可以试试如下的代码:

#include "pch.h"
#include "GLFW/glfw3.h"
#include "glad/glad.h"
#pragma comment(lib, "glfw3dll.lib")
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    return 0;
}

如果编译成功,说明之前所有步骤都正确了,如果失败,那么参照第二小节,把问题解决好。在这你可能遇到的错误是无法打开文件"khrplatform.h",并且会携带非常多的符号未定义错误,这是因为我们没有把它丢到VS默认的VC++编译器的包含目录下的关系,如果你真的遇到了,请打开glad.h,把里面的#include <KHR/khrplatform.h>更改成正确的文件位置。它在include/KHR目录下。如果你收到了没有预编译头的错误,这多半是glad.c导致的,请修改它,为它添加预编译头

4. 创建窗口

一切准备就绪,我们可以开始创建窗口了。因为我使用了预编译头,所以有必要展示一下pch.h里面的内容,它是:

#pragma once
#include <stdio.h>
#include <tchar.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <locale.h>
#include <Windows.h>
#include "glad/glad.h"
#include "GLFW/glfw3.h"
#pragma comment(lib, "opengl32.lib")
#pragma comment(lib, "glfw3dll.lib")

在之后的文章中,我默认你知道这个预编译头的内容。这其中一些include是无所谓的,我只是猜测它们或许会被用到。好啦,所以接下来的代码只会有一个空落落的#include "pch.h",不要忘记它里面包含了好些头文件

我们需要使用下面的代码去实例化窗口,然后创建并初始化glad。所有的信息都编写在了注释里面:

#include "pch.h"
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    GLFWwindow  *win;
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);              // OpenGL 4.x
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    if ((win = glfwCreateWindow(480, 320, "Graph3D", NULL, NULL) ) == NULL)
    {                                                           // 创建窗口
        // 无法创建窗口
        goto err;
    }
    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, keyInput);
    glfwSetFramebufferSizeCallback(win, framebufferResize);
    if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0)// 初始化glad
    {
        // 无法初始化GLad, 请检查您的设备是否支持OpenGL 4.0
        goto err;
    }
    initRender();                                               // 初始化渲染
    while (glfwWindowShouldClose(win) == 0)
    {
        renderLoop();                                           // 渲染图形
        glfwPollEvents();                                       // 检查, 触发事件
        glfwSwapBuffers(win);
    }
err:
    glfwTerminate();                                            // 退出
    return 0;
}

上面的代码有两个回调函数,分别处理窗口尺寸变化和按键输入。在前面几行,我们初始化glfw,然后暗示glfw的OpenGL版本是4.x,最低版本要求也是4.x,当然,你可以指定别的。接着,我们表示我们的配置是CORE(如果你还记得,我们的glad是Core配置的)。接着,创建窗口,Graph3D是窗口标题,保险起见,用英文,设置当前上下文。

接下来,我们设置回调函数——一个是按键响应,一个是窗口尺寸改变响应。按键响应的回调函数看起来是下面这样的,关于它的详细解释会在需要用到的时候补充,目前来说,我处理了松开键盘上Esc按键的情况——这个动作之后,程序将退出

void keyInput(GLFWwindow *win, int key, int scancode, int action, int mods)
{
    if (action == GLFW_RELEASE)                             // 松开按键
        if (key == GLFW_KEY_ESCAPE)
        {
            glfwSetWindowShouldClose(win, true);            // 告诉glfw应该关闭窗口
        }
}

尺寸改变中有一个glViewport,它更改OpenGL的渲染区域大小。如果你好奇去掉会怎么样,请不要慌,到最终我们得到那个三角形的时候,在来试试去掉它会怎么样:

void framebufferResize(GLFWwindow* win, int w, int h)
{
    glViewport(0, 0, w, h);
}

接着,我们初始化glad。然后初始化渲染:

void initRender()
{

}

这看起来什么也没有做,当然什么也没做,毕竟都没写。它是为了我们之后编写方便的——我讨厌一天没事老在入口点函数里面折腾。而且这种结构看起也有利于我们写程序。良好的结构应该从此时开始,如果你以前从不这样

最后是一个while循环,姑且叫做渲染循环吧,所有的绘图工作都在这完成——如果有记得,我希望不要抱怨效率,这个while看着就觉得很讨厌。循环内调用了绘图函数:

void renderLoop()
{


}

当然什么也没做,毕竟都没写。同样的,它是为了我们之后写程序方便。

渲染循环的末尾处理了积累下来的消息,然后用glfwSwapBuffers交换缓冲区——这里解释一下。一般来说,如果我们直接在屏幕上面刷图形,效果是很差的,因为图形是慢慢一步步绘制出来的,人眼对此很敏感,你会看到图像闪烁(这点在做动态效果的时候尤其显著,比如我们绘制音乐频谱,它跳来跳去更新很快,这样直接绘制就会看到图案一会儿闪一次一会儿闪一次)。解决之道是把图案画在B上,然后绘制完成后,把B一次性粘贴到屏幕上,这样一来,我们就感觉不到图像的闪烁(比起绘制,这个过程很快而且更自然些),这项技术被称作双缓冲,在缓冲区A上绘制好,然后交换缓冲区,这样就把绘制的图案显示出来了

在最后,渲染循环退出(即glfwWindowShouldClose为true,表示窗口要关闭了)之后,我们使用glfwTerminate释放掉所有资源,自此,程序结束。组合上面的代码,你应该得到下面的结果:

结果

一个黑巴巴,无聊透顶的窗口。

如果你没有得到这个纯色背景的窗口,或者不知道组合上面的代码,可以参考我的这份。目前来说不要灰心,我知道做了这么多努力最后得到个黑色的玩意很难受。我添加了一个MessageBox用于错误信息提示,并且,我给项目里面添加了一个资源文件,之后的着色器脚本会放到资源文件里面,你也应该准备下,并且,我添加了调试模式下的控制台,这可以让你在Debug配置下看到一个控制台窗口,并且可以使用printf等在上面输出内容。当然,你可以扔掉它。这不是必须的

#include "pch.h"
#include "resource.h"
void  framebufferResize(GLFWwindow *, int, int);
void  keyInput(GLFWwindow *win, int key, int scancode, int action, int mods);
void  initRender();
void  renderLoop();
void  popMessageBox(const TCHAR *format, ...);
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    GLFWwindow  *win;
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);                 // OpenGL 4.x
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#if _DEBUG
    FILE *ous;
    AllocConsole();
    setlocale(LC_CTYPE, "chs");                                    // 中文字符集
    freopen_s(&ous, "CONOUT$", "w", stdout);                       // 输出重定向
#endif
    if ((win = glfwCreateWindow(480, 320, "Graph3D", NULL, NULL) ) == NULL)
    {
        popMessageBox(_T("无法创建窗口"));
        goto err;
    }
    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, keyInput);
    glfwSetFramebufferSizeCallback(win, framebufferResize);
    if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0)   // 初始化glad
    {
        popMessageBox(_T("无法初始化GLad, 请检查您的设备是否支持OpenGL 4.0"));
        goto err;
    }
    initRender();
    while (glfwWindowShouldClose(win) == 0)
    {
        renderLoop();
        glfwPollEvents();                                          // 检查, 触发事件
        glfwSwapBuffers(win);
    }
err:
    glfwTerminate();
#if _DEBUG
    FreeConsole();
#endif
    return 0;
}
// 窗口尺寸被改变
void framebufferResize(GLFWwindow* win, int w, int h)
{
    glViewport(0, 0, w, h);
}
// 键盘输入
void keyInput(GLFWwindow *win, int key, int scancode, int action, int mods)
{
    if (action == GLFW_RELEASE)                                    // 松开按键
        if (key == GLFW_KEY_ESCAPE)
        {
            glfwSetWindowShouldClose(win, true);                   // 告诉glfw应该关闭窗口
        }
}
// 初始化
void initRender()
{

}
// 渲染循环
void renderLoop()
{

}
// 弹出一条消息
void popMessageBox(const TCHAR *format, ...)
{
    va_list ap;
    TCHAR   msg[512] = { 0 };
    va_start(ap, format);
#if _UNICODE
    vswprintf_s(msg, format, ap);
    MessageBoxW(0, msg, L"Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#else
    vsprintf_s(msg, format, ap);
    MessageBoxA(0, msg, "Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#endif // _UNICODE
    va_end(ap);
}

自此,创建窗口成功了。接下来我们要在上面绘制东西。出于我们程序的结构,接下来的内容只会修改initRender()函数和renderLoop()结构,因此,之后的代码也只会展示这两个结构。

出于广泛的考虑,或许有读者在使用老一些的设备,如果你使用了我的这段代码,编译通过,但是运行失败,而且找不到解决之道,或许就该考虑版本问题了。在有控制台的情况下,用下面的代码查看一下你的OpenGL版本号。请把它放到glad初始化完成的后面。

printf("%s\n", glGetString(GL_VERSION));

检查你的版本号,保证它是4.0及以上版本。

如果你运行的提示信息表示找不到XXX.dll,请把对应的dll放到你的exe目录下。对于VS,应该准备一个dll在项目的源代码目录下。典型的问题是找不到glfw3.dll,这个dll在我们下载的glfw压缩包里面。把它放到合适的位置。

5. 纯色背景

这个黑色的窗口当然很无聊。我们改一下背景颜色吧???

在每次迭代新开始的时候,我们总是希望清空屏幕。我知道你又要开始抱怨效率了,还是那句话,我们才刚开始,不要关注效率。这样全部清空的目的是为了下次绘制的时候能够在一个干净的地方绘制。

OpenGL类似于一个超大的状态机——使用一些API设置状态,然后另一些API按照这些状态进行绘图等等。这点要理解和牢记,因为我们马上开始使用OpenGL绘图。为了清空背景,我们使用glClear函数完成清空工作。这个函数的参数是一个标志位,它表示我们希望清除什么东西。有GL_COLOR_BUFFER_BITGL_DEPTH_BUFFER_BITGL_STENCIL_BUFFER_BIT。因为现在我们只关心颜色问题,所以我们只用GL_COLOR_BUFFER_BIT。修改renderLoop:

void renderLoop()
{
    glClearColor(0.2f, 0.3f, 0.5f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
}

编译运行,你应该可以看到一个深蓝色背景的窗口,它看着应该是这样的:

深蓝色的背景

这段代码可以理解为glClearColor设置了一个状态,然后glClear按照这个状态去清空屏幕。可以把颜色换成自己喜欢的颜色,glClearColor的参数从左到右分别对应RGBA,最小值为0,最大值为1。

6. 准备渲染

OpenGL本身是可以渲染3D图形的,现实中的物体是客观地拥有长宽高属性的,但是出现在人眼中不一样——它总是一个二维的投影,对于画家的画也是如此,我们会觉得它有立体感,但不可否认,那张画就是个平面的玩意。我们的显示屏可不是三维的玩意,它只能显示二维图案。OpenGL可以接受三维坐标,它需要经过一系列操作,才能得到一个正确的二维坐标,负责这套操作的是渲染管线,它其实就像一条流水线,把三维坐标送进去,期间经过各种各样的处理,最终得到二维坐标。作为约定,下文中的坐标表示一个真实的坐标,它可以是1.25之类的,而像素坐标(强调像素了)表示在屏幕上的坐标,它总是整数

6.1 渲染管线

现在的显卡中存在着非常大量的内核,动则几百上千,这些内核中会运行着渲染管线上的各种变换,从而高效的帮你完成图形呈现工作。在早期的OpenGL中(1.x版本),这些操作是固定的,称之为固定管线。现在一般是可编程管线,即这套操作是我们可以自己定义的。这听起来颇为麻烦,但却更灵活——例如Minecraft游戏的光影,其实就是大量的对这套操作进行新的定义。

这些GPU内核上跑着的小程序被叫做着色器,GPU没有默认的着色器,因此我们要自己写。OpenGL的着色器通过GLSL(OpenGL Shading Language)写成。它酷似C语言,在不久后我们将见到它。渲染管线看起来像这样:

渲染管线

可以看到它包含很多部分,蓝色的部分是我们可以(也必须准备至少一个)自己编写的。顶点数据描述了三维坐标信息。它是一个数组,数组里面的元素被叫做顶点。它使用顶点属性来描述。顶点属性可以包含很多东西,比如坐标和颜色。为了简单考虑,假如送进去的三个顶点包含了坐标和颜色。

首先进行处理的是顶点着色器,它输入一个三维坐标(回忆我们之前说的小程序,它们在各个核心上"独立"运行,因此会有三个小程序一起处理这三个顶点),然后输出另外一个三维坐标。它们有差异,在之后我们将亲自编写一个简单的顶点着色器程序,你将见到这个差异。

第二步进行图元装配,可以看到这三个顶点组成了一个三角形。注意,OpenGL不知道你输入的东西要变成什么样,因此你需要指定它。比如GL_POINTSGL_TRIANGLESGL_LINE_STRIP......

紧接着,数据进入几何着色器,它可以按照输入的顶点构造一些新的图案。比如我们送进去的是个正方形,但是这个正方形是略微侧视的——这时,或许我们就需要两个小平行四边形来代替它了。通常我们不需要编写几何着色器,使用默认的就好。在图上的例子,三角形变成了两个三角形。

光栅化将几何着色器输出变为具体的像素坐标,它已经对应到屏幕上的像素点了。在送入片段着色器之前,超过显示范围的像素将被裁剪。

然后,片段着色器将对每个像素进行着色,出于此,它的英文翻译有时候会变成像素着色器。它们是一个东西。在这里,所有漂亮的元素会产生,例如光照、阴影、纹理等等

最后的测试与混合将对各个结果进行处理,按照透明度设置,把不可见的部分丢掉(考虑两个正方体,第一个把第二个挡住了,很显然第二个正方体不应该出现在屏幕上)等等

至此,你看见了屏幕上出现的图案。为了加强理解,我们可以想象:顶点着色器把脑海里的正方体变成了空间中具体的坐标,而图元装配则告诉我们这个正方体的轮廓。紧接着,几何着色器将按照正等轴测图之类的投影图方式把正方体映射到纸上,光栅化使得正方体的图映射到实际中纸张上的位置,丢弃掉纸张外的部分,我们使用片段着色器对正方体上色、补上阴影,最后把它和其它的画叠在一起。

现代OpenGL中,我们只需要自己完成顶点着色器和片段着色器。几何着色器通常是默认的。这段内容非常多且复杂,静下心来亲。

6.2 顶点输入与标准化设备坐标

在得到一个三角形之前,我们需要准备好顶点。顶点是三维的,包括X, Y, Z三个坐标

float vetPos[] = 
{/*  X      Y     Z   */
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

因为我们目前来说想得到一个2D的三角形,因此我把Z坐标设置为了0。这样每个顶点的深度(可以理解为Z坐标,即Depth,它表示顶点离屏幕的距离,离得很远就不一定看得到了)都一样,这样它就是个平面的。目前来说,不要气馁,挺过这些,再玩3D就简单很多了。

顶点们被送入顶点着色器,顶点着色器的输出应该是在标准化设备坐标上的,不在这上面的顶点会被丢弃。在OpenGL里面,它和你的渲染窗口对应:

标准化设备坐标

最左边是-1.0,最右边是1.0,这是X轴。最上面是1.0,最下面是-1.0,这是Y轴,注意它和屏幕坐标是相反的。Z轴坐标也是-1到1这个范围的。同样的,原点位置也在窗口客户区中心,而不是左上角。顶点着色器输出会按照你的glViewport提供的数据进行变换,得到对应的屏幕空间坐标,然后才送入后面的步骤

6.3 准备顶点数组

我们需要把它作为输入发送给顶点着色器。它会在GPU上创建内存,用于储存顶点数据,当然,我们还要配置OpenGL如何解释它们,并且指定其如何发送给显卡。顶点着色器会处理在内存中指定数量的顶点。这些通过顶点缓冲对象VBO(Vertex Buffer Objects)完成。它会存放非常多的顶点数据,然后一次性将这大批数据送往显卡。我们通过glGenBuffers去创建一个缓冲,创建之后,我们用glBindBuffer绑定到顶点缓冲,最后使用glBufferData把顶点数组刷进去:

// 初始化
float vetPos[] = 
{/*  X      Y     Z   */
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
GLuint  hVBO;
#define VBO_ID      1
// 下面代码在initRender()中
glGenBuffers(VBO_ID, &hVBO);                          // 创建并绑定顶点缓冲
glBindBuffer(GL_ARRAY_BUFFER, hVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vetPos), vetPos, GL_STATIC_DRAW);

glBufferData的最后一个参数表明我们希望显卡如何管理数据,GL_STATIC_DRAW表示数据基本上不可能改变,GL_DYNAMIC_DRAW表示数据经常会被更改,GL_STREAM_DRAW表示数据每次绘制都会被更新。我们应该按照顶点数据实际的情况来选择。这个例子中,三角形的顶点数据压根不会被修改,所以我们使用GL_STATIC_DRAW

6.4 准备顶点着色器

顶点着色器是我们必须自己准备的东西之一。目前来说,讨论它太早了,因此我们只做个大概了解。这个例子中用的顶点着色器是这样的:

#version 400 core
layout (location = 0) in vec3 pos;

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

可以看到,它非常的像C语言。

第一行声明了版本号,400对应于OpenGL 4.0,同样的,450就对应OpenGL 4.5。然后我们使用in关键字表明了它的输入。然后,把位置数据赋值给内部预定义的gl_Position变量,它是vec4类型,表示输出。最后一个1.0其实是齐次坐标,并不是什么四维空间之类的高级玩意。这是个非常基础的顶点着色器,它把输入什么都不修改直接丢给输出了。你可能会有一些直觉的写法,我非常建议尝试。

接下来,我们需要编译这个顶点着色器。目前来说,我们暂时先用一个简单的方案——把它作为C字符串。当然,我们应该把它丢到资源文件里面,不过暂时为了简单,不这样做:

const char vetShader[] =
{
    "#version 400 core                                \n\
     layout (location = 0) in vec3 pos;               \n\
     void main()                                      \n\
     {                                                \n\
        gl_Position = vec4(pos.x, pos.y, pos.z, 1.0); \n\
     }"
};

嗯...其实还可以对吧...

同样的,我们需要创建一个顶点着色器对象,用glCreateShader(GL_VERTEX_SHADER)创建, 然后把顶点着色器源代码附加到这个对象上,用glShaderSource完成,最后,再用glCompileShader编译。在这之后,我们再使用glGetShaderiv检查编译错误,如果成功了,那么顶点着色器代码编译也就成功了。整理一下得到compileShader函数,用于编译单个的着色器程序。参数vetShader是的glCreateShader返回值:

// 编译着色器
bool compileShader(const char *src, GLuint shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glShaderSource(shader, 1, &src, NULL);           // 绑定, 1表示只有一份源代码字符串
    glCompileShader(shader);
    glGetShaderiv(shader, GL_COMPILE_STATUS, &err);  // 取得错误信息
    if (err == 0)
    {
#if _DEBUG
        glGetShaderInfoLog(shader, 512, NULL, errMsg);
        // Debug配置下打印错误信息
        printf("着色器编译错误: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}

6.5 准备片段着色器

下面是片段着色器。回忆我们之前所讲,片段着色器的输出是具体的像素颜色。在这里我们为了简单,让它一直输出一种颜色:

#version 400 core
out vec4 outColor;

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

同样的,它的语法也很像C语言。

有一点不同的是,我们指定了输出outColor,这是一个vec4类型,不难理解它对应于RGBA。编译过程依然与顶点着色器类似,我们先用C字符串表示它:

const char pixelShader[] =
{
    "#version 400 core                                \n\
     out vec4 outColor;                               \n\
     void main()                                      \n\
     {                                                \n\
         outColor = vec4(0.5f, 0.2f, 0.5f, 1.0f);     \n\
     }"
};

然后直接调用之前我们准备的compileShader函数编译片段着色器。所不同的是,这次需要用glCreateShader(GL_FRAGMENT_SHADER)创建片段着色器对象,并用它的返回值作为编译函数的第二个参数。

6.6 链接着色器程序

顶点着色器和片段着色器都已经编译准备就绪。接下来是链接它们,得到着色器程序。着色器程序可以是很多个片段着色器和顶点着色器组成,就像你写的C/C++程序有很多个源文件那样。

由于OpenGL的封装,链接也变得像编译那样简单。我们使用glCreateProgram创建一个着色器程序对象,然后使用glAttachShader添加着色器编译输出,最后,用glLinkProgram链接它们。和编译类似,我们可以使用那两个函数来检查链接错误。整理一下,得到我们的链接函数linkShaderProg:

// 链接着色器程序
bool linkShaderProg(GLuint prog, GLuint vet_shader, GLuint pixel_shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glAttachShader(prog, vet_shader);
    glAttachShader(prog, pixel_shader);
    glLinkProgram(prog);
    glGetProgramiv(prog, GL_LINK_STATUS, &err);     // 取得链接的结果
    if (err == 0)
    {
#if _DEBUG
        glGetProgramInfoLog(prog, 512, NULL, errMsg);
        printf("着色器链接错误: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}

接着,我们使用glUseProgram(prog)来激活这个刚创建的着色器程序对象。因为着色器的那两个编译结果已经没有再用的必要了,因此要使用函数glDeleteShader来删除着色器对象。这个需要记得。

现在,我们已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。但是不要忘记,OpenGL还不知道怎么处理内存中的顶点数据,以及怎么把将顶点数据对应到顶点着色器的输入上。很显然,我们就快完工了。

6.7 对接顶点数据到顶点着色器

在这步我们将把顶点数据,即那个float数组,对应到顶点着色器上。我们的顶点数据在内存中看起来像这样:

每个数据的宽度是4,每个顶点的大小是12。而且,它们之间没有任何间隔,是紧密排布的。这样,我们就可以告诉OpenGL了:

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

第一个函数的参数非常的多:

  • 第一个参数0是顶点的位置属性,一种理解是它为顶点的id。还记得我们在顶点着色器中写的代码吗? layout (location = 0) in vec3 pos; 0将对应到这里的location去。

  • 第二个参数指定顶点数据的大小,很明显,一个顶点有三个数据X, Y, Z,因此它是3。

  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中的vec3, vec4等等都是由float组成)。

  • 第四个参数指定是否需要对数据进行标准化(Normalize,这样好理解,它将数据全部缩放到[0, 1]区间中去)。如果为GL_TRUE,那么数据将会被Normalize。

  • 第五个参数是顶点数据的大小。

  • 最后一个参数是一个偏移量,在之后的文章记录中会解释。

好了,现在我们已经告诉OpenGL顶点数据是什么样的了,下面,我们调用glEnableVertexAttribArray,启动它。函数的参数是顶点的location属性值,这里是0。到目前位置,我们使用一个VBO将顶点数据扔到一个缓冲中,准备了一个顶点着色器和一个片段着色器,并告诉了OpenGL如何对应顶点数据到顶点着色器的顶点属性上。一切皆完成,我们可以看到我们的三角形了:

// 载入顶点数据
glBindBuffer
glBufferData
// 设置顶点数据的一些信息
glVertexAttribPointer
glEnableVertexAttribArray
// 设置着色器程序
glUseProgram
// 绘制物体

但是还差一步?

嗯是的,还有一步。坚持到这真不容易,不过确实还有一步。每当我们需要绘制的时候,上面的步骤都需要重复一遍。三个顶点倒是不觉得。但是三百万个就不一样的————。显卡距离内存是很遥远的,因此,这样会很低效。我们需要把这些配置保存到一个对象中,然后每次都使用那个对象去绘制。那个对象叫做顶点数组对象VAO(Vertex Array Objects)

6.8 顶点数组对象

VAO可以使得切换配置和绘图变得简单且高效。它和VBO类似,也可以绑定。OpenGL的Core模式要求我们使用VAO。因此我们需要它。

VAO的创建和VBO类似,使用glGenVertexArrays函数完成。这样的话,渲染过程就变成了这样:

// 初始化代码
// 绑定VAO
glBindVertexArray
// 复制数据
glBindBuffer
glBufferData
// 设置属性信息
glVertexAttribPointer
glEnableVertexAttribArray
// .........
// 持续渲染
glUseProgram
glBindVertexArray
// 绘制物体

初始化的时候,我们绑定VAO和携带着配置属性的VBO,然后当我们希望绘制物体的时候,使用简单的绑定即可。

好了,我们马上就可以见到我们的三角形了。当你希望绘制很多个物体的时候,先绑定一个VAO,然后解绑它,以绑定一个新的用于绘制下一个物体。前面做了那么多,现在一切将成为现实。

7. 绘制三角形

按照我们之前的思路,在绘制物体的位置添加下面的代码:

glDrawArrays(GL_TRIANGLES, 0, 3);

编译运行,你会看到:

结果

一个暗紫红色的三角形。如果你不知道怎么组合代码,可以参考我下面的程序。

#include "pch.h"
#include "resource.h"
#define  VBO_ID        1
#define  VAO_ID        1
void  framebufferResize(GLFWwindow *, int, int);
void  keyInput(GLFWwindow *win, int key, int scancode, int action, int mods);
bool  initRender();
void  renderLoop();
bool  compileShader(const char *src, GLuint shader);
bool  linkShaderProg(GLuint prog, GLuint vet_shader, GLuint pixel_shader);
void  popMessageBox(const TCHAR *format, ...);
const float vetPos[] =
{/*  X      Y     Z   */
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
const char vetShader[] =
{
    "#version 400 core                                \n\
     layout (location = 0) in vec3 pos;               \n\
     void main()                                      \n\
     {                                                \n\
        gl_Position = vec4(pos.x, pos.y, pos.z, 1.0); \n\
     }"
};
const char pixelShader[] =
{
    "#version 400 core                                \n\
     out vec4 outColor;                               \n\
     void main()                                      \n\
     {                                                \n\
         outColor = vec4(0.5f, 0.2f, 0.5f, 1.0f);     \n\
     }"
};
GLuint  hVBO, hVAO;
GLuint  hShaderProg, hVetShader, hPixelShader;
int APIENTRY wWinMain(
    _In_        HINSTANCE   hInstance,
    _In_opt_    HINSTANCE   hPrevInstance,
    _In_        LPWSTR      lpCmdLine,
    _In_        int         nCmdShow
)
{
    GLFWwindow  *win;
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);      // OpenGL 4.x
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 4);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#if _DEBUG
    FILE *ous;
    AllocConsole();
    setlocale(LC_CTYPE, "chs");                         // 中文字符集
    freopen_s(&ous, "CONOUT$", "w", stdout);            // 输出重定向
#endif
    if ((win = glfwCreateWindow(480, 320, "Graph3D", NULL, NULL) ) == NULL)
    {
        popMessageBox(_T("无法创建窗口"));
        goto err;
    }
    glfwMakeContextCurrent(win);
    glfwSetKeyCallback(win, keyInput);
    glfwSetFramebufferSizeCallback(win, framebufferResize);
    if (gladLoadGLLoader((GLADloadproc)glfwGetProcAddress) == 0)
    {
        popMessageBox(_T("无法初始化GLad, 请检查您的设备是否支持OpenGL 4.0"));
        goto err;
    }
    if (initRender() == false)
    {
        popMessageBox(_T("无法初始化渲染"));
        goto err;
    }
    while (glfwWindowShouldClose(win) == 0)
    {
        renderLoop();
        glfwPollEvents();                               // 检查, 触发事件
        glfwSwapBuffers(win);
    }
    goto succ;
err:
#if _DEBUG
    system("pause");
#endif
succ:
    glfwTerminate();
#if _DEBUG
    FreeConsole();
#endif
    return 0;
}
// 窗口尺寸被改变
void framebufferResize(GLFWwindow* win, int w, int h)
{
    glViewport(0, 0, w, h);
}
// 键盘输入
void keyInput(GLFWwindow *win, int key, int scancode, int action, int mods)
{
    if (action == GLFW_RELEASE)                         // 松开按键
        if (key == GLFW_KEY_ESCAPE)
        {
            glfwSetWindowShouldClose(win, true);        // 告诉glfw应该关闭窗口
        }
}
// 初始化
bool initRender()
{
    glGenBuffers(VBO_ID, &hVBO);                        // 创建并绑定顶点缓冲
    glGenVertexArrays(VAO_ID, &hVAO);

    hVetShader = glCreateShader(GL_VERTEX_SHADER);
    if (compileShader(vetShader, hVetShader) == false)
    {
        return false;
    }
    hPixelShader = glCreateShader(GL_FRAGMENT_SHADER);
    if (compileShader(pixelShader, hPixelShader) == false)
    {
        return false;
    }

    hShaderProg = glCreateProgram();
    if (linkShaderProg(hShaderProg, hVetShader, hPixelShader) == false)
    {
        return false;
    }
    glDeleteShader(hVetShader);
    glDeleteShader(hPixelShader);

    glBindVertexArray(hVAO);                            // 绑定VAO

    glBindBuffer(GL_ARRAY_BUFFER, hVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vetPos), vetPos, GL_STATIC_DRAW);

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

    return true;
}
// 渲染循环
void renderLoop()
{
    glClearColor(0.2f, 0.3f, 0.5f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    glUseProgram(hShaderProg);
    glBindVertexArray(hVAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
}
// 编译着色器
bool compileShader(const char *src, GLuint shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glShaderSource(shader, 1, &src, NULL);              // 绑定, 1表示只有一份源代码字符串
    glCompileShader(shader);
    glGetShaderiv(shader, GL_COMPILE_STATUS, &err);     // 取得错误信息
    if (err == 0)
    {
#if _DEBUG
        glGetShaderInfoLog(shader, 512, NULL, errMsg);
        printf("着色器编译错误: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}
// 链接着色器程序
bool linkShaderProg(GLuint prog, GLuint vet_shader, GLuint pixel_shader)
{
    char errMsg[512] = { 0 };
    int  err;
    glAttachShader(prog, vet_shader);
    glAttachShader(prog, pixel_shader);
    glLinkProgram(prog);
    glGetProgramiv(prog, GL_LINK_STATUS, &err);         // 取得链接的结果
    if (err == 0)
    {
#if _DEBUG
        glGetProgramInfoLog(prog, 512, NULL, errMsg);
        printf("着色器链接错误: %s\n", errMsg);
#endif
        return false;
    }
    return true;
}
// 弹出一条消息
void popMessageBox(const TCHAR *format, ...)
{
    va_list ap;
    TCHAR   msg[512] = { 0 };
    va_start(ap, format);
#if _UNICODE
    vswprintf_s(msg, format, ap);
    MessageBoxW(0, msg, L"Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#else
    vsprintf_s(msg, format, ap);
    MessageBoxA(0, msg, "Graph3D - 信息", MB_OK | MB_ICONINFORMATION);
#endif // _UNICODE
    va_end(ap);
}

如果你的代码有编译错误,那么你需要仔细检查程序,如果使用我的代码依然存在着编译错误,那么很可能你很早就做错了,检查一下配置正确否。

8. 结语

可编程管线固然复杂些,这是我刚接触OpenGL的记录,大约有不完善的地方,欢迎批评指正。

发布了4 篇原创文章 · 获赞 1 · 访问量 6444

猜你喜欢

转载自blog.csdn.net/YanEast/article/details/104091184
今日推荐