OpenGL ES 2 第六章:进入第三维
文章传送门
OpenGL ES 2.0 for Android教程(一)
OpenGL ES 2.0 for Android教程(二)
OpenGL ES 2.0 for Android教程(三)
OpenGL ES 2.0 for Android教程(四)
OpenGL ES 2.0 for Android教程(五)
OpenGL ES 2.0 for Android教程(七)
OpenGL ES 2.0 for Android教程(八)
OpenGL ES 2.0 for Android教程(九)
想象一下,你现在在游戏厅,站在一张曲棍球桌前,看着对手的另一端。从你的角度来看,这张桌子会是什么样子的?你的桌子在这一端看起来会更大,你从更低的角度看桌子,而不是直接从正上方看。毕竟,没有人站在桌子上看冰球。
虽然OpenGL非常擅长在2D渲染事物,但当我们添加第三个维度的坐标时,屏幕上的内容更加明亮。在本章中,我们将学习如何执行3D渲染,这样我们就可以在桌子对面俯视对手。
本章的计划如下:
- 首先,我们将学习OpenGL的透视除法(perspective division),以及如何使用w分量在2D屏幕上创建3D效果。
- 一旦我们理解了w的重量,我们将学习如何设置透视投影(perspective projection),以便我们能看到3D桌子的版本。
从三维艺术出发
几个世纪以来,艺术家们用了很多技巧来愚弄人们的眼睛,让他们把平面二维绘画视为一个完整的三维场景。他们使用的一种技能是线性投影(linear projection)—— 通过将平行线连接到假想的消失点,创造透视错觉。
当我们站在一对笔直的轨道上时,我们可以看到这种效果的典型例子;当我们看着远处的轨道时,它们似乎越来越近,直到它们消失在地平线上:
随着离我们越来越远,铁路枕木似乎越来越小。如果我们测量每个枕木的表观尺寸,它们的测量尺寸会随着它们与我们眼睛的距离而成比例减小。这个技能是创建真实的3D让我们继续学习如何使用投影所需的秘密OpenGL这样做。
将着色器坐标转换到屏幕上
我们现在熟悉标准化设备坐标(NDC),知道为了在屏幕上显示顶点,它x、yz的重量需要在[-1,1]的范围内。让我们查看下面的流程图,看看坐标是如何从顶点着色器中写入原始的gl_Position
转换到屏幕上的最终坐标:
上图展现了两个转换步骤和三个不同的坐标空间。
裁剪空间
当顶点着色器将值写入gl_Position
时,OpenGL希望该位置位于(Clip Space)中。裁剪空间背后的逻辑非常简单:对于任何给定位置,x、y和z分量都需要在该位置的-w和w之间。例如,如果位置的w定为1,则x、y和z分量都需要介于-1和1之间。任何超出此范围的内容都不会显示在屏幕上。
其他分量取决于w分量的原因在我们学习了透视除法之后就会很明显了。
透视除法
在顶点位置成为标准化设备坐标之前,OpenGL实际上会执行一个额外的步骤,称为。透视除法执行后,位置将在标准化设备坐标中,其中每个可见坐标的x、y和z分量的都将位于[-1,1]范围内,而不管渲染区域的大小或形状如何。
为了在屏幕上创建3D效果,OpenGL会获取每个gl_Position
,并将x、y和z分量除以w分量。当使用“w”分量表示距离时,距离更远的对象将被移动到更靠近渲染区域中心的位置,渲染区域的中心此时便起到了消失点的作用。这就是OpenGL如何使用艺术家几个世纪以来一直使用的相同技巧来欺骗我们,让我们仿佛看到3D场景的原理。
例如,假设某个物体有两个顶点,每个顶点都在3D空间中的相同位置,具有相同的x、y和z分量,但具有不同的w分量。假设这两个坐标是(1,1,1,1)和(1,1,1,2)。在OpenGL使用这些作为标准化设备坐标之前,它将进行一次透视除法,并将前三个组件除以w。现在每个坐标的划分如下:(1/1,1/1,1/1)和(1/2,1/2,1/2)。经过此划分后,标准化设备坐标将为(1,1,1)和(0.5,0.5,0.5)。w较大的坐标移近(0,0,0),即渲染区域的中心(在标准化设备坐标中)。
在下图中,我们可以看到这种效果的一个例子,随着w值的增加,具有相同x、y和z的坐标将越来越靠近中心:
在OpenGL中,3D效果是线性的,并沿直线完成。在现实生活中,事情要复杂得多(想象一下鱼眼镜头),但这种线性投影是一种合理的近似。
注:鱼眼镜头(Fisheye lens)是一种超广角镜头。
齐次坐标
由于透视除法,裁剪空间中的坐标通常被称为(齐次坐标的概念由August Ferdinand Möbius于1827年引入)。它们被称为齐次的原因是裁剪空间中的多个坐标可以映射到同一点。例如,以以下几点为例:
(1, 1, 1, 1), (2, 2, 2, 2), (3, 3, 3, 3), (4, 4, 4, 4), (5, 5, 5, 5)
进行透视除法后,这些点都将映射到标准化设备坐标中的(1,1,1)。
除以W的优点
你可能想知道为什么我们不简单地除以z。毕竟,如果我们将z解释为距离,并且有两个坐标,(1,1,1)和(1,1,2),那么我们可以除以z得到两个标准化坐标(1,1)和(0.5,0.5)。
虽然这是可行的,但添加w作为第四个组件有其他优势。我们可以将透视效果与实际的z坐标解耦,这样就可以在正交投影和透视投影之间切换。保留z分量作为深度缓冲区(depth buffer)还有一个好处,我们将在后面章节的”用深度缓冲区移除隐藏曲面“来介绍这一点。
视口变换
在我们看到最终结果之前,OpenGL需要将标准化设备坐标的x和y分量映射到屏幕上的一个区域内,这个区域是操作系统留出的用于显示的区域,被称作,这些映射的坐标称为窗口坐标。除了告诉OpenGL如何进行映射之外,我们不需要太关心这些映射坐标。我们目前通过在onSurfaceChanged()
中调用glViewport()
来设置viewport的大小。当OpenGL进行这种映射时,它会将(-1,-1,-1)到(1,1,1)的范围映射到为显示而预留的窗口。超出此范围的标准化设备坐标将被剪裁。
添加W分量以创建透视图
如果我们实际看到w分量的作用,那么就能更容易理解它的效果,所以让我们把它添加到我们的顶点数据中,看看会发生什么。由于我们现在将指定位置的x、y、z和w分量,因此我们首先需要更新POSITION_COMPONENT_COUNT
常量,然后再次更新顶点数组,如下所示:
private val tableVerticesWithTriangles: FloatArray = floatArrayOf(
// 属性的顺序: X, Y, Z, W, R, G, B
// 三角形扇形
0f, 0f, 0f, 1.5f, 1f, 1f, 1f,
-0.5f, -0.8f, 0f, 1f, 0.7f, 0.7f, 0.7f,
0.5f, -0.8f, 0f, 1f, 0.7f, 0.7f, 0.7f,
0.5f, 0.8f, 0f, 2f, 0.7f, 0.7f, 0.7f,
-0.5f, 0.8f, 0f, 2f, 0.7f, 0.7f, 0.7f,
-0.5f, -0.8f, 0f, 1f, 0.7f, 0.7f, 0.7f,
// 中线
-0.5f, 0f, 0f, 1.5f, 1f, 0f, 0f,
0.5f, 0f, 0f, 1.5f, 1f, 0f, 0f,
// 两个木槌
0f, -0.4f, 0f, 1.25f, 0f, 0f, 1f,
0f, 0.4f, 0f, 1.75f, 1f, 0f, 0f
)
companion object {
private const val POSITION_COMPONENT_COUNT = 4
...
}
我们在顶点数据中添加了一个z和一个w分量。我们已经更新了所有顶点,使屏幕底部附近的顶点w为1,而屏幕顶部附近的顶点w为2;我们还更新了中线和木槌,使其具有介于两者之间的分数w。这会使桌子的顶部看起来比底部小,就好像我们从一端望向另一端一样。我们把所有的z分量都设置为零,因为不需要在z分量有任何值就能获得立体效果。
OpenGL将自动使用我们指定的w值为我们进行透视除法,我们当前的正交投影只会复制这些w值。让我们继续运行我们的项目,看看它是什么样子:
桌子看起来有点立体感了!我们只需要指定w就可以做到这一点。然而,如果我们想获得更加动态的效果,比如改变桌子的角度或放大缩小,该怎么办?我们应当使用矩阵为我们生成值,而不是硬编码w值。让我们还原刚才对顶点和常量的更改。在下一节中,我们将学习如何使用透视投影矩阵自动生成w值。
转为使用透视投影
在我们进入透视投影背后的矩阵数学之前,让我们从视觉层面来讨论一下。在前一章中,我们使用正交投影矩阵,通过调整转换为标准化设备坐标的区域的宽度和高度来补偿屏幕的宽高比。
在下图中,我们把正交投影可视化为一个包围整个场景的立方体,表示OpenGL最终将在视口上渲染的内容,也是我们能够看到的内容: 不同的视角显示相同的场景:
视锥体
当我们切换到投影矩阵时,场景中的平行线会在屏幕上的一个消失点相交,物体将随着距离越来越远而变得越来越小。我们所看到的空间区域不再是立方体,而与下图“通过视锥体的投影”更为相似。
这种形状被称为视锥体(frustum),这个观察空间是通过透视投影矩阵和透视除法创建的。所谓的frustum就是通过让原来的正方体的远侧的一面变得比近侧更大,从而令它变成视锥体的形状。两面大小差异越大,视角就越广,我们能看到的就越多。
视锥体还拥有一个焦点(focal point)。这个焦点可以通过延长视锥体的两条侧棱来得到,它们相交的点就是焦点。当您使用透视投影查看场景时,这个场景看起来就像你的视角被放在了这个焦点上。焦点和视锥体较小的面之间的距离称为焦距,这影响了视锥体小端和大端与相应视角之间的比例。
在下图中,我们可以从焦点处看视锥体内的场景:
焦点的另一个有趣特性是,在焦点上观察,视锥体两端在屏幕上似乎占据了面积相同的空间。(想一想那个延长线你就能明白为什么看起来“面积相等”)。视锥体的远端更大,但由于距离更远,它占用的空间反而与近端相同。这和日食的原理相似:月球比太阳小得多,但因为它离我们近得多,所以它看起来大得足以遮住太阳。
定义透视投影
为了重现3D的魔力,我们的透视投影矩阵需要与透视除法一起工作。投影矩阵本身不能进行透视除法,透视除法需要某些东西才能起作用。
随着一个物体离我们越来越远,它应该向屏幕的中心移动,大小也相应减小,所以我们的投影矩阵最重要的任务是为w创建合适的值,这样当OpenGL进行透视除法时,远的物体会比近的物体显得更小。其中一种方法是将z分量定义为物体与焦点之间的距离,然后将该距离映射到w。距离越大,w越大,生成的对象越小。
“透视投影背后的数学”一节位于本文附录,该节将对透视投影矩阵进行推导,如果读者已经对透视矩阵熟稔于心,完全可以忽略这一节的内容。
调整宽高比和视角
透视投影矩阵如下,它允许我们调整视角的同时调整屏幕宽高比: [ a a s p e c t 0 0 0 0 a 0 0 0 0 − f + n f − n − 2 f n f − n 0 0 − 1 0 ] \begin{bmatrix} \frac{a}{aspect} & 0 & 0 & 0\\ 0 & a & 0 & 0\\ 0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n}\\ 0 & 0 & -1 & 0 \end{bmatrix} ⎣⎢⎢⎡aspecta0000a0000−f−nf+n−100−f−n2fn0⎦⎥⎥⎤ 下面是对该矩阵中定义的变量的快速解释:
变量 | 解释 |
---|---|
a | 这个变量代表焦距。焦距由$\frac{1}{\tan(Y轴视角角度/2)} $计算,视角必须小于180度。例如,当视角为90度时,焦距将等于1。 |
aspect | 屏幕的宽高比,即宽度/高度。 |
f | 坐标原点距离较远的平面的距离,这个值必须为正且大于距离近平面的距离。 |
n | 坐标原点与较近的平面的距离,这个值必须为正。例如,如果将其设置为1,则近平面将位于z=-1的位置。 |
随着视角变小,焦距变长,映射到归一化设备坐标中的范围[-1,1]所对应的x和y也相应减小。这会使视锥体变窄。
在下图中,左侧的视锥体的视角为90度,而右侧的视锥体的视角为45度:
可以看到,对于45度的视锥体,焦点和近平面之间的焦距稍长。
以下是两个视锥体各自从焦点上看到的场景:
在视角较窄的情况下,通常很少出现失真问题。另一方面,随着视角变宽,最终图像的边缘将扭曲得更严重。在现实生活中,较宽的视角会使一切看起来都是弯曲的,就像在相机上使用鱼眼镜头时看到的效果一样。由于OpenGL使用沿直线的线性投影,最终的图像会被拉伸。
在代码中创建投影矩阵
现在,我们准备向代码中添加透视投影。我们可以使用Android中的Matrix
类的perspectiveM()
方法,也可以创建自己的方法来实现上一节中定义的矩阵,下面来实现一个与perspectiveM()
非常类似的方法。
在util包中创建MatrixHelper
类:
object MatrixHelper {
fun perspectiveM(m: FloatArray, yFovInDegrees: Float, aspect: Float, n: Float, f: Float) {
// TODO
}
}
我们要做的第一件事是基于y轴上的视角来计算焦距。在方法签名之后添加以下代码:
// 计算弧度制的角度
val angleInRadians = (yFovInDegrees * Math.PI / 180.0).toFloat()
// 计算焦距
val a = (1.0 / tan(angleInRadians / 2.0)).toFloat()
我们使用Java的Math类来计算正切值,因为它需要以弧度为单位的角度,所以我们将视角从度转换为弧度,然后计算焦距。接下来我们输出矩阵结果:
m[0] = a / aspect
m[1] = 0f
m[2] = 0f
m[3] = 0f
m[4] = 0f
m[5] = a
m[6] = 0f
m[7] = 0f
m[8] = 0f
m[9] = 0f
m[10] = -((f + n) / (f - n))
m[11] = -1f
m[12] = 0f
m[13] = 0f
m[14] = -((2f * f * n) / (f - n))
m[15] = 0f
这会将矩阵数据写入参数m中定义的浮点数组,该数组至少需要16个元素。OpenGL以列优先顺序来存储矩阵数据,这意味着开始的四个值指的是第一列,接下来的四个值指的是第二列,依此类推。
我们现在已经完成了perspectiveM()
,并准备在代码中使用它。我们的方法与Matrix
源代码中的方法非常相似,只是做了一些细微的更改,使其更具可读性。
切换到投影矩阵
现在我们将转而使用透视投影矩阵。打开AirHockeyRenderer
并从onSurfaceChanged()
中删除所有代码,只保留对glViewport()
的调用,然后添加以下代码:
MatrixHelper.perspectiveM(projectionMatrix, 55f, width.toFloat() / height.toFloat(), 1f, 10f)
这将创建一个垂直视角为55度的透视投影矩阵。视锥体将以 z = − 1 z=-1 z=−1为近平面, z = − 10 z=-10 z=−10为远平面。
添加MatrixHelper
的导入后,继续运行程序。你可能会注意到我们的曲棍球桌不见了。因为我们没有为桌子指定z分量,所以默认情况下z=0,但是我们视锥体的最近的平面在 z = − 1 z=-1 z=−1,我们至少得把它们移动到 z = − 1 z=-1 z=−1以后才有可能看到它们。
与其硬编码z值,不如先使用平移矩阵将桌子移出,然后再使用投影矩阵进行投影。按照惯例,我们将这个矩阵称为模型矩阵(Model Matrix)。
使用模型矩阵移动对象
让我们在AirHockeyRenderer
添加一个类变量:
/** * 模型矩阵 */
private val modelMatrix = FloatArray(16)
我们将使用这个矩阵将空气曲棍球台移动到远处。在onSurfaceChanged()
的末尾,添加以下代码:
Matrix.setIdentityM(modelMatrix, 0)
Matrix.translateM(modelMatrix, 0, 0f, 0f, -2f)
这会将模型矩阵设置为单位矩阵,然后设置沿z轴的平移量为-2。当我们用这个矩阵乘以我们的曲棍球桌坐标时,这些坐标最终会沿着负z轴移动2个单位。(如果忘记如何使用矩阵进行平移变换,参见第五章)
相乘一次还是两次
我们现在要做一次选择:我们需要将模型矩阵应用到每个顶点,所以我们的第一个方案是将额外的矩阵添加到顶点着色器中。我们先将每个顶点乘以模型矩阵,沿负z轴移动2个单位,然后将每个顶点乘以投影矩阵,这个方案的缺点是我们需要再修改一遍代码,声明新的矩阵变量,并把值传递给OpenGL,这些过程稍显繁琐。还有一个更好的方案:我们可以将模型矩阵和投影矩阵相乘,然后将得出的矩阵结果传递给顶点着色器。这样我们就只需要在着色器中保留一个矩阵。
复习矩阵乘法
可能你已经忘记矩阵乘法的规则了,让我们稍微复习一下。矩阵乘法总结起来就是,结果矩阵的第M行N列的结果,由左边矩阵的第M行行向量乘以右边矩阵的第N行列向量得出。下面给出了第二行第三列元素的生成计算式。 [ ? ? ? ? a 1 a 2 a 3 a 4 ? ? ? ? ? ? ? ? ] [ ? ? b 1 ? ? ? b 2 ? ? ? b 3 ? ? ? b 4 ? ] = [ ? ? ? ? ? ? a 1 b 1 + a 2 b 2 + a 3 b 3 + a 4 b 4 ? ? ? ? ? ? ? ? ? ] \begin{bmatrix} ? & ? & ? & ?\\ a_1 & a_2 & a_3 & a_4\\ ? & ? & ? & ?\\ ? & ? & ? & ?\\ \end{bmatrix} \begin{bmatrix} ? & ? & b_1 & ?\\ ? & ? & b_2 & ?\\ ? & ? & b_3 & ?\\ ? & ? & b_4 & ?\\ \end{bmatrix}= \begin{bmatrix} ? & ? & ? & ?\\ ? & ? & a_1b_1+a_2b_2+a_3b_3+a_4b_4 & ?\\ ? & ? & ? & ?\\ ? & ? & ? & ?\\ \end{bmatrix} ⎣⎢⎢⎡?a1???a2???a3???a4??⎦⎥⎥⎤⎣⎢ 标签: 撕裂传感器限位开关zwn