一、初识游戏图形
1、什么是渲染?
渲染实际上就是创建图像的过程,在渲染过程中创建的图像被称为渲染或者帧,该图像(帧)以每秒多次在计算机屏幕上进行呈现,即帧率。
负责渲染图像(帧)的部分称为渲染引擎,渲染引擎用于将网格数据转换为新的图像的一系列操作步骤被称为"渲染管线"。渲染引擎例如我们平时接触的cocos2dx、cocos creator等,那么什么是网格和渲染管线呢?
2、网格是什么?
网格是用计算机能够理解的方式描述形状的方法之一。网格需要存储3个信息:顶点、边、面。但是一般只有网格的顶点信息存储在了计算机的内存中,而网格的边和面则是由顶点的顺序隐式定义的。有时顶点顺序只是网格的形状数据中顶点的存储顺序,它由被称为"索引缓冲区"的数据结构定义。
"索引缓冲区"很重要,一般网格的面使用的是三角面,如果使用顶点来定义一个四边形网格,我们不是需要6个顶点数据来定义两个三角面组成一个四边形网格。而是使用4个顶点来定义两个三角面组成一个四边形网格,此时由两个顶点是可以重复利用的。对于重复利用的两个顶点是什么,我们通过使用"索引缓冲区"来进行定义。
网格的顶点具有自己的属性信息,例如:顶点的位置、顶点的颜色、顶点的纹理(uv)坐标等。这些顶点的属性数据,我们在顶点着色器中接收的时候通过"in"关键字,并且使用"attribute"修饰符。
3、在计算机图像学中定义颜色
在计算机图形学中,一般使用归一化的颜色,即颜色的数值范围在[0.0,1.0]内。但是我们平常在使用颜色的过程中,对于rgba的取值范围都是[0,255]的,如果将它们转化为归一化的颜色呢?除以255不就好了吗?才开始写着色器代码的时候,对于颜色数值的取值定义我也很懵逼。
4、渲染管线
前面提到了渲染引擎通过渲染管线将网格数据转换为图像(帧),那么什么是渲染管线呢?"渲染管线"的定义是:渲染引擎用于将网格数据转换为新的图像的一系列操作步骤。这一系列步骤是哪些??渲染管线真正的处理流程要比下图中的流程复杂的多,不过目前我们只需要重点关注这几个阶段即可。
(1)网格顶点数据:
渲染管线主要处理的是网格数据,网格包含顶点、线、面。但是实际上在内存中存储的只有网格的顶点数据,对于网格顶点数据而言,它是有一些顶点属性的,例如:顶点位置、顶点的颜色、顶点的uv坐标等,这些都是顶点的自有属性。
(2)顶点着色器阶段:
顶点着色器阶段实际上关心的是弄清楚当前正在处理的网格的每个顶点在屏幕上渲染时的最终位置。后面会讲到,在这个阶段会涉及到模型空间、世界空间、视图空间、齐次裁剪空间,即网格顶点需要经过一系列的矩阵变化才能正确的显示在屏幕的某一个位置上面。
提示:
在顶点着色器阶段并不是说处理一个顶点,然后就传递给渲染管线的下一个阶段。实际上是一个网格的所有顶点数据全部都处理完毕之后,才会进行渲染管线的下一个阶段。
在才开始写着色器代码的时候我也是有点懵逼的,这些数据之间是如何进行传递的?一直以为一个顶点在经过顶点着色器处理之后会立刻进行片元着色器的处理。
(3)形状装配:
网格包含顶点、线、面。这个阶段应该是通过网格的顶点信息、以及网格的存储顺序或者前面说到的"索引缓冲区"将网格顶点组成线、面的过程。
(4)几何着色器阶段:
(5)光栅化阶段:
光栅化阶段GPU会为计算出网格可能会占据哪些屏幕像素,并且为每一个像素创建一个对应的片元。可以把片元理解为一个潜在的像素。
光栅化阶段会为每一个像素创建一个片元,此时像素与片元的关系是一对一。但是可能存在多个网格占据同一个片元,此时像素与片元的关系就是一对多的关系。并且最终在"片元处理阶段"存在片元丢弃、片元混合操作,即最终的一个像素渲染效果是一个或者多个片元共同作用的结果。
(6)片元着色器阶段:
光栅化阶段并不知道网格的表面信息,因此在"片元着色器"阶段需要对每个片元进行着色处理。
(7)片元处理阶段:
"片元处理阶段"将上述步骤中所有处理过的片元做一个"片元处理标记",并且会进行两个主要作用:片元测试和混合操作。
片元测试:通过设定一个透明度阈值,当透明度低于这个阈值的时候,会通过一个"discard"指令进行丢弃,不再传递给渲染管线的下一个阶段。
混合操作:混合操作主要分为两种,透明度混合和加法混合。
5、着色器是什么?
这玩意具体是啥我也有点说不清楚、道不明白。我们暂且就把着色器理解为渲染管线在某些渲染步骤中执行的程序,用于控制这些步骤的输出效果。我们主要关心的是"顶点着色器"和"片元着色器",即渲染管线可编程化阶段。
二、杂谈:
1、像素坐标、归一化的设备坐标:
(1)像素坐标:
游戏开发过程中,我们接触最多的坐标应该就是像素坐标了,即坐标(x,y)。在cocos2d-x中,左下角是像素坐标的原点,向右为x轴正方向,向上为y轴正方向。在FairyGui中左上角为像素坐标的原点,向右为x轴的正方向,向下为y轴的正方向,每个引擎中对于像素坐标原点的定义方式不一样。
例如在cocos2d-x中,对于一个720*1280分辨率的屏幕,左下角的像素坐标是(0,0)、左上角的像素坐标是(0,1280)、右上角的像素坐标是(720,1280)、右下角的像素坐标是(720,0)。
我们前面提到过,顶点着色器的过程中会确定网格顶点对应的屏幕位置。这个顶点位置和屏幕像素位置之间的匹配关系是依赖于什么进行的呢?像素坐标?那我们就先假设是依赖于像素坐标来进行匹配的,并推论一下此时会发生什么问题?
假设一个网格顶点的像素坐标是(400,800)。假设左上角为坐标原点,那么这个顶点在400 * 800的窗口上面是处于右下角的;这个顶点在400 * 1000的窗口上面并不是处于左下角的。这意味着具有相同像素坐标的网格顶点,在不同屏幕分辨率的窗口上面显示的方式是不一样的,像素坐标是依赖于分辨率的。这个现象很明显是不满足我们对于屏幕适配的需求的。
为了解决这个问题,引入了"归一化的设备坐标"。
(2)归一化设备坐标:
"归一化的设备坐标"通过将所有尺寸的屏幕映射到同一组坐标来解决不同设备分辨率的问题。在使用归一化坐标描述位置的时候,x轴和y轴的取值范围都是[-1,1]之间,并且点(0,0)表示的是归一化坐标的原点。
GPU渲染的时候,将屏幕坐标映射为归一化的设备坐标。渲染完毕之后将图像输出到屏幕像素点的时候,再将归一化设备坐标映射为屏幕像素坐标。这里面涉及到了空间的矩阵变换,后面会进行讨论。
2、GLSL语法相关认识:
(1)限定符:const、attribute、uniform、varying
const修饰符:
const这个修饰符就不说了,很明显的常量。
attribute修饰符:
attribute:表示应用程序与顶点着色器之间进行通信,用于确定顶点的格式。
我们在顶点着色器中通过in来定义输入参数的时候,这些参数是从哪个地方传递给顶点着色器的呢?这个阶段处于渲染管线的前期,参数是由应用程序app传递过来的,通常使用"attribute"来修饰app向顶点着色器传递的参数。
而且我们可以惊奇的发现在顶点着色器中通过in接收的输入参数一般都是顶点的位置、顶点的颜色、以及纹理坐标等。想一想为什么是这些数据而不是别的数据呢?因为这些数据是网格顶点的自有属性数据。
// 顶点位置
in vec3 a_position;
// 等价于
attribute in vec3 a_position;
//(如果是cocos creator的话,可以通过右侧的面板看一下这些数据编译后的结果就知道)
uniform修饰符:
uniform用于修饰应用程序与着色器(包括顶点着色器、片元着色器)之间交互数据,在顶点着色器和片元着色器中保持一致。
前面提到了对于网格顶点的自有属性我们可以通过attribute修饰传递参数,那么如果这些自有属性无法满足我们的编程需求怎么办?可以通过使用uniform来修饰、定义自定义属性,通过uniform修饰的变量在顶点着色器、片元着色器中保持统一、一致、不可变。
// 例如cocos creator中定义的自定义属性
# 自定义属性
properties: &props
# 扫光颜色
_lineColor: { value: [1, 1, 1, 1], editor: { type: color }}
# 线宽
_lineWidth: { value: 0.1 }
# 光线的角度:欧拉角,沿y轴顺时针为正
_lineAngle: { value: 30.0 }
# 扫光的速度
_speed: { value: 1.0 }
# 两次扫光的间隔时间
_interval: { value: 1.0 }
// 片元着色器中定义对应的unifrom变量:
// uniform变量
uniform Content {
vec4 _lineColor;
float _lineWidth;
float _lineAngle;
float _speed;
float _interval;
};
上述是以cocos creator中的自定义属性为例,而且示例比较简单、潦草。这个地方在cocos creator中需要注意的地方还是挺多的,例如UBO布局等等,这里不在发散,需要的同学可以自行研究。
varying修饰符:
varying修饰符用于顶点着色器传输给片元着色器的插值变量的限定。
这里提到了片元插值的问题,后面会进行详细的讲解讨论。我们先说一下顶点着色器向片元着色器传递的参数,顶点着色器接收参数的方式有两种:第一种是前面提到的使用uniform变量,在顶点着色器和片元着色器中共享;第二中就是顶点着色器处理完网格的顶点数据之后传递给片元着色器,要求变量名保持一致。
// 顶点着色器中输出参数
out vec4 v_color;
out vec2 v_uv;
// 片元着色器中接收参数,参数名要与顶点着色器中保持一致
in vec4 v_color;
in vec2 v_uv;
//上述的代码片段等价于
// 顶点着色器
varying out vec4 v_color;
varying out vec2 v_uv;
// 片元着色器
varying in vec4 v_color;
varying in vec2 v_uv;
其它补充:
顶点着色器和片元着色器接收参数的方式都有两种:它们共有的方式是传递自定义变量,通过uniform修饰的变量。其次,对于顶点着色器而言,它从应用程序中接收网格顶点的自有数据属性,例如顶点位置、顶点颜色等;对于片元着色器而言,它将顶点着色器的输出参数作为自己的输入参数。
(除了uniform变量外)个人习惯在顶点着色器中的输入参数以"a_"开头来进行定义,例如a_position、a_texCoord等,表示有app传递过来。在顶点着色器的输出参数以及片元着色器的输入参数以"v_"开头来进行定义,例如v_position、v_uv、v_color,表示由顶点着色器传递过来。但是无论怎样命名,都应该保证参数传递过程中的变量名字保持一致。
(2)布局限定符语法:
前面提到了对于网格顶点的自有数据属性是由app传递过来的?但是我们怎么知道app传递过来的是哪些属性数据呢?以什么样的顺序来正确的接收这些属性数据呢?
额,问题是一个好问题,但是我不是一个能够解答这个问题的人啊,我只能说一说我对于这点的认识吧。
在不同的引擎中应该要求和规范是不一样的,但是本质肯定是一样的。据我所知道的是有一个布局限定符这个玩意。布局限定符语法用于告诉顶点着色器接收顶点数据的顺序,例如:
// C++版顶点着色器代码
// 指定openGL的版本号
#version 410
// 通过布局限定符语法来告诉顶点着色器接收顶点数据的顺序
layout(location = 0) in vec3 a_position;
layout(location = 3) in vec2 a_uv;
out vec2 v_uv;
void main() {
vec3 scaleValue = vec3(0.5, 0.75, 1.0);
//gl_Position = vec4(a_position, 1.0);
gl_Position = vec4(a_position * scaleValue, 1.0);
v_uv = vec2(a_uv.x, 1.0 - a_uv.y);
}
我在cocos creator中见到的好像是约定好的变量,而且我感觉creator在不同的版本中约定的变量好像还不太一样,导致我从网上下载一个2.x的shader,到3.x不能用,要慢慢改。例如之前版本里面我们使用a_uv来接收纹理坐标,但是在3.X里面好像需要使用a_texCoord来接收纹理坐标。
4、片元插值:
顶点着色器传递给片元着色器的参数都是以片元插值的形式传递的。我们知道网格包含顶点、线、面,而且在内存中只存储了与顶点相关的数据,对于线、面是通过顶点的顺序(索引缓冲区)计算出来的。渲染网格的时候,我们将网格所可能覆盖的的每一个像素点都分配一个片元,然后在顶点着色器中处理完该网格的所有顶点数据,在片元着色器中进行逐片元着色处理。
那么问题来了,网格的顶点数量肯定是有限的,我们如何使用这些有限的顶点数据信息来为所有的片元进行着色处理呢?
片元(暂且就理解为像素点吧)是处于顶点之间的,GPU在对片元进行着色的时候并不是只取一个顶点的数据信息,而是从构成该片元所在的面上获取多个(3个,三角面)顶点数据,将选定的顶点的输出数据发送给当前在处理的片元。GPU将这个多个顶点的数据进行线性混合,这个混合过程在计算机图形学中的术语就是线性插值。
三、使用纹理:
为什么要使用纹理呢?我们创建网格,然后给定网格顶点数据属性:位置、颜色等不就可以渲染出我们想要的任何效果吗?理论上的确是这样的,但是如果要渲染人像、风景等这样复杂的场景,我们可能就需要给网格增加数百万个顶点,并且逐顶点的处理数据了,这样的话人不会累死吗?后期调整维护心态不会爆炸吗?
由于这些原因,我们通常不使用逐顶点变化的顶点颜色来渲染详细物体的表面信息。取而代之,我们使用可以进行逐片元变化的图像文件(纹理)来提供颜色信息。
纹理通过一个称为纹理映射的过程应用于网格,网格上的每个顶点都会被指定一个对应的纹理坐标(UV坐标),该纹理坐标在顶点着色器中也是以片元插值的形式传递给片元着色器。在片元着色器中,我们可以通过这个纹理坐标来查找获取指定位置的颜色值,并应用于片元的渲染,即纹理采样。
1、纹理坐标
定义:纹理坐标(UV坐标)即网格顶点通过纹理映射之后在纹理贴图中与该顶点对应的2D坐标,用于在纹理贴图中进行采样。
纹理坐标的取值范围值[0.0, 1.0],但是对于纹理坐标的坐标系我见过两种表述方式:第一种,以左下角为坐标原点,并且向右、向上进行uv坐标的递增;第二种,以左上角为坐标原点,并且以向右、向下进行uv坐标的递增。在openGL中就是采用的第二种方式。对于坐标系处理方式的不同,我见过的一种解释是:几乎所有的2D图像格式都是将最上面的行作为存储数据的第一行,但是openGL中将图像数据的最下面一行像素作为数据存储的第一行。(openGL内心独白:我牛逼,我特殊,怎么了?)
所以有时候我们会看到渲染出来的纹理图像是一个倒立的,此时就需要做一下y坐标的翻转即可。下图是两种纹理坐标系的表示方式:
2、纹理采样:
上面提到了uv坐标的取值范围是[0.0, 1.0],但是有些朋友就是喜欢跳出一下游戏规则的约束,使用了不在这个范围之内的uv坐标,这个时候GPU怎么处理呢?
在计算机图形学中,纹理如何对超出0~1范围的坐标进行采样的处理被称为"循环模式"。这里简单列举几个常见的循环模式:
-
重复(Repeat):默认的纹理循环模式是重复的,也被称为平铺模式。在该模式下,当纹理坐标超出贴图范围的时候,将其重复平铺贴在纹理坐标以内。
-
夹取(Clamp):在此模式下当纹理范围超过1时,将使用1处的值代替;当纹理范围小于0时,将使用0来代替这个超出范围的值。
3、颜色混合:
当使用多个纹理对一个网格进行渲染的时候,我们就需要考虑纹理之间如何进行混合处理了。纹理的混合模式分为两种:颜色混合、透明度混合。
这里我们先说一说颜色混合,这种混合方式的使用场景还是比较常见的:增加描边、阴影、烟雾效果、扫光等。纹理混合是通过将不同的纹理按照一定的权重进行线性插值的方式完成的,但是需要注意的是:线性插值无法保证结果的归一化。对于混合指令可以使用mix、lerp等完成,不同的着色器可能指定不一样。
4、纹理单元、纹理对象:
纹理单元、纹理单元的索引、纹理对象
四、半透明与深度
在前面我们对于网格的讨论一般都局限于单个网格,对于颜色的讨论主要是红、绿、蓝三个颜色通道而忽略了透明度通道。下面我们将讨论一下多个网格的遮挡、渲染顺序问题以及透明度通道的处理。以及在片元处理阶段GPU到底在处理什么东西。
1、透明度测试与丢弃
进行透明度测试与丢弃我们需要关注两个点:设置透明度阈值、使用discard进行片元丢弃。GPU在进行渲染的时候,对于透明度大于设置的阈值的片元将正常执行渲染逻辑,对于透明度小于设置的阈值的片元将会被丢弃。使用discard进行片元丢弃发生在片元着色器阶段,并且使该片元无法传递到渲染管线的下一阶段,即无法抵达片元处理阶段。
2、使用深度测试构建场景
前面提到如果使用discard关键字进行了片元丢弃,那么该片元则无法传递到片元处理阶段。假如片元可以传递到片元处理阶段将会发生什么呢?在这个阶段GPU会进行大量的透明度混合测试以及深度计算。
对于网格渲染的先后顺序,我们常见的有两种处理方式:第一种,调整网格的渲染顺序,先渲染的处于下层,后渲染的处于上层。第二种,设置网格的深度,GPU通过深度检测决定渲染顺序。
上述两种网格渲染顺序的处理,我们以cocos2d-x中给一个根节点添加两个精灵为例:创建两个具有相同zOrder值的精灵,此时这两个精灵的渲染顺序取决于被addChild的先后顺序,先被addChild的精灵会先调用对应的渲染接口,则处于下层。如果采用第二种方式,可以调整精灵的zOrder值来决定渲染顺序,zOrder越大,层级越高,越后渲染,处于上层。
对于上面提到的第二种调整渲染顺序的方式实际上就是通过深度计算来使GPU决定渲染顺序的。在归一化的设备坐标中,网格的z坐标用于GPU进行深度检测的依据,z坐标越小越后渲染,层级越高;z坐标越大越先渲染,层级越低。
那么GPU在进行深度检测的时候是如何处理的呢?GPU在处理多个网格渲染遮挡问题的时候,会创建一个"深度缓冲区",这是一种特殊的纹理,GPU在绘制一帧中的每一个网格的时候可以将它们的深度信息保存起来。当处理完一帧中的所有网格之后,GPU通过"深度缓冲区"中存放的网格深度信息来决定网格的绘制顺序,这个阶段发生在"片元处理阶段"。
3、混合的工作原理
核心三要素:
-
后置缓冲区
-
先渲染不透明网格,再渲染透明网格
-
半透明网格禁止发送深度信息
混合由渲染管线的"片元处理"阶段完成,这是"片元着色器"的下一个阶段。需要注意的是,我们在片元处理阶段正在写入的是片元,而不是屏幕像素。这里我们前面其实提到过了,渲染开始的时候对于一个网格而言是一个像素分配一个片元,如果是多个网格的话就是一个像素对应分配了多个片元。在"片元处理"阶段会将与这一个像素相关联的所有片元进行混合处理。
(1)透明度混合:
透明度混合公式:$$vec4 outColor = src * scr.a + (1.0 - src.a) * des;$$
透明度混合是将背景色与前景色通过线性插值的方式进行混合,src和dst是进行混合的两个片元的颜色值的名称。渲染管线在每个网格上单独执行,因此绘制到屏幕上的第一个网格将在下一个网格的片元着色器完成之前完成其片元处理步骤。渲染管线每次传递的结果都会存储在"后置缓冲区"中。"后置缓冲区"是当前正在渲染的帧完全完成之后将要显示的屏幕上的图像。混合方程中dst是指混合发生时后置缓冲区的当前值,src是指需要与当前存储在后置缓冲区中的值进行混合的新片元。
下面我们以单个像素的混合流程为示例,来看一看GPU是如何执行混合操作的:假设我们当前这个像素点包括背景图像中的一片树叶和前景图像中的一朵白云,那么GPU将此像素的正确颜色输出到屏幕上的过程如下所示:
-
背景图像被绘制在屏幕上。此时GPU会为背景图像的叶子生成一个不透明的绿色片元。对于不透明的片元,GPU不执行混合混合逻辑,而是直接覆盖替换后置缓冲区中的内容,这样颜色就被写入了后置缓冲区。(缓冲区之前是没有数据的)
-
接下来将云朵绘制到屏幕上面。GPU同样为前景图像的云朵生成一个片元,但是该片元是白色半透明的。在片元着色器阶段处理完毕之后,该片元将会被发送的渲染管线的片元处理阶段。
-
GPU接收到云朵片元之后,发现这个屏幕像素的后置缓冲区中已经有了一个值,并且新的片元是半透明的,因此需要在两个片元之间执行混合操作。GPU将云朵的片元设置为混合方程中的src变量,并将此屏幕像素的后置缓冲区的当前值写入dst变量。
-
然后计算混合方程,并且此混合的结果存储在当前屏幕像素对应的后置缓冲区中,刷新后置缓冲区中的内容。
这里需要注意的是,对于半透明的片元,GPU会通过使用混合方程执行混合操作;但是对于不透明的片元,GPU不会执行混合方程,而是直接替换当前屏幕像素对应的后置缓冲区中的数据。这就要求半透明的网格需要在场景中所有不透明的网格完成渲染之后才绘制。这个要求在现在主流的渲染引擎中已经帮我们处理了。
(2)加法混合:
透明度混合非常适用于背后有物体的半透明物体,透过半透明的物体我们可以看到其后面的物体内容。但是如果我们想要半透明的物体照亮屏幕的一部分,而不是遮盖其后面的物体,此时就需要使用"加法混合"。
加法混合公式:$$vec4 outColor = src + dst;
五、摄像机与坐标
一个物体从创建,然后通过渲染管线输出的不同设备分辨率的屏幕上面大概需要使用到以下坐标空间。对象(模型)空间、世界空间、相机空间、齐次裁剪空间。这几个空间之间的变换依次需要使用到模型矩阵、视图矩阵、投影矩阵。
1、坐标空间:
-
对象(模型)空间:
-
世界空间:
-
视图(相机)空间:这是顶点坐标乘以视图矩阵之后所处的坐标空间,视图空间完全相当于摄像机。视图空间的原点位于摄像机所在的位置、z轴指向摄像机所指的方向、x轴和y轴也会调整以匹配摄像机的视图。
-
齐次裁剪空间:是使用归一化设备坐标的坐标空间的名称。在该空间下,只需要提供相关的视口信息、GPU将通过处理这些信息从而将归一化的设备坐标映射到不同设备分辨率的屏幕上。
2、变换矩阵:
-
模型矩阵:用来在世界坐标空间中定位网格的矩阵。通过矩阵变换,可以将顶点在模型空间中的位置转换到世界空间中。
-
视图矩阵:将世界空间中的位置坐标,转换为相机(视图)空间的位置坐标。
-
投影矩阵:将视图空间的位置坐标映射到齐次裁剪空间下新的位置坐标。