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 -> {
...
}
}
...
}
其效果如下: