相信做技术的同学,特别是做客户端开发的同学,都听说过OpenGL。要想对客户端的渲染机制有一个深入的了解,不对OpenGL了解一番恐怕是做不到的。而且,近年来客户端开发中对于图像和视频处理的需求,成上升趋势,要想胜任这些稍具「专业性」的工作,对于OpenGL的学习也是必不可少的。然而,OpenGL的学习曲线相对来说比较陡峭,尤其是涉及到一些计算机图形学方面的专业知识,不免会让很多人望而生畏。
要想熟练地掌握OpenGL,有两方面相关的知识是需要重点关注的。
本文所要探讨的主题,将主要围绕上述第二个方面的知识,也就是坐标变换。这部分涉及到一点数学知识,显得更难理解一些,并且网上的资料也散落在各处,很少有系统而详尽的描述。严格来说,这部分理论知识并不完全属于OpenGL规范所规定的范围,但却与之有着非常密切的关系。接下来,就坐标变换这个主题,我会写一个小系列,由多篇技术文章组成,将坐标变换相关的资料整理在一起,并尽力用通俗易懂的语言表达出来,希望能为学习OpenGL和图像处理的同学扫清理论上的障碍。
本着理论联系实际的原则,我们将结合Android系统上的API介绍相关的理论。之所以选择Android环境,是因为上手简单,大部分程序员都能很快地跑起一个Android程序,并且OpenGL相关的编程环境在Android上是现成的,几乎不用太多的配置。在Android上,实际广泛使用的是OpenGL ES 2.0,它可以看成是OpenGL对应版本的一个子集。我们在接下来的讨论中,也以OpenGL ES 2.0为准。
另外,很多实际中的开发任务只涉及到2D图像的处理,而不会涉及3D的处理。使用OpenGL ES做2D的图像处理,确实处理流程会简化一些,然而,个人认为,搞清3D的渲染机制,对于理解整件事有至关重要的作用。理解了3D,便能理解2D,反之则不成立。而且,只有在3D的语境下,坐标变换的概念才能被完整地理解。因此,我们一开始便从3D开始,等介绍完3D空间中的坐标变换之后,我们再回到2D的特殊情况加以讨论。
很多OpenGL的入门文章,都以画一个三角形开始。但是,对于讨论坐标变换这件事来说,画一个三角形的例子并不太合适,因为三角形是一个平面图形,对它应用了完整的坐标变换之后,会得到看似很奇怪的结果,反而让初学者比较迷惑。所以,本篇给出的例子程序画的是立方体(cube)。程序下载地址:
下面是程序输出截图:
没错,程序画了三个立方体的木箱子,它们的位置、大小、角度各不相同。但实际上,上面的大木箱子和下面的小木箱子都是由中间的那个木箱子经过一定的坐标变换(缩放、旋转、平移)之后得到的。而中间的木箱子所在的位置是原始的位置,即世界坐标的原点处(世界坐标的概念我们马上就会介绍)。
在本篇中,我们先不过早地深入到代码细节,而是留到后面的文章再讨论。接下来,我们先把坐标变换的整个过程做一个概览。
我们前面提到过,坐标变换的目标,简单来说,就是把一个3D空间中的对象最终投射到2D的屏幕上去(严格来说,OpenGL ES支持离屏渲染,所以最终未必是绘制到一个「可见」的屏幕上,不过在本文中我们忽略这一细节)。这也正是计算机图形学(computer graphics)所要解决的其中一个基础问题。当我们观察3D世界的时候,是通过一块2D的屏幕,我们真正看到的实际是3D世界在屏幕上的一个投影。坐标变换就是要解决在给定的观察视角下,3D世界的每个点最终对应到屏幕上的哪个像素上去。当然,对于一个3D对象的坐标变换,实际中是通过对它的每一个顶点(vertex)来执行相同的变换得到的。最终每个顶点变换到2D屏幕上,再经过后面的光栅化(rasterization)的过程,整个3D对象就对应到了屏幕的像素上,我们看到的效果就相当于透过一个2D屏幕「看到了」3D空间的物体(3D对象)。
下面的图展示了整个坐标变换的过程:
我们先来简略地了解一下图中各个过程:
为了更好地理解以上各个步骤,下面我们来看几张图。
上面这张图展示的是本地坐标。3D对象是一个立方体,本地坐标的原点(0, 0, 0)位于立方体的中心。红色、绿色、蓝色的坐标轴分别表示x轴、y轴、z轴。
上面这张图展示的是世界坐标。可以这样认为,最初,世界坐标系和立方体的本地坐标系是重合的,但立方体经过了某些缩放、旋转和平移之后,两个坐标系不再重合。图中虚线表示的坐标轴,就是原来的本地坐标系。
上面这张图展示的是相机坐标。左下实线表示的坐标轴即是相机坐标系,右边虚线表示的坐标轴是世界坐标系。相机坐标系可以看成是相机(或眼睛)看向3D空间中的某一点形成的一个观察视角,以上图为例,相机观察的方向正对着世界坐标系的(0,2,0)这一点。相机坐标系的原点正是相机(或眼睛)所在的位置。这里需要注意的一点细节是,按照OpenGL ES的定义习惯,相机坐标系的z轴方向与观察方向正好相反。也就是说,相机(或眼睛)看向z轴的负方向。
我们前面提到的view变换,指的就是在世界坐标系中的各个顶点(vertex),经过这样一个变换,就到了相机坐标系下,也就是各个顶点的坐标变成了以相机坐标的值来表示了。
仔细观察的话,我们会发现,相机坐标系实际上可以看成是由世界坐标系经过旋转和平移操作得到的。这在后面我们还会详细讨论。
至此,我们已经转换到了相机坐标系下了。接下来是非常关键的一步变换,要将3D坐标(以相机坐标表示)投射到2D屏幕上。如前所述,这个变换是通过投影变换(projection)得到的。为了使得投射到2D屏幕上的图像看起来像是3D的,我们需要让这个变换满足人眼的一些直觉。根据实际经验,我们眼中看到的东西,离我们越远,显得越小;反之,离我们越近,显得越大。就像我们正对着一列铁轨或一个走廊看过去的那种效果一样,如下图:
所以,投影变换也要保持这种效果。经过投影变换后,我们就得到了裁剪坐标,在此基础上再附加一个perspective division的过程,就变换到了NDC坐标。像前面所讲的一样,perspective division的细节我们先不追究,我们暂且认为相机坐标经过了投影变换就得到了NDC坐标。这个投影的过程,是通过从相机出发构建一个视锥体(frustum)得到的,如下图所示:
上图中,从相机所在位置(也就是相机坐标系原点)沿着相机坐标系的z轴负方向望出去,同时指定一个近平面(N)和远平面(F),在两个平面之间就截出一个视锥体。它由6个面组成,近平面(N)和远平面(F)分别是前后两个面,另外它还有上下左右四个面。其中,近平面(N)对应着最终要投影的2D屏幕。落在视锥体内部的顶点坐标,最终将投影到2D屏幕上;而落在视锥体外部的顶点坐标,则被裁剪掉。而且,落在视锥体内部的3D对象,它的位置越是靠近近平面,这个3D对象在近平面上的投影越大;相反越是远离近平面,则投影越小。
以视锥体中的某点为原点,建立一个坐标系,就得到了NDC坐标,也就是上图中位于右上部的实线红、绿、蓝坐标轴。视锥体的6个面正好对应着NDC坐标每个维度的最大取值(-1和1)。
有两个细节需要注意一下:
上面左图是左手坐标系,右图是右手坐标系。到底应该用左手坐标系还是右手坐标系,是一种约定俗成的习惯,不同的图形系统和规范很可能选择不一样的坐标系类型。但按照OpenGL的习惯,我们应该使用如前面所讲的坐标系类型。
OpenGL ES涉及到的主要的坐标变换过程,我们把大概的概况已经讨论清楚了。在这个系列后面的文章中,我们将逐步讨论各个变换过程的细节,包括理论推导,以及在Android上如何用代码来实现。
(完)
其它精选文章: