在网络通信中,很多情况下通信双方传达的都是字符信息。但是,字符信息并不能直接传递,这些字符信息首先需要被转换成一个字节序列后才能在网络中传输。将字符信息转换为字节序列的过程称为编码。当这些字节传送到网络的接收方时,接收方需要反过来将字节序列再转换为字符信息,这种过程称为解码

字符集

字符(Character)是各种文字和符号的总称,包括各国家文字、标点符号、图形符号、数字等。字符集(Character set)是多个字符的集合。常见的字符集有以下三种:

ASCII字符集

ASCII(American Standard Code for Information Interchange,美国标准信息交换代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。使用指定的7 位或8 位二进制数组合来表示128 或256 种可能的字符。7 位二进制数主要表示所有的大写和小写字母,数字0 到9、标点符号, 以及在美式英语中使用的特殊控制字符。后128个称为扩展ASCII码。

非ASCII字符集

由于ASCII字符集是针对英语设计的,当处理汉字等其他字符时,这种编码就不能适应了。 为了解决这些问题,不同的国家和地区指定了自己的编码标准。我国一般使用国标码,常用有GB2312-1980编码和GB18030-2000编码,其中,GB18030编码汉字更多,是我国计算机系统必须遵循的基础性标准之一。

在GB2312编码中,汉字都是采用双字节编码。为了与系统中基本的ASCII字符集区分开,所有汉字编码的每个字节的第一位都是1。GB2312的汉字编码规则为:第一个字节的值在0xB0 ~ 0xF7之间,第二个字节的值在0xA0 ~ 0xFE之间。

GB18030是对GB2312的扩展,其编码长度由2个字节变为1~4个字节。其中包括:

  1. 单字节:其值为0 ~ 0X7F。
  2. 双字节:第一个字节的值为0X81 ~ 0xFE,第二个字节的值为0X40 ~ 0xFE(不包括0x7F)。
  3. 四字节:第一个字节的值为0x81 ~ 0xFE,第二个字节的值为0x30 ~ 0X39,第三个字节的值为0x81 ~ 0xFE,第四个字节的值为0x30~0X39。

可以看出,GB18030的容量非常大,共有码位160万左右。

Unicode字符集

由于每个国家都拥有独立的编码方式,同一个二进制数字可以被解释成不同的符号。因此,要想打开一个文本文件,就必须知道它的编码方式,否则就可能出现乱码。 为了使国际信息交流更加方便,国际组织制定了Unicode字符集。它为各种语言中的每一个字符规定了统一并且唯一的字符,这种编码只用2个字节就可以表示地球上绝大部分地区的文字。

在C#中,字符默认都是Unicode码,即一个英文字符占两个字节,一个汉字也是两个字节。

Unicode虽然能够表示大部分国家的文字,但由于它比ASCII古用大一倍的空间,这对能用ASCII字符集来表示的字符来说就显得有些浪费。为了解决这个问题,又出现了一些中间格式的字符集,它们被称为通用转换格式,即UTF(Umversal Transformation Format)。目前流行的UTF格式有UTF-8、UTF-16以及UTF-32。

UTF-8是在因特网上使用最广泛的一种UTF格式。它是Unicode的一种变长字符编码,将一个Unicode字符编为1~4个字节组成的UTF-8格式,根据不同的符号而变化字节长度。UTF-8是与字节顺序无关的,它的字节顺序在所有系统中都是一样的,因此这种编码可以使排序变得很容易。

UTF-16将每个码位表示为一个由1 ~ 2个16位整数组成的序列。 UTF-32将每个码位表示为一个32位整数。

UTF-16和UTF-32既可以使用大端字节顺序,又可以使用小端字节顺序。如对于UTF-16,大写字母A(U+0041),写入到地址0x0000开始的内存:

Encoding类

Encoding类位于System.Text命名空间,通过这个类我们可以为不同字符集进行转换以及获取字符集的相关信息 。

不同编码之间的转换:

利用Encoding.Convert()可以将字节数组从一种编码转换为另一种编码,转换结果为一个byte数组。 原型为:

1
public static byte[] Convert( Encoding srcEncoding, Encoding dstEncoding, byte[] bytes )

可以查看MSDN示例,将使用 Unicode 编码的字符串转换为 ASCII 编码的字符串。

Encoder 和 Decoder 类

C#中提供了EncoderDecoder类,分别对字符进行编码和对字节序列进行解码。通过使用它们,我们可以很方便进行对字符和字节序列进行编码和解码操作。

Encoder:

利用Encoder进行字符编码时,首先要获取Encoder的实例。由于Encoder的构造函数是protected,因此需要通过 Encoding 提供的 GetEncoder 方法获取实例。 获取实例后,就可以使用GetBytes方法将字符编码转换为字节序列。 查看MSDN示例

Decoder:

Decoder类可以将已编码的字节序列解码为字符。 同样需要先用 Encoding 提供的 GetDecoder 方法获取实例。然后利用实例的GetChars方法获取字节序列。 查看MSDN示例

IP地址:

一个IP地址主要由两部分组成,一部分用于标识该地址所属的网络号,另一部分指明网络内的主机号。 IP编址方案有两种,一种是采用IPv4编址方案,即一个32位的二进制数,我们常见的形式是分为4个字节分别用十进制表示,中间用圆点分开,这种方法叫做点分十进制表示法。

分配原则:

对于IPv4,网络地址分配有以下原则:

  1. 网络地址必须唯一。
  2. 网络标识不能以数字127开头,以数字127开头的地址用于内部回送函数。
  3. 网络标识的第一个字节不能为255,第一个字节为255表示广播地址。
  4. 网络标识的第一个字节不能为0,第一个字节为0表示该地址是本地主机

对于IPv4,主机地址分配有以下原则:

  1. 主机标识在同一网络内必须是唯一的。
  2. 主机标识的各字节不能全为255,全为255表示该机地址是广播地址。
  3. 主机标识的各字节不能全为0,全为0表示“只有这个网络”,而这个网络上没有任何主机。

地址分类

使用IP地址的点分十进制表示法,因特网地址空间又划分为5类,具体如下:
A类:0.x.x.x ~ 127.x.x.x (32位二进制最高位为0)
B类:128.x.x.x ~ 191.x.x.x (32位二进制最高2位为10)
C类:192.x.x.x ~ 223.x.x.x (32位二进制最高3位为110)
D类:224.x.x.x ~ 239.x.x.x (32位二进制最高3位为1110)
E类: 240.x.x.x ~ 255.x.x.x (32位二进制最高3位为11110)

  • A类IP地址,由1字节的网络地址和3字节主机地址组成,网络地址的最高位必须是“0”,可用网络标识长度为7位。后3个字节为主机标识。网内主机数达1600多万台。
  • B类IP地址,由2字节的网络地址和2字节主机地址组成,网络地址的最高位必须是“10”,可用网络标识长度为14位。后2个字节为主机标识。每个网络所能容纳的计算机数为6万多台。
  • C类IP地址,由3字节的网络地址和1字节主机地址组成,网络地址的最高位必须是“110”,可用网络标识长度为21位。最后1个字节为主机标识。适用于小规模的局域网,每个网内最多只能包含254台计算机。
  • D类地址属于一种特殊类型的IP地址,TCP/IP规定,凡IP地址中的第一个字节以“1110”开始的地址都叫多点广播地址。因此,任何第一个字节大于223小于240的IP地址都是多点广播地址。
  • E类IP地址则以”11110”开头,作为特殊用途使用。

子网掩码:

在这些网络分类中,每类网络又可以与后面的一个或多个字节组合,进一步分成不同的网络,称为子网。每个子网必须用一个公共的网址把它与该类网络中的其他子网分开。

为了识别IP地址的网络部分,又为特定的子网定义了子网掩码。 子网掩码不能单独存在,它必须结合IP地址一起使用。子网掩码用于屏蔽IP地址的一部分以区别网络标识和主机标识,它是判断任意两台计算机的IP地址是否属于同一子网的依据,并说明该IP地址是在局域网上,还是在远程网上。

把所有的网络位(二进制)用1来标识,主机位用0来标识,就得到了子网掩码。

IP地址与子网掩码的关系可以简单地理解为:两台计算机各自的地址与子网掩码进行一进制“与”运算后,如果得出的结果是相同的,则说明这两台计算机处于同一个子网,否则就是处于不同的子网上。

假设子网掩码为255.255.255.0,转化为二进制为11111111.11111111.11111111.000000,则IP地址和子网掩码进行二进制“与”运算后,前3个字节构成网络标示(子网号),第4个字节为0。

例如,对于IP地址192.168.0.X,可以将子网掩码设置为255.255.255.0,则该子网内所有的IP地址为: 192.168.0.0、192.168.0.1、192.168.0.2 …… 192.168.0.254、192.168.0.255

其中,192.168.0.0和192.168.0.255有特殊的含义(192.168.0.0表示本机地址(回环地址),192.168.0.255表示广播地址),所以该子网(192.168.0)内实际可用的IP地址为254个(192.168.0.1到192.168.0.254)。

端口

概念:

端口包括物理端口和逻辑端口。物理端口是用于连接物理设备之间的接口,逻辑端口是逻辑上用于区分服务的端口。TCP/IP协议中的端口就是逻辑端口。一个IP地址的端口通过16bit进行编号,最多可以有65536个端口。范围是从0 到65535。

端口有什么用呢?一台拥有IP地址的主机可以提供许多服务,比如Web服务、FTP服务、SMTP服务等,这些服务都可以通过一个IP地址来实现,那如何区分不同的服务呢,这时就要通过“IP地址+端口号”来区 分不同的服务。

可以这样理解: 知乎shotgun的答案:网络地址是家庭住址,端口是门牌号码,网络数据包是快递,快递员(网络驱动)根据不同的门牌号(端口)把包裹(数据包)送到不同的人家(应用或服务)。

理论上只有服务器软件才会绑定固定端口,客户端的一般是随机的。客户端端口和服务端端口是独立的。就像你和你朋友的门牌号不同,却可以互相通信(只要在通信时互相知道对方的网络地址和端口)。

使用:

TCP与UDP段结构中端口0-65535范围,对于这65536个端口号有以下的使用规定:

  1. 端口号小于256的定义为常用端口,服务器一般都是通过常用端口号来识别的。任何TCP/IP实现所提供的服务都用1—1023之间的端口号,是由ICANN来管理的;
  2. 客户端只需保证该端口号在本机上是惟一的就可以了,使用过后释放供其它服务使用。客户端口号因存在时间很短暂又称临时端口号;
  3. 大多数TCP/IP实现给临时端口号分配1024—5000之间的端口号。大于5000的端口号是为其他服务器预留的。

进程

进程是正在运行的程序的实例。是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。 ps:进程和线程关系推荐这篇:进程与线程的一个简单解释

线程

概念:

通常在一个进程中可以包含若干个独立的执行流,即线程。它们可以利用进程所拥有的资源。 每个线程都是作为利用CPU的基本单位,是花费最小开销的实体。 在一个进程中的多个线程之间,可以并发执行。同样,不同进程中的线程也能并发执行。

对于C#,任何C#程序都有一个默认的线程,即主线程。一个线程要么是前台线程,要么是后台线程。后台线程与前台线程类似,区别是后台线程不会影响进程终止。当属于进程的前台线程全部终止后,不管后台线程是否结束,进程都结束。 如果使用一个线程监视某些服务(如套接字连接),可以将其设置IsBackground设为True,以便该线程不会影响进程终止。

终止线程的方法:

  1. 事先设置一个控制变量,在其他线程修改它来表示是否终止线程。在该线程中检测控制变量,以确定是否退出线程。这是比较好的方法。
  2. 调用Thread中的Abort方法,强行终止线程。使用Abort方法时线程实际不一定会立刻终止。因为结束线程前需要进行代码清理等工作,但我们并不知道这会消耗多久时间,因此可能出现类似死机的现象。为避免这个问题,可在主线程中调用子线程的Join方法,并指定主线程等待子线程结束的等待时间。

暂停线程:

Thread.Sleep(1000); 暂停该语句所在线程1000毫秒。

合并线程:

Join方法用于把指定线程合并到当前线程中。如果一个线程T1执行过程中需要等待另一个线程T2结束才能继续执行,可以在T1中调用T2.Join();

Volatile关键字:

volatile修饰符表示所声明字段可以被多个并发执行的线程修改。读取这个变量的值时候每次都是从momery里面读取而不是从cache读。编译器将不再对该字段优化,可以保证该字段在任何时间呈现的都是最新的值。

线程优先级:

在C#中线程有5个优先级,由高到低是: AboveNormal、BelowNormal、Highest、Lowest、Normal。 创建时如果不指定则默认是Normal。

线程同步:

多线程解决了吞吐量和响应速度的问题,但也带来了资源共享问题,如死锁和资源争夺。 同步是多线程中一个非常重要的概念。所谓同步,是指多个线程之间在后行顺关联关系。如果一个线程必须在另一个线程完成某个工作后才能继续执行,则必须考虑如何让其保持同步,以确保在系统上同时运行多个线程而不会出现死锁或逻辑错。

lock语句:

C#提供了一个lock语句。lock关键字能确保当一个线程位于代码的临界区(可以理解为一段代码)时,另一个线程不进入临界区。如果其他线程试图进入锁定的代码段,则它将一直等待(即被阻塞),直到锁定的对象被释放以后才能进入临界区。 lock关键字将代码段(语句块)标记为临界区,它的实现原理是首先锁定某一个私有对象,然后执行代码段中的语句,当代码段中的语句执行完毕后,再解除该锁。

线程池:

线程池是在后台执行多个任务的线程集合。 线程池不会占用主线程,也不会延迟后续请求的处理。一旦池中的某个线程用完规定的时间段,它将返回到等待线程队列中,等待被再次使用。这种重用使应用程序可以避免为每个任务创建新线程引起的资源和时间消耗。线程池有一个最大线程数限制。如果所有线程都繁忙,则额外的任务将放入队列中,直到有线程可用时才能够得到处理。一旦一项工作任务被加入到线程池的队列中,就不能取消该任务,直到该任务完成。

注意,托管线程池中的线程为后台线程,即它们的IsBackground为True。意味着所有前台线程退出后,ThreadPool也会退出。

进程与端口

从本质上看,进程和端口毫无关系。 端口是为了区分同一IP地址的主机中的服务而出现的,就相当于电话的分机号码一样。进程是为了管理一个程序中的线程才引入的概念。两个之间没有必然的联系。 端口是为了进行通信,虚拟出的一种概念;而进程,相当于一种执行体,加载了数据、执行代码等等。

只是,现在很多的东西依赖于网络,因此,基于网络服务的程序会需要打开端口监听,这个时候,进程打开一个端口,外部网络客户端访问这个端口,两者就可以建立起连接进行通信。 一个进程绑定了一个端口比如10000,另一个进程就无法再绑定这个端口了。 一个进程和一个端口并不完全是一对一的关系。因为一个进程是可以绑定多个端口的。

引入法线贴图

我们的场景中已经充满了多边形物体,其中每个都可能由成百上千平坦的三角形组成。我们以向三角形上附加纹理的方式来增加额外细节,提升真实感,并隐藏了多边形几何体是由无数三角形组成的事实。然而当你近看它们时,这个事实便隐藏不住了。现实中的物体表面并非是平坦的,而是表现出无数(凹凸不平的)细节。

例如,砖块的表面非常粗糙,显然不是完全平坦的。如果我们在一个有光的场景中看这样一个砖块的表面,问题就出来了。下图中我们可以看到砖块纹理应用到了平坦的表面,并被一个点光源照亮。

光照并没有呈现出任何裂痕和孔洞,完全忽略了砖块之间凹进去的线条;表面看起来完全就是平的。我们可以使用specular贴图根据深度或其他细节阻止部分表面被照的更亮,以此部分地解决问题,但这并不是一个好方案。我们需要的是某种可以告知光照系统给所有有关物体表面类似深度这样的细节的方式。

如果我们以光的视角来看这个问题:是什么使表面被视为完全平坦的来照亮?答案会是表面的法线向量。以光照算法的视角考虑的话,只有一件事决定物体的形状,这就是垂直于它的法线向量。砖块表面只有一个法线向量,表面完全根据这个法线向量以一致的方式照亮。如果每个fragment都是用自己的不同的法线会怎样?这样我们就可以根据表面细微的细节对法线向量进行改变;这样就会获得一种表面看起来要复杂得多的幻觉:

每个fragment使用了自己的法线,我们就可以让光照相信一个表面由很多微小的(垂直于法线向量的)平面所组成,物体表面的细节将会得到极大提升。这种每个fragment使用各自的法线,替代一个面上所有fragment使用同一个法线的技术叫做法线贴图(normal mapping)或凹凸贴图(bump mapping)。应用到砖墙上,效果像这样:

如你所见细节获得了极大提升,开销却不大。因为我们只需要改变每个fragment的法线向量,并不需要改变所有光照公式。现在我们为每个fragment传递一个法线,不再使用插值表面法线。这样光照使表面拥有了自己的细节。

法线贴图:

为使法线贴图工作,我们需要为每个fragment提供一个法线。像diffuse贴图和specular贴图一样,我们可以使用一个2D纹理来储存法线数据。2D纹理不仅可以储存颜色和光照数据,还可以储存法线向量。这样我们可以从2D纹理中采样得到特定纹理的法线向量。

由于法线向量是个几何工具,而纹理通常只用于储存颜色信息,用纹理储存法线向量不是非常直接。如果你想一想,就会知道纹理中的颜色向量用r、g、b元素代表一个3D向量。类似的我们也可以将法线向量的x、y、z元素储存到纹理中,代替颜色的r、g、b元素。法线向量的范围在-1到1之间,所以我们先要将其映射到0到1的范围:

1
vec3 rgb_normal = normal * 0.5 - 0.5; // transforms from [-1,1] to [0,1]  

将法线向量变换为像这样的RGB颜色元素,我们就能把每一个fragment的法线保存在2D纹理中。砖块的法线贴图如下所示:

这会是一种偏蓝色调的纹理。这是因为所有法线的指向都偏向z轴(0, 0, 1),这是一种偏蓝的颜色。法线向量从z轴方向也向其他方向轻微偏移,颜色也就发生了轻微变化,这样看起来便有了一种深度。例如,你可以看到在每个砖块的顶部,颜色倾向于偏绿,这是因为砖块的顶部的法线偏向于指向正y轴方向(0, 1, 0),这样它就是绿色的了。

加载纹理,把它们绑定到合适的纹理单元,然后使用下面的改变了的片段着色器来渲染一个平面:

1
2
3
4
5
6
7
8
9
10
uniform sampler2D normalMap;  
void main()
{           
    // 从法线贴图范围[0,1]获取法线
    normal = texture(normalMap, fs_in.TexCoords).rgb;
    // 将法线向量转换为范围[-1,1]
    normal = normalize(normal * 2.0 - 1.0);   
    [...]
    // 像往常那样处理光照
}

这里我们将被采样的法线颜色从0到1重新映射回-1到1,便能将RGB颜色重新处理成法线,然后使用采样出的法线向量应用于光照的计算。

然而有个问题限制了刚才讲的那种法线贴图的使用。我们使用的那个法线贴图里面的所有法线向量都是指向正z方向的。上面的例子能用,是因为那个平面的表面法线也是指向正z方向的。

可是,如果我们在表面法线指向正y方向的平面上使用同一个法线贴图,会发现光照看起来完全不对!发生这种情况是平面的表面法线现在指向了y,而采样得到的法线仍然指向的是z。结果就是光照仍然认为表面法线和之前朝向正z方向时一样;这样光照就不对了。下面的图片展示了这个表面上采样的法线的近似情况:

你可以看到所有法线都指向z方向,它们本该朝着表面法线指向y方向的。一个可行方案是为每个表面制作一个单独的法线贴图。如果是一个立方体的话我们就需要6个法线贴图,但是如果模型上有无数的朝向不同方向的表面,这就不可行了(实际上对于复杂模型可以把朝向各个方向的法线储存在同一张贴图上,你可能看到过不只是蓝色的法线贴图,不过用那样的法线贴图有个问题是你必须记住模型的起始朝向,如果模型运动了还要记录模型的变换,这是非常不方便的;此外就像作者所说的,如果把一个diffuse纹理应用在同一个物体的不同表面上,就像立方体那样的,就需要做6个法线贴图,这也不可取)。

另一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正z方向;所有的光照向量都相对与这个正z方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间(tangent space)

切线空间

在切线空间中,法线贴图中的法线永远指着正z方向。切线空间是位于三角形表面之上的空间:法线相对于单个三角形的本地参考框架。它就像法线贴图向量的本地空间;它们都被定义为指向正z方向,无论最终变换到什么方向。使用一个特定的矩阵我们就能将本地/切线空间中的法线向量转成世界或视图坐标,使它们转向到最终的贴图表面的方向。

法线贴图在切线空间中定义,所以为了解决上面正y方向的贴图问题,我们可以计算出一个矩阵,把法线从切线空间变换到一个不同的空间,这样它们就能和表面法线方向对齐了:法线向量都会指向正y方向。切线空间的一大好处是我们可以为任何类型的表面计算出一个这样的矩阵,由此我们可以把切线空间的z方向和表面的法线方向对齐。

这种矩阵叫做TBN矩阵,这三个字母分别代表tangent、bitangent和normal vector。这些是构建这个矩阵所需的向量。要构建这样一个把切线空间转变为不同空间的变换矩阵,我们需要三个相互垂直的向量,它们沿一个表面的法线贴图对齐于:上、右、前;这和我们在摄像机教程中做的类似。

已知上向量是表面的法线向量。右和前向量是切线和副切线向量。下面的图片展示了一个表面的三个向量:

计算出切线和副切线并不像法线向量那么容易。从图中可以看到法线贴图的切线和副切线与纹理坐标的两个方向对齐。我们就是用到这个特性计算每个表面的切线和副切线的。请看下图:

图中我们可以看到边E2纹理坐标的不同, E2是一个三角形的边,这个三角形的另外两条边是ΔU2和ΔV2,它们与切线向量T和副切线向量B方向相同。这样我们可以把边和E1和E2用切线向量T和副切线向量B的线性组合表示出来(注:注意T和B都是单位长度,在TB平面中所有点的T、B坐标都在0到1之间,因此可以进行这样的组合):

我们也可以写成这样:

E是两个向量位置的差,ΔU和ΔV是纹理坐标的差。然后我们得到两个未知数(切线T和副切线B)和两个等式。由此我们可以解得T和B。

上面的方程允许我们把它们写成另一种格式:矩阵乘法

两边都乘以 ΔU ΔV 的逆矩阵等于:

这样我们就可以解出T和B了。这需要我们计算出Δ纹理坐标矩阵的逆矩阵。最终为:

如果你对这些数学内容不理解也不用担心。当你知道我们可以用一个三角形的顶点和纹理坐标(因为纹理坐标和切线向量在同一空间中)计算出切线和副切线你就已经部分地达到目的了。

手工计算切线和副切线

现在我们使用切线空间来实现法线贴图,所以我们可以使平面朝向任意方向,法线贴图仍然能够工作。根据前面讨论的数学方法,我们来手工计算出表面的切线和副切线向量。

假设平面使用下面的向量建立起来(1、2、3和1、3、4,它们是两个三角形):

1
2
3
4
5
6
7
8
9
10
11
12
// positions
glm::vec3 pos1(-1.0,  1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3(1.0, -1.0, 0.0);
glm::vec3 pos4(1.0, 1.0, 0.0);
// texture coordinates
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// normal vector
glm::vec3 nm(0.0, 0.0, 1.0);  

我们先计算第一个三角形的边和 delta UV坐标:

1
2
3
4
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;  

然后根据前面公式列出下列等式:

1
2
3
4
5
6
7
8
9
10
11
12
13
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);

bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);  

[...] // similar procedure for calculating tangent/bitangent for plane's second triangle

我们预先计算出等式的分数部分f,然后把它和每个向量的元素进行相应矩阵乘法。最后我们还要进行标准化,来确保切线/副切线向量最后是单位向量。

因为一个三角形永远是平坦的形状,我们只需为每个三角形计算一个切线/副切线,它们对于每个三角形上的顶点都是一样的。要注意的是大多数实现通常三角形之间都会共享顶点。这种情况下开发者通常将每个顶点的法线和切线/副切线等顶点属性平均化,以获得更加柔和的效果。我们平面的三角形之间分享了一些顶点,但是因为两个三角形相互并行,因此并不需要将结果平均化,但无论何时只要你遇到这种情况记住这样做都是好的。

最后的切线和副切线向量的值应该是(1, 0, 0)和(0, 1, 0),它们和法线(0, 0, 1)组成相互垂直的TBN矩阵。在平面上显示出来TBN应该是这样的:

每个顶点定义了切线和副切线向量,我们就可以开始实现正确的法线贴图了。

切线空间法线贴图

为让法线贴图工作,我们先得在着色器中创建一个TBN矩阵。我们先将前面计算出来的切线和副切线向量传给顶点着色器,作为它的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;

void main()
{
[...]
    // 创建TBN矩阵
vec3 T = normalize(vec3(model * vec4(tangent,   0.0)));
vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal,    0.0)));
mat3 TBN = mat3(T, B, N)
}

先将所有TBN向量变换到我们所操作的坐标系中,目前是在世界空间,所以我们可以乘以model矩阵。然后我们创建实际的TBN矩阵,直接把相应的向量应用到mat3构造器就行。注意,如果我们希望更精确的话就不要将TBN向量乘以model矩阵,而是使用正规矩阵,但我们只关心向量的方向,不会平移也和缩放这个变换。

从技术上讲,顶点着色器中无需副切线。所有的这三个TBN向量都是相互垂直的所以我们可以在顶点着色器中用T和N向量的叉乘,自己计算出副切线:vec3 B = cross(T, N); 现在我们有了TBN矩阵,如何来使用它呢?基本有两种方式可以使用,我们会把这两种方式都说明一下:

  1. 我们可以用TBN矩阵把所有向量从切线空间转到世界空间,传给片段着色器,然后把采样得到的法线用TBN矩阵从切线空间变换到世界空间;法线就处于和其他光照变量一样的空间中了。
  2. 我们用TBN的逆矩阵把所有世界空间的向量转换到切线空间,使用这个矩阵将除法线以外的所有相关光照变量转换到切线空间中;这样法线也能和其他光照变量处于同一空间之中。

我们来看看第一种情况。我们从法线贴图采样得来的法线向量,是以切线空间表达的,然而其他光照向量是以世界空间表达的。把TBN传给片段着色器,我们可以把采样得来的切线空间的法线乘以这个TBN矩阵,将法线向量变换到和其他光照向量一样的参考空间中。这种方式对于随后所有光照计算都更便于理解。

把TBN矩阵发送到片段着色器:

1
2
3
4
5
6
7
8
9
10
11
out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    mat3 TBN;
} vs_out;  

void main()
{
    [...]
    vs_out.TBN = mat3(T, B, N);
}

在片段着色器中我们用mat3作为输入变量:

1
2
3
4
5
in VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    mat3 TBN;
} fs_in;

有了TBN矩阵我们现在就可以更新法线贴图代码,引入切线到世界空间变换:

1
2
3
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);   
normal = normalize(fs_in.TBN * normal);

因为最后normal在世界空间中了,就不用改变其他片段着色器的代码了,因为光照代码就是假设法线向量在世界空间中。

再来看看第二种方法,我们用TBN矩阵的逆矩阵将所有相关的世界空间向量转变到采样所得法线向量的空间:切线空间。TBN的构建还是一样,但我们在将其发送给片段着色器之前先求逆矩阵:

1
vs_out.TBN = transpose(mat3(T, B, N));

注意,这里我们使用transpose函数,而不是inverse函数。正交矩阵(每个轴既是单位向量同时相互垂直)的一个属性是它的置换矩阵与它的逆矩阵相等。因为逆矩阵的开销比较大,所以求转置是一样的。

在像素着色器中我们不用对法线向量变换,但我们要把其他相关向量转换到切线空间,它们是lightDir和viewDir。这样每个向量还是在同一个空间(切线空间)中了。

1
2
3
4
5
6
7
8
9
void main()
{           
    vec3 normal = texture(normalMap, fs_in.TexCoords).rgb;
    normal = normalize(normal * 2.0 - 1.0);   

    vec3 lightDir = fs_in.TBN * normalize(lightPos - fs_in.FragPos);
    vec3 viewDir  = fs_in.TBN * normalize(viewPos - fs_in.FragPos);    
    [...]
}

第二种方法看似要做的更多,它还需要在片段着色器中进行更多的乘法操作,那为何还用第二种方法呢?

将向量从世界空间转换到切线空间有个额外好处,我们可以把所有相关向量在顶点着色器中转换到切线空间,不用在片段着色器中做这件事。这是可行的,因为lightPos和viewPos不是每个fragment运行都要改变,对于fs_in.FragPos,我们也可以在顶点着色器计算它的切线空间位置。基本上,第二种方法不需要把任何向量在片段着色器中进行变换,而第一种方法中就是必须的,因为采样出来的法线向量对于每个片段着色器都不一样。

所以现在要做的不是把TBN的逆矩阵发送给片段着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给片段着色器。这样我们就不用在片段着色器里进行矩阵乘法了。这是一个极佳的优化,因为顶点着色器通常比片段着色器运行的少。这也是为什么这种方法是一种更好的实现方式的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} vs_out;

uniform vec3 lightPos;
uniform vec3 viewPos;

[...]

void main()
{    
    [...]
    mat3 TBN = transpose(mat3(T, B, N));
    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vec3(model * vec4(position, 0.0));
}

在片段着色器中我们使用这些新的输入变量来计算切线空间的光照。因为法线向量已经在切线空间中了,光照就有意义了。

将法线贴图应用到切线空间上,这次我们可以将平面朝向各个方向,光照一直都会是正确的:

复杂的物体:

我们已经说明了如何通过手工计算切线和副切线向量,来使用切线空间和法线贴图。幸运的是,计算这些切线和副切线向量对于你来说不是经常能遇到的事;大多数时候,在模型加载器中实现一次就行了,我们是在使用了Assimp的那个加载器中实现的。

Assimp有个很有用的配置,在我们加载模型的时候调用aiProcess_CalcTangentSpace。当aiProcess_CalcTangentSpace应用到Assimp的ReadFile函数时,Assimp会为每个加载的顶点计算出柔和的切线和副切线向量,它所使用的方法和我们本教程使用的类似。

1
2
3
const aiScene* scene = importer.ReadFile(
    path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);

我们可以通过下面的代码用Assimp获取计算出来的切线空间:

1
2
3
4
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;

然后,你还必须更新模型加载器,用以从带纹理模型中加载法线贴图。wavefront的模型格式(.obj)导出的法线贴图有点不一样,Assimp的aiTextureType_NORMAL并不会加载它的法线贴图,而aiTextureType_HEIGHT却能,所以我们经常这样加载它们:

1
2
3
vector<Texture> specularMaps = this->loadMaterialTextures(
    material, aiTextureType_HEIGHT, "texture_normal"
);

当然,对于每个模型的类型和文件格式来说都是不同的。同样了解aiProcess_CalcTangentSpace并不能总是很好的工作也很重要。计算切线是需要根据纹理坐标的,有些模型制作者使用一些纹理小技巧比如镜像一个模型上的纹理表面时也镜像了另一半的纹理坐标;这样当不考虑这个镜像的特别操作的时候(Assimp就不考虑)结果就不对了。

使用法线贴图也是一种提升你的场景的表现的重要方式。在使用法线贴图之前你不得不使用相当多的顶点才能表现出一个更精细的网格,但使用了法线贴图我们可以使用更少的顶点表现出同样丰富的细节。高精度网格和使用法线贴图的低精度网格几乎区分不出来。所以法线贴图不仅看起来漂亮,它也是一个将高精度多边形转换为低精度多边形而不失细节的重要工具。

最后一件事

关于法线贴图还有最后一个技巧要讨论,它可以在不必花费太多性能开销的情况下稍稍提升画质表现。

当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当法线贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果。这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移,但这仍然可以改进。

使用叫做格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做:

1
2
3
4
5
6
7
8
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(tangent, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);

mat3 TBN = mat3(T, B, N);

这样稍微花费一些性能开销就能对法线贴图进行一点提升。看看最后的那个附加资源: Normal Mapping Mathematics视频,里面有对这个过程的解释。

附加资源


参考 LearnOpenGL-CN 法线贴图

几何着色器(Geometry Shader)

在顶点和片段着色器之间有一个可选的着色器,叫做几何着色器(geometry shader)。几何着色器以一个或多个表示为一个基本图元(primitive)的顶点作为输入,比如可以是一个点或者三角形。几何着色器在将这些顶点发送到下一个着色阶段之前,可以将这些顶点转变为它认为合适的内容。几何着色器有意思的地方在于它可以把(一个或多个)顶点转变为完全不同的基本图形(primitive),从而生成比原来多得多的顶点。

我们用一个例子了解一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (points) in;
layout (line_strip, max_vertices = 2) out;

void main() {
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
    EmitVertex();

    gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
    EmitVertex();

    EndPrimitive();
}

每个几何着色器开始位置我们需要声明输入的基本图形(primitive)类型,这个输入是我们从顶点着色器中接收到的。我们在in关键字前面声明一个layout标识符。这个输入layout修饰符可以从一个顶点着色器接收以下基本图形值:

基本图形 描述
points 绘制GL_POINTS基本图形的时候(1)
lines 当绘制GL_LINES或GL_LINE_STRIP(2)时
lines_adjacency GL_LINES_ADJACENCY或GL_LINE_STRIP_ADJACENCY(4)
triangles GL_TRIANGLES, GL_TRIANGLE_STRIP或GL_TRIANGLE_FAN(3)
triangles_adjacency GL_TRIANGLES_ADJACENCY或GL_TRIANGLE_STRIP_ADJACENCY(6)

这是我们能够给渲染函数的几乎所有的基本图形。如果我们选择以GL_TRIANGLES绘制顶点,我们要把输入修饰符设置为triangles。括号里的数字代表一个基本图形所能包含的最少的顶点数。

当我们需要指定一个几何着色器所输出的基本图形类型时,我们就在out关键字前面加一个layout修饰符。和输入layout标识符一样,输出的layout标识符也可以接受以下基本图形值:

  • points
  • line_strip
  • triangle_strip

使用这3个输出修饰符我们可以从输入的基本图形创建任何我们想要的形状。为了生成一个三角形,我们定义一个triangle_strip作为输出,然后输出3个顶点。

几何着色器同时希望我们设置一个它能输出的顶点数量的最大值(如果你超出了这个数值,OpenGL就会忽略剩下的顶点),我们可以在out关键字的layout标识符上做这件事。在这个特殊的情况中,我们将使用最大值为2个顶点,来输出一个line_strip。

这种情况,你会奇怪什么是线条:一个线条是把多个点链接起来表示出一个连续的线,它最少有两个点来组成。每多一个点都会在这个新点和之前一个点渲染线,如下图,其中包含5个顶点:

上面的着色器,我们只能输出一个线段,因为顶点的最大值设置为2。

为生成更有意义的结果,我们需要某种方式从前一个着色阶段获得输出。GLSL为我们提供了一个内建变量,它叫做gl_in,它的内部看起来可能像这样:

1
2
3
4
5
6
in gl_Vertex
{
    vec4 gl_Position;
    float gl_PointSize;
    float gl_ClipDistance[];
} gl_in[];

这里它被声明为一个接口块(interface block),它包含几个有意思的变量,其中最有意思的是gl_Position,它包含着和我们设置的顶点着色器的输出相似的向量。

要注意的是,它被声明为一个数组,因为大多数渲染基本图形由一个以上顶点组成,几何着色器接收一个基本图形的所有顶点作为它的输入。

使用来自前一个顶点着色阶段的顶点数据,我们就可以开始生成新的数据了,这是通过2个几何着色器函数EmitVertex和EndPrimitive来完成的。几何着色器需要你去生成/输出至少一个你定义为输出的基本图形。在我们的例子里我们打算至少生成一个线条(line strip)基本图形。

每次我们调用EmitVertex,当前设置到gl_Position的向量就会被添加到基本图形上。无论何时调用EndPrimitive,所有为这个基本图形发射出去的顶点都将结合为一个特定的输出渲染基本图形。一个或多个EmitVertex函数调用后,重复调用EndPrimitive就能生成多个基本图形。这个特殊的例子里,发射了两个顶点,它们被从顶点原来的位置平移了一段距离,然后调用EndPrimitive将这两个顶点结合为一个单独的有两个顶点的线条。

现在你了解了几何着色器的工作方式,你就可能猜出这个几何着色器做了什么。这个几何着色器接收一个基本图形——点,作为它的输入,使用输入点作为它的中心,创建了一个水平线基本图形。如果我们渲染它,结果就会像这样:

并不是非常引人注目,但是考虑到它的输出是使用下面的渲染命令生成的就很有意思了:

1
glDrawArrays(GL_POINTS, 0, 4);

这是个相对简单的例子,它向你展示了我们如何使用几何着色器来动态地在运行时生成新的形状。

使用几何着色器

为了展示几何着色器的使用,我们将渲染一个简单的场景,在场景中我们只绘制4个点,这4个点在标准化设备坐标的z平面上。这些点的坐标是:

1
2
3
4
5
6
GLfloat points[] = {
-0.5f,  0.5f, // 左上方
0.5f,  0.5f,  // 右上方
0.5f, -0.5f,  // 右下方
-0.5f, -0.5f  // 左下方
};

顶点着色器只在z平面绘制点,所以我们只需要一个基本顶点着色器:

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

void main()
{
    gl_Position = vec4(position.x, position.y, 0.0f, 1.0f);
}

我们会简单地为所有点输出绿色,我们直接在片段着色器里进行硬编码:

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

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

为点的顶点生成一个VAO和VBO,然后使用glDrawArrays进行绘制:

1
2
3
4
shader.Use();
glBindVertexArray(VAO);
glDrawArrays(GL_POINTS, 0, 4);
glBindVertexArray(0);

效果是黑色场景中有四个绿点。现在我们将为场景添加一个几何着色器。

出于学习的目的我们将创建一个叫pass-through的几何着色器,它用一个point基本图形作为它的输入,并把它无修改地传(pass)到下一个着色器。

1
2
3
4
5
6
7
8
#version 330 core
layout (points) in;
layout (points, max_vertices = 1) out;
void main() {
    gl_Position = gl_in[0].gl_Position;
    EmitVertex();
    EndPrimitive();
}

现在这个几何着色器应该很容易理解了。它简单地将它接收到的输入的无修改的顶点位置发射出去,然后生成一个point基本图形。

一个几何着色器需要像顶点和片段着色器一样被编译和链接,但是这次我们将使用GL_GEOMETRY_SHADER作为着色器的类型来创建这个着色器:

1
2
3
4
5
6
geometryShader = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometryShader, 1, &gShaderCode, NULL);
glCompileShader(geometryShader);  
...
glAttachShader(program, geometryShader);
glLinkProgram(program);

编译着色器的代码和顶点、片段着色器的基本一样。要记得检查编译和链接错误! 运行后效果和前面一样,还是四个绿点。

创建几个房子

绘制点和线没什么意思,所以我们将在每个点上使用几何着色器绘制一个房子。我们可以通过把几何着色器的输出设置为triangle_strip来达到这个目的,总共要绘制3个三角形:两个用来组成方形和另表示一个屋顶。

在OpenGL中三角形带(triangle strip)绘制起来更高效,因为它所使用的顶点更少。第一个三角形绘制完以后,每个后续的顶点会生成一个毗连前一个三角形的新三角形:每3个毗连的顶点都能构成一个三角形。如果我们有6个顶点,它们以三角形带的方式组合起来,那么我们会得到这些三角形:(1, 2, 3)、(2, 3, 4)、(3, 4, 5)、(4,5,6)因此总共可以表示出4个三角形。一个三角形带至少要用3个顶点才行,它能生成N-2个三角形;6个顶点我们就能创建6-2=4个三角形。下面的图片表达了这点:

使用一个三角形带作为一个几何着色器的输出,我们可以轻松创建房子的形状,只要以正确的顺序来生成3个毗连的三角形。下面的图像显示,我们需要以何种顺序来绘制点,才能获得我们需要的三角形,图上的蓝点代表输入点:

上图的内容转变为几何着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#version 330 core
layout (points) in;
layout (triangle_strip, max_vertices = 5) out;

void build_house(vec4 position)
{
    gl_Position = position + vec4(-0.2f, -0.2f, 0.0f, 0.0f);// 1:左下角
    EmitVertex();
    gl_Position = position + vec4( 0.2f, -0.2f, 0.0f, 0.0f);// 2:右下角
    EmitVertex();
    gl_Position = position + vec4(-0.2f,  0.2f, 0.0f, 0.0f);// 3:左上
    EmitVertex();
    gl_Position = position + vec4( 0.2f,  0.2f, 0.0f, 0.0f);// 4:右上
    EmitVertex();
    gl_Position = position + vec4( 0.0f,  0.4f, 0.0f, 0.0f);// 5:屋顶
    EmitVertex();
    EndPrimitive();
}

void main()
{
    build_house(gl_in[0].gl_Position);
}

这个几何着色器生成5个顶点,每个顶点是点(point)的位置加上一个偏移量,来组成一个大三角形带。接着最后的基本图形被像素化,片段着色器处理整三角形带,结果是为我们绘制的每个点生成一个绿房子。 可以看到,每个房子实则是由3个三角形组成,都是仅仅使用空间中一点来绘制的。绿房子看起来还是不够漂亮,所以我们再给每个房子加一个不同的颜色。我们将在顶点着色器中为每个顶点增加一个额外的代表颜色信息的顶点属性。

下面是更新了的顶点数据:

1
2
3
4
5
6
GLfloat points[] = {
    -0.5f,  0.5f, 1.0f, 0.0f, 0.0f, // 左上
    0.5f,  0.5f, 0.0f, 1.0f, 0.0f, // 右上
    0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // 右下
    -0.5f, -0.5f, 1.0f, 1.0f, 0.0f  // 左下
};

然后我们更新顶点着色器,使用一个接口块来向几何着色器发送颜色属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (location = 0) in vec2 position;
layout (location = 1) in vec3 color;

out VS_OUT {
    vec3 color;
} vs_out;

void main()
{
    gl_Position = vec4(position.x, position.y, 0.0f, 1.0f);
    vs_out.color = color;
}

接着我们还需要在几何着色器中声明同样的接口块(使用一个不同的接口名):

1
2
3
in VS_OUT {
    vec3 color;
} gs_in[];

因为几何着色器把多个顶点作为它的输入,从顶点着色器来的输入数据总是被以数组的形式表示出来,即使现在我们只有一个顶点。

然后我们还要为下一个像素着色阶段声明一个输出颜色向量:

1
out vec3 fColor;

因为片段着色器只需要一个颜色,传送多个颜色没有意义。fColor向量这样就不是数组。当发射一个顶点时,为了它的片段着色器运行,每个顶点都会储存最后在fColor中储存的值。对于这些房子来说,我们可以在第一个顶点被发射,对整个房子上色前,只使用来自顶点着色器的颜色填充fColor一次:

1
fColor = gs_in[0].color; //只有一个输出颜色,所以直接设置为gs_in[0]

使用几何着色器,你可以使用最简单的基本图形就能获得漂亮的新玩意。因为这些形状是在你的GPU超快硬件上动态生成的,这要比使用顶点缓冲自己定义这些形状更为高效。几何缓冲在简单的经常被重复的形状比如体素(voxel)的世界和室外的草地上,是一种非常强大的优化工具。

爆炸式物体

当我们说对一个物体进行爆破的时候并不是说我们将要把之前的那堆顶点炸掉,而是把每个三角形沿着它们的法线向量移动一小段距离。效果是整个物体上的三角形看起来就像沿着它们的法线向量爆炸了一样。这样一个几何着色器效果的一大好处是,它可以用到任何物体上,无论它们多复杂。

因为我们打算沿着三角形的法线向量移动三角形的每个顶点,我们需要先计算它的法线向量。我们可以使用叉乘获取一个垂直于两个其他向量的向量。下面的几何着色器函数做的正是这件事,它使用3个输入顶点坐标获取法线向量:

1
2
3
4
5
6
vec3 GetNormal()
{
vec3 a = vec3(gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
vec3 b = vec3(gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
return normalize(cross(a, b));
}

一定要注意,如果我们调换了a和b的叉乘顺序,我们得到的法线向量就会使反的,顺序很重要!

接下来我们创建一个explode函数,函数返回的是一个新向量,它把位置向量沿着法线向量方向平移:

1
2
3
4
5
6
vec4 explode(vec4 position, vec3 normal)
{
    float magnitude = 2.0f;
    vec3 direction = normal * ((sin(time) + 1.0f) / 2.0f) * magnitude;
    return position + vec4(direction, 0.0f);
}

sin函数把一个time变量作为它的参数,它根据时间来返回一个-1.0到1.0之间的值。因为我们不想让物体坍缩,所以我们把sin返回的值做成0到1的范围。最后的值去乘以法线向量,direction向量被添加到位置向量上。

爆炸效果的完整的几何着色器是这样的,它使用我们的模型加载器,绘制出一个模型:

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
#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;

in VS_OUT {
    vec2 texCoords;
} gs_in[];

out vec2 TexCoords;

uniform float time;

vec4 explode(vec4 position, vec3 normal) { ... }

vec3 GetNormal() { ... }

void main() {
    vec3 normal = GetNormal();

    gl_Position = explode(gl_in[0].gl_Position, normal);
    TexCoords = gs_in[0].texCoords;
    EmitVertex();
    gl_Position = explode(gl_in[1].gl_Position, normal);
    TexCoords = gs_in[1].texCoords;
    EmitVertex();
    gl_Position = explode(gl_in[2].gl_Position, normal);
    TexCoords = gs_in[2].texCoords;
    EmitVertex();
    EndPrimitive();
}

注意我们同样在发射一个顶点前输出了合适的纹理坐标。

最后的结果是一个随着时间持续不断地爆炸的3D模型(不断爆炸不断回到正常状态)。尽管没什么大用处,它却向你展示出很多几何着色器的高级用法。

把法线向量显示出来

在这部分我们将使用几何着色器写一个例子,非常有用:显示一个法线向量。当编写光照着色器的时候,你最终会遇到奇怪的视频输出问题,你很难决定是什么导致了这个问题。通常导致光照错误的是,不正确的加载顶点数据,以及给它们指定了不合理的顶点属性,又或是在着色器中不合理的管理,导致产生了不正确的法线向量。我们所希望的是有某种方式可以检测出法线向量是否正确。把法线向量显示出来正是这样一种方法,恰好几何着色器能够完美地达成这个目的。

思路是这样的:我们先不用几何着色器,正常绘制场景,然后我们再次绘制一遍场景,但这次只显示我们通过几何着色器生成的法线向量。几何着色器把一个三角形基本图形作为输入类型,用它们生成3条和法线向量同向的线段,每个顶点一条。伪代码应该是这样的:

这次我们会创建一个使用模型提供的顶点法线,而不是自己去生成。为了适应缩放和旋转,我们会在变换到裁切空间坐标前,先使用正规矩阵来变换法线。(几何着色器用他的位置向量做为裁切空间坐标,所以我们还要把法线向量变换到同一个空间)。这些都能在顶点着色器中完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;

out VS_OUT {
    vec3 normal;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0f); 
    mat3 normalMatrix = mat3(transpose(inverse(view * model)));
    vs_out.normal = normalize(vec3(projection * vec4(normalMatrix * normal, 1.0)));
}

经过变换的裁切空间法线向量接着通过一个接口块被传递到下个着色阶段。几何着色器接收每个顶点(带有位置和法线向量),从每个位置向量绘制出一个法线向量:

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
#version 330 core
layout (triangles) in;
layout (line_strip, max_vertices = 6) out;

in VS_OUT {
    vec3 normal;
} gs_in[];

const float MAGNITUDE = 0.4f;

void GenerateLine(int index)
{
    gl_Position = gl_in[index].gl_Position;
    EmitVertex();
    gl_Position = gl_in[index].gl_Position + vec4(gs_in[index].normal, 0.0f) * MAGNITUDE;
    EmitVertex();
    EndPrimitive();
}

void main()
{
    GenerateLine(0); // First vertex normal
    GenerateLine(1); // Second vertex normal
    GenerateLine(2); // Third vertex normal
}  

由于把法线显示出来通常用于调试的目的,我们可以在片段着色器的帮助下把它们显示为单色的线(如果你愿意也可以更炫一点)。

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

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

参考 LearnOpenGL-CN 几何着色器