OpenGL 知识点总结

2020/03/02 OpenGL

网上的 OpenGL 的教程是挺多的,但是总感觉各有优缺点,因此就想着总结一个包含各种优点的、更适合零基础用户的 OpenGL 的教程。

参考:

  • OpenGL 几乎算是最好的教程:教程中的 No. 1,从零开始,提供代码,解释详细。还有对应的中文版:https://learnopengl-cn.github.io/,翻译的也很棒。不过,个人认为它的几个缺点是:
    • 它更像是一个只教你如何编程的教程,对其它内容例如 OpenGL 变换等的讲解不够详细。
    • 它使用的 GLM 数学工具库个人感觉也有些小众,更建议使用 Eigen。
  • 韩国人 Song Ho Ahn (안성호)的教程:内容并不长,但是解释感觉更详细,尤其是关于 OpenGL Transformation 的数学计算的细节,有详细的数学推导,强烈推荐。

OpenGL is mainly considered an API (an Application Programming Interface) that provides us with a large set of functions that we can use to manipulate graphics and images. However, OpenGL by itself is not an API, but merely a specification, developed and maintained by the Khronos Group.

一个最大的误区是,OpenGL 是一个图形学相关的 API 吗?其实并不是:OpenGL 只是由 Khronos Group 最先提出并维护的一个标准。OpenGL 规定了一些函数应该做什么和建议如何实现,但是真正实现这些函数的通常是开发者,例如显卡公司和硬件提供商。例如,Apple 提供自己的 OpenGL 版本供它的硬件使用,Nvidia 为自己的显卡提供专门版本(通常包含在显卡驱动中),而 Linux 中则就有很多不同的版本了。

OpenGL的客户端和服务器模式

参考:

在一台工作站上,绘图的整个过程不过是把数据从系统的内存中复制到图形卡中,然后绘制出图形。

<img src=https://upload-images.jianshu.io/upload_images/4727958-913f63c07082064e.png?imageMogr2/auto-orient/strip imageView2/2/w/1200/format/webp>

OpenGL 是按照客户端-服务器模式(client-server paradigm)设计的。我们认为可以将整个 OpenGL 系统分为两部分,一部分是客户端,它负责发送 OpenGL 命令。一部分是服务端,它负责接收 OpenGL 命令并执行相应的操作。比如我们编写的程序就是一个客户端,而我们的计算机图形硬件制造商提供的OpenGL的实现就是服务器。对于个人计算机来说,可以将 CPU、内存等硬件,以及用户编写的 OpenGL 程序看做客户端,而将 OpenGL 驱动程序、显示设备(GPU)等看做服务端。

客户端-服务器模式对于分析 OpenGL 中多个工具的工作原理非常有用。例如,顶点数组(Vertex Array)就是存放在内存(客户端)的,每次绘制时,将其发送到服务器端。而显示列表(Display List)和顶点缓冲对象(VBO)等则是存放在服务器端的内存中。它们各有自己的优缺点。

常见的 OpenGL 相关工具

参考:

OpenGL 仅仅是一个标准/规范,但是具体使用 OpenGL 开发应用时,则需要依赖具体的开发环境,例如 windows, linux 等,不同环境下的交互甚至都是不同的,例如常见的窗口,IO 控制等交互逻辑。因此 glut, freeglut, glfw这类的库便产生了。它们的主要目的是,提供跨平台的简化版的交互工具给用户。

另外类似的,OpenGL 是一个标准/规范,具体的实现是由驱动开发商针对特定的显卡而实现。支持OpenGL 的驱动版本众多,大多数函数的地址(内存地址)无法在编译时候确定下来,需要运行的时候查询。所以在运行的时候,获取函数的内存地址并把其保存在一个函数指针中供后续使用。而 glew, glad, gl3w 基本都是实现类似的功能,它们的目的是找到你的机器的显卡所支持的 OpenGL 函数是在哪实现的。如果不使用这类工具,那么用户使用几乎每个 OpenGL 中的函数都要先写一些语句找到它们的实现,非常繁琐。

常见的一些工具

  • gl, glu:OpenGL 自带的,一般系统中默认都有了。接下来的全部都要下载安装。
  • glut:OpenGL实用工具库。需要下载配置安装,非常老的一个库了,现在都停止维护了,不建议使用,在比较旧的教材中比较常见。
  • freeglut:OpenGL 实用工具库开源版本。需要下载配置安装,完全兼容 glut,算 glut 的代替品,但是 bug 较多,也是比较老的了。
  • glfw:比较新的工具,一个专门针对 OpenGL 的 C 语言库,它提供了一些渲染物体所需的最低限度的接口,类似于一个简化版本的跨平台 OpenGL 工具。它允许用户创建 OpenGL 上下文,定义窗口参数以及处理用户输入等。目前 glfw 还在维护,可以说 glfw 库是代替 glut 和 freeglut 的。
  • glew:基于 OpenGL 图形接口的跨平台的 C++ 扩展库。它的出现是为了方便的管理平台与 OpenGL 版本不匹配,以及方便的解决不同显卡特有的硬件接口支持。只要包含一个 glew.h 头文件,你就能使用 gl, glu,glext,wgl,glx 等多种工具的全部函数。GLEW能自动识别当前平台所支持的全部OpenGL高级扩展涵数。GLEW支持目前流行的各种操作系统。
  • glad:基于官方规格的多语言 GL / GLES / EGL / GLX / WGL 装载机 - 生成器。一般结合 GLFW 使用。和 glew 的功能非常类似。
  • OpenGL ES:就是专门为各种移动端设备设置的简化版的 OpenGL,支持常见的所有移动端系统,例如 iOS 和 Android。同样也是由 Khronos Group 开发并维护。一个简单介绍:https://juejin.im/post/5c74b06bf265da2d90584313

在 PC 端实现时,通常使用 glfw + glew 或者 glfw + glad 等的组合。

glew 和 glad 的优缺点对比

  • glew 的最大好处是,只要包含一个 glew.h 头文件就可以了。不过缺点是,它因为是包含了基本上所有硬件和系统平台的代码,因此比较臃肿(当然也是一个优点)。另外,glew 的使用似乎要比使用 glad 要更麻烦一点。
  • glad 优点是比 glew 要新,并且网上有人说“似乎” bug 要更少。另一个优点(同时也是缺点)是,每个 OpenGL 版本对应的 GLAD 是不同的,需要用户自行从 GLAD 官网 下载你所支持的版本。好处是比较小,坏处是每个都要这么搞一遍。另外,每次还都要把 glad.c 文件放入自己的工程。而 glew 则已经包含了全部硬件和平台了,只需要 include 头文件即可,使用更简单。

查看你的机器中的 OpenGL 版本

https://learnopengl-cn.github.io/01%20Getting%20started/03%20Hello%20Window/

通常很多教程都要求你的机器支持至少 OpenGL 3.3 及以上的版本。如果想要查看 OpenGL 版本:

  • Linux:可以用 glxinfo 命令;
  • Windows:需要使用特殊工具,例如OpenGL Extension Viewer
  • Mac:由你的系统和机型决定,查看:https://support.apple.com/en-us/HT202823
  • 如果你的OpenGL版本低于3.3,检查一下显卡是否支持OpenGL 3.3+(不支持的话你的显卡真的太老了),并更新你的驱动程序,有必要的话请更新显卡。

OpenGL Rendering Pipeline

这两个教程已经写的很详细了:

这里简要总结一下,内容基本上全部来自上面的教程。

在 OpenGL 中,任何事物都在 3D 空间中,而屏幕和窗口却是 2D 像素数组,这导致 OpenGL 的大部分工作都是关于把 3D 坐标转变为适应你屏幕的 2D 像素。3D 坐标转为 2D 坐标的处理过程是由 OpenGL 的图形渲染管线Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)来管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。

图形渲染管线接受一组 3D 坐标,然后把它们转变为你屏幕上的有色 2D 像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在 GPU 上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器Shader)。

有些着色器允许开发者自己配置,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在 GPU 上,所以它们可以给我们节约宝贵的 CPU 时间。OpenGL 着色器是用 OpenGL 着色器语言(OpenGL Shading Language, GLSL)写成的。

首先,我们以数组的形式传递 3 个 3D 坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据Vertex Data);顶点数据是一系列顶点的集合。一个顶点(Vertex)是一个 3D 坐标的数据的集合。而顶点数据是用顶点属性(Vertex Attribute)表示的,它可以包含任何我们想用的数据,但是简单起见,我们还是假定每个顶点只由一个 3D 位置和一些颜色值组成的。

接下来介绍渲染管线中的每个步骤。

Vertex Shader

图形渲染管线的第一个部分是顶点着色器Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把 3D 坐标转为另一种 3D 坐标,同时顶点着色器允许我们对顶点属性进行一些基本处理。顶点着色器也是我们经常要编写的一个 shader。

Shape/Primitive Assembly

图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入,并所有的点装配成指定图元的形状。本节例子中是一个三角形。OpenGL 需要用户指定你的数据的渲染类型,即图元类型。常见的有:

  • GL_POINTS:顶点。
  • GL_TRIANGLES:三角形,需要指定三个顶点。
  • GL_LINES:线段,需要指定两个顶点。
  • GL_LINE_STRIP:依然是线段,不过区别是,还会把最后一个点和第一个点连起来,最终组成一个闭合图形。

Geometry Shader

图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。

Rasterization Stage

几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

A fragment in OpenGL is all the data required for OpenGL to render a single pixel.

Fragment Shader

片段着色器(Fragment Shader)的主要目的是计算一个像素的最终颜色,这也是所有 OpenGL 高级效果产生的地方。通常,片段着色器包含 3D 场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

值得注意的是,通常前面的 vertex shader 中,输入的颜色只在点上。例如,要绘制一个三角形,但通常只是定义了三角形三个顶点的颜色,并没有它们内部的颜色(当然,用户也没法定义了)。于是,Fragment Shader 其实默认会插值(Interpolate)所有片段着色器的输入变量。比如说,我们有一个线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的 70% 的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是 30% 蓝 + 70% 绿。对于一个三角形来说,它可能包含的fragments 比如有 50000 左右。Fragment shader 为这些像素进行插值颜色。

Testing and Blending

在所有对应颜色值确定以后,最终的对象将会被传到最后一个阶段,我们叫做 Alpha Test and Blending 阶段。这个阶段检测片段的对应的深度和模板(Stencil)值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查 alpha 值(即物体的透明度)并对物体进行混合。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。

总结

可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了。在现代 OpenGL 中,我们必须定义至少一个顶点着色器和一个片段着色器(因为 GPU 中没有默认的顶点/片段着色器)。当然了,这两个着色器可以很简单(例如输入和输出完全相同)。

顶点输入和 Vertex Buffer Objects (VBO)

OpenGL 仅当 3D 坐标在 3 个轴(XYZ)上都为 [-1.0, 1.0] 的范围内时才处理它。所有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上,在这个范围以外的坐标都不会显示。因此,用户必须首先将所有你要显示的顶点的坐标标准化(normalization)到这个范围内。

定义顶点数据以后,我们会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器(Vertex Shader)。它会在 GPU上 创建内存用于储存我们的顶点数据,还要配置 OpenG L如何解释这些内存,并且指定其如何发送给显卡。顶点着色器接着会处理我们在内存中指定数量的顶点。

VBO 可以用来管理这些数据的内存,它的优点是,使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上。而不是每个顶点发送一次。从 CPU 把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。

注意:这里提到的OpenGL 传入的“顶点”(vertex)这个词指的并非只是一个三维坐标,而是一个泛指的数据单位。一个顶点可以包含各种和多种自定义的信息,例如三维坐标(3 floats)、颜色值(3 floats)、向量坐标(3 floats)、某种 intensity(1 float)、UV 纹理坐标(2 floats)等等,每种信息就被称为是一个属性(attribute)

有关 VBO 的用法不多说了,参见教程:Hello Triangle

glVertexAttribPointer()

这个函数挺重要的,单独拉出来解释一下。该函数指定了我们传入 VBO 中的一个属性的数据到底是如何分布的。

用教程中的简单例子,该例子是绘制一个三角形。每个顶点就只有三维坐标这一个属性。因此,VBO 中数据分布是这样的:

可以看出,每个属性是 3 floats,代表三维坐标。使用该函数设置这个三维坐标属性的例子如下:

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

各个参数含义是:

  • 0:这个是该属性在顶点中的 index(即,它是第几个属性,从 0 开始)。本例中,每个顶点只有三维坐标这一个属性,因此直接放在 VBO 中 0 的位置(注:这也是 vertex shader 中开头所示的 location = 0 的含义)。如果顶点还其它的属性值,例如增加 3 个 floats 的颜色值,那么当再次调用该函数设置颜色属性时,这个参数就是 1(当然 shader 也要修改)。
  • 3:该属性的的元素个数。每个顶点是 3 个 floats,因此就是 3。
  • GL_FLOAT: 该属性中每个元素的类型。
  • GL_FALSE: 元素是否需要数据被标准化(有符号数会被映射到 [-1, 1] 之间,无符号的话就是 [0, 1] 之间)。这是因为,OpenGL 只会绘制 [-1,1] 之间的坐标值,而将超出范围的值给 clip 掉。通常这里是被设置为 false,然后通常需要用户自行将最终传入的属性值标准化到 [-1, 1] 之间。不过注意,这并非一定要通过直接修改输入的坐标才可以标准化的,而是还可以通过一些其它流程,例如通过投影映射(projective transformation)就可以(例如,在 RGB-D 点云绘制时就是如此)。即,我们只需要保证该属性的元素值最终在 [-1,1] 范围即可。
  • 3 * sizeof(float):这个是步长(stride),即一个属性的总长度。单位是 bytes。
  • (void*)0:这个值是 offset,不过需要强制转换成 void * 类型。offset 指的是该属性在顶点数据段中的 offset。本例中,一个顶点就一个属性,因此 offset = 0。当一个顶点有多个属性时,那么在设置第二个属性时,该参数就是第二个属性开始的位置(同时也是第一个属性的长度)。单位也是 bytes。

下面是多属性顶点的一个例子,具体内容参考教程:Shaders。注意里面相关参数的变化。

使用注意

  • 实现时,有时候会使用一个 struct 表示顶点类,其中定义多个成员变量来记录每个属性。此时,可以使用标准库中的函数offsetof来找出一个属性的 offset 数值(可以参考这里),好处是不用自己每次去计算。用法举例:
    struct MyType{
    float attr1[3];
    int attr2[2];
    double attr3;
    bool attr4;
    };
    int offset = offsetof(MyType, attr2); // 第一个参数是结构体名,第二个参数是成员变量名
    
  • 该函数使用后,通常紧跟着是 glEnableVertexAttribArray(location_idx)函数,正式启用该属性。这里的参数就是glVertexAttribPointer中的第一个参数,即该属性在顶点中的 index。

  • 能声明的顶点属性的个数是有上限的,它一般由硬件来决定。不过,OpenGL 确保至少有 16 个 4-component 分量的顶点属性可用,但是有些硬件或许允许更多的顶点属性,可以通过下面方法获取你的机器支持的个数。不过通常 16 个属性已经非常足够用了。
int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);

Vertex Array Object (VAO)

每次要绘制一个物体(一批顶点)或者一个属性(一批属性点)时,都需要绑定一次它所对应的 VBO。那么,如果有上百个物体呢?显然绑定上百次是很麻烦。因此,OpenGL 提供了顶点数组对象(vertex array object,VAO)来解决这个问题。VAO 的好处是,只需要最开始时绑定一次 VAO,随后任何 VBO 的绑定调用都会存储在这个 VAO 中。即,一个 VAO 就包含了一批 VBO(和其它类型的 buffer objects,例如 EBO 等)。当你绑定各种顶点属性时,只需执行一次。并且当你绘制图形时,也只需要绑定相应的 VAO 就行了。这样就使得在不同顶点数据和属性配置之间切换变得非常简单了,只需绑定不同的 VAO 即可。

VAO 的使用和 VBO 非常类似,参见同一个链接。另外,VAO 同样也能包含 EBO。

Element Buffer Object (EBO)

EBO 是专门用于存储索引(index)的,它的作用是让 OpenGL 知道要用那些顶点的索引来绘制。例如,绘制顶点之间有重复的三角形时,使用 EBO 的话就无需重复定义顶点了,这样可以节省很多空间。

EBO 的用法和 VBO 也很类似,用法参见的上面给出的链接。和 VBO 类似,EBO 也能被包含在 VAO 中。

着色器(Shader)

参考教程:Shaders

前面提到了,着色器(Shader)是运行在 GPU 上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说,着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。

着色器是用 GLSL 语言写成,它很类似 C 语言。接下来会记录 GLSL 中比较特别的地方。

向量类型

GLSL 中的向量只有 1-4 分量,记为vecn,n 是分量个数。最大就是 4 分量了,即 vec4,4-floats 类型。向量的分量按顺序依次是 xyzw。另外还有不同类型的分量bvec4,ivec3等。

Shaders 之间传输数据

如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL 就会把两个变量链接到一起,它们之间就能发送数据了。例如,可以在 Vertex Shader 中定义一个

out vec4 vertexColor; // 颜色输出,可以作为接下来的 shader 的输入
... // do sth on vertexColor

然后,在接下来的 Fragment Shader 中就可以使用它了:

in vec4 vertexColor; // 变量名和类型必须都和前面定义的完全相同
... // use vertexColor here

Uniform 全局变量

Uniform 是一种从 CPU 中的应用向 GPU 中的着色器发送数据的方式。uniform 和顶点属性有些不同。首先,uniform 是全局的(Global),其实就是 GLSL 中的全局变量。全局意味着:

  • uniform 变量必须在每个着色器程序对象中都是独一无二的,即不能重名。
  • 它可以被任意着色器在任意阶段访问。
  • 无论你把 uniform 值设置成什么,uniform 会一直保存它们的数据,直到它们被重置或更新。

uniform 的用法举例如下:

// ------------------------------------------------
// 某个 Shader 文件。定义 uniform 变量是在 shader 文件中的

uniform vec4 ourColor;
...

// ------------------------------------------------
// 主程序文件。
  
// 首先要找到该 uniform 变量的位置。这里,第一个参数就是这个 shader program ID,第二个就是 uniform 变量名,必须和你在 shader 中定义的变量名完全相同。
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
// 要注意:必须要先启动该 shader program 才能赋值,因为 uniform 变量只能在激活的 shader 程序中修改。而上面的查询变量并不需要激活 shader(当然激活了也行)。
glUseProgram(shaderProgram);
// 启动 shader 后,才能修改 uniform 的值。
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

使用注意:

  • 如果你定义了 uniform 变量,那么建议一定要给它赋值,否则很容易直接就报错。即,uniform 变量似乎是没有默认初始值的。
  • glUniform{1|2|3|4}[u]{f|i}是一系列函数,基本上支持了常见的 int, unsigned int, float 等类型(不过注意,没有 bool 和 char 类型),其中加上 u 就是 unsigned 类型。这种形式的函数输入就是向量中每个元素的值,就像上面的例子一样。同时,OpenGL 还提供了重载函数glUniform{1|2|3|4}[u]{f|i}v函数(即在最后加了一个 v),此时输入变成了数组指针。同时,还有针对 uniform 矩阵的函数glUniformMatrix{2|3|4}[x{2|3|4}]fv函数,支持对 4 阶矩阵以内的所有矩阵维度,不过只支持 float 类型了(可以看出,函数名中的 fv 此时是固定的),并且输入只能是二维矩阵的首元素指针。注意,OpenGL 中矩阵是 column-major 的。有关这些函数的详细用法可以参考官方的 API 文档
  • uniform 变量随时可以修改,例如可以在你的主函数的 glfw 循环中不断调用 glUniform函数来修改变量,这样便可以做出动态效果出来了。例如,可以不断修改 transformation 变量来变换模型位置;修改 color 变量来变换物体颜色,等等。

纹理(Texture)

参考教程:Textures

注意事项

将一些小的注意点总结在这里:

  • SRT 轴:有些函数诸如glTexParameteri中会有 S 轴和 T 轴(还有 R 轴)的参数。其实,S 轴就是横轴,即 U 轴或 X 轴的方向。而 T 轴就是纵轴,即 V 轴或者 Y 轴的方向。R 轴就是三维纹理中的第三个轴。之所以是 STR 这种命令,估计是 OpenGL 的历史原因。
  • 纹理坐标系和图片坐标系的 Y 轴是反过来的:纹理坐标系中,左下角是 [0,0],右上角是 [1,1]。但是,通常使用第三方库载入图片时(例如 OpenCV),图片数据是从左上方开始读入并存储的。也就是说,图片坐标系中的左上角才是 [0,0]。即,图片坐标系和纹理坐标系的上下(Y 轴)颠倒了。因此,要么你需要翻转图片,要么翻转纹理坐标的 V 值。这两种做法都挺常见的,并且各有优缺点。

GLSL 中的纹理变量

纹理变量在 Fragment Shader 中默认是一个 uniform 类型变量,例如 uniform sampler2D ourTexture;。使用方法也很简单:color = texture(ourTexture, TexCoord),这里的 texture()是 GLSL 提供的函数,第一个参数就是纹理变量,第二个参数是 vec2类型的二维坐标。

多个纹理对象的绑定

值得注意的是,和顶点的属性类似的,GLSL 中的纹理对象也是有位置的,一个纹理的默认位置是 0。如果你的程序只定义了一个纹理对象的话,那么就无需在主程序中使用 glUniform之类的函数对你的唯一的纹理对象赋值了(即在 Fragment Shader 中定义的那个ourTexture),即便它是一个 uniform 变量。也就是说,你在主程序中创建的纹理对象会被默认绑定到 Fragment Shader 的第一个 uniform sampler2D 变量上。当然,如果你定义了多个纹理对象时,就需要显式绑定纹理对象和 Fragment Shader 中的对应的 sampler2D 变量了。

使用多个纹理的流程大概是如下所示(以两个纹理为例)。参考:Texture code

///--------------------------------------------------------
/// Fragment Shader 文件
// 定义两个纹理对象
uniform sampler2D texture1;
uniform sampler2D texture2;
...

///--------------------------------------------------------
/// 主程序文件

// 先使用 glTexImage2D 等众多函数创建并设置两个纹理对象。省略了,参考教程。
...
// 同样的,先要开启 shader program 才能修改 Uniform 变量
glUseProgram(shaderProgram); 
// 设置纹理单元的对应关系。注意,虽然 Fragment Shader 中的纹理对象是 uniform sampler2D 类型,但是这里其实是设置它为一个整数。通常纹理对象的对应关系是,第一个纹理对应是 0,接着逐渐增大。另外,如果你只有一个纹理对象的话,这一步就可以省略了。
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0);
glUniform1i(glGetUniformLocation(ourShader.ID, "texture2"), 1);

// 主循环
while ()
{
   ...
   // 要先激活纹理才能绑定。类似的,如果你只有一个纹理对象,那么下面这句激活语句可以省略。
   glActiveTexture(GL_TEXTURE0);
   glBindTexture(GL_TEXTURE_2D, textureID1); // 先激活再绑定
   // 使用第二个纹理(会叠加到第一个纹理上)。当然你也可以选择性的只使用一个纹理(激活并绑定相应纹理)
   glActiveTexture(GL_TEXTURE1);
   glBindTexture(GL_TEXTURE_2D, textureID2);
}

glTexImage2D()

这个函数很常见,它用于在 GPU 中分配一块内存来存储纹理图片。一个例子:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);

几个参数是:

  • 第一个参数指定了纹理目标(Target)。设置为 GL_TEXTURE_2D 意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到 GL_TEXTURE_1D 和 GL_TEXTURE_3D 的纹理不会受到影响)。
  • 第二个参数为纹理指定多级渐远纹理(mipmap)的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填 0,也就是基本级别。
  • 第三个参数告诉 OpenGL 我们希望把纹理储存为何种格式。这里我们的图像只有RGB值,因此我们也把纹理储存为RGB值。
  • 第四个和第五个参数设置最终的纹理的宽度和高度,其实就是图片的宽和高。
  • 接下来第六个参数总是被设为0(历史遗留的问题)。
  • 第七第八个参数定义了原始图像的格式和数据类型。这里我们使用 RGB 值加载这个图像,并把它们储存为char数组。注意,如果用 OpenCV 载入图像,那么图片默认是 BGR 顺序。
  • 最后一个参数是真正的图像数据的 char* 指针。

坐标系统和变换(Coordinate System)

参考教程:

OpenGL 希望在 Vertex Shader 运行后,我们可见的所有顶点都为标准化设备坐标(Normalized Device Coordinate, NDC),而后者必须是在[-1.0, 1.0] 之间。也就是说,每个顶点的xyz坐标都应该在[-1.0, 1.0] 之间,超出这个坐标范围的顶点都将不可见。我们通常会自己设定一个坐标的范围,之后再在 Vertex Shader 中将这些坐标变换为标准化设备坐标。接着将这些标准化设备坐标传入光栅器(Rasterizer),将它们变换为屏幕上的二维坐标或像素。

将三维坐标转换为 NDC 坐标是分布进行的,这整个过程最终会计算一个 4x4 的变换矩阵(transformation,如果坐标是齐次的话),可以直接用于 Vertex Shader 中用户输入的三维坐标上。整个流程如下图:

img

这个更直观看一些:

可以看出,这个流程一共涉及到了五个空间,并按顺序计算了四个矩阵。

  • 局部空间(Local Space)就是最开始的原始空间了。局部坐标是对象相对于局部原点的坐标,也是物体起始的三维坐标。
  • 全局坐标/世界坐标(World Space)就是相对于世界原点的三维坐标。只有一个世界坐标系。这一步要计算的是就是从局部空间到全局空间的转移矩阵,我们称之为模型矩阵(Model Matrix),或者干脆就叫全局的转移矩阵。最常见的转移矩阵是欧式变换阵,只包含旋转和平移,即我们通常不对待渲染的物体做变形处理。当然,根据用户需要,也可以使用相似变换阵(即旋转乘以一个缩放系数),又或者变成一般情况下的仿射变换阵或者更一般的投影变换阵。
  • 观察坐标(View Space)是从相机(Camera)的角度看的三维坐标。即,我们需要设置相机放在 OpenGL 空间的哪个位置,并设置相机的朝向和向上的方向,这样才能知道看到的模型是什么样子的。这个步骤计算的矩阵称为相机观察矩阵(Camera View Matrix)
  • 裁剪空间(Clip Space)中的坐标就是二维坐标了,这个过程就是从三维观察空间投影到二维坐标的过程。这一步计算的矩阵称为投影矩阵(Projection Matrix)。常见的投影方式有正交投影(Orthographic Projection)透视投影(Perspective Projection) 这两种。之后,这个二维坐标还会被裁剪至 [-1.0, 1.0] 的范围内(即 NDC 坐标),范围之外的点会被抛弃。这一步是自动的,通常不需要用户计算这个裁剪矩阵。
  • 屏幕空间(Screen Space)是最终的空间坐标了。我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将上一步得到的二维坐标变换到由 glViewport() 函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段(Fragments)。最后这一步并不需要用户计算矩阵。

因此,OpenGL 需要用户计算的通常只有上面所述的三个矩阵,最终的矩阵是这三个矩阵相乘:

Final_Matrix = Projection_Matrix * Camera_View_Matrix * Model_Matrix

注意,这几个矩阵相乘的顺序是固定的。

OpenGL 三维坐标系和相机的坐标系

OpenGL 的三维坐标系如下图所示。它遵循右手定则。默认情况下,+Z 轴是朝屏幕外。

相机(Camera)也有一个三维坐标系。相机通常有三个向量:相机的朝向(Facing Vector),相机正上方的上向量(Up Vector),以及相机右边的右向量(Right Vector)。当然,已知两个向量,使用正交就可以计算出来第三个了。默认情况下,OpenGL 中,相机的位置是 OpenGL 坐标系的原点,摄像机的朝向是 OpenGL 坐标系的 -Z 方向,相机上向量则是 +Y 方向。

相机视点矩阵(Camera View Matrix)

值得注意的是,相机有一个方向向量(Direction Vector)的概念,它指的是被观察物体的位置朝向相机位置的向量,即相机的朝向正好反过来。方向向量很少会直接使用,不过它会出现在 Camera View Matrix 的矩阵表达式中:

该矩阵中,\(l_x, l_y, l_z\) 是相机的右向量(Right Vector),\(u_x,u_y,u_z\) 是上向量(Up Vector),\(f_x,f_y,f_z\) 就是方向向量,\(x_e,y_e,z_e\) 就是相机的位置坐标。

OpenGL 提供的 gluLookAt()函数可以用来计算 Camera View Matrix,该函数的输入参数是:相机位置,相机观察对象的位置,以及相机的上向量。如果自行实现时,一定注意方向向量和相机朝向的差别。

平移或旋转物体

如果想要移动物体(例如平移或者旋转),做法有两种:

  1. 修改模型的 Model Matrix,例如直接左乘一个包含平移和旋转的转移矩阵即可;
  2. 修改相机的 Camera View Matrix,朝着和物体移动方向完全相反的方向移动相机。

这两种方法自然都是可行的。但是,个人偏向第 2 种方法。这是因为,我们很多时候还需要知道待渲染物体中点的原始三维坐标,因此最好不要去修改它。因此,修改 Camera View Matrix 更加安全。不过,再次强调,相机的移动方向和物体的移动方向是相反的。

物体变换时法向量的变化

参考:http://www.songho.ca/opengl/gl_normaltransform.html

一个值得注意的问题是,当待绘制物体的顶点经过 model matrix 变换后,顶点的法向量是如何变换的?即,法向量的转移矩阵(这里暂时称之为 normal matrix)是什么?难道就是简单等于 model matrix?其实不一定。

首先给出 normal matrix 的推导。设 normal matrix 是 \(\mathbf{N}\),model matrix 是 \(\mathbf{M}\)。对于任意一个顶点的法向量 \(\mathbf{n}\) 来说,假设与它垂直的切向量是 \(\mathbf{t}\),因此,转换之前有 \(\mathbf{n}^\top\mathbf{t} = 0\)。转换后,切向量就被转换成了 \(\mathbf{Mt}\)(这很显然,因为切向量的两个端点都是通过 \(\mathbf{M}\) 变换的),而法向量则成为 \(\mathbf{Nn}\)。经过转换后,切向量和法向量还必须是垂直的,因此有:\((\mathbf{Nn})^\top\mathbf{Mt} = 0\),于是 \(\mathbf{n}^\top \mathbf{N}^\top\mathbf{Mt} = 0\)。该式对于任意的 \(\mathbf{t}\) 都成立(即任意三角形),因此只有 \(\mathbf{N}^\top\mathbf{M} = \mathbf{I}\) 时才成立,于是我们得到了 normal matrix 的表达式: \(\mathbf{N} = (\mathbf{M}^{-1})^\top\) 可以看出,如果 \(\mathbf{M}\) 是普通的欧氏变换(即只有平移和旋转),那么显然 \(\mathbf{N=M}\),即,此时 \(\mathbf{M}\) 的左上角的 \(3 \times 3\) 阵必须是旋转阵。如果不是的话,例如增加了一个缩放系数,那么两者就不相等了。

光照

参考:基础光照

常见的光照模型是冯氏光照模型(Phong Lighting Model),主要包括三个分量:

这几个分量分别是:

  • 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。计算时,一个顶点的环境光照通常就是一个常量值,对每个点都完全相等。

  • 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是冯氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。计算时,一个顶点的漫反射光照是由光源点和改顶点间的向量和该顶点的法向量之间的夹角决定。这个夹角越小,漫反射光照越强。

  • 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。计算时,一个顶点的镜面光照是由观测点和顶点间的向量照到该顶点的光的反射向量之间的夹角决定。该夹角越小,镜面光照越强。该夹角如下图所示:

Search

    Table of Contents