opengl

可编程渲染管线

先来说说什么是渲染管线。管线的英文是pipeline,其实正确的翻译应该是流水线,或者一系列流程。那么渲染管线就可以看作我可以输入一堆原始图形数据,经过这个流水线,能得到每个像素颜色的输出。

渲染管线大致分为两个部分:一是将3D信息转换为2D信息,二是将2D信息转换为一系列有颜色的像素。具体流程如下图:

所有的这些阶段在不同的图形API(例如本文涉及的OpenGL)上都是高度专门化的,并且它们十分容易并行执行,因此可以将各个阶段的程序搬到GPU上去运行,由这些程序操纵被绑到GPU显存上的顶点数据,完成渲染工作。

其中有几个阶段是用户可编程的(上图蓝色部分),例如最常用到的vertex shader(将3D坐标转换为另一种3D坐标)和fragment shader(计算某个像素的颜色)。我们可以向OpenGL注入我们自己写的shader,所使用的语言就是GLSL(Games202需用)。

crash course

NDC(Normalized Device Coordinates)

这个在Games101提到MVP变换中有提及,它的中文叫做标准化设备坐标,就是x,y,z都是在[-1,1]的空间,对应的就是MVP中的Projection。

前面提到,Vertex shader的作用是将3D坐标装换成另外的3D坐标,因此可以推测出,MVP变换通常是在Vertex shader中进行的。

VBO(Vertex Buffer Objects)

顶点缓冲对象,听上去是拿来存储顶点信息的,事实也正是如此,只不过后面加了个对象的概念。

对于OpenGL中的对象,它们都对应着一个ID。对于VBO,可以使用 glGenBuffers 来生成并获得对应ID:

1
2
unsigned int VBO;
glGenBuffers(1, &VBO);

关于OpenGL的API详细文档,推荐查阅 docs.gl

既然VBO内部其实是Buffer,那么就会有很多其它的Buffer Object,因此需要分个类,俗称缓冲类型。VBO的缓冲类型是GL_ARRAY_BUFFER(没毛病,其实就是数组)。

OpenGL实质上是一个状态机,因此在渲染流程中,要渲染的VBO应该属于当前的OpenGL上下文,即被绑定。要绑定某个VBO,可以:

1
glBindBuffer(GL_ARRAY_BUFFER, VBO);  

绑定完成后,我们对于任何VBO(GL_ARRAY_BUFFER)的修改,都会影响到这个被绑定的VBO。

说了半天,我们VBO内部是要有顶点的,那么数据在哪呢?我们可以自己定义一个float数组:

1
2
3
4
5
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};

这里有三个顶点,顶点属性尚且只有坐标(其实可以加上颜色、法向等)。可以观察到所有坐标都是在NDC范围内的。

好了,现在我们有了直接在cpp里hard code的顶点属性,我们需要和当前绑定的VBO(并不是指定VBO哦,记住opengl的逻辑是一个状态机):

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

这里的最后一个参数涉及到API实现方的优化,具体见 docs.gl

Vertex shader

这里的Shader language是GLSL,对于当前的Vertex shader,我们并不需要给它进行什么复杂的变换(例如MVP变换),因此直接返回对应的齐次坐标即可:

1
2
3
4
5
6
7
#version 330 core
layout (location = 0) in vec3 aPos;

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

第一行是版本说明,也明确表示我们使用核心模式。第二行指定了输入数据。main 函数内则指定了输出位置 gl_Position

接下来我们要在openGL创建Vertex shader(注意没有绑定,后面会提到,绑定的是整个program):

1
2
3
4
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

Fragment shader

和Vertex shader相似:

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

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

Fragment shader只需要一个输出变量RGBA,我们可以在开头声明。

链接着色器程序

创建一个program对象,然后将shader链接进去即可,最后如果不需要的话可以删掉前面创建的shader,我们只需要program:

1
2
3
4
5
6
7
8
9
10
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

glUseProgram(shaderProgram);

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

解释顶点属性分布

我们前面用 glBufferData 绑定了VBO,现在需要解释顶点属性分布,这样shader才知道输入数据。

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

这里第一个参数对应着Vertex shader的输入变量 layout (location = 0) in vec3 aPos;,第二个参数是顶点属性的大小,第三个参数是类型,第四个参数表示是否希望数据被标准化,第五个参数是步长,即两个顶点属性之间的间隔,第六个参数是偏移量。

glEnableVertexAttribArray 表示启用顶点属性,这里0表示第一个顶点属性,即 layout (location = 0) in vec3 aPos;

至此其实已经具备了渲染一个简单三角形的能力,代码会长的像这样:

1
2
3
4
5
6
7
8
9
10
// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();

VAO (Vertex Array Object)

VAO会记录以下顶点属性调用: 1、glEnableVertexAttribArray和glDisableVertexAttribArray的调用 2、通过glVertexAttribPointer设置的顶点属性配置 3、通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象VBO

创建一个VAO:

1
2
unsigned int VAO;
glGenVertexArrays(1, &VAO);

因此,在绘制一个物体的时候,我们可以首先绑定一个VAO,然后绑定配置VBO及其属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 1. 绑定VAO
glBindVertexArray(VAO);
// 2. 把顶点数组复制到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

[...]

// ..:: 绘制代码(渲染循环中) :: ..
// 4. 绘制物体
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();

IBO (Index Buffer Object)

IBO是用来存储顶点索引的,可以减少VBO的内存占用。在绑定VAO的时候,我们可以同时绑定IBO,这样在绘制的时候,OpenGL会根据IBO的索引去VBO中找到对应的顶点属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#include <glad/glad.h>
#include <GLFW/glfw3.h>

#include <iostream>

void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow* window);

// settings
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;

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";
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";

float vertices[] = {
0.5f, 0.5f, 0.0f, // top right
0.5f, -0.5f, 0.0f, // bottom right
-0.5f, -0.5f, 0.0f, // bottom left
-0.5f, 0.5f, 0.0f // top left
};

unsigned int indices[] = {
0, 1, 3,
1, 2, 3
};

int main()
{
// glfw: initialize and configure
// ------------------------------
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

// glfw window creation
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

// glad: load all OpenGL function pointers
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}

unsigned int VAO, VBO, EBO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, false, sizeof(float) * 3, (void*)0);
glEnableVertexAttribArray(0);

glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

glBindBuffer(GL_ARRAY_BUFFER, 0);
// 注意不能在绑定VAO的情况下解绑EBO!EBO是存储在VAO里的!
glBindVertexArray(0);

unsigned int VertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(VertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(VertexShader);
// check for shader compile errors
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;
}
unsigned int FragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(FragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(FragmentShader);
// check for shader compile errors
glGetShaderiv(FragmentShader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(FragmentShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
}

unsigned int Program = glCreateProgram();
glAttachShader(Program, VertexShader);
glAttachShader(Program, FragmentShader);
glLinkProgram(Program);
// check for linking errors
glGetProgramiv(Program, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(Program, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
glDeleteShader(FragmentShader);
glDeleteShader(VertexShader);

// render loop
// -----------
while (!glfwWindowShouldClose(window))
{
// input
// -----
processInput(window);

// render
// ------
glBindVertexArray(VAO);
glUseProgram(Program);

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

// glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
// -------------------------------------------------------------------------------
glfwSwapBuffers(window);
glfwPollEvents();
}

glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteBuffers(1, &EBO);

glDeleteProgram(Program);

// glfw: terminate, clearing all previously allocated GLFW resources.
// ------------------------------------------------------------------
glfwTerminate();
return 0;
}

// process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}

// glfw: whenever the window size changed (by OS or user resize) this callback function executes
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
// make sure the viewport matches the new window dimensions; note that width and
// height will be significantly larger than specified on retina displays.
glViewport(0, 0, width, height);
}

着色器GLSL

着色器英文叫做shader,是运行在GPU上的小程序,不同类型的shader分别为图形管线中的不同阶段提供不同的功能。对于OpenGL,我们采用GLSL语言来编写shader。

输入输出

GLSL定义 inout 关键字来表示输入和输出,对于每个shader(vertex shader和fragment shader),我们都需要定义输入和输出。上个阶段的shader的输出就是下一个阶段的输入。

vertex shader有特定的输入,它前面采用 layout (location = 0) in vec3 aPos; 这种类型的方式,这个其实对应的是CPU端 glVertexAttribPointer 的第一个参数。因此layout关键字能够实现CPU和GPU端的数据传递。

另一种数据传输的方式是 uniform ,它不同于用 inout 关键字定义的变量,uniform 是全局的,它不能被shader修改,而是由CPU来修改。

绑定Program后,我们可以使用 glGetUniformLocation 来获取 uniform 的地址,然后使用 glUniform 系列函数来修改它的值。

可以采用OOP的方式将shader封装进一个类。