Cherno OpenGL教程

引言

这是 TheCherno 的 OpenGL 系列教程。关于这个系列,我在 GitHub 和码云上自建了源码仓库,如果有需要的朋友可以去看看。

欢迎来到OpenGL


在这个视频中,主要包括 OpenGL 实际是什么、怎么使用它以及它能为我们做什么和它不是什么等等,这个系列也是如此。

什么是OpenGL

首先如果你听过 OpenGL 但不确定它是什么,你可能想到与图形有关的,那就是 OpenGL。OpenGL 是一种图形接口,现在 API 代表应用程序接口,基本上就是一大堆我们能够调用的函数去做一些事情。在这种情况下,由于 OpenGL 是一种图形 API,它允许我们做一些与图形相关的事情,特别的是它允许我们访问 GPU 也就是显卡,图形处理单元(Graphics Processing Unit)。

调用显卡也更好地绘制图形,所以实际上为了利用电脑或其他设备(比如手机)中强大的图形处理器,需要调用一些 API 访问固件。OpenGL 正好是允许访问和操作 GPU 的许多接口中的一种,当然我们也有一些其他的接口,比如 Direct3D、Vulcan 和 Metal 等等,所以某种角度来说 OpenGL 允许我们控制显卡。

关于OpenGL误解

让我们来澄清一些人关于 OpenGL 的误解。

首先,许多人称它为一个类库或一种引擎或一些其他的框架,但这些都不是。OpenGL 核心本身只是一种规范,和 CPP 规范差不多。实际上它没有确定任何代码和类似的事情本身就是规范,比如“嘿,这个函数应该存在,需要这些参数并且返回这个值”,它只是一种你能利用这种 API 做什么规范,没有任何具体的实现,这意味它绝不是一个类库,因为 OpenGL 本身没有代码,它只是一种规范。


去哪儿下载 OpenGL 是另一个常见的问题。

然而,答案是你不需要真的去下载 OpenGL,它本身只是一种规范。那么,谁去实现它呢,谁去为你调用的 OpenGL 函数写代码呢?这个答案是 GPU 制造商,所以如果你使用的是 NVIDIA 显卡,那么你的显卡驱动(也就是 NVIDIA 驱动)实际上包含了 OpenGL 的实现,每个显卡制造商比如 AMD、Intel 等都会有它们自己的实现,每家关于 OpenGL 的实现都会有些不同,这也就是有些游戏能在 NVIDIA 驱动的显卡上运行但在一个 AMD 电视或者其他显卡设备上运行有些区别、甚至会出现问题的原因。

但不管怎么说,关键在于你的显卡制造商实现的 OpenGL,这又可能导致下一个有关 OpenGL 常见的误解:它是开源的。我不知道人们为什么会这么觉得,也许是名字中带着“Open”,但是它根本不是开源的,你看不到 OpenGL 的源码,因为首先它是由 GPU 制造商实现的,它们肯定不会发布它们的驱动源码。


OpenGL 提供了什么能激发和导致许多人不幸地说 OpenGL 是无与伦比的?原因在于它是跨平台的,所以你的 OpenGL 代码可以在 Windows、Mac、Linux 和 Android 上正常执行,以至于人们立马就会意识到 OpenGL 比 Direct3D 更优越,因为它能在所有平台上运行,但是请不要这么说。

从我 EA 技术中心并处理过许多引擎中图形接口的经验来看,因为 OpenGL 是跨平台的 API,而制作一款游戏不会只涉及实现一个独立的图形 API。如果游戏引擎是跨平台的,那意味着它不仅实现了 Xbox,也包括其他一些平台,它们不得不实现大量其他的图形接口。

因此,我们面临的问题是图形 API 是为特定平台设计的。例如 Direct3D 是微软为 Windows 设计的,它在 Windows 上的表现要比跨平台的 API 好些。现在请记住,实际编写这些代码的人不是微软,即使微软的确为了更好的代码质量和 GPU 制造商合作过。所以关于 API 的比较是没有任何意义的,因为通常平台原生的东西会更健壮更友好。


就 OpenGL 的复杂性而言,它可以说是现在可以学习的最简单的 API 了,所以 OpenGL 是绝对值得学习的。Vulkan 是另一个跨平台的 API,但它更底层更严谨,不适合初学者直接入门,老实说我并不想使用 Vulkan 去开发游戏,OpenGL 更加稳定。

传统与现代OpenGL

另外本系列学习的主要是现代 OpenGL。OpenGL 于 90 年代发布,那时的情况和现在大不相同,那时的 GPU 是可编程的,十分灵活,制造商给了程序员和开发者更多的控制权。

传统 OpenGL 和现代 OpenGL 之间最大的区别就是着色器。如果你对图形学感兴趣的话可能听说过着色器,它可能有点像 shadow 这个单词或者是光源,有些人将它和光源或其他比较。抛开这些,着色器是程序,它是在你 GPU 上运行的代码,这就是着色器,它是在你 GPU 上运行的一段程序。。

那么如果你用 C++、Java 或 C# 或不管什么语言写代码,这段代码都会运行在你的 CPU 上。但当我们开始处理图形的大部分时间里,我们想要更为精确的控制显卡运行,可能要将大部分代码从 CPU 转到 GPU 上,因为它在 GPU 上运行更快,这就是着色器存在的意义:允许我们在 GPU 上运行代码。所以可编程的着色器是最大的区别。

设置OpenGL和C++中创建一个窗口


上一节我们只说了 OpenGL 到底是什么以及这个系列实际会讲什么。今天我们的任务是让我的操作系统为我创造一个窗口,在未来我们可能会在窗口内绘制图形。


我们会使用一个向我提供窗口创建和窗口管理的实际代码类库,不管 Windows、Mac 还是 Linux。GLFW 就是满足上述条件的类库,我喜欢这个类库的原因是它确实是一个轻量级类库,它虽然不如 SDL 那么全面(实际上它就是个渲染器),但依然可以创建窗口、OpenGL context 以及给我们访问一些类似输入之类的基础东西。

下载类库

由于我使用的是 Mac,所以使用 brew 下载:

brew install glew
brew install glfw

下载后默认的路径为 /usr/local/Cellar/

配置CMake文件

我将项目名称设置为 OpenGL,在 CLion 中配置:

cmake_minimum_required(VERSION 3.16)
project(OpenGL)

set(CMAKE_CXX_STANDARD 17)

# Add head file
set(GLEW_H /usr/local/Cellar/glew/2.2.0_1/include/GL)
set(GLFW_H /usr/local/Cellar/glfw/3.3.7/include/GLFW)
include_directories(${GLEW_H} ${GLFW_H})

# Add target link
set(GLEW_LINK /usr/local/Cellar/glew/2.2.0_1/lib/libGLEW.2.2.dylib)
set(GLFW_LINK /usr/local/Cellar/glfw/3.3.7/lib/libglfw.3.dylib)
link_libraries(${OPENGL} ${GLEW_LINK} ${GLFW_LINK})

set(SOURCE_FILES main.cpp)
add_executable(OpenGL ${SOURCE_FILES})

if (APPLE)
target_link_libraries(OpenGL "-framework OpenGL")
target_link_libraries(OpenGL "-framework GLUT")
endif()

编译项目

复制下面内容到 main.cpp

#include <glew.h>
#include <glfw3.h>
#include <iostream>
using namespace std;

int main()
{
/* Initialize the library */
if(!glfwInit())
return -1;

/* Create a Windowed mode and its OpenGL context */
GLFWwindow* window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if(!window)
{
glfwTerminate();
return -1;
}

/* Make the window's context current */
glfwMakeContextCurrent(window);

/* Loop until the user closes the window */
while(!glfwWindowShouldClose(window))
{
/* Render here */
glClear(GL_COLOR_BUFFER_BIT);

/* Swap front and back buffers */
glfwSwapBuffers(window);

/* Poll for and events */
glfwPollEvents();
}

glfwTerminate();
return 0;
}

运行代码,我们会得到一个黑色的窗口。

画一个三角形

下面我们用传统 OpenGL 设定三个顶点,绘制一个三角形:

#include <glew.h>
#include <glfw3.h>
#include <iostream>
using namespace std;

int main()
{
/* Initialize the library */
if(!glfwInit())
return -1;

/* Create a Windowed mode and its OpenGL context */
GLFWwindow* window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if(!window)
{
glfwTerminate();
return -1;
}

/* Make the window's context current */
glfwMakeContextCurrent(window);

/* Loop until the user closes the window */
while(!glfwWindowShouldClose(window))
{
/* Render here */
glClear(GL_COLOR_BUFFER_BIT);

+ glBegin(GL_TRIANGLES);
+ glVertex2f(-0.5f, -0.5f);
+ glVertex2f( 0.0f, 0.5f);
+ glVertex2f( 0.5f, -0.5f);
+ glEnd();

/* Swap front and back buffers */
glfwSwapBuffers(window);

/* Poll for and events */
glfwPollEvents();
}

glfwTerminate();
return 0;
}

在C++中使用现代OpenGL


顶点缓冲区和在现代OpenGL中画一个三角形


现代 OpenGL 比那种传统 OpenGL 更具可编程性,它的扩展性更好也更加强大,你可以用它做许多事情,但代价是在绘制一个简单三角形前,我们实际上需要做很多设置。而前面我们已经用传统方法简单绘制了三角形,非常简单并且不需要什么设置。

让我们聊聊需要什么东西才可以绘制一个三角形。对于现代 OpenGL 而言,首先我们需要能够创建一个顶点缓冲区,然后还要创建一个着色器(之后可能会单独讲一下图形渲染管线是如何运行的,特别是 OpenGL)。让我们快速地说一下这两个东西是什么吧。

顶点缓冲区与着色器

顶点缓冲区基本上就是去掉 vertex,它只是一个内存缓冲区,一个内存字节数组,从字面上讲就是一块用来存字节的内存。但是顶点缓冲区又和 C++ 中像字符数组的内存缓冲区不太一样,它是 OpenG 中的内存缓冲区,这意味着它实际上在显卡显存(Video RAM)上。

所以这里的基本思路就是我要定义一些数据来表示三角形,我要把它放入显卡的 VRAM 中,然后还需要发出 DrawCall 绘制指令。实际上我们还需要告诉显卡如何读取和解释这些数据,以及如何把它放到我们屏幕上,一旦我们发出 DrawCall 指令,我们需要告诉显卡:好了,一旦你在显卡端获得了这些数据,我要你像这样把它摆出来,我希望你把它画出来在屏幕上给我显示一个三角形。所以我们需要告诉显卡怎么做,需要对显卡编程,这就是着色器,着色器只是一个运行在显卡上的程序,它是一堆我们可以编写的在显卡上以一种非常特殊的方式运行的代码。


听起来很复杂,但是不要想太多。简而言之我们有一个可以指定的内存,还有一些我可以指定的数据,告诉显卡:嘿,这是数据。然后从显卡那边说:好了,现在读一下这些数据并解释一下(例如屏幕上的位置),可能的话把它们连成一个三角形。这就是整件事的原理,也是 OpenGL 渲染的流程。

要注意 OpenGL 是作为一个状态机来运行的,这意味着你不必把它当作一个对象或任何类似的东西来对待,你所做的是设置一系列的状态,然后当你说一些事比如给我画一个三角形,这是非常 contextual 的。我的意思是,我不只是说,嘿,给我画一个三角形,然后传递 OpenGL 需要绘制三角形的所有东西。实际上它已经知道画三角形需要什么了,因为那是状态的一部分。

代码实践

让我们来创建这个顶点缓冲区来看看能做些什么。

glBegin(GL_TRIANGLES);
glVertex2f(-0.5f, -0.5f);
glVertex2f( 0.0f, 0.5f);
glVertex2f( 0.5f, -0.5f);
glEnd();

这是之前使用传统 OpenGL 绘制三角形的代码。现代 OpenGL 需要创建顶点缓冲区,发送到 OpenGL 显存,然后发出一个 DrawCall 指令说:嘿,请画出我的缓冲区。

首先我们要创建自己的缓冲区,这个过程非常简单,只需要调用 glGenBuffers()

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

第一个参数指定需要几个缓冲区;第二个参数指定返回整数的内存地址,这也是生成的缓冲区的 id。记住 OpenGL 是作为一个状态机工作,这意味着你可以生成一切,而 OpenGL 中生成所有东西都分配了一个唯一的标识符,它只是一个整数,也是你实际对象的 id,当你想要使用这个对象的时候就用这个数字。

因为我要渲染我的三角形,需要说明用哪个缓冲区来渲染三角形,只需要传递这个整数即可。现在我们有了这个 id,一旦创建缓冲区后,我们现在就要选择那个缓冲区。选择(Selecting)在 OpenGL 中被称为绑定(Binding):

glBindBuffer(GL_ARRAY_BUFFER, buffer);

下一步是指定数据。一个简单的方式是在声明数据的时候直接把顶点数据填充进去:

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

接着调用 glBufferData() ,查阅文档:

glBufferData(GL_ARRAY_BUFFER, 6 * sizeof(float), positions, GL_STATIC_DRAW);

很好,现在我们为 OpenGL 指定了 positions,通常还需要创建一个索引缓冲区,也都是后话了。不过我们还没有着色器,去解释如何用这些数据进行绘制,我们将在下一期讨论那个问题。今天我们要做的只是想看看能不能把它绘制出来。

在没有索引缓冲区的情况下我们可以调用 glDrawArrays() 绘制指定图元:

/* Render here */
glClear(GL_COLOR_BUFFER_BIT);

-glBegin(GL_TRIANGLES);
-glVertex2f(-0.5f, -0.5f);
-glVertex2f( 0.0f, 0.5f);
-glVertex2f( 0.5f, -0.5f);
-glEnd();

+glDrawArrays(GL_TRIANGLES, 0, 3);

我喜欢用 Photoshop 打比方。如果我选择一个图层,然后在该图层上用画笔绘制一些东西,它只会影响这一图层。然而如果我没有选择任何东西或者选错了东西,它不会影响到我想绘制的那个图层。OpenGL 也是一样,在使用它之前你需要选择或绑定所有东西,因为这就是它的运行原理,它是上下文相关的,它是一个状态机。

OpenGL中顶点的属性和布局


上一节我们讨论了顶点缓冲区,但实际上有一些部分我们忽略了:顶点属性和着色器。今天我们将学习顶点属性。

顶点属性

所以什么是顶点属性?OpenGL 渲染管线的工作原理是我们为我们的显卡提供数据,我们在显卡上存储一些内存,它包含了我们想要绘制的所有数据;然后我们使用一个着色器在显卡上读取数据,并且完全显示在屏幕上。

通常我们绘制几何图元的方式就是使用一个叫顶点缓冲区的东西,也就是一个存储在显卡上的内存缓冲区,所以当对着色器编程时实际上是从读取顶点缓冲区开始的,它需要知道缓冲区的布局,这个缓冲区包含的浮点数指定了每个顶点的位置、纹理坐标、法线之类的。

代码实践

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

在我继续之前想先定义顶点是什么意思,因为人们在错误的使用这个词。

顶点与位置无关,顶点就是几何图形上的一个点。大多数人在视觉上对它们的明显印象是通过它的位置,所以如果我给你画一个三角形,你会看到三个顶点。但顶点不是位置,一个顶点可以不仅仅包含一个位置,顶点可以包含位置更多的数据例如纹理坐标、法线、颜色等等,它们可能都在一个顶点。所以我们需要告诉 OpenGL 外面的数据布局,调用 glVertexAttribPointer() 函数。

  • index:Specifies the index of the generic vertex attribute to be modified.

    基本上我们的着色器读取所有这些的方式是通过索引。一般来说如果我们有一个位置在索引 0 处,我们需要把它作为索引 0 来引用;而当我们有三种属性,我想让我的位置在下标 0,纹理坐标在索引 1,法线在索引 2.所以当我开始从着色器和显卡读取数据时,然后进入那个缓冲区,我可以简单地引用它们。这就是索引,它只是缓冲区实际属性的索引。

  • size:Specifies the number of components per generic vertex attribute. Must be 1, 2, 3, 4. Additionally, the symbolic constant GL_BGRA is accepted by glVertexAttribPointer. The initial value is 4.

    这里的 size 可能有点误导人,它是每个通用顶点属性的组件数,只能是 1,2,3,4。所以这个 size 和字节没有关系,和它们实际占用了多少内存也没关系。在本例中每个顶点的坐标有 x 和 y 两组分量,所以 size 为 2。

  • type:Specifies the data type of each component in the array. The symbolic constants GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT, GL_UNSIGNED_SHORT, GL_INT, and GL_UNSIGNED_INT are accepted by glVertexAttribPointer and glVertexAttribIPointer. Additionally GL_HALF_FLOAT, GL_FLOAT, GL_DOUBLE, GL_FIXED, GL_INT_2_10_10_10_REV, GL_UNSIGNED_INT_2_10_10_10_REV and GL_UNSIGNED_INT_10F_11F_11F_REV are accepted by glVertexAttribPointer. GL_DOUBLE is also accepted by glVertexAttribLPointer and is the only token accepted by the type parameter for that function. The initial value is GL_FLOAT.

    这里是我们指定数据类型 type,在本例中则是位置的浮点类型 GL_FLOAT

  • normalized:For glVertexAttribPointer, specifies whether fixed-point data values should be normalized (GL_TRUE) or converted directly as fixed-point values (GL_FALSE) when they are accessed.

    标准化其实不用太担心,如果我们处理的是浮点数,因为它们已经被规范化了。假设我们要指定一个颜色字节在 0 到 255 之间,它在我们的实际着色器作为一个浮点数需要被规范化到 0 到 1 之间,这不是一个你可以在 CPU 上做的事情,但你可以让 OpenGL 替你做。

  • stride:Specifies the byte offset between consecutive generic vertex attributes. If stride is 0, the generic vertex attributes are understood to be tightly packed in the array. The initial value is 0.

    stride 指针会让很多人感到困惑,如文档所示它就是连续通用顶点属性之间的字节偏移量,也可以理解为每个顶点之间的字节数。举个例子我们有位置 vec3、纹理坐标 vec2 和法线 vec3,那么我们的 stride 就是 3 * 4 + 2 * 4 + 3 * 4 = 32 字节,它是每个顶点的字节大小。

    如果我们想从一个顶点跳到下一个顶点,我需要在缓冲区中加上 32 个字节。所以如果我们有一个指针指向缓冲区的开始,然后经过缓冲区的 32 个字节,我应该在下一个顶点的起点,这就是 stride。

  • pointer:Specifies a offset of the first component of the first generic vertex attribute in the array in the data store of the buffer currently bound to the GL_ARRAY_BUFFER target. The initial value is 0.

    pointer 文档的表述上第一个组件的一个偏移量,它是指向实际属性的指针。不要管有多少个顶点,聚焦于一个顶点,里面包含位置、纹理坐标和法线。对于位置偏移量为 0,因为它是缓冲区的第一个字节;然后我们前进 12 个字节到达纹理坐标,所以对于我的纹理坐标属性这个值(pointer)是 12;最后再前进 8 字节得到顶点的法线,所以对于顶点法线属性 20 是这个 pointer 的值。

最后别忘了调用 glEnableVertexAttribArray() 去启用 glVertexAttribPointer():

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

这两段代码告诉 OpenGL 缓冲区的布局是什么,理论上如果有一个着色器就可以看到在屏幕上看到三角形了。

#include <glew.h>
#include <glfw3.h>
#include <iostream>
using namespace std;

int main()
{
/* Initialize the library */
if(!glfwInit())
return -1;

/* Create a Windowed mode and its OpenGL context */
GLFWwindow* window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if(!window)
{
glfwTerminate();
return -1;
}

/* Make the window's context current */
glfwMakeContextCurrent(window);


if(glewInit() != GLEW_OK)
std::cout << "Error!" << std::endl;

std::cout << glGetString(GL_VERSION) << std::endl;

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

unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, 6 * sizeof(float), positions, GL_STATIC_DRAW);

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

glBindBuffer(GL_ARRAY_BUFFER, 0);

/* Loop until the user closes the window */
while(!glfwWindowShouldClose(window))
{
/* Render here */
glClear(GL_COLOR_BUFFER_BIT);

glDrawArrays(GL_TRIANGLES, 0, 3);

/* Swap front and back buffers */
glfwSwapBuffers(window);

/* Poll for and events */
glfwPollEvents();
}

glfwTerminate();
return 0;
}

OpenGL中着色器的原理


今天我们讨论的都是关于着色器。

着色器

每个新手脑海里的第一个问题是什么是着色器?

着色器就是一个运行在显卡上的程序代码,它是我们可以在计算机上以文本或者字符串形式编写的代码,然后我们可以把它给 OpenGL 发到显卡上像其他程序一样编译链接,最后运行它,不同的是它是在我们的显卡上运行而不是 C++ 那样在我们的 CPU 上。那么为什么我们需要程序完全在显卡上运行呢?

究其原因是我们希望能够告诉显卡该做些什么,显卡处理图形的速度要快得多,我们想要利用显卡的能力在屏幕上绘制图形。这并不意味着所有的工作必须在显卡上完成,CPU 有自己擅长的部分,也许之后我们可以将结果数据发送给显卡同时仍然在 CPU 上进行处理。

着色器类型

对于大多数图形编程,我们会把重点放在两种着色器:顶点着色器和片段着色器(又称像素着色器)。

虽然还没有正式讲过标准图形渲染管线,但是我们应该对它大致是如何工作的应该了解:我们在 CPU 上写了一堆数据,向显卡发送这些数据并且发出一个叫做 DrawCall 指令的东西,也绑定了某些状态,最后我们进入了着色器阶段,GPU 实际处理 DrawCall 指令并在屏幕上绘制一些东西。这个特定的过程基本上就是渲染管道,我们如何在屏幕上从数据到结果的。

现在当显卡开始绘制三角形时,着色器就派上用场了。顶点着色器和片段着色器是渲染管线两种不同的着色器类型,所以当我们真正发出 DrawCall 指令时,顶点着色器会被调用,然后片段着色器会被调用,最后我们会在屏幕上看到结果。


顶点着色器

那么顶点着色器是做什么的?

它会被我们渲染的每个顶点调用,在这个例子中我们有一个三角形三个顶点,这意味着顶点着色器会被调用三次,每个顶点调用一次。顶点着色器的主要目的是告诉 OpenGL 你希望这个顶点在屏幕空间的什么位置。再强调一次,顶点着色器的主要目的是提供那些顶点的位置,如果有必要我们需要能够提供一些变换以便 OpenGL 能把这些数字转化成屏幕坐标,这样我们就能在窗口中看到我们的图形在对的位置。

片段着色器

一旦顶点着色器运行结束,我们就进入了管道的下一个阶段:片段着色器或者像素着色器。

虽然片段和像素在术语上有点小差别,但现在你可以把像素当成片段或者把片段想象成像素,因为片段着色器会为每个需要光栅化的像素运行一次。我们的窗口基本上是由像素组成的,我们指定的那三个顶点组成我们的三角形现在需要用实际的像素填充,这就是光栅化阶段所做的。

片段着色器或像素着色器就是对三角形中需要填充的每个像素调用一次,主要决定这个像素是什么颜色,这就是它的作用,它决定了像素的输出颜色,这样像素就可以用正确的颜色着色。形象一点可以把它想象成一本涂色本,当你只有东西的轮廓时需要给它上色,这就是片段着色器的职责。


相比于顶点着色器,片段着色器里面的东西代价要高得多,因为它会为每个像素运行。

话虽如此,有些东西显然需要按像素计算例如光源。如果你在计算光源,每个像素都有一个颜色值,这个值是由很多东西决定:光源、环境、纹理、提供给表面的材质……所有这些一起来确定一个特定像素的正确颜色。显然这取决于一些输入,例如相机的位置在哪里,而这些所有的东西结束后你在片段着色器中的决定仅仅是单个像素的颜色,这就是片段着色器的作用。

OpenGL中写一个着色器


让我们开始写一些着色器代码吧。

准备工作

首先创建一个 CreateShader() 创建着色器函数,传入两个着色器字符串,这些字符串都是实际的代码:

static int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{

}

和创建缓冲区一致,我们需要返回一个整型作为标识符,当我们想绑定的时候可以绑定那个缓冲区 id。

所以我们要做的第一件事就是创建一个程序,基本上我们只需要输入 glCreateProgram(),该函数不需要传入整数引用之类的东西,它会返回一个无符号的整数(顺带一提,这里和后面不使用 OpenGL 自带类型的原因是个人处理多种类型的图形 API,更倾向于 C++ 类型):

unsigned int program = glCreateProgram();

接下来我们需要做的是创建我们的两个着色器对象:

unsigned int vs = glCreateShader(GL_VERTEX_SHADER);

考虑到这个工作在今后的复用性,可以另创建一个函数 CompileShader()

static unsigned int CompileShader(unsigned int type, const std::string& source)
{
unsigned int id = glCreateShader(type);
}

然后可以加一个初始化函数,查找字符串中的第一个字符然后返回它的内存地址:

// const char* src = &source[0];
const char* src = source.c_str();

最后就是调用 glShaderSource()glCompileShader()

static unsigned int CompileShader(unsigned int type, const std::string& source)
{
unsigned int id = glCreateShader(type);
const char* src = source.c_str();
glShaderSource(id, 1, &src, nullptr);
glCompileShader(id);

// TODO: Error handling

return id;
}

回到 CreateShader()

static unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
unsigned int program = glCreateProgram();
unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexShader);
unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentShader);
}

这个过程很像 C++编译中将两个不同的文件链接在一起,以便可以同时使用它们:

glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glValidateProgram(program);

最后我们实际上现在可以删了我们的着色器,因为它们已经被链接到一个程序中,所以我们可以删除这些中间文件:

glDeleteShader(vs);
glDeleteShader(fs);

最终我们的函数源码:

static unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
unsigned int program = glCreateProgram();
unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexShader);
unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentShader);

glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
glValidateProgram(program);

glDeleteShader(vs);
glDeleteShader(fs);

return program;
}

错误处理

让我们快速做一下这个错误处理。

static unsigned int CompileShader(unsigned int type, const std::string& source)
{
unsigned int id = glCreateShader(type);
const char* src = source.c_str();
glShaderSource(id, 1, &src, nullptr);
glCompileShader(id);

// TODO: Error handling

return id;
}

编译着色器实际上不会返回任何东西,所以如果任何东西出错我们无法检索找出问题。但我们可以通过调用 glGetShaderiv() 实现:

int result;
glGetShaderiv(id, GL_COMPILE_STATUS, &result);

判断 result,检索错误信息的长度并输出信息:

static unsigned int CompileShader(unsigned int type, const std::string& source)
{
unsigned int id = glCreateShader(type);
const char* src = source.c_str();
glShaderSource(id, 1, &src, nullptr);
glCompileShader(id);

// TODO: Error handling
int result;
glGetShaderiv(id, GL_COMPILE_STATUS, &result);
if (result == GL_FALSE)
{
int length;
glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);

char* message = (char*)alloca(length * sizeof(char));
glGetShaderInfoLog(id, length, &length, message);

std::cout << "Failed to complie " << (type == GL_VERTEX_SHADER ? "vertex" : "fragment") << "shader!" << std::endl;
std::cout << message << std::endl;

glDeleteShader(id);
return 0;
}


return id;
}

着色器编写

首先 #version 330 core 意味着我们将使用 GLSL(OpenGL 的着色器),其次指定位置和颜色:

std::string vertexShader = R"(
#version 330 core

layout(loacation = 0) in vec4 position;

void main()
{
gl_Position = position;
}
)";

std::string fragmentShader = R"(
#version 330 core

layout(loacation = 0) out vec4 color;

void main()
{
color = vec4(1.0, 0.0, 0.0, 1.0);
}
)";

OpenGL中如何处理着色器


OpenGL中的索引缓冲区


OpenGL中处理错误


OpenGL中的统一变量


OpenGL中的顶点数组


抽象顶点索引缓冲区成类


OpenGL中的缓冲区和布局的抽象


OpenG中抽象着色器


OpenGL写一个基础的渲染器类


OpenGL中的纹理


OpenGL中的混合


OpenGL中的数学


OpenGL中的投影矩阵


OpenGL中的模型视图投影矩阵


OpenGL中的ImGui


批量渲染对象


为OpenGL建立一个测试框架


创建测试


创建一个纹理测试


如何让统一变量更快


批渲染

简介


颜色


纹理


动态几何


索引


挑战一小时完成批渲染器