Learn OpenGL:入门
OpenGL
OpenGL是什么?
OpenGL不是API,只是一个规范,规定了函数如何执行以及输出值是什么,没有规定具体实现,这点类似于STL。
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; |
创建三角形
图形渲染管线
- 输入顶点数据(Vertex Data):顶点数据是一个数组,其中包含了顶点属性(Vertex Attribute),比如顶点的坐标、颜色、法向量、uv
- 顶点着色器(Vertex Shader):输入的是一个顶点,主要是完成坐标转换,也可以对顶点属性做基本处理
- 图元装配(Primitive Assembly):输入的是顶点着色器输出的顶点,把点装配成指定图元的形状,比如三角形
- 几何着色器(Geometry Shader):输入的是图元形式的点集,这里可以产生新的顶点来构造出新的图元
- 光栅化阶段(Rasterization Stage):把图元映射为最终屏幕上相应的像素,OpenGL渲染一个像素所需的所有数据称为一个片段(Fragment)
- 片段着色器(Fragment Shader):运行之前会执行裁切(Clipping),丢弃视图以外的像素。输入多种数据,比如光照和光的颜色等,最终计算出像素的颜色
- 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
glVertexAttribPointer的参数
shader里对应的location
、这个属性有几个元素、每个元素的类型、是否要归一化、相邻同种属性之间的间隔、第一个点的这种属性与数组开头的偏移量。
着色器
GLSL
运行在GPU上,语言结构类似于C语言,自带向量和矩阵运算
输入输出和数据传递
- 输入使用
in
关键字,输出使用out
关键字。uniform
是一种全局变量,可以在C++程序里通过glGetUniformLocation
获取位置后glUniformxxx
指定某种类型的值。 - 顶点着色器的输入来自顶点数据,除了用
in
,还需要用layout
指定location
,而这个就是glVertexAttribPointer
的第一个参数。输出顶点的位置赋值给内置的变量gl_Position
- 片段着色器需要
out
一个vec4
的变量作为像素的颜色。可以用in
获取顶点着色器输出的同名变量
纹理
纹理坐标(Texture Coordinate)
横u竖v,图片左下角为原点。顶点数据里指定了顶点对应的纹理坐标,其它片段对应的纹理坐标会通过插值计算出来
纹理数据格式
函数glTexImage2D
:
1 | void glTexImage2D(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, |
第一行的参数都是内部(显存)保存格式,第二行是外部(内存)的数据格式。OpenGL会使用第一行的参数进行空间分配,把外部的数据按指定格式复制过来。
format
指定了data
里数据的组成通道,比如GL_RGB
,并没有指定每个通道的大小和格式。type
才是指定每个通道大小和格式的,比如GL_FLOAT
。指定了这两个才能正常读取data
里的数据。读取后就要保存下来。
internalformat
指定的就是保存数据的格式,或者说是开辟空间时一个元素的大小和格式,比如GL_RGB16F
,注意如果是GL_RGB
而没有指定数值,那么默认就是[0,1]的浮点数(直接截断)。元素个数自然就是width
和height
的积。
纹理环绕和过滤选项
- 纹理环绕(Wrap):对于纹理坐标不属于(0,0)到(1,1)的处理方式。有重复、镜像重复、边缘拉伸、指定颜色
- 纹理过滤(Flitering):纹理被放大(Magnify)和缩小(Minify)的时候如何处理。可以直接取对应像素、周围像素求均值
- 多级渐远纹理(Mipmap):当距离比较远时,把纹理换成低分辨率的。开启之后也需要设置纹理过滤方式
stb_image库
- 需要定义且仅定义一次
STB_IMAGE_IMPLEMENTATION
,可以单独使用一个stb_image.cpp
文件引入头文件并定义这个宏 - 如果加载的图片显示出来是上下颠倒的,需要在加载前
stbi_set_flip_vertically_on_load(true)
shader纹理采样
首先需要一张纹理,
sampler2D
定义的是纹理的单元,至少有0~15号。声明为uniform
后就可以在外面通过glUniform1i
指定采样的纹理单元(当然要先激活单元然后绑定一张纹理)还需要
vec2
类型的纹理坐标,一般就是顶点数据的一部分,可以用顶点着色器传递给片段着色器。texColor = texture(ourTexture, TexCoord)
即可得到ourTexture
在TexCoord
坐标处的颜色
使用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 | quat.x = Axis.x · sin(Angle / 2) |
在glm/gtc/quaternion.hpp
中
1 | glm::quat // 创建四元数 |
glm库
配置时要把glm/glm/dummy.cpp
从项目里移除,因为这里面也定义了main
函数
获取实际数据地址:
1 |
|
创建变换矩阵:
1 | glm::mat4 modelMat(1); |
个人理解:
按照变换的应用顺序“缩放-旋转-平移”,对一个单位矩阵逐个按照“缩放-旋转-平移”的顺序左乘,就可以得到变换矩阵。但是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::translate
、glm::rotate
和glm::scale
得到;观察矩阵可以用glm::lookAt
得到;两个投影矩阵分别对应glm::ortho
和glm::perspective
OpenGL坐标系
OpenGL采用右手坐标系,面朝屏幕时:x轴向右,y轴向上,z轴垂直于屏幕向外。
2D的屏幕坐标系的原点在左下角,x轴向右,y轴向上。而GLFW的屏幕坐标,也就是鼠标位置,原点在左上角,x轴向右,y轴向下。
摄像机
确定一个摄像机
其实就是在世界空间中确定一个坐标系:原点和三个相互垂直的坐标轴
- 位置:是摄像机坐标空间的原点
- 方向:实际是从观察目标指向摄像机,也就是指向摄像机的后面,在摄像机的坐标空间中是正z轴
- 右轴:是摄像机空间的正x轴。当不考虑摄像机的roll时,可以通过世界空间的向上轴于摄像机的z轴求叉积,也就是
glm::cross(worldUp, camera.direction)
。注意这里虽然叫direction,实际上是指向相机的后面,是它的z轴 - 上轴:是摄像机空间的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 | camera.direction = normalize(glm::vec3( |