Android OpenGL 进入三维

想象一下,你正置身于一个桌子面前,你站在桌子的一端,目光穿过桌面,从你的角度来看,桌面呈现出一种倾斜的视觉效果,因为你是从侧面以一定的角度向下观察,而不是像鸟儿一样从空中俯瞰。

OpenGL在渲染二维图形方面表现出色,但当我们引入第三维度时,整个场景就会变得更加生动和立体。在本篇中,我们将探索如何利用OpenGL进入三维世界,体验从桌子的一侧向对端望去的视角。

本篇的学习计划如下:

  • 首先,我们将掌握OpenGL中的透视除法概念,了解如何利用W分量在二维屏幕上创造出三维的视觉效果。
  • 理解W分量后,我们将学习设置透视投影,以便能够看到桌子的三维形态。

我们将在上一篇的项目上继续,开始我们的三维之旅。

三维的艺术

在艺术史上,艺术家们已经掌握了一种将二维平面转化为三维空间幻象的技艺,这种技艺被称为线性投影。通过在假想的消失点汇聚平行线,他们创造出了一种深度和立体的视觉效果。

想象一下站在铁轨旁,当你望向远方,铁轨似乎逐渐靠近,最终在地平线上汇聚于一点。这种视觉上的汇聚现象,正是线性投影效果的生动体现。随着距离的增加,铁轨上的枕木看起来越来越小,它们的尺寸与观察者之间的距离成反比。

在接下来的内容中,我们将深入探讨OpenGL是如何实现这种三维投影的。通过观察右图中的枕木宽度随距离递减的变化,我们可以更好地理解这种技术是如何在计算机图形学中被应用的。让我们继续学习OpenGL看如何将这种视觉效果转化为实现。

着色器到屏幕坐标转换

我们已经对归一化设备坐标有了基本的了解,并且知道为了在屏幕上正确显示顶点,其x、y和z坐标值必须位于[-1, 1]的范围内。接下来,让我们通过下面的流程图来回顾一下,顶点着色器中的原始gl_Position坐标是如何经过一系列变换,最终映射到屏幕坐标的。

这个转换过程包括两个主要的变换步骤,并且涉及到三个不同的坐标空间。

1.剪裁空间

当顶点着色器输出顶点位置到gl_Position时,OpenGL要求这个位置必须位于剪裁空间内。在剪裁空间中,顶点的X、Y和Z坐标值必须位于-W和W之间。例如,如果顶点的W分量是1,那么X、Y和Z坐标的值必须在-1到1的范围内。超出这个范围的顶点将不会被渲染到屏幕上。透视除法的逻辑依赖于这个W分量,因为它通过除以W分量来调整坐标,确保顶点在屏幕上的透视效果得以正确表现。

一旦我们理解了透视除法,它依赖于顶点的W分量的原因就会变得清晰。

2.透视除法

在OpenGL中,顶点位置在成为归一化设备坐标(NDC)之前,会经过一个称为透视除法的步骤。透视除法的作用是将顶点的x、y和z分量除以其W分量,这样处理后的坐标,无论渲染区域的实际大小和形状如何,其x、y和z分量的值都将位于[-1, 1]的范围内。

透视除法是创建三维视觉效果的关键。通过将gl_Position的x、y和z分量除以W分量,远处的物体在视觉上被拉近,仿佛它们都汇聚于一个消失点。这种技术模仿了艺术家们几个世纪以来使用的视觉欺骗技巧。

例如,考虑两个顶点,它们在三维空间中具有相同的x、y和z坐标,但W分量不同。假设这两个顶点的坐标分别是(1, 1, 1, 1)和(1, 1, 1, 2)。在进行透视除法后,这些坐标的前三个分量分别除以它们的W分量,结果变为(1, 1, 1)和(0.5, 0.5, 0.5)。归一化设备坐标最终变为(1, 1, 1)和(0.5, 0.5, 0.5),其中W值较大的顶点在视觉上更接近屏幕中心。

在OpenGL中,这种三维效果是线性的,并且沿着直线完成,这与现实生活中的复杂视觉效果(如鱼眼镜头产生的效果)不同,这种线性投影只是合理的近似。

同质化坐标

因为透视除法,剪裁空间中的坐标经常也被称为同质化坐标(homogenous coordinates)。这种坐标系统是由August Ferdinand Möbius在1827年引入的。同质化坐标之所以被称为“同质化”,是因为不同的坐标值可以通过相同的比例因子转换到NDC中的相同点。

例如,考虑以下一组点,它们在剪裁空间中具有不同的坐标值:
(1, 1, 1, 1)
(2, 2, 2, 2)
(3, 3, 3, 3)
(4, 4, 4, 4)
(5, 5, 5, 5)

尽管这些点在剪裁空间中的坐标各不相同,但通过透视除法,即将每个坐标值除以其W分量,它们都可以被转换到NDC中的同一个点(1, 1, 1)。

除以w的优势

为什么在OpenGL中,我们不仅仅简单地除以z坐标,而是使用一个额外的W分量来进行透视除法。

首先,让我们考虑一个简单的例子。假设我们有两个点,它们的坐标分别是(1,1,1)和(1,1,2)。如果我们按照直观的想法,仅仅将x和y坐标除以z坐标,我们会得到两个归一化的二维坐标(1,1)和(0.5,0.5)。这个方法看似有效,但OpenGL选择了一个更灵活的方法。

引入W分量,也就是同质化坐标,我们不仅能够实现归一化,还能在不同的投影模式之间灵活切换。这样做的好处是,我们可以将投影的计算(例如,从透视投影切换到正交投影)与实际的z坐标(代表深度)分离开来。这种分离让我们在处理复杂的图形变换时更加方便。

此外,保留z分量作为深度缓冲区(depth buffer)的一部分,对于我们处理遮挡和深度测试等图形渲染问题也是至关重要的。这将在我们后续的篇章中详细讨论。

使用W分量进行透视除法,虽然在某些情况下看起来有些复杂,但它为我们提供了更大的灵活性和更强的控制力,使我们能够更有效地处理各种图形渲染任务。

3.视口变换

在OpenGL中,我们处理完归一化设备坐标后,接下来的一个重要步骤是将这些坐标映射到屏幕上的特定区域,这个区域被称为视口(viewport)。映射后的坐标就称为窗口坐标(window coordinates)。这个过程相对直接,我们通常不需要过多干预。

在OpenGL的渲染流程中,归一化设备坐标的x和y分量会被映射到视口的范围内。这个映射过程通过glViewport函数来指定。一旦设置好视口,OpenGL就会自动将从(-1, -1, -1)到(1, 1, 1)范围内的坐标映射到视口上。如果坐标超出这个范围,它们将被裁剪掉,不会出现在最终的渲染结果中。

重要的是要理解,无论视口的实际尺寸如何,这个映射的范围始终保持一致。这意味着,无论屏幕分辨率或窗口大小如何变化,OpenGL都会按照这个固定的范围来进行坐标映射。

在实际的代码实现中,我们通常会在onSurfaceChanged回调函数中设置视口,确保OpenGL知道如何将坐标映射到当前的显示区域。这样,无论在何种显示设备上,OpenGL都能够正确地渲染图形。

通过w分量创建三维图形

如果我们实际看一下W分量的使用,会更容易理解它的影响,因此,让我们把它加入矩形的顶点数据中,看看会发生什么。因为我们现在要指定一个位置的x、y、z和w分量,作为开始,让我们把POSITION_COMPONENT_COUNT更新如下:

private val POSITION_COMPONENT_COUNT = 4

对于所使用的一切,我们都必须给OpenGL传递正确的分量计数;否则就可能出现花屏、什么都不显示,或者应用程序崩溃。下一步是更新所有的顶点数据:

private var rectangleVertices  = floatArrayOf(
    0f  ,  0f  ,   0f , 1.5f ,  1f  , 1f ,   1f ,
    -0.5f , -0.8f, 0f , 1f , 0.3f ,  0.3f , 0.3f ,
    0.5f , -0.8f,  0f , 1f , 0.3f  , 0.3f , 0.3f ,
    0.5f ,  0.8f,  0f , 2f , 0.3f  ,  0.3f , 0.3f ,
    -0.5f ,  0.8f, 0f , 2f , 0.3f , 0.3f , 0.3f ,
    -0.5f , -0.8f, 0f , 1f , 0.3f , 0.3f , 0.3f ,
)

还是在我们之前的矩形图形的基础上进行修改,我们为顶点数据添加了z和w分量,更新了所有的顶点,为接近屏幕底部的顶点设置了w值为1,而接近屏幕顶部的顶点则设置了W值为2。这样的设置使得桌子的顶部在视觉上比底部更小,给人一种远近感。

我们把所有顶点的z分量都设置为0,因为在当前的正交投影中,我们并不需要z分量的值来创建立体效果。OpenGL会利用我们指定的w值自动进行透视除法。当我们继续运行项目时,会发现场景看起来与之前有所不同,更具有三维感。

然而,如果我们想要让物体更加动态,比如改变矩形的角度或者进行缩放和旋转,我们就需要用到矩阵来动态生成W的值,而不是硬编码。因此,我们将撤销之前所做的更改,恢复到原来的代码状态。接下来,我们将学习如何使用透视投影矩阵来自动生成w的值,以实现更复杂的变换效果。

透视投影

在我们深入探讨透视投影背后的矩阵数学之前,先从视觉层面上进行讨论。在前面篇节中,我们使用了正交投影矩阵来调整屏幕的宽高比,使其适应归一化设备坐标的显示区域。

正交投影可以被形象地想象成一个立方体,它包围了整个场景,代表了OpenGL在视口上渲染的内容,也就是我们所能看到的区域。在下图中“被投影的场景”从另一个视角展示了相同的场景,帮助我们更好地理解正交投影是如何工作的。通过这种方式,我们可以更直观地看到场景是如何被投影并显示在屏幕上的。

视椎体

当我们应用了投影矩阵后,场景中的平行线会在屏幕上的一个点汇聚,这个点被称为消失点。随着物体距离的增加,它们在视觉上会逐渐变小。如果我们不使用立方体来表示,可以看到的空间区域,如下视图所示,呈现出一个特定的形状。

这个形状被称为视椎体(frustum)。视椎体是由透视投影矩阵和透视除法共同作用形成的观看空间。简单来说,视椎体可以想象为一个金字塔形状,其远端比近端宽,形成一个被截断的金字塔形状。视椎体两端的大小差异越大,观察的范围就越宽广,我们能够看到的场景也就越多。这种视觉效果是透视投影的一个重要特性,它帮助我们在二维屏幕上创造出深度感和三维空间的错觉。

视椎体是透视投影中的一个关键概念,它具有一个特定的点,称为焦点(focal point)。这个焦点可以通过沿着从视椎体较大的一端向较小端延伸的直线来确定,这些直线穿过较小端并继续向前延伸,直到它们在一点上汇聚。当我们通过透视投影观察一个场景时,就好像我们的视线是从这个焦点发出的。

焦点与视椎体较小端之间的距离定义为焦距(focal length)。焦距是一个重要的参数,因为它影响着视椎体两端的大小比例以及整个视野的范围。焦距越长,视椎体的两端差异越大,提供的视野也就越宽广。

在下面的图像中,我们展示了从焦点内部观察到的场景,这代表了我们通过透视投影所看到的视角。这种视角的设置帮助我们更好地理解透视投影是如何工作的,以及它是如何在三维空间中创建深度感的。

视椎体的焦点还具有一个特别有趣的特性:从这个焦点观察,视椎体的两端在屏幕上占据的空间大小是相同的。尽管视椎体的远端实际上比近端要大,但由于它距离更远,所以在我们的视野中,它看起来与近端占据的空间大小相似。

这个现象与我们观察日食时的原理相似:尽管月亮的体积远小于太阳,但由于它距离地球更近,所以在天空中看起来足够大,能够遮挡住太阳。这种视觉效果是由观察位置的优势所决定的。

定义透视投影

透视投影矩阵和透视除法共同作用,以创造出三维空间的视觉效果。投影矩阵本身并不执行透视除法,而透视除法则需要投影矩阵提供正确的w分量才能发挥作用。

当物体向屏幕中心移动,即离观察者越来越远时,它的大小会逐渐减小。投影矩阵的关键在于为w分量生成正确的值,这样在OpenGL执行透视除法时,远处的物体就会在视觉上比近处的物体显得更小。实现这一点的一种方法是使用物体的z分量,即物体与焦点之间的距离,并将这个距离映射到w分量。物体与焦点的距离越远,w值就越大,经过透视除法后,物体在屏幕上的投影也就越小。

这里就不深入讨论背后的数学原理,但如果对这些细节感兴趣,可以自行了解,百度很多。

对宽高比和视野进行调整

让我们看一个更加通用的投影矩阵,它允许我们调整视野以及屏幕的宽高比。

变量描述
a如果我们想象一个相机拍摄的场景,这个变量就代表那个相机的焦距。焦距是由1/tan(视野/2)(tan表示正切函数)计算得到的。这个视野必须小于180度。比如,一个90度的视野,它的焦距会被设置为1/tan(90°/2),也就是1/1或者1。
aspect屏幕的宽高比,等于宽度除以高度。
f到远处平面的距离,必须是正值且大于到近处平面的距离。
n到近处平面的距离,必须是正值。如果设为1,则近处平面位于 ( z ) 值为 -1 处。

随着视野变小,焦距变长,可以映射到归一化坐标中[-1,1]范围内的x和y值的范围就越小。这会产生使视椎体变窄的效果。在下图中,左边的视椎体有90度的视野,而右边的视椎体只有45度的视野。

你可以看到,对于45度的视椎体,它的焦点与近端之间的焦距有点长。
看看下面的图像,它们是在相同的视椎体内分别从它们的焦点处所看到的图像。

较窄的视野通常很少有扭曲的问题。反过来说,随着视野变宽,最终的图像的边缘看起来会扭曲得更加严重。在现实生活中,较宽的视野会让一切看上去都是弯曲的,就像在照相机上使用鱼眼镜头观察到的效果。因为OpenGL使用的是沿着直线的线性投影,最终的图像反而会被拉伸。

在代码中创建投影矩阵

我们现在准备好在代码中添加透视投影了。Android的Matrix类为它准备了两个方法frustumM()和perspectiveM()。不幸的是,frustumM()有个缺陷,它会影响某些类型的投影,而perspectiveM()只是从Android的ICS(IceCreamSandwich,冰淇淋三明治)版本开始才被引入,在早期的Android版本里并没有这个方法。我们就直接忽略掉Android的ICS以前的版本,这部分的安卓设备在用的应该不多了吧。直接用perspectiveM()函数

在Renderer的onSurfaceChanged()去掉所有的代码,只保留glViewPort()调用。加入如下代码:

 Matrix.perspectiveM(projectionMatrix,0,45f,width.toFloat()/height.toFloat(),1f,10f)

这会用45度的视野创建一个透视投影。这个视椎体从z值为-1的位置开始,在z值为-10的位置结束。
继续运行这个程序,你可能注意到我们的矩形消失了!因为我们没有给矩形指定z的位置,默认情况下它处于z为0的位置。因为这个视椎体是从z值为-1的位置开始的,除非把它移到那个距离内,否则我们无法看到矩形。
不要硬编码z的值,在使用投影矩阵进行投影之前,让我们使用一个平移矩阵把桌子移出来。依照惯例,我们把这个矩阵称为模型矩阵(modelmatrix)。

利用模型矩阵移动物体

在Renderer的顶部添加如下代码:

  private var modelMatrix = FloatArray(16)

接着继续在onSurfaceChanged()中继续添加如下代码:

  Matrix.setIdentityM(modelMatrix,0)
  Matrix.translateM(modelMatrix,0,0f,0f,-2f)

第一行代码先把模型矩阵设为单位矩阵,再沿着z轴平移-2。当我们把矩形的坐标与这个矩阵相乘的时候,那些坐标最终会沿着z轴负方向移动2个单位。

相乘一次还是两次

矩阵与矩阵乘法的原理与矩阵与向量的乘法很相似。
举个例子,假定有两个通用的矩阵,如下所示:

为了得到其结果的第一个元素,我们把第一个矩阵的第一行与第二个矩阵的第一列相乘,并加在一起,得到的结果就是:

然后计算其结果的第二个元素,我们把第一个矩阵的第二行与第二个矩阵的第一列相来,并把它们相加,得到的结果就是:

以此类推,继续得出其结果矩阵的每个后续元素。

乘法顺序

既然我们知道如何把两个矩阵相乘了,我们需要小心,要确保它们按正确的顺序相乘。相乘时,我们既可以把投影矩阵放在左边、模型矩阵放在右边;也可以把模型矩阵放在左边、投影矩阵放在右边。

与常规乘法不同,这个顺序很重要!如果我们把顺序弄错了,物体可能看起来很怪异,甚至我们可能什么都看不到!下面就是两个矩阵按其中一个特定顺序相乘的例子:

下面是同样两个矩阵按相反顺序相乘的结果:

顺序不同,结果也不同。

选择适当的顺序

为了弄清楚我们应该使用那种顺序,让我们看一下只使用投影矩阵的数学运算:

vertexclip = ProjectionMatrix * vertexeye

vertexeye代表场景中的顶点在与投影矩阵相乘之前的位置。我们一旦加入模型矩阵来移动那个桌子,这个数学运算看起来就像这样:

vertexeye = ModelMatrix * vertexmodel
vertexclip = ProjectionMatrix * vertexeye

verteXmodel代表顶点在被模型矩阵放进场景中之前的位置。把这两个表达式合在一起,最后得到如下公式:

vertexclip = ProjectionMatrix * ModelMatrix * vertexmodel

为了用一个矩阵替换这两个矩阵,我们就不得不把投影矩阵乘以模型矩阵,就是把投影矩阵放在左边,把模型矩阵放在右边。

更新代码

让我继续在onSurfaceChanged()中添加代码:

    var temp = FloatArray(16)
    Matrix.multiplyMM(temp,0,projectionMatrix,0,modelMatrix,0)
    System.arraycopy(temp,0,projectionMatrix,0,temp.size)

不论什么时候把两个矩阵相乘,都需要一个临时变量来存储其结果。如果尝试直接写入这个结果,这个结果将是未定义的!

我们首先创建一个临时的浮点数组用来存储其临时结果;然后调用multiplyMM()把投影矩阵和模型矩阵相乘,其结果存进这个临时数组。下一步,我们调用System.arraycopy()把结果存回projectionMatrix,它现在包含模型矩阵与投影矩阵的组合效应。

如果我们现在运行这个应用程序,结果应该如下图所示。把那个空气矩形推动到那个距离内,这个距离足够把它放在我们的视椎体内,但是矩形还是直立的。

增加旋转

既然我们已经有了一个配置好的投影矩阵和一个可以移动桌子的模型矩阵,那么我们需要做的就是旋转这个矩形,以便可以从某个角度观察它。如果使用旋转矩阵,我们只需一行代码就可以做到。我们还从来没有用过旋转,现在花一些时间了解一下这些旋转是怎么工作的。

1.旋转方向

需要弄清楚的第一件事情是我们需要围绕哪个轴旋转以及旋转多少度。要搞清楚一个物体怎样围绕一个给定的轴旋转,我们将使用右手坐标规则:伸出你的右手,握拳,让大拇指指向正轴的方向。倘若是一个正角度的旋转,蜷曲的手指会告诉你一个物体是怎样围绕那个轴旋转的。观察下图,当你把大拇指指向x轴的正方向时,看看旋转的方向是怎样跟随手指的绕轴线卷曲的。

分别用x轴、y轴和z轴试一下这个旋转。如果绕着y轴旋转,桌子会绕着它的顶端和底端水平旋转。如果绕着z轴旋转,矩形会在一个圆圈内旋转。我们想要做的是让矩形绕着x轴向后旋转,因为这会让桌子看起来更有层次。

2. 旋转矩阵

我们将使用一个旋转矩阵去做实际的旋转。旋转矩阵使用正弦和余弦三角函数把旋转角转换成缩放因子。下面就是绕x轴旋转所用矩阵的定义:

然后是绕y轴旋转所用的矩阵:

最后,还有一个绕z轴旋转所用的矩阵:

把所有这些矩阵合并为一个通用的旋转矩阵,使其可以基于任意一个角度和向量旋转,这也是可能的。

作为一个测试,让我们试试绕x轴的旋转。我们从一个点开始,它在原点上面一个单位,也就是y值是1。把它绕x轴旋转90度。首先,让我们准备这个旋转矩阵:

让我们把这个矩阵与这个点的位置向量相乘,看着得到什么:

这个点从(0 ,1 , 0)被移动到了(0 , 0 , 1),对x轴使用右手规则,我们可以看到正向旋转是如何把一个点沿着一个绕x轴的圈移动的。

3.在代码中加入旋转

我们现在准备好把这个旋转加入代码了。回到onSurfaceChangedO,调整那个平移矩阵,并加人一个旋转矩阵,如下:

    Matrix.translateM(modelMatrix,0,0f,0f,-3.5f)
    Matrix.rotateM(modelMatrix,0,-60f,1f,0f,0f)

我们把这个矩形放得更远点儿,因为我们一旦把它旋转了,它的底部会距离我们更近。我们接着把它绕着x轴旋转60度,这会让桌子处于一个很好的角度,就像我们站在它前面一样。

这张桌子现在看起来应该如图下图所示:

小结

本章的信息量很大,当我们学习投影矩阵以及它们怎样与OpenGL的透视除法一起工作的时候,引入了更多有关矩阵数学的内容。接着我们学习了如何使用第二个矩阵移动和旋转那个矩形。

好的一点是我们不需要深入理解这些投影和旋转背后的数学原理及理论,因为我们只是要使用它们。只要基本理解了什么是视椎体以及矩阵如何帮我们移动东西,你就会发现今后用OpenGL开发更加容易。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值