Learn OpenGL:入门

OpenGL

OpenGL是什么?

OpenGL不是API,只是一个规范,规定了函数如何执行以及输出值是什么,没有规定具体实现,这点类似于STL。

OpenGL3.3的规范文档

OpenGL状态

OpenGL是一个巨大的状态机,当前的状态称为上下文Context

创建窗口

GLFW的作用

创建OpenGL上下文,定义窗口参数,接受用户输入,处理事件。

GLAD或GLEW的作用

自动识别平台支持的OpenGL函数并初始化,让我们的函数调用可以跨平台。(OpenGL只是一个标准/规范,具体的实现是由驱动开发商针对特定显卡实现。由于驱动版本众多,大多数函数的位置都无法在编译时确定,而运行时获取函数地址的方法又因平台而异,还很复杂)

使用GLEW需要配置的静态库

项目配置时先选中AllConfiguration和x32

1
glfw3.lib;glew32s.lib;opengl32.lib;user32.lib;gdi32.lib;shell32.lib;

创建三角形

图形渲染管线

  1. 输入顶点数据(Vertex Data):顶点数据是一个数组,其中包含了顶点属性(Vertex Attribute),比如顶点的坐标、颜色、法向量、uv
  2. 顶点着色器(Vertex Shader):输入的是一个顶点,主要是完成坐标转换,也可以对顶点属性做基本处理
  3. 图元装配(Primitive Assembly):输入的是顶点着色器输出的顶点,把点装配成指定图元的形状,比如三角形
  4. 几何着色器(Geometry Shader):输入的是图元形式的点集,这里可以产生新的顶点来构造出新的图元
  5. 光栅化阶段(Rasterization Stage):把图元映射为最终屏幕上相应的像素,OpenGL渲染一个像素所需的所有数据称为一个片段(Fragment)
  6. 片段着色器(Fragment Shader):运行之前会执行裁切(Clipping),丢弃视图以外的像素。输入多种数据,比如光照和光的颜色等,最终计算出像素的颜色
  7. Alpha测试和混合(Blending):检测深度(Depth)值和模板(Stencil)值,判断像素的前后关系并丢弃像素。alpha指的是透明度,使用透明度对物体颜色进行混合

OpenGL在上述过程中用到的顶点着色器和片段着色器没有默认的,必须自定义

坐标变化过程

顶点着色器执行后得到标准化设备坐标,标准化设备坐标根据glViewPort数据进行视口变换为屏幕空间坐标,输入到片段着色器

VAO、VBO、IBO(EBO)

  • 顶点缓冲对象(Vertex Buffer Objects, VBO):在GPU中保存各种顶点数据

  • 顶点数组对象(Vertex Array Object, VAO):一个VAO对应一个VBO(一个VAO一般也就对应了一个模型),一个VAO里含有0~15号属性指针(Attribute Pointer),分别指定该号属性在VBO里的存放格式(原理就是指针有起始位置和偏移间隔,从而++运算就可以得到下一个元素,就跟数组指针一样)

    也就是说一个点最多可以有16种属性,这0~15号对应shader里设置的location值,所以location的取值范围也就是[0,15]

    但是这个16也是至少,OpenGL确保至少有16个包含4分量的顶点属性可用,通过GL_MAX_VERTEX_ATTRIBS来获取具体的上限

    因为同时只会有一个VAO,所以绑定VBO和指定属性格式前要先绑定VAO。每种属性格式都需要单独设置一次。

  • 索引缓冲对象(Index Buffer Object,IBO或者Element Buffer Object,EBO):一个VAO对应一个IBO,IBO指定了顶点的绘制顺序,没有IBO的话,绘制时就会按照VBO里的顶点数据的存放顺序逐个绘制。

把VBO和IBO绑到VAO上面后,绘制时只需要绑定VAO。如果绑定了IBO指定绘制顺序,要用glDrawElements来绘制,否则用glDrawArrays

流程示例

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
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[] = { // 保存的是vertices里的顶点下标,第一个点的下标从0开始
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
unsigned int VBO, VAO, EBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
// 先绑定VAO
glBindVertexArray(VAO);
// 绑定VBO到VAO上
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 绑定EBO/IBO到VAO上
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 定义顶点属性指针(一定要在绑定VBO之后),这里只有一个位置属性(0号、3个数、float、不归一化、相隔3个float、起始为0)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
// 启用0号顶点属性
glEnableVertexAttribArray(0);

// 可以取消绑定VBO:因为已经设置好VAO里的属性指针了,而属性指针就在VAO里,所以VAO已经可以通过这些指针访问VBO的数据
glBindBuffer(GL_ARRAY_BUFFER, 0);
// 不可以取消绑定EBO/IBO:因为EBO/IBO是保存在VAO里的,它代表了索引数据,取消绑定后就无法访问索引数据了
//glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
// 最好取消绑定VAO,用的时候再绑定
glBindVertexArray(0);

// 在渲染循环里绑定并绘制
glBindVertexArray(VAO);
//glDrawArrays(GL_TRIANGLES, 0, 6);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)

glVertexAttribPointer的参数

shader里对应的location、这个属性有几个元素、每个元素的类型、是否要归一化、相邻同种属性之间的间隔、第一个点的这种属性与数组开头的偏移量。

着色器

流程示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// vertex shader
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
/* check for shader compile errors... */

// fragment shader
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
/* check for shader compile errors... */

// link shaders
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
/* check for linking errors... */

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

// 渲染循环里,绘制调用之前
glUseProgram(shaderProgram);

GLSL

运行在GPU上,语言结构类似于C语言,自带向量和矩阵运算

输入输出和数据传递

  • 输入使用in关键字,输出使用out关键字。uniform是一种全局变量,可以在C++程序里通过glGetUniformLocation获取位置后glUniformxxx指定某种类型的值。
  • 顶点着色器的输入来自顶点数据,除了用in,还需要用layout指定location,而这个就是glVertexAttribPointer的第一个参数。输出顶点的位置赋值给内置的变量gl_Position
  • 片段着色器需要out一个vec4的变量作为像素的颜色。可以用in获取顶点着色器输出的同名变量

纹理

纹理坐标(Texture Coordinate)

横u竖v,图片左下角为原点。顶点数据里指定了顶点对应的纹理坐标,其它片段对应的纹理坐标会通过插值计算出来

纹理数据格式

函数glTexImage2D

1
2
void glTexImage2D(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border,
GLenum format, GLenum type, const void * data);

第一行的参数都是内部(显存)保存格式,第二行是外部(内存)的数据格式。OpenGL会使用第一行的参数进行空间分配,把外部的数据按指定格式复制过来。

format指定了data里数据的组成通道,比如GL_RGB,并没有指定每个通道的大小和格式。type才是指定每个通道大小和格式的,比如GL_FLOAT。指定了这两个才能正常读取data里的数据。读取后就要保存下来。

internalformat指定的就是保存数据的格式,或者说是开辟空间时一个元素的大小和格式,比如GL_RGB16F,注意如果是GL_RGB而没有指定数值,那么默认就是[0,1]的浮点数(直接截断)。元素个数自然就是widthheight的积。

纹理环绕和过滤选项

  • 纹理环绕(Wrap):对于纹理坐标不属于(0,0)到(1,1)的处理方式。有重复、镜像重复、边缘拉伸、指定颜色
  • 纹理过滤(Flitering):纹理被放大(Magnify)和缩小(Minify)的时候如何处理。可以直接取对应像素、周围像素求均值
  • 多级渐远纹理(Mipmap):当距离比较远时,把纹理换成低分辨率的。开启之后也需要设置纹理过滤方式

stb_image库

  • 需要定义且仅定义一次STB_IMAGE_IMPLEMENTATION,可以单独使用一个stb_image.cpp文件引入头文件并定义这个宏
  • 如果加载的图片显示出来是上下颠倒的,需要在加载前stbi_set_flip_vertically_on_load(true)

流程示例

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
// 载入数据
int width, height, nrChannels;
unsigned char *data = stbi_load(path, &width, &height, &nrChannels, 0);
if(!data){
std::cout << "Failed to load texture: " << path << std::endl;
exit(1);
}

// 生成纹理对象
unsigned int texture;
glGenTextures(1, &texture);

// 绑定之后才可以设置参数
glBindTexture(GL_TEXTURE_2D, texture);
GLenum format, internalFormat;
if (_nrComponents == 1) format = GL_RED;
else if (_nrComponents == 3) format = GL_RGB;
else if (_nrComponents == 4) format = GL_RGBA;
if (format == GL_RGBA && type == TextureType::Diffuse)
internalFormat = GL_SRGB_ALPHA; // gamma矫正
else
internalFormat = format;
glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, width, height, 0, format, GL_UNSIGNED_BYTE, data);
// 生成MipMap
glGenerateMipmap(GL_TEXTURE_2D);
// glTexParamteri(....)设置环绕、过滤方式

// 释放内存
stbi_image_free(data);

// 激活纹理单元;0号默认是激活的,这里可以不写;GL_TEXTUREn就是GL_TEXTURE0+n
glActiveTexture(GL_TEXTURE0);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, texture);

// 为shader的采样器指定纹理单元
glUseProgram(shaderProgram);
glUniform1i(glGetUniformLocation(shaderProgram, "texture1"), 0);

shader纹理采样

  1. 首先需要一张纹理,sampler2D定义的是纹理的单元,至少有0~15号。声明为uniform后就可以在外面通过glUniform1i指定采样的纹理单元(当然要先激活单元然后绑定一张纹理)

  2. 还需要vec2类型的纹理坐标,一般就是顶点数据的一部分,可以用顶点着色器传递给片段着色器。

  3. texColor = texture(ourTexture, TexCoord)即可得到ourTextureTexCoord坐标处的颜色

使用mix(color1, color2, 0.2)在两个颜色间插值,color2权重为0.2,可以用来混合多张纹理

注意定义的采样器要跟对应的数据类型加前缀,比如保存无符号数据的纹理对应的采样器声明是usampler2D

变换

缩放、平移、旋转

  • 缩放矩阵,x、y、z缩放系数分别为S1、S2、S3 \[ \begin{pmatrix} S_1 & 0 & 0 & 0 \\ 0 & S_2 & 0 & 0 \\ 0 & 0 & S_3 & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \]

  • 位移矩阵,x、y、z位移量分别为T1、T2、T3 \[ \begin{pmatrix} 1 & 0 & 0 & T_x \\ 0 & 1 & 0 & T_y \\ 0 & 0 & 1 & T_z \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \]

  • 旋转矩阵

    沿x轴旋转\(\theta\)(弧度) \[ \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & cos\theta & -sin\theta & 0 \\ 0 & sin\theta & cos\theta & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \]

    沿y轴旋转\(\theta\)(弧度) \[ \begin{pmatrix} cos\theta & 0 & sin\theta & 0 \\ 0 & 1 & 0 & 0 \\ -sin\theta & 0 & cos\theta & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \]

    沿z轴旋转\(\theta\)(弧度) \[ \begin{pmatrix} cos\theta & -sin\theta & 0 & 0 \\ sin\theta & cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \]

在齐次坐标里,表示位置的列向量的最后一个数值为1,而表示方向的最后一个数字为0,因为方向向量可以平移,与位移矩阵相乘之后不变。

这三种变换按照“缩放-旋转-平移”的顺序进行才不会导致物体发生不均匀的拉伸。

四元数

Axis轴旋转Angle弧度对应的四元数quat

1
2
3
4
quat.x = Axis.x · sin(Angle / 2)
quat.y = Axis.y · sin(Angle / 2)
quat.z = Axis.z · sin(Angle / 2)
quat.w = cos(Angle / 2)

glm/gtc/quaternion.hpp

1
2
3
glm::quat // 创建四元数
glm::angleAxis // 由旋转角度和旋转轴创建四元数
glm::mat4_cast // 四元数转为旋转矩阵

glm库

配置时要把glm/glm/dummy.cpp从项目里移除,因为这里面也定义了main函数

获取实际数据地址:

1
2
#include <glm.gtc/type_ptr.hpp>
auto p = glm::value_ptr(mat);

创建变换矩阵:

1
2
3
4
glm::mat4 modelMat(1);
modelMat = glm::translate(modelMat, glm::vec3(1));
modelMat = glm::rotate(modelMat, glm::radians(90), glm::vec3(0, 0, 1)); // 绕z轴
modelMat = glm::scale(modelMat, glm::vec3(0.5f, 0.5f, 0.5f));

个人理解:

按照变换的应用顺序“缩放-旋转-平移”,对一个单位矩阵逐个按照“缩放-旋转-平移”的顺序左乘,就可以得到变换矩阵。但是glm库的矩阵是按照列主序存放的,也就是转置矩阵 \[ m = t \times r \times s \\ m^T = (t \times r \times s)^T =s^T \times r^T \times t^T \] 使用glUniformMatrix4fv设置矩阵时倒数第二个参数是GL_FALSE,表示不进行转置就是因为OpenGL也按照列主序存放。因此在C++中使用glm库的矩阵乘法时也需要使用右乘!

坐标系统

各种坐标空间

  • 局部空间:一个模型的顶点相对于模型原点的坐标,这就是最开始输入的顶点坐标数据
  • 世界空间:一个模型整体在世界空间中可能从世界原点开始发生缩放、旋转和平移,对应模型上的顶点坐标就会发生变化。从局部空间变换到实际空间需要左乘模型矩阵(Model Matrix)
  • 观察空间:这是以摄像机的视角来看的。从世界坐标系变换到观察空间需要左乘观察矩阵(View Matrix)
  • 裁剪空间:通过定义一个平截头体,把世界坐标投影到平面上。这个过程需要一个投影矩阵,如果是正射投影,就叫做正射投影矩阵(Orthographic Projection Matrix);如果是透视投影,就叫做透视投影矩阵(Perspective Projection Matrix)。投影之后执行透视除法,令坐标的每个分量除以w,变换为标准设备坐标(Normalized Device Coordinates, NDC)。范围是[-1.0,1.0],超出这个范围的就会被剪裁。透视除法和剪裁由OpenGL自动执行。
  • 屏幕空间:OpenGL会使用glViewPort的参数把标准化设备坐标映射到屏幕坐标,一个坐标对应一个像素点,左下角为(0,0)。这个过程叫做视口变换

顶点着色器输出的gl_Position就是剪裁空间坐标。得到这个坐标需要三个矩阵:模型矩阵、观察矩阵和投影矩阵。模型矩阵可以依次由glm::translateglm::rotateglm::scale得到;观察矩阵可以用glm::lookAt得到;两个投影矩阵分别对应glm::orthoglm::perspective

OpenGL坐标系

OpenGL采用右手坐标系,面朝屏幕时:x轴向右,y轴向上,z轴垂直于屏幕向外。

2D的屏幕坐标系的原点在左下角,x轴向右,y轴向上。而GLFW的屏幕坐标,也就是鼠标位置,原点在左上角,x轴向右,y轴向下。

摄像机

确定一个摄像机

其实就是在世界空间中确定一个坐标系:原点和三个相互垂直的坐标轴

  1. 位置:是摄像机坐标空间的原点
  2. 方向:实际是从观察目标指向摄像机,也就是指向摄像机的后面,在摄像机的坐标空间中是正z轴
  3. 右轴:是摄像机空间的正x轴。当不考虑摄像机的roll时,可以通过世界空间的向上轴于摄像机的z轴求叉积,也就是glm::cross(worldUp, camera.direction)。注意这里虽然叫direction,实际上是指向相机的后面,是它的z轴
  4. 上轴:是摄像机空间的y轴。由上面求出的z轴和x轴的叉积即可得到,也就是glm::cross(camera.direction, camera.right)

求三个轴向时,要记得使用glm::normalize进行单位化

变换到摄像机空间

只需要一个LookAt矩阵 \[ LookAt= \begin{pmatrix} R_x & R_y & R_z & 0 \\ U_x & U_y & U_z & 0 \\ D_x & D_y & D_z & 0 \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \times \begin{pmatrix} 1 & 0 & 0 & -P_x \\ 0 & 1 & 0 & -P_y \\ 0 & 0 & 1 & -P_z \\ 0 & 0 & 0 & 1 \\ \end{pmatrix} \] 将LookAt矩阵左乘物体的世界坐标即可得到观察空间坐标

之前确定摄像机空间实际只使用了三个量:摄像机坐标、摄像机方向和世界的上轴。因为已知摄像机的坐标,所以摄像机方向这个量可以改为摄像机观察的目标位置。于是通过glm::lookAt(camera.position, target.position, glm::vec3(0, 1, 0))即可得到这个观察矩阵。

用欧拉角确定一个相机

欧拉角有俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll)。对摄像机来说,设pitch是俯角,yaw是左转角(都是弧度)

1
2
3
camera.direction = normalize(glm::vec3(
cos(pitch) * sin(yaw), sin(pitch), cos(pitch) * cos(yaw)
)