1、概述
前面几篇关于OpenGLES的文章:
前面的文章有讨论观察矩阵以及如何使用观察矩阵移动场景。OpenGL ES本身没有摄像机的概念,但可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种观察者在移动的感觉,而不是场景在移动。
本节将会讨论如何在OpenGL ES中配置一个摄像机,并且将会讨论FPS风格的摄像机,使得能够在3D场景中自由移动。
2、摄像机/观察空间
当讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,需要它在世界空间中的位置、观察的方向、一个指向它右测的向量以及一个指向它上方的向量。实际上是创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。
2.1 摄像机位置
摄像机位置就是世界空间中一个指向摄像机位置的向量。把摄像机位置设置为:
public var cameraPos = floatArrayOf (0.0f, 0.0f,3.0f)
z轴是从屏幕指向用户的方向,如果希望摄像机向后移动,就沿着z轴的正方向移动。
2.2 摄像机方向
摄像机的方向指的是摄像机指向哪个方向。现在让摄像机指向场景原点:(0, 0, 0)。用场景原点向量减去摄像机位置向量的结果就是摄像机的指向向量。由于摄像机指向z轴负方向,但希望方向向量(Direction Vector)指向摄像机的z轴正方向。这样就需要交换相减的顺序:
public var cameraTarget = floatArrayOf (0.0f, 0.0f,0f)
public var cameraDirection =Utils.vectorSub(cameraPos,cameraTarget)
上面Utils.vectorSub()是向量相减函数。
2.3 右轴
需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x轴的正方向。为获取右向量需要先使用一个小技巧:先定义一个上向量(Up Vector)。接下来把上向量和2.2得到的方向向量进行叉乘。两个向量叉乘的结果会同时垂直于两向量,因此会得到指向x轴正方向的那个向量如果交换两个向量叉乘的顺序就会得到相反的指向x轴负方向的向量:
public var up = floatArrayOf(0.0f, 1.0f, 0.0f)
public var cameraRight =Utils.vector3DCross(up,cameraDirection)
上面Utils.vector3DCross()是向量叉乘函数。
2.4 上轴
现在已经有了x轴向量和z轴向量,获取一个指向摄像机的正y轴向量可以把右向量和方向向量进行叉乘:
public var cameraUp = Utils.vector3DCross(cameraDirection, cameraRight)
3、LookAt
上面一节分别定义了三个互相垂直的方向向量,可以用这些向量来创建一个LookAt矩阵。使用矩阵的好处之一是如果使用3个相互垂直(或非线性)的轴定义了一个坐标空间,可以用这3个轴外加一个平移向量来创建一个矩阵,并且可以用这个矩阵乘以任何向量来将其变换到那个坐标空间。这正是LookAt矩阵所做的,现在有了3个相互垂直的轴和一个定义摄像机空间的位置坐标,我们可以创建我们自己的LookAt矩阵了:
其中R是右向量,是U上向量,D是方向向量,P是摄像机位置向量。注意,位置向量是相反的,因为最终希望把世界平移到与自身移动的相反方向。把这个LookAt矩阵作为观察矩阵可以很高效地把所有世界坐标变换到刚刚定义的观察空间。LookAt矩阵就像它的名字表达的那样:它会创建一个看着(Look at)给定目标的观察矩阵。android.opengl.Matrix 提供该支持,开发者要做的只是定义一个摄像机位置,一个目标位置和一个表示世界空间中的上向量的向量(即前面计算右向量使用的那个上向量)。接着android.opengl.Matrix 就会创建一个LookAt矩阵,我们可以把它当作观察矩阵:
private val mViewMatrix = FloatArray(16)
fun draw() {
...
Matrix.setLookAtM(mViewMatrix, 0,
0f, 0f, 3.0f,
0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f
)
for (i in 0..3) {
...
}
...
}
Matrix.setLookAtM()需要一个位置、目标和上向量。它会创建一个观察矩阵。
下面来做一个的摄像机在场景中旋转的效果。会将摄像机的注视点保持在(0, 0, 0)。需要用到一点三角学的知识来在每一帧创建一个x和z坐标,它会代表圆上的一点,将会使用它作为摄像机的位置。通过重新计算x和y坐标,会遍历圆上的所有点,这样摄像机就会绕着场景旋转了。预先定义这个圆的半径radius。
// Triangle.kt
private val mViewMatrix = FloatArray(16)
fun draw() {
...
mAngle = (System.currentTimeMillis() / 300) % 360
mRadius = 15.0f
var camX = Math.sin(mAngle.toDouble() ).toFloat() * mRadius
var camZ = Math.cos(mAngle.toDouble() ).toFloat() * mRadius
Matrix.setLookAtM(mViewMatrix, 0,
camX, 0f, camZ,
0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f
)
for (i in 0..3) {
...
}
...
}
效果如下:
4、相机自由移动
这边实现相机随着滑动触摸屏进行相应的移动。首先必须设置一个摄像机系统,需要定义一些成员变量:
// Triangle.kt
public var cameraPos = floatArrayOf(0.0f, 0.0f, 3.0f)
public var cameraFront = floatArrayOf(0.0f, 0.0f, -1.0f)
public var cameraUp = floatArrayOf(0.0f, 1.0f, 3.0f)
此时LookAt函数现在成了:
// Triangle.kt
fun draw() {
...
Matrix.setLookAtM(mViewMatrix, 0,
cameraPos[0], cameraPos[1], cameraPos[2],
cameraPos[0] + cameraFront[0],
cameraPos[1] + cameraFront[1],
cameraPos[2] + cameraFront[2],
cameraUp[0], cameraUp[1], cameraUp[2]
)
for (i in 0..3) {
...
}
...
}
首先将摄像机位置设置为之前定义的cameraPos。方向是当前的位置加上刚刚定义的方向向量。这样能保证无论怎么移动,摄像机都会注视着目标方向。接下来对MyGLSurfaceView类进行触摸监听,当触摸事件发生在下半快屏幕时根据触摸点滑动方向进行相应的位移。这边需要注意将MyGLSurfaceView中的RENDERMODE_WHEN_DIRTY 注释打开,也就是说手动控制其渲染操作。
// MyGLSurfaceView.kt
public var mPreCameraPos = FloatArray(3)
public var mPreCameraFront = FloatArray(3)
public var mPreCameraUp = FloatArray(3)
init {
...
renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
}
override fun onTouchEvent(e: MotionEvent): Boolean {
val x = e.x
val y = e.y
when (e.action) {
MotionEvent.ACTION_DOWN -> {
mPreCameraPos[0] = mRenderer.mTriangle?.cameraPos?.get(0)!!
mPreCameraPos[1] = mRenderer.mTriangle?.cameraPos?.get(1)!!
mPreCameraPos[2] = mRenderer.mTriangle?.cameraPos?.get(2)!!
mPreCameraFront[0] = mRenderer.mTriangle?.cameraFront?.get(0)!!
mPreCameraFront[1] = mRenderer.mTriangle?.cameraFront?.get(1)!!
mPreCameraFront[2] = mRenderer.mTriangle?.cameraFront?.get(2)!!
mPreCameraUp[0] = mRenderer.mTriangle?.cameraUp?.get(0)!!
mPreCameraUp[1] = mRenderer.mTriangle?.cameraUp?.get(1)!!
mPreCameraUp[2] = mRenderer.mTriangle?.cameraUp?.get(2)!!
...
}
MotionEvent.ACTION_MOVE -> {
var dx = x - mPreviousX
var dy = y - mPreviousY
if (y > height / 2) {
mRenderer.mTriangle?.cameraPos =
Utils.vectorAdd(mRenderer.mTriangle?.cameraPos, Utils.vectorMul(mRenderer.mTriangle?.cameraFront, dy / 5))
mRenderer.mTriangle?.cameraPos =
Utils.vectorAdd(mRenderer.mTriangle?.cameraPos, Utils.vectorMul(Utils.vector3DCross(mRenderer.mTriangle?.cameraFront
, (mRenderer.mTriangle?.cameraUp))
, dx / 5))
} else {
...
}
requestRender()
}
MotionEvent.ACTION_UP -> {
mRenderer.mTriangle?.cameraPos?.set(0, mPreCameraPos[0])
mRenderer.mTriangle?.cameraPos?.set(1, mPreCameraPos[1])
mRenderer.mTriangle?.cameraPos?.set(2, mPreCameraPos[2])
mRenderer.mTriangle?.cameraFront?.set(0, mPreCameraFront[0])
mRenderer.mTriangle?.cameraFront?.set(1, mPreCameraFront[1])
mRenderer.mTriangle?.cameraFront?.set(2, mPreCameraFront[2])
mRenderer.mTriangle?.cameraUp?.set(0, mPreCameraUp[0])
mRenderer.mTriangle?.cameraUp?.set(1, mPreCameraUp[1])
mRenderer.mTriangle?.cameraUp?.set(2, mPreCameraUp[2])
requestRender()
}
}
mPreviousX = x
mPreviousY = y
return true
}
这边触摸事件按下时保存原始位置,触摸事件左右滑动时相机对应左右移动,上下移动时相机对应向目标前后移动。触摸事件抬起时回到原位,同时调用requestRender()进行手动刷新数据。
效果如下:
这边注意,最好对叉乘数据进行标准化,也就是使其向量长度为1。如果没对这个向量进行标准化,最后的叉乘结果会根据cameraFront变量返回大小不同的向量。如果不对向量进行标准化,就得根据摄像机的朝向不同加速或减速移动了,但如果进行了标准化移动就是匀速的。
5、相机视角移动
为了能够改变视角,需要根据触摸移动改变cameraFront向量。这边需要一些三角学的知识。
5.1 欧拉角
欧拉角是可以表示3D空间中任何旋转的3个值,由莱昂哈德·欧拉(Leonhard Euler)在18世纪提出。一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),下面的图片展示了它们的含义:
俯仰角是描述如何往上或往下看的角,可以在第一张图中看到。第二张图展示了偏航角,偏航角表示往左和往右看的程度。滚转角代表如何翻滚摄像机,通常在太空飞船的摄像机中使用。每个欧拉角都有一个值来表示,把三个角结合起来就能够计算3D空间中任何的旋转向量了。
对于摄像机系统来说,只关心俯仰角和偏航角,所以不会讨论滚转角。给定一个俯仰角和偏航角,可以把它们转换为一个代表新的方向向量的3D向量。
俯仰角计算如下图
想象在xz平面上,看向y轴,可以基于三角形来计算它的长度/y方向的强度(Strength),即往上或往下看多少。从图中可以看到对于一个给定俯仰角的y值等于sin pitch:
direction.y = sin(pitch);
这里只更新了y值,仔细观察x和z分量也被影响了。从三角形中可以看到它们的值等于:
direction.x = cos(pitch);
direction.z = cos(pitch);
偏航角分量如下:
就像俯仰角的三角形一样,可以看到x分量取决于cos(yaw)的值,z值同样取决于偏航角的正弦值。把这个加到前面的值中,会得到基于俯仰角和偏航角的方向向量:
direction.x = cos(pitch) * cos(yaw);
direction.y = sin(pitch);
direction.z = cos(pitch) * sin(yaw);
5.2 视角触摸事件
这边用上半快屏幕的触摸事件来处理角度的调整:
// MyGLSurfaceView.kt
private var mPitch: Double =0.toDouble()
private var mYaw: Double =0.toDouble()
override fun onTouchEvent(e: MotionEvent): Boolean {
val x = e.x
val y = e.y
when (e.action) {
MotionEvent.ACTION_DOWN -> {
...
}
MotionEvent.ACTION_MOVE -> {
var dx = x - mPreviousX
var dy = y - mPreviousY
if (y > height / 2) {
...
} else {
mPitch += dy/100
mYaw += dx/100
mRenderer.mTriangle?.cameraFront?.set(0, (Math.cos(mPitch) * Math.cos(mYaw)).toFloat())
mRenderer.mTriangle?.cameraFront?.set(1, Math.sin(mPitch).toFloat())
mRenderer.mTriangle?.cameraFront?.set(2, (Math.cos(mPitch) * Math.sin(mYaw)).toFloat())
}
requestRender()
}
MotionEvent.ACTION_UP -> {
...
}
}
...
}
其效果如下: