【OpenGL-ES】模型变换与坐标系统

前言

此前我们创建的物体是静态的,我们可以尝试在每一帧来改变物体的顶点,用新的顶点重新配置缓冲区,从而使物体动起来,但是这样做太过繁琐了,而且会消耗许多不必要的时间。

更好的解决方案是,使用一个或多个矩阵对象来与不变的顶点坐标进行计算,从而实现物体变换的效果。

矩阵是一种非常有用的数学工具,但是在这里不会做过多的介绍,就只简单介绍一下向量与矩阵的一些简单知识以及运算规则,作为实现变换操作的一点基础,其他更详细的内容请自行查看关于向量、矩阵以及线性代数等相关数学知识。本章节第二部分会介绍一下OpenGL中的多个坐标系统,以及最后产出一个应用了模型变换的Demo。

本章节结构以及主要内容来自Learn OpenGL教程的TransformationsCoordinate Systems,主要将Demo由OpenGL转为OpenGLES,同时在教程内容上补充了一些自己的理解和看法,图片效果会以手机上的贴出来,如果是视频的话就以教程里面的视频为准,具体效果请运行Demo在手机上查看。

Part1

向量

向量最基本的定义就是一个方向。或者更正式的说,向量有一个方向(Direction)大小(Magnitude,也叫做强度或长度)。你可以把向量想像成一个藏宝图上的指示:“向左走10步,向北走3步,然后向右走5步”;“左”就是方向,“10步”就是向量的长度。那么这个藏宝图的指示一共有3个向量。向量可以在任意维度(Dimension)上,但是我们通常只使用2至4维。如果一个向量有2个维度,它表示一个平面的方向(想象一下2D的图像),当它有3个维度的时候它可以表达一个3D世界的方向。

下面你会看到3个向量,每个向量在2D图像中都用一个箭头(x, y)表示。我们在2D图片中展示这些向量,因为这样子会更直观一点。你可以把这些2D向量当做z坐标为0的3D向量。由于向量表示的是方向,起始于何处并不会改变它的值。下图我们可以看到向量 $\overrightarrow{\color{red}v}$ 和 $\overrightarrow{\color{blue}w}$ 是相等的,尽管他们的起始点不同:

向量用在公式中时它们通常是这样的:

由于向量是一个方向,所以有些时候会很难形象地将它们用位置(Position)表示出来。为了让其更为直观,我们通常设定这个方向的原点为(0, 0, 0),然后指向一个方向,对应一个点,使其变为位置向量(Position Vector)(你也可以把起点设置为其他的点,然后说:这个向量从这个点起始指向另一个点)。比如说位置向量(3, 5)在图像中的起点会是(0, 0),并会指向(3, 5)。我们可以使用向量在2D或3D空间中表示方向与位置。

矩阵

简单来说矩阵就是一个矩形的数字、符号或表达式数组。矩阵中每一项叫做矩阵的元素(Element)。下面是一个2×3矩阵的例子:

矩阵可以通过(i, j)进行索引,i是行,j是列,这就是上面的矩阵叫做2×3矩阵的原因(3列2行,也叫做矩阵的维度(Dimension))。这与你在索引2D图像时的(x, y)相反,获取4的索引是(2, 1)(第二行,第一列)(译注:如果是图像索引应该是(1, 2),先算列,再算行)。

矩阵基本也就是这些了,它就是一个矩形的数学表达式阵列。和向量一样,矩阵也有非常漂亮的数学属性。矩阵有几个运算,分别是:矩阵加法、减法和乘法。加法减法这边不多说,主要简单讲一下矩阵乘法的一些注意的点,因为最后我们需要用一个或者多个矩阵去与我们的顶点坐标相乘,以及简单介绍一下对应的平移旋转缩放等操作是如何去影响矩阵的值的

矩阵乘法

矩阵与矩阵相乘

矩阵之间的乘法不见得有多复杂,但的确很难让人适应。矩阵乘法基本上意味着遵照规定好的法则进行相乘。当然,相乘还有一些限制:

  • 只有当左侧矩阵的列数与右侧矩阵的行数相等,两个矩阵才能相乘
  • 矩阵相乘不遵守交换律(Commutative),也就是说A⋅B≠B⋅A

矩阵与向量相乘

我们用向量来表示位置,表示颜色,甚至是纹理坐标。让我们更深入了解一下向量,它其实就是一个N×1矩阵,N表示向量分量的个数(也叫N维(N-dimensional)向量)。如果你仔细思考一下就会明白。向量和矩阵一样都是一个数字序列,但它只有1列。那么,这个新的定义对我们有什么帮助呢?如果我们有一个M×N矩阵,我们可以用这个矩阵乘以我们的N×1向量,因为这个矩阵的列数等于向量的行数,所以它们就能相乘。

因此当表示我们用一个向量来表示顶点坐标来与模型变换等矩阵相乘时,顶点坐标需要放在右侧,越靠近顶点坐标的变换效果越先应用,这也是为什么说看变换顺序要倒着看的原因

缩放

我们从单位矩阵(一个除了对角线以外都是0的N×N矩阵,这种变换矩阵使一个向量完全不变)了解到,每个对角线元素会分别与向量的对应元素相乘。如果我们把1变为3会怎样?这样子的话,我们就把向量的每个元素乘以3了,这事实上就把向量缩放3倍。如果我们把缩放变量表示为(S1,S2,S3)我们可以为任意向量(x,y,z)定义一个缩放矩阵:

注意,第四个缩放向量仍然是1,因为在3D空间中缩放w分量是无意义的。w分量另有其他用途,在后面我们会看到。

位移

和缩放矩阵一样,在4×4矩阵上有几个特别的位置用来执行特定的操作,对于位移来说它们是第四列最上面的3个值。如果我们把位移向量表示为(Tx,Ty,Tz),我们就能把位移矩阵定义为:

这样是能工作的,因为所有的位移值都要乘以向量的w行,所以位移值会加到向量的原始值上(想想矩阵乘法法则)。而如果你用3x3矩阵我们的位移值就没地方放也没地方乘了,所以是不行的。

齐次坐标(Homogeneous Coordinates)

向量的w分量也叫齐次坐标。想要从齐次向量得到3D向量,我们可以把x、y和z坐标分别除以w坐标。我们通常不会注意这个问题,因为w分量通常是1.0。

使用齐次坐标有几点好处:它允许我们在3D向量上进行位移(如果没有w分量我们是不能位移向量的),而且下一章我们会用w值创建3D视觉效果。

如果一个向量的齐次坐标是0,这个坐标就是方向向量(Direction Vector),因为w坐标是0,这个向量就不能位移(译注:这也就是我们说的不能位移一个方向)。

旋转

平移缩放的变换内容相对容易理解,在2D或3D空间中也容易表示出来,但旋转(Rotation)稍复杂些,我们这里只简单介绍一下绕不同的轴进行旋转,矩阵上哪些元素会受到影响。

首先我们来定义一个向量的旋转到底是什么。2D或3D空间中的旋转用角(Angle)来表示。角可以是角度制或弧度制的,周角是360角度或2 PI弧度。

大多数旋转函数需要用弧度制的角,但幸运的是角度制的角也可以很容易地转化为弧度制的:

弧度转角度:角度 = 弧度 * (180.0f / PI)

角度转弧度:弧度 = 角度 * (PI / 180.0f)

PI约等于3.14159265359。

在3D空间中旋转需要定义一个角和一个旋转轴(Rotation Axis)。物体会沿着给定的旋转轴旋转特定角度。当2D向量在3D空间中旋转时,我们把旋转轴设为z轴(尝试想象这种情况)。

旋转矩阵在3D空间中每个单位轴都有不同定义,旋转角度用 θ 表示:
绕x轴旋转:

绕y轴旋转:

绕z轴旋转:

利用旋转矩阵我们可以把我们的位置向量沿一个单位轴进行旋转。也可以把多个矩阵结合起来,比如先沿着x轴旋转再沿着y轴旋转。但是这会很快导致一个问题——万向节死锁(Gimbal Lock)。我们不会讨论它的细节,但是一个更好的解决方案是沿着任意轴比如(0.662, 0.2, 0.7222)(注意这是个单位向量)旋转,而不是使用一系列旋转矩阵的组合。这样一个(超级麻烦的)矩阵是存在的,见下面这个公式,(Rx,Ry,Rz)代表任意旋转轴:

讨论如何生成这样的矩阵已经超出了本节内容,这里只是告诉大家之后在处理旋转变换的效果时很容易遇到的一个坑,也就是万向节死锁,即使这样一个矩阵也不能完全解决万向节死锁问题(尽管会极大地避免)。避免万向节死锁的真正解决方案是使用四元数(Quaternion),它不仅安全,而且计算更加友好。有关四元数会在后面的教程中讨论。

实践

了解了一些矩阵和向量的基础之后,我们来实现一个简单的模型变换的Demo,以此来熟悉代码中实现变换的基本步骤以及注意事项,这里只贴出和之前章节有区别的代码。

着色器

首先修改我们的着色器,顶点着色器:

//设置的顶点坐标数据
attribute vec4 vPosition;
//设置的纹理坐标数据
attribute vec4 inputTextureCoordinate;                   
//输出到片元着色器的纹理坐标数据
varying vec2 textureCoordinate;
uniform mat4 transform;
void main() {
  //设置最终坐标
  gl_Position = transform * vPosition;
  textureCoordinate = inputTextureCoordinate.xy;
}";

片段着色器:

//设置float类型默认精度,顶点着色器默认highp,片元着色器需要用户声明
precision mediump float;
//从顶点着色器传入的纹理坐标数据
varying vec2 textureCoordinate;
//用户传入的纹理数据
uniform sampler2D inputImageTexture;
uniform sampler2D inputImageTexture2;
void main() {
  //该片元最终颜色值,80%的inputImageTexture,20%的inputImageTexture2
  gl_FragColor = mix(texture2D(inputImageTexture, textureCoordinate), 
                              texture2D(inputImageTexture2, textureCoordinate), 0.2);
}";

相比较上一章节我们在顶点着色器中加入了uniform mat4 transform变换矩阵(mat4为4x4矩阵,还有mat2、mat3等)用以与顶点坐标相乘来实现变换效果。同时修改片段着色器为两张纹理做混合,取第一张纹理颜色以80%的透明度与第二张纹理颜色的20%透明度混合为结果色。

设置矩阵

略去获取着色器中对应变量句柄的操作,我们默认顶点坐标是铺满屏幕,我们希望先对其x、y缩放0.5,然后旋转一定的角度,最后平移到某个位置上,具体代码设置如下:

//设置变换矩阵
Matrix.setIdentityM(mTransformMatrix, 0);
//代码上的设置顺序与实际想要的顺序需要相反
//在世界坐标系中,首先先以原点即屏幕中心为锚点缩放0.5,然后以屏幕中心为锚点旋转角度,最后平移
//如果旋转和平移反过来,那么就会先做平移,然后以屏幕中心为锚点进行旋转,效果不同
Matrix.translateM(mTransformMatrix, 0, 0.5f, -0.5f, 0.0f);
Matrix.rotateM(mTransformMatrix, 0, mRotation, 0.0f, 0.0f, 1.0f);
Matrix.scaleM(mTransformMatrix, 0, 0.5f, 0.5f, 1.0f);
//绑定变换矩阵
GLES20.glUniformMatrix4fv(mTransformMatrixId, 1, false, mTransformMatrix, 0);

在设置完矩阵后我们通过glUniformMatrix4fv函数把矩阵数据发送给着色器。第一个参数你现在应该很熟悉了,它是uniform的位置值。第二个参数告诉OpenGL我们将要发送多少个矩阵,这里是1。第三个参数询问我们我们是否希望对我们的矩阵进行置换(Transpose),也就是说交换我们矩阵的行和列。OpenGL开发者通常使用一种内部矩阵布局,叫做列主序(Column-major Ordering)布局。Android SDK中OpenGL包下的Matrix工具类,查看源码计算方式可知就是以列主序的方式计算的,所以并不需要置换矩阵,我们填false。最后一个参数是真正的矩阵数据,这里把我们计算后的float数组传入即可。

顺序

矩阵设置重要的是要注意设置的顺序,因为矩阵乘法不满足交换律,所以先后顺序会影响我们最终生成的效果。因为OpenGL是列优先机制,所以顶点向量可以看作是4*1的列矩阵,因为矩阵乘法的规则,所以顶点列向量需要放置在公式的最后一个位置上,而在OpenGL中,矩阵连乘的顺序是从右到左,故最后的变换最先出现,最先的变换反倒在最后出现,将我们的模型变换步骤拆分成一个个独立的步骤来看,最终的顶点坐标计算公式为:

假设按照我们想要的顺序来设置,那么结果会变成:

后者中与OriginalVector我们所设置的原始顶点坐标最近的反而是平移矩阵,其变换效果实际上是先平移,再旋转,缩放最后,得到的效果与我们想要的完全不同。

矩阵设置的顺序非常重要,不仅是在模型变换中,在后面我们模型视图投影三者变换叠加中也是一样。在模型变换中,顺序上也可以按照正着理解,在第二部分的坐标系统中,我们所设置的顶点坐标实际上是在局部坐标系也就是物体坐标系中,在应用了模型变换后就转至世界坐标系。

按照坐标系的理解上,倒着设置矩阵是以世界坐标系为基准,坐标系始终不动,物体相对坐标系进行变换,正着设置可以理解为是在物体坐标系中,每次变换坐标系都跟着移动,类似于Android中的画布变换操作,但是这两种理解方式要达到同样的效果,代码上是不相同的,相同的代码两种理解方式下会产生不同的效果。

但是本身模型变换就是将物体坐标系中的物体放置到世界坐标系中,这里建议全部按照在世界坐标系中变换(即倒着理解)的方式来理解,这样既符合后面MVP变换叠加顺序的理解,从数学角度也与矩阵相乘能够自洽,不至于混乱。

至此我们便完成了本章节的第一个Demo,详细效果与代码见Demo中的第三节模型变换,PC上的效果如下:

Part2

坐标系统

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

将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统(Coordinate System)。将物体的坐标变换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易。对我们来说比较重要的总共有5个不同的坐标系统:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

这就是一个顶点在最终被转化为片段之前需要经历的所有不同状态。接下来我们对每个坐标空间都会做一个简单的介绍。

概述

为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型(Model)观察(View)投影(Projection)三个矩阵。我们的顶点坐标起始于局部空间(Local Space),在这里它称为局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate)观察坐标(View Coordinate)裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:

  • 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
  • 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
  • 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。
  • 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
  • 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。

我们之所以将顶点变换到各个不同的空间的原因是有些操作在特定的坐标系统中才有意义且更方便。例如,当需要对物体进行修改的时候,在局部空间中来操作会更说得通;如果要对一个物体做出一个相对于其它物体位置的操作时,在世界坐标系中来做这个才更说得通,等等。如果我们愿意,我们也可以定义一个直接从局部空间变换到裁剪空间的变换矩阵,但那样会失去很多灵活性。

局部空间

局部空间是指物体所在的坐标空间,即对象最开始所在的地方。想象你在一个建模软件(比如说Blender)中创建了一个立方体。你创建的立方体的原点有可能位于(0, 0, 0),即便它有可能最后在程序中处于完全不同的位置。甚至有可能你创建的所有模型都以(0, 0, 0)为初始位置(译注:然而它们会最终出现在世界的不同位置)。所以,你的模型的所有顶点都是在局部空间中:它们相对于你的物体来说都是局部的。

世界空间

如果我们将我们所有的物体导入到程序当中,它们有可能会全挤在世界的原点(0, 0, 0)上,这并不是我们想要的结果。我们想为每一个物体定义一个位置,从而能在更大的世界当中放置它们。世界空间中的坐标正如其名:是指顶点相对于(游戏)世界的坐标。如果你希望将物体分散在世界上摆放(特别是非常真实的那样),这就是你希望物体变换到的空间。物体的坐标将会从局部变换到世界空间;该变换是由模型矩阵(Model Matrix)实现的。

模型矩阵是一种变换矩阵,它能通过对物体进行位移、缩放、旋转来将它置于它本应该在的位置或朝向。你可以将它想像为变换一个房子,你需要先将它缩小(它在局部空间中太大了),并将其位移至郊区的一个小镇,然后在y轴上往左旋转一点以搭配附近的房子。

观察空间

观察空间经常被人们称之OpenGL的摄像机(Camera)(所以有时也称为摄像机空间(Camera Space)视觉空间(Eye Space))。观察空间是将世界空间坐标转化为用户视野前方的坐标而产生的结果。因此观察空间就是从摄像机的视角所观察到的空间。而这通常是由一系列的位移和旋转的组合来完成,平移/旋转场景从而使得特定的对象被变换到摄像机的前方。这些组合在一起的变换通常存储在一个观察矩阵(View Matrix)里,它被用来将世界坐标变换到观察空间。在下一节中我们将深入讨论如何创建一个这样的观察矩阵来模拟一个摄像机。

裁剪空间

在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间(Clip Space)名字的由来。

因为将所有可见的坐标都指定在-1.0到1.0的范围内不是很直观,所以我们会指定自己的坐标集(Coordinate Set)并将它变换回标准化设备坐标系,就像OpenGL期望的那样。

为了将顶点坐标从观察变换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0)。所有在范围外的坐标不会被映射到在-1.0到1.0的范围之间,所以会被裁剪掉。在上面这个投影矩阵所指定的范围内,坐标(1250, 500, 750)将是不可见的,这是由于它的x坐标超出了范围,它被转化为一个大于1.0的标准化设备坐标,所以被裁剪掉了。

如果只是图元(Primitive),例如三角形,的一部分超出了裁剪体积(Clipping Volume),则OpenGL会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。

由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection),因为使用投影矩阵能将3D坐标投影(Project)很容易地映射到2D的标准化设备坐标系中。

一旦所有顶点被变换到裁剪空间,最终的操作——透视除法(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行

在这一阶段之后,最终的坐标将会被映射到屏幕空间中(使用glViewport中的设定),并被变换成片段。

将观察坐标变换为裁剪坐标的投影矩阵可以为两种不同的形式,每种形式都定义了不同的平截头体。我们可以选择创建一个正交投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)

正交投影

正交投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。创建一个正交投影矩阵需要指定可见平截头体的宽、高和长度。在使用正射投影矩阵变换至裁剪空间之后处于这个平截头体内的所有坐标将不会被裁剪掉。它的平截头体看起来像一个容器:

上面的平截头体定义了可见的坐标,它由由近(Near)平面和远(Far)平面所指定。任何出现在近平面之前或远平面之后的坐标都会被裁剪掉。正射平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标,因为每个向量的w分量都没有进行改变;如果w分量等于1.0,透视除法则不会改变这个坐标。

正射投影矩阵直接将坐标映射到2D平面中,即你的屏幕,但实际上一个直接的投影矩阵会产生不真实的结果,因为这个投影没有将透视(Perspective)考虑进去。所以我们需要透视投影矩阵来解决这个问题。

透视投影

如果你曾经体验过实际生活给你带来的景象,你就会注意到离你越远的东西看起来更小。这个奇怪的效果称之为透视(Perspective)。透视的效果在我们看一条无限长的高速公路或铁路时尤其明显,正如下面图片显示的那样:

正如你看到的那样,由于透视,这两条线在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。这个投影矩阵将给定的平截头体范围映射到裁剪空间,除此之外还修改了每个顶点坐标的w值,从而使得离观察者越远的顶点坐标w分量越大。被变换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的坐标都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内,作为顶点着色器最后的输出,因此,一旦坐标在裁剪空间内之后,透视除法就会被应用到裁剪空间坐标上:

顶点坐标的每个分量都会除以它的w分量,距离观察者越远顶点坐标就会越小。这是也是w分量非常重要的另一个原因,它能够帮助我们进行透视投影。最后的结果坐标就是处于标准化设备空间中的。

同样,Matrix.perspectiveM(float[] m, int offset, float fovy, float aspect, float zNear, float zFar)所做的其实就是创建了一个定义了可视空间的大平截头体,任何在这个平截头体以外的东西最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被看作一个不均匀形状的箱子,在这个箱子内部的每个坐标都会被映射到裁剪空间上的一个点。下面是一张透视平截头体的图片:

当你把透视矩阵的 near 值设置太大时(如10.0f),OpenGL会将靠近摄像机的坐标(在0.0f和10.0f之间)都裁剪掉,这会导致一个你在游戏中很熟悉的视觉效果:在太过靠近一个物体的时候你的视线会直接穿过去。

当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视除法(它仍然会进行透视除法,只是w分量没有被改变(它保持为1),因此没有起作用)。因为正射投影没有使用透视,远处的物体不会显得更小,所以产生奇怪的视觉效果。由于这个原因,正射投影主要用于二维渲染以及一些建筑或工程的程序,在这些场景中我们更希望顶点不会被透视所干扰。某些如 Blender 等进行三维建模的软件有时在建模时也会使用正射投影,因为它在各个维度下都更准确地描绘了每个物体。下面你能够看到在Blender里面使用两种投影方式的对比:

你可以看到,使用透视投影的话,远处的顶点看起来比较小,而在正射投影中每个顶点距离观察者的距离都是一样的。

组合

我们为上述的每一个步骤都创建了一个变换矩阵:模型矩阵、观察矩阵和投影矩阵。一个顶点坐标将会根据以下过程被变换到裁剪坐标:

注意矩阵运算的顺序是相反的(记住我们需要从右往左阅读矩阵的乘法)。最后的顶点应该被赋值到顶点着色器中的gl_Position,OpenGL将会自动进行透视除法和裁剪。

然后呢?

顶点着色器的输出要求所有的顶点都在裁剪空间内,这正是我们刚才使用变换矩阵所做的。OpenGL然后对裁剪坐标执行透视除法从而将它们变换到标准化设备坐标。OpenGL会使用glViewPort内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中是一个800x600的屏幕)。这个过程称为视口变换。

进入3D

既然我们知道了如何将3D坐标变换为2D坐标,我们可以开始使用真正的3D物体,而不是枯燥的2D平面了。

在开始进行3D绘图时,我们首先创建一个模型矩阵。这个模型矩阵包含了位移、缩放与旋转操作,它们会被应用到所有物体的顶点上,以变换它们到全局的世界空间。让我们变换一下我们的平面,将其绕着x轴旋一定角度,使它看起来像放在地上一样。修改模型矩阵为:

//设置变换矩阵
Matrix.setIdentityM(mTransformMatrix, 0);
//代码上的设置顺序与实际想要的顺序需要相反
Matrix.translateM(mTransformMatrix, 0, 0.5f, -0.5f, 0.0f);
Matrix.rotateM(mTransformMatrix, 0, -75.0f, 1.0f, 0.0f, 0.0f);
Matrix.scaleM(mTransformMatrix, 0, 0.5f, 0.5f, 1.0f);

通过将顶点坐标乘以这个模型矩阵,我们将该顶点坐标变换到世界坐标。我们的平面看起来就是在地板上,代表全局世界里的平面。

接下来我们需要创建一个观察矩阵。我们想要在场景里面稍微往后移动,以使得物体变成可见的(当在世界空间时,我们位于原点(0,0,0))。要想在场景里面移动,先仔细想一想下面这个句子:

  • 将摄像机向后移动,和将整个场景向前移动是一样的。

这正是观察矩阵所做的,我们以相反于摄像机移动的方向移动整个场景。因为我们想要往后移动,并且OpenGL是一个右手坐标系(Right-handed System),所以我们需要沿着z轴的正方向移动。我们会通过将场景沿着z轴负方向平移来实现。它会给我们一种我们在往后移动的感觉。

右手坐标系(Right-handed System)

按照惯例,OpenGL是一个右手坐标系。简单来说,就是正x轴在你的右手边,正y轴朝上,而正z轴是朝向后方的。想象你的屏幕处于三个轴的中心,则正z轴穿过你的屏幕朝向你。坐标系画起来如下:

为了理解为什么被称为右手坐标系,按如下的步骤做:

  • 沿着正y轴方向伸出你的右臂,手指着上方。
  • 大拇指指向右方。
  • 食指指向上方。
  • 中指向下弯曲90度。

如果你的动作正确,那么你的大拇指指向正x轴方向,食指指向正y轴方向,中指指向正z轴方向。如果你用左臂来做这些动作,你会发现z轴的方向是相反的。这个叫做左手坐标系,它被DirectX广泛地使用。注意在标准化设备坐标系中OpenGL实际上使用的是左手坐标系(投影矩阵交换了左右手)。

在下一个摄像机系统的教程中我们将会详细讨论如何在场景中移动。就目前来说,观察矩阵是这样的:

//设置视图矩阵
Matrix.setIdentityM(mViewMatrix, 0);
Matrix.translateM(mViewMatrix, 0, 0, 0, -3.0f);

最后我们需要做的是定义一个投影矩阵。我们希望在场景中使用透视投影,所以像这样声明一个投影矩阵:

//设置投影矩阵
Matrix.setIdentityM(mProjectionMatrix, 0);
Matrix.perspectiveM(mProjectionMatrix, 0, 45.0f, (float) mVPWidth / (float) mVPHeight,
        0.1f, 100.0f);

既然我们已经创建了变换矩阵,我们应该将它们传入着色器。首先,让我们在顶点着色器中声明一个uniform变换矩阵然后将它乘以顶点坐标:

//设置的顶点坐标数据
attribute vec4 vPosition;
//设置的纹理坐标数据
attribute vec4 inputTextureCoordinate;
//输出到片元着色器的纹理坐标数据
varying vec2 textureCoordinate;
//MVP矩阵
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
  //设置最终坐标
  gl_Position = projection * view * model * vPosition;
  textureCoordinate = vec2(inputTextureCoordinate.x, 1.0 - inputTextureCoordinate.y);
}

我们还应该将矩阵传入着色器(这通常在每次的渲染迭代中进行,因为变换矩阵会经常变动):


int modelMatrixId = GLES20.glGetUniformLocation(mProgramId, "model");
GLES20.glUniformMatrix4fv(modelMatrixId, 1, false, mModelMatrix, 0);
... // 观察矩阵和投影矩阵与之类似

我们的顶点坐标已经使用模型、观察和投影矩阵进行变换了,最终的物体应该会:

  • 稍微向后倾斜至地板方向。
  • 离我们有一些距离。
  • 有透视效果(顶点越远,变得越小)。

让我们检查一下结果是否满足这些要求:

它看起来就像是一个3D的平面,静止在一个虚构的地板上。

更进一步

到目前为止,我们一直都在使用一个2D平面,而且甚至是在3D空间里!所以,让我们大胆地拓展我们的2D平面为一个3D立方体。要想渲染一个立方体,我们一共需要36个顶点(6个面 x 每个面有2个三角形组成 x 每个三角形有3个顶点),这36个顶点的位置如下:


private static final float VERTICES[] = {
        -0.5f, -0.5f, -0.5f,
        0.5f, -0.5f, -0.5f,
        0.5f, 0.5f, -0.5f,
        0.5f, 0.5f, -0.5f,
        -0.5f, 0.5f, -0.5f,
        -0.5f, -0.5f, -0.5f,

        -0.5f, -0.5f, 0.5f,
        0.5f, -0.5f, 0.5f,
        0.5f, 0.5f, 0.5f,
        0.5f, 0.5f, 0.5f,
        -0.5f, 0.5f, 0.5f,
        -0.5f, -0.5f, 0.5f,

        -0.5f, 0.5f, 0.5f,
        -0.5f, 0.5f, -0.5f,
        -0.5f, -0.5f, -0.5f,
        -0.5f, -0.5f, -0.5f,
        -0.5f, -0.5f, 0.5f,
        -0.5f, 0.5f, 0.5f,

        0.5f, 0.5f, 0.5f,
        0.5f, 0.5f, -0.5f,
        0.5f, -0.5f, -0.5f,
        0.5f, -0.5f, -0.5f,
        0.5f, -0.5f, 0.5f,
        0.5f, 0.5f, 0.5f,

        -0.5f, -0.5f, -0.5f,
        0.5f, -0.5f, -0.5f,
        0.5f, -0.5f, 0.5f,
        0.5f, -0.5f, 0.5f,
        -0.5f, -0.5f, 0.5f,
        -0.5f, -0.5f, -0.5f,

        -0.5f, 0.5f, -0.5f,
        0.5f, 0.5f, -0.5f,
        0.5f, 0.5f, 0.5f,
        0.5f, 0.5f, 0.5f,
        -0.5f, 0.5f, 0.5f,
        -0.5f, 0.5f, -0.5f
};

纹理坐标如下:


private static final float TEXTURE[] = {
        0.0f, 0.0f,
        1.0f, 0.0f,
        1.0f, 1.0f,
        1.0f, 1.0f,
        0.0f, 1.0f,
        0.0f, 0.0f,

        0.0f, 0.0f,
        1.0f, 0.0f,
        1.0f, 1.0f,
        1.0f, 1.0f,
        0.0f, 1.0f,
        0.0f, 0.0f,

        1.0f, 0.0f,
        1.0f, 1.0f,
        0.0f, 1.0f,
        0.0f, 1.0f,
        0.0f, 0.0f,
        1.0f, 0.0f,

        1.0f, 0.0f,
        1.0f, 1.0f,
        0.0f, 1.0f,
        0.0f, 1.0f,
        0.0f, 0.0f,
        1.0f, 0.0f,

        0.0f, 1.0f,
        1.0f, 1.0f,
        1.0f, 0.0f,
        1.0f, 0.0f,
        0.0f, 0.0f,
        0.0f, 1.0f,

        0.0f, 1.0f,
        1.0f, 1.0f,
        1.0f, 0.0f,
        1.0f, 0.0f,
        0.0f, 0.0f,
        0.0f, 1.0f
};

然后我们使用glDrawArrays来绘制立方体,但这一次总共有36个顶点,同时我们该用GL_TRIANGLES的方式来绘制:

GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 36);

如果一切顺利的话你应该能得到下面这样的效果:

这的确有点像是一个立方体,但又有种说不出的奇怪。立方体的某些本应被遮挡住的面被绘制在了这个立方体其他面之上。之所以这样是因为OpenGL是一个三角形一个三角形地来绘制你的立方体的,所以即便之前那里有东西它也会覆盖之前的像素。因为这个原因,有些三角形会被绘制在其它三角形上面,虽然它们本不应该是被覆盖的。

幸运的是,OpenGL存储深度信息在一个叫做Z缓冲(Z-buffer)的缓冲中,它允许OpenGL决定何时覆盖一个像素而何时不覆盖。通过使用Z缓冲,我们可以配置OpenGL来进行深度测试。

Z缓冲

OpenGL存储它的所有深度信息于一个Z缓冲(Z-buffer)中,也被称为深度缓冲(Depth Buffer)。深度值存储在每个片段里面(作为片段的z值),当片段想要输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较,如果当前的片段在其它片段之后,它将会被丢弃,否则将会覆盖。这个过程称为深度测试(Depth Testing),它是由OpenGL自动完成的。

然而,如果我们想要确定OpenGL真的执行了深度测试,首先我们要告诉OpenGL我们想要启用深度测试;它默认是关闭的。我们可以通过glEnable函数来开启深度测试。glEnable和glDisable函数允许我们启用或禁用某个OpenGL功能。这个功能会一直保持启用/禁用状态,直到另一个调用来禁用/启用它。现在我们想启用深度测试,需要开启GL_DEPTH_TEST:

GLES20.glEnable(GLES20.GL_DEPTH_TEST);

因为我们使用了深度测试,我们也想要在每次渲染迭代之前清除深度缓冲(否则前一帧的深度信息仍然保存在缓冲中)。就像清除颜色缓冲一样,我们可以通过在glClear函数中指定DEPTH_BUFFER_BIT位来清除深度缓冲:

GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);

我们来重新运行下程序看看OpenGL是否执行了深度测试:

就是这样!一个开启了深度测试,各个面都是纹理,并且还在旋转的立方体!

更多的立方体

现在我们想在屏幕上显示10个立方体。每个立方体看起来都是一样的,区别在于它们在世界的位置及旋转角度不同。立方体的图形布局已经定义好了,所以当渲染更多物体的时候我们不需要改变我们的缓冲数组和属性数组,我们唯一需要做的只是改变每个对象的模型矩阵来将立方体变换到世界坐标系中。

首先,让我们为每个立方体定义一个位移向量来指定它在世界空间的位置。我们将在一个二维数组中定义10个立方体位置:

// 通过模型变换将物体放置到世界坐标系中的不同位置
private final static float[][] CUBE_POSITIONS = {
        new float[]{0.0f, 0.0f, 0.0f},
        new float[]{2.0f, 5.0f, -15.0f},
        new float[]{-1.5f, -2.2f, -2.5f},
        new float[]{-3.8f, -2.0f, -12.3f},
        new float[]{2.4f, -0.4f, -3.5f},
        new float[]{-1.7f, 3.0f, -7.5f},
        new float[]{1.3f, -2.0f, -2.5f},
        new float[]{1.5f, 2.0f, -2.5f},
        new float[]{1.5f, 0.2f, -1.5f},
        new float[]{-1.3f, 1.0f, -1.5f}
};

然后让我们在旋转的计算上多花一点心思,让这些箱子转的不一样一些:

final float[] rotation = new float[10];
final long startTime = System.currentTimeMillis();
new Thread(new Runnable() {
    @Override
    public void run() {
        while (true) {
            for (int i = 0; i < rotation.length; i++) {
                float result = 20 * (i + 1);
                result = 
                        (float) ((System.currentTimeMillis() - startTime) / 50f
                                * Math.toRadians(result));
                rotation[i] = result;
            }
            render.setRotation(rotation);
            glSurfaceView.requestRender();
        }
    }
}).start();

现在,在游戏循环中,我们调用glDrawArrays 10次,但这次在我们渲染之前每次传入一个不同的模型矩阵到顶点着色器中。我们将会在游戏循环中创建一个小的循环用不同的模型矩阵渲染我们的物体10次:

for (int i = 0; i < CUBE_POSITIONS.length; i++) {
    //设置模型变换矩阵
    Matrix.setIdentityM(mModelMatrix, 0);
    //代码上的设置顺序与实际想要的顺序需要相反
    //在世界坐标系中,首先先以原点即屏幕中心为锚点缩放0.5,然后以屏幕中心为锚点旋转角度,最后平移
    //如果旋转和平移反过来,那么就会先做平移,然后以屏幕中心为锚点进行旋转,效果不同
    Matrix.translateM(mModelMatrix, 0, CUBE_POSITIONS[i][0], CUBE_POSITIONS[i][1],
            CUBE_POSITIONS[i][2]);
    Matrix.rotateM(mModelMatrix, 0, mRotation[i], 1.0f, 0.3f, 0.5f);
    //绑定变换矩阵
    GLES20.glUniformMatrix4fv(mModelMatrixId, 1, false, mModelMatrix, 0);

    //设置视图矩阵
    Matrix.setIdentityM(mViewMatrix, 0);
    Matrix.translateM(mViewMatrix, 0, 0, 0, -3.0f);
    GLES20.glUniformMatrix4fv(mViewMatrixId, 1, false, mViewMatrix, 0);

    //设置投影矩阵
    Matrix.setIdentityM(mProjectionMatrix, 0);
    Matrix.perspectiveM(mProjectionMatrix, 0, 45.0f, (float) mVPWidth / (float) mVPHeight,
            0.1f, 100.0f);
    GLES20.glUniformMatrix4fv(mProjectionMatrixId, 1, false, mProjectionMatrix, 0);

    //使用GL_TRIANGLES方式绘制纹理
    GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 36);
}

这段代码将会在每次新立方体绘制出来的时候更新模型矩阵,如此总共重复10次。然后运行你的Demo,这时候应该就能看到一个拥有10个正在奇葩地旋转着的立方体的世界。

总结

至此我们简单了解了一些向量和矩阵的相关内容,以及介绍了OpenGL中的多个坐标空间,通过这些基础我们逐步实现了多个立方体经过模型变换、视图变换、投影变换后的效果,本节重点在于模型变换,其他两种变换只是辅助使用,在下一章节中我们将继续讲述视图变换的内容,也就是摄像机系统。

参考

Transformations

Coordinate Systems

矩阵-DirectX与OpenGL的不同


版权声明

![Creative Commons BY-NC-ND 4.0 International License](/images/cc.png)

Lam’s Blog by Binghe Lin is licensed under a Creative Commons BY-NC-ND 4.0 International License.
林炳河创作并维护的Lam’s Blog采用创作共用保留署名-非商业-禁止演绎4.0国际许可证

本文首发于Lam’s Blog - Knowledeg as Action,版权所有,侵权必究。

本文永久链接:http://linbinghe.com/2018/4fc970d4.html