OpenGL ES 2 第九章:添加触摸反馈
通过触摸支持实现良好的用户交互是许多游戏和应用程序的基石;它可以给用户一种与真实事物交互的感觉,即使他们只是在看屏幕上的像素。一些手机游戏之所以变得非常流行,仅仅是因为它们提出了一种新的触摸模式。
空中曲棍球中有了更好看的木槌,但如果我们真的能使用它们,那不是很好吗?在本章中,我们将通过添加触摸支持,使我们的程序更具互动性。我们将学习如何添加三维相交测试和碰撞检测,这样我们就可以抓住木槌并在屏幕上拖动它。
以下是本章的计划:
-
我们将首先在空中曲棍球项目中添加触摸互动。我们会复习必要的数学知识,让它工作起来。
-
然后,我们将学习如何使我们的木槌与冰球互动,并保证它停留在边界内。
当我们读完这一章,我们就可以用木槌击打冰球,看着它在桌子上弹跳!
在AirGLSurfaceView中添加触摸支持
我们将AirGLSurfaceView
修改如下,保存Renderer的引用,然后注册触摸事件监听:
class AirGLSurfaceView
@JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
): GLSurfaceView(context, attrs) {
private val renderer = AirHockeyRenderer(context)
init {
setEGLContextClientVersion(2)
setRenderer(renderer)
setOnTouchListener { v, event ->
val normalizedX = (event.x / width.toFloat()) * 2 - 1
val normalizedY = -((event.y / height.toFloat()) * 2 - 1)
// TODO
true
}
}
}
我们先把把触摸事件的坐标转换为标准化设备坐标,因此Y轴进行了一次反转。
然后,我们根据触摸事件类型调用Renderer的相关API:
// 在AirHockeyRenderer添加以下方法,待会我们再实现它们
fun handleTouchPress(normalizedX: Float, normalizedY: Float) {
// TODO
}
fun handleTouchDrag(normalizedX: Float, normalizedY: Float) {
// TODO
}
// 然后,在TouchListener添加以下代码
setOnTouchListener { v, event ->
val normalizedX = (event.x / width.toFloat()) * 2 - 1
val normalizedY = (event.y / height.toFloat()) * 2 - 1
when(event?.actionMasked) {
MotionEvent.ACTION_DOWN -> {
queueEvent {
renderer.handleTouchPress(normalizedX, normalizedY)
}
true
}
MotionEvent.ACTION_MOVE -> {
queueEvent {
renderer.handleTouchDrag(normalizedX, normalizedY)
}
true
}
else -> {
performClick()
false
}
}
}
这里要注意的一点是,Android的UI在主线程中运行,而GLSurfaceView
在单独的线程中运行OpenGL,因此我们需要保证两者之间通信的线程安全。我们使用queueEvent()
向OpenGL线程发送Runnable
,这些Runnable
将在Renderer
运行的线程上被处理。
添加相交测试
现在我们已经将屏幕的触摸区域设置为标准化设备坐标,我们需要确定触摸区域是否包含木槌。我们需要进行相交测试,这是处理3D游戏和应用程序时非常重要的操作。以下是我们需要做的:
- 首先,我们需要将二维屏幕坐标转换回三维空间,看看我们触摸到了什么。我们需要将触摸点投射到一条射线上,这条射线从我们的视角来看就是一道跨越三维场景的光线。
- 然后,我们需要检查射线是否与木槌相交。为了让事情更简单,我们假设木槌实际上是一个同样大小的的包围球(Bounding Sphere),然后我们将这个球体来进行测试。
首先让我们在AirHockeyRenderer
添加两个类变量:
private var malletPressed = false
private lateinit var blueMalletPosition: Point
我们将使用malletPressed
来跟踪木槌当前是否已被按到。我们还将木槌的位置存储在blueMalletPosition
中,需要将其初始化为一个默认值,因此让我们将以下内容添加到onSurfaceCreated()
blueMalletPosition = Point(0F, mallet.height / 2F, 0.4F)
我们添加handleTouchPress
的代码,代码中的未实现方法将在下面的叙述中完善:
fun handleTouchPress(normalizedX: Float, normalizedY: Float) {
// 计算出触摸点在三维世界中形成的线
val ray: Ray = convertNormalized2DPointToRay(normalizedX, normalizedY)
// 创建木槌的包围球
val malletBoundingSphere = Sphere(
Point(
blueMalletPosition.x,
blueMalletPosition.y,
blueMalletPosition.z
),
mallet.height / 2f
)
// 判定是否触摸到木槌,设置malletPressed的值
malletPressed = malletBoundingSphere.intersects(ray)
}
为了计算接触点是否与木槌相交,我们首先将接触点投射到射线上,接着,用一个边界球包裹木槌,然后测试光线是否与该球体相交。下图展示了触摸场景,我们的曲棍球桌现在显示在屏幕所在的平面上,而现在触摸的区域位于下面的黑色圆圈中:
很明显我们碰到了木槌。然而,我们接触的区域是二维空间,木槌在三维空间内。我们如何测试接触点是否与木槌相交?为了测试这一点,我们首先将这个平面的点转换为两个三维空间上的点:一个在视锥体的近端,另一个在视锥体的远端。在三维场景中如果我们从侧面观察,看上去就像下图:
让我们从定义convertNormalized2DPointToRay()
开始,并解决第一部分的问题:将接触点转换为一条射线。
定义射线类Ray
我们在Geometry.kt
添加两个类,一个是表示矢量的类Vector
,另外一个是表示射线的类Ray
。
class Vector(
val x: Float,
val y: Float,
val z: Float,
)
class Ray(
val point: Point,
val vector: Vector
)
然后我们还要在Geometry.kt
添加几个辅助方法:
// 创建矢量的中缀函数
infix fun Point.to(target: Point): Vector = vectorBetween(this, target)
fun vectorBetween(from: Point, target: Point): Vector =
Vector(
target.x - from.x,
target.y - from.y,
target.z - from.z
)
将点延伸为三维射线
通常,当我们将三维场景投影到只是一个平面的屏幕上时,我们使用透视投影和透视除法将顶点转换为标准化设备坐标。现在我们反过来进行这一步:我们有触摸点的标准化设备坐标,现在我们想找出触摸点在三维世界中对应的位置。要将接触点转换为三维空间上的线,我们需要撤消透视投影和透视除法。
目前我们已经得到了接触点的x和y坐标,但我们不知道接触点应该有多近或多远。为了解决z坐标的模糊性,我们将把接触点映射到三维空间中的一条直线:直线的近端映射到由投影矩阵定义的视锥体的近端,直线的远端映射到视锥体的远端。要进行这种转换,我们需要一个视图和投影矩阵的逆矩阵,它将撤销视图、投影矩阵的效果。让我们在AirHockeyRenderer
添加一个类变量:
/**
* [viewProjectionMatrix]的逆矩阵
*/
private val invertedViewProjectionMatrix = FloatArray(16)
然后,我们在AirHockeyRenderer
类的onDrawFrame()
中间插入一部分代码:
override fun onDrawFrame(gl: GL10?) {
// 清除之前绘制的内容
glClear(GL_COLOR_BUFFER_BIT)
multiplyMM(viewProjectionMatrix, 0, projectionMatrix, 0, viewMatrix, 0)
// 求4x4矩阵的逆矩阵
invertM(invertedViewProjectionMatrix, 0, viewProjectionMatrix, 0)
// 省略之前写好的下面的代码...
}
这个调用将创建一个逆矩阵,我们可以使用它将二维接触点转换为一对三维坐标。如果场景可以四处移动,视图矩阵将会影响场景的哪个部分在我们的手指下,因此我们必须考虑视图矩阵,而不能只求出投影矩阵的逆矩阵。因此我们求出投影矩阵右乘视图矩阵的结果的逆矩阵。
反转透视投影和透视除法
现在我们可以给出convertNormalized2DPointToRay()
的定义了,在AirHockeyRenderer
添加以下代码:
private fun convertNormalized2DPointToRay(normalizedX: Float, normalizedY: Float): Ray {
// 先在NDC坐标空间下进行考虑,给出触摸点对应的空间上的两点,
// 这件事很简单,NDC就是个正方体,肯定是其中某个点的z坐标为-1,而另一个点的z坐标为1
val nearPointNdc = floatArrayOf(normalizedX, normalizedY, -1F, 1F)
val farPointNdc = floatArrayOf(normalizedX, normalizedY, 1F, 1F)
val nearPointWorld = FloatArray(4)
val farPointWorld = FloatArray(4)
// 接下来用逆矩阵对两个点分别撤销变换,得到真实的世界坐标系下的坐标
multiplyMV(
nearPointWorld, 0, invertedViewProjectionMatrix, 0, nearPointNdc, 0
)
multiplyMV(
farPointWorld, 0, invertedViewProjectionMatrix, 0, farPointNdc, 0
)
// TODO 未完待续
}
为了将接触点变为空间上的射线,我们在NDC坐标中设置了两个点:一个点是z为-1的接触点,另一个点是z为+1的接触点。我们分别将这些点存储在nearPointNdc
和farPointNdc
中。因为我们不知道w分量应该是什么,所以我们把两者的w都设为1。然后,我们将每个点与invertedViewProjectionMatrix
相乘,得到世界空间中的坐标。
另外,我们还需要撤销透视除法的影响。viewProjectionMatrix
有一个有趣的特性:在我们将顶点与viewProjectionMatrix
的逆矩阵相乘之后,近点世界坐标nearPointWorld
和远点世界坐标farPointWorld
实际上将包含一个反转之后形成的w值。这是因为通常情况下,投影矩阵的主要的作用就是创建不同的w值,以便透视除法可以发挥其魔力。所以如果我们使用投影矩阵的逆矩阵进行乘法操作,我们也会得到一个反转的w值。我们需要做的就是用这些反转的w除以x,y,z,这样就消除了透视除法的影响。
让我们继续往convertNormalized2DPointToRay()
添加代码,并定义一个新函数divideByW()
private fun convertNormalized2DPointToRay(normalizedX: Float, normalizedY: Float): Ray {
// 省略...
divideByW(nearPointWorld)
divideByW(farPointWorld)
val nearPointRay = Point(nearPointWorld[0], nearPointWorld[1], nearPointWorld[2])
val farPointRay = Point(farPointWorld[0], farPointWorld[1], farPointWorld[2])
return Ray(
nearPointRay,
nearPointRay to farPointRay
)
}
private fun divideByW(vector: FloatArray) {
vector[0] /= vector[3]
vector[1] /= vector[3]
vector[2] /= vector[3]
}
执行相交测试
下一步是给出球体的定义,这样就能为木槌创建包围球。让我们在Geometry.kt
添加Sphere
类:
class Sphere(
val center: Point,
val radius: Float
)
我们现在还需要定义intersects
方法,以判断球体是否与射线相交。下面是示意图:
我们的目的是计算出球体和光线之间的距离,并与球的半径作比较,如果该距离小于半径,则射线与球体相交。我们首先取射线上的两个点:初始点和结束点,结束点由初始点加上射线的方向向量得出。然后,我们在这两点和球心之间创建一个假想的三角形,然后通过计算三角形的高度得到距离。
/**
* 判断[Sphere]是否与[Ray]在空间上相交
*/
fun Sphere.intersects(ray: Ray): Boolean =
distanceBetween(center, ray) < radius
/**
* 求点线距离
*/
fun distanceBetween(point: Point, ray: Ray): Float {
// TODO
}
判断球与射线是否相交的问题,现在转换为求点线距离的问题。关键点在于能否求出ray.p1、ray.p2与sphere.center三个点围成的三角形的面积。恰巧,向量叉积(Cross product)的大小存在几何意义,它是向量围成的三角形面积的两倍(或者说是平行四边形的面积)。
如何求拥有三个分量的向量的叉积?我们可以用行列式来表示具体过程:
m
⃗
×
n
⃗
=
(
a
1
,
b
1
,
c
1
)
×
(
a
2
,
b
2
,
c
2
)
=
∣
i
⃗
j
⃗
k
⃗
a
1
b
1
c
1
a
2
b
2
c
2
∣
=
(
b
1
c
2
−
b
2
c
1
)
i
⃗
+
(
a
2
c
1
−
a
1
c
2
)
j
⃗
+
(
a
1
b
2
−
a
2
b
1
)
k
⃗
=
(
b
1
c
2
−
b
2
c
1
,
a
2
c
1
−
a
1
c
2
,
a
1
b
2
−
a
2
b
1
)
\vec{m}\times\vec{n}=(a_1,b_1,c_1)\times(a_2,b_2,c_2)= \begin{vmatrix} \vec{i} & \vec{j} & \vec{k}\\ a_1 & b_1 & c_1 \\ a_2 & b_2 & c_2 \end{vmatrix} \\=(b_1c_2-b_2c_1)\vec{i}+(a_2c_1-a_1c_2)\vec{j}+(a_1b_2-a_2b_1)\vec{k} \\=(b_1c_2-b_2c_1, a_2c_1-a_1c_2, a_1b_2-a_2b_1)
m×n=(a1,b1,c1)×(a2,b2,c2)=∣∣∣∣∣∣ia1a2jb1b2kc1c2∣∣∣∣∣∣=(b1c2−b2c1)i+(a2c1−a1c2)j+(a1b2−a2b1)k=(b1c2−b2c1,a2c1−a1c2,a1b2−a2b1)
现在让我们添加distanceBetween
的具体实现:
class Point(
val x: Float,
val y: Float,
val z: Float,
) {
fun translateY(distance: Float): Point = Point(x, y + distance, z)
// 新增加的方法,根据方向向量计算新的点
fun translate(vector: Vector): Point {
return Point(
x + vector.x,
y + vector.y,
z + vector.z
)
}
}
class Vector(
val x: Float,
val y: Float,
val z: Float,
) {
// 新增方法,计算大小
fun length(): Float = sqrt(x * x + y * y + z * z)
// 新增方法,计算向量叉积
fun crossProduct(other: Vector): Vector =
Vector(
y * other.z - z * other.y,
z * other.x - x * other.z,
x * other.y - y * other.x
)
}
/**
* 求点线距离
*/
fun distanceBetween(point: Point, ray: Ray): Float {
// 计算两个向量
val centerToP1 = ray.point to point
val centerToP2 = ray.run { point.translate(vector) } to point
// 计算向量叉积的大小,这个值是三角形面积的两倍
val areaOfTriangleTimesTwo = centerToP1.crossProduct(centerToP2).length()
// 计算方向向量的长度
val lengthOfBase = ray.vector.length()
// 点线距离(高)=三角形面积x2/底
return areaOfTriangleTimesTwo / lengthOfBase
}
为了得到三角形的面积,我们首先需要计算这两个向量的叉积。计算叉积将得到与前两个向量垂直的第三个向量,但对我们来说更重要的是,这个向量的长度将等于前两个向量定义的三角形面积的两倍。
一旦我们有了三角形的面积,我们就可以使用三角形公式来计算三角形的高度,这将给出射线到球体中心的距离。三角形面积的两倍除以底部长度,就可以得到对应的三角形高,同时也是点线距离。一旦我们有了这个距离,我们可以将它与球体的半径进行比较。
通过拖动移动物体
现在我们已经能够测试木槌是否被触碰了,我们将努力解决下一部分的内容:当我们拖动木槌时,它要去哪里?我们可以这样思考:木槌平放在桌子上,所以当我们移动手指时,木槌应该随着我们的手指移动,并继续保持平放在桌子上的状态。我们可以通过执行射线—平面相交测试来计算木槌应该处于的位置。
让我们完成handleTouchDrag()
的定义,未实现的方法将稍后实现:
fun handleTouchDrag(normalizedX: Float, normalizedY: Float) {
if (malletPressed) {
val ray = convertNormalized2DPointToRay(normalizedX, normalizedY)
// 定义一个平面以表示我们的桌子
val plane = Plane(Point(0F, 0F, 0F), Vector(0F, 1F, 0F))
// 找出接触点与平面相交的位置。我们将沿着这个平面移动木槌。
val touchedPoint: Point = plane.intersectionPoint(ray)
blueMalletPosition = Point(touchedPoint.x, mallet.height / 2f, touchedPoint.z)
}
}
首先我们要检查是否触碰到木槌,只有触碰到木槌的情况下才应当移动它。如果是,那么我们将执行与handleTouchPress()
相同的射线转换。一旦我们有了代表接触点的射线,我们就可以找出射线与空中曲棍球台代表的平面相交的点,然后我们把木槌移动到那个点。
我们在Geometry.kt
添加对平面的定义Plane
类:
class Plane(
val point: Point,
val normal: Vector
)
平面的定义非常简单:它由一个法向量和该平面上的一个点组成;平面的法向量就是垂直于该平面的向量。平面还有其他的定义,但我们选择这种定义来表示平面。
在下图中,我们可以看到经过点(0,0,0)的平面的示例,其法向量为(0,0,1)。还有一条射线位于(-3,0,2),方向向量为(1,0,-1)。我们将用这个平面和射线来解释如何计算交点坐标。
我们打算求出上图中的点C坐标,其中的一个思路就是求出我们究竟需要沿着点A前进多少个方向向量,才能到达点C,也就是说我们希望求出
∣
A
C
∣
∣
u
⃗
∣
\frac{\lvert AC\rvert}{\lvert\vec{u}\rvert}
∣u∣∣AC∣的值,其中
u
⃗
\vec{u}
u是射线的法向量。我们之前为Point
类定义了translate(vector: Vector)
的方法,只要求出AC与
u
⃗
\vec{u}
u的长度比值,我们就可以利用translate
方法计算点C的坐标。
那么,我们如何求这个比值呢?我们首先给出一个结论:
设
θ
是
A
C
⃗
与
f
⃗
的
夹
角
,
α
是
A
B
⃗
与
f
⃗
的
夹
角
,
有
如
下
结
论
:
∣
A
C
⃗
∣
cos
θ
=
∣
A
B
⃗
∣
cos
α
即
A
C
⃗
⋅
f
⃗
=
A
B
⃗
⋅
f
⃗
∣
A
C
⃗
∣
cos
θ
与
∣
A
B
⃗
∣
cos
α
都
代
表
了
点
A
离
平
面
的
距
离
,
点
A
是
定
点
,
因
此
∣
A
C
⃗
∣
cos
θ
=
∣
A
B
⃗
∣
cos
α
设\theta是\vec{AC}与\vec{f}的夹角,\alpha是\vec{AB}与\vec{f}的夹角,有如下结论:\\ \lvert\vec{AC}\rvert\cos\theta=\lvert\vec{AB}\rvert\cos\alpha\\ 即\vec{AC}\cdot\vec{f}=\vec{AB}\cdot\vec{f}\\ \lvert\vec{AC}\rvert\cos\theta与\lvert\vec{AB}\rvert\cos\alpha都代表了点A离平面的距离,\\ 点A是定点,因此\lvert\vec{AC}\rvert\cos\theta=\lvert\vec{AB}\rvert\cos\alpha
设θ是AC与f的夹角,α是AB与f的夹角,有如下结论:∣AC∣cosθ=∣AB∣cosα即AC⋅f=AB⋅f∣AC∣cosθ与∣AB∣cosα都代表了点A离平面的距离,点A是定点,因此∣AC∣cosθ=∣AB∣cosα
稍微对结论做一下变形:
∣
A
C
⃗
∣
cos
θ
=
∣
A
B
⃗
∣
cos
α
∣
A
C
⃗
∣
=
∣
A
B
⃗
∣
cos
α
cos
θ
∣
A
C
⃗
∣
∣
u
⃗
∣
=
∣
A
B
⃗
∣
cos
α
∣
u
⃗
∣
cos
θ
∣
A
C
⃗
∣
∣
u
⃗
∣
=
∣
f
⃗
∣
∣
A
B
⃗
∣
cos
α
∣
f
⃗
∣
∣
u
⃗
∣
cos
θ
∣
A
C
⃗
∣
∣
u
⃗
∣
=
A
B
⃗
⋅
f
⃗
u
⃗
⋅
f
⃗
\lvert\vec{AC}\rvert\cos\theta=\lvert\vec{AB}\rvert\cos\alpha\\ \lvert\vec{AC}\rvert=\frac{\lvert\vec{AB}\rvert\cos\alpha}{\cos\theta}\\ \frac{\lvert\vec{AC}\rvert}{\lvert\vec{u}\rvert}=\frac{\lvert\vec{AB}\rvert\cos\alpha}{\lvert\vec{u}\rvert\cos\theta}\\ \frac{\lvert\vec{AC}\rvert}{\lvert\vec{u}\rvert}=\frac{\lvert\vec{f}\rvert\lvert\vec{AB}\rvert\cos\alpha}{\lvert\vec{f}\rvert\lvert\vec{u}\rvert\cos\theta}\\ \frac{\lvert\vec{AC}\rvert}{\lvert\vec{u}\rvert}=\frac{\vec{AB}\cdot\vec{f}}{\vec{u}\cdot\vec{f}}
∣AC∣cosθ=∣AB∣cosα∣AC∣=cosθ∣AB∣cosα∣u∣∣AC∣=∣u∣cosθ∣AB∣cosα∣u∣∣AC∣=∣f∣∣u∣cosθ∣f∣∣AB∣cosα∣u∣∣AC∣=u⋅fAB⋅f
我们最终可以通过求向量点积的形式来求取我们想要的结果,以下是intersectionPoint()
的具体实现,添加到Geometry.kt
中:
class Vector(
val x: Float,
val y: Float,
val z: Float,
) {
fun length(): Float = sqrt(x * x + y * y + z * z)
fun crossProduct(other: Vector): Vector =
Vector(
y * other.z - z * other.y,
z * other.x - x * other.z,
x * other.y - y * other.x
)
fun dotProduct(other: Vector): Float = x * other.x + y * other.y + z * other.z
fun scale(f: Float): Vector = Vector(x * f, y * f, z * f)
}
/**
* 计算[Plane]与[Ray]的相交点坐标。如果它们互相平行,返回值的三个分量值将会为[Float.NaN]
*/
fun Plane.intersectionPoint(ray: Ray): Point {
val rayToPlaneVector = ray.point to this.point
val scaleFactor = rayToPlaneVector.dotProduct(normal) / ray.vector.dotProduct(normal)
return ray.point.translate(ray.vector.scale(scaleFactor))
}
当射线平行于平面时,射线和平面之间不可能存在交点。因此方向向量与法向量的点积为0,当我们试图计算scaleFactor
时,我们将得到Float.NaN
现在,我们已经添加了使handleTouchDrag()
工作所需的所有内容。只剩下一部分:我们需要回到AirHockeyRenderer
,在绘制蓝色木槌时实际使用新的点。让我们更新onDrawFrame()
中的对positionObjectInScene()
的第二个调用,如下所示:
positionObjectInScene(blueMalletPosition.x, blueMalletPosition.y,
blueMalletPosition.z)
来吧,运行程序试试看;你现在应该可以拖动木槌了。
添加碰撞检测
现在你已经可以把木槌拖来拖去了,但在这个过程中,你可能注意到了我们的面临的一个问题:木槌可能会出界,如下图所示。在本节中,我们将添加一些基本的碰撞检测,还将添加一些基本的物理原理,让我们能够桌子周围击打冰球。
让玩家的木槌保持在边界内
让我们首先向AirHockeyRenderer
添加以下边界定义,并添加一个计算边界值的辅助方法:
/**
* 边界
*/
private val leftBound = -0.5F
private val rightBound = 0.5F
private val farBound = -0.8F
private val nearBound = 0.8F
// 保证value的值不小于min而且不大于max
private fun clamp(value: Float, min: Float, max: Float): Float {
/*return when {
value < min -> {
min
}
value > max -> {
max
}
else -> {
value
}
}*/
// 这行代码和上面的注释代码的含义一致,
return max.coerceAtMost(value.coerceAtLeast(min))
}
这些类变量对应于空中曲棍球桌的边缘。现在,我们可以更新handleTouchDrag()
,并用以下代码替换当前对blueMalletPosition
的赋值:
fun handleTouchDrag(normalizedX: Float, normalizedY: Float) {
if (malletPressed) {
val ray = convertNormalized2DPointToRay(normalizedX, normalizedY)
// 定义一个平面以表示我们的桌子
val plane = Plane(Point(0F, 0F, 0F), Vector(0F, 1F, 0F))
// 找出接触点与平面相交的位置。我们将沿着这个平面移动木槌。
val touchedPoint: Point = plane.intersectionPoint(ray)
blueMalletPosition = Point(
clamp(
touchedPoint.x,
leftBound + mallet.baseRadius,
rightBound - mallet.baseRadius
),
mallet.height / 2f,
clamp(
touchedPoint.z,
0f + mallet.baseRadius,
nearBound - mallet.baseRadius
)
)
}
}
如果我们回顾一下handleTouchDrag()
,我们就会想起touchedPoint
代表我们触摸屏幕的位置和空中曲棍球桌所在的平面之间的交点。木槌想要移动到这一点。
但是,为了防止木槌超出桌子的边界,我们使用clamp()
函数来保证接触点的坐标不超过我们规定的边界。木槌不能超过桌子两边的边缘,我们还考虑了桌子的分界线,使用0F代替farBound
,这样玩家就不能越过分界线,另外还要考虑木槌的半径,这样木槌的边缘也不能超出边界。
继续运行应用程序。你现在应该发现你的蓝色木槌拒绝出界。
增加速度和方向属性
现在我们可以添加一些代码来用木槌敲打冰球。为了了解冰球应该做出什么反应,我们需要回答两个问题:
- 木槌的速度有多快?
- 木槌向哪个方向移动?
为了能够回答这些问题,我们需要跟踪是如何随着时间推移,木槌是如何移动的。我们要做的第一件事是向AirHockeyRenderer
添加一个名为previousBlueMalletPosition
的新成员变量:
private lateinit var previousBlueMalletPosition: Point
在handleTouchDrag()
更新blueMalletPosition
的值之前,我们需要把blueMalletPosition
的值给保存到previousBlueMalletPosition
,在blueMalletPosition
赋值之前添加以下代码:
// 保存上一个位置
previousBlueMalletPosition = blueMalletPosition
下一步是存储冰球的位置、速度和方向。将以下成员变量添加到AirHockeyRenderer
,并在onSurfaceCreated
添加以下初始化代码:
/**
* 冰球的位置和速度
*/
private lateinit var puckPosition: Point
private lateinit var puckVector: Vector
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
//...
puckPosition = Point(0f, puck.height / 2f, 0f)
puckVector = Vector(0F, 0F, 0F)
}
现在我们可以在handleTouchDrag()
的结尾处添加以下碰撞检测代码,记得把这段代码放在语句if (malletPressed) {
内部。
val distance: Float = vectorBetween(blueMalletPosition, puckPosition).length()
// 如果距离小于两者半径,说明发生了碰撞
if (distance < puck.radius + mallet.baseRadius) {
// 设置最初的速度矢量
puckVector = previousBlueMalletPosition to blueMalletPosition
}
该代码将首先检查蓝色木槌和冰球之间的距离,然后查看该距离是否小于两个半径的总和。如果是的话,那么木槌已经击打了冰球,我们使用之前的木槌位置和当前的木槌位置来创建冰球的速度向量。木槌的速度越快,矢量就越大,冰球的速度也就越快。
接下来我们需要更新onDrawFrame()
,以便冰球在每一帧上持续移动。让我们在onDrawFrame()
的开头添加以下代码:
// 根据速度向量更新冰球的位置
puckPosition = puckPosition.translate(puckVector)
接下来,我们依然需要更新对positionObjectInScene()
的调用,让它使用最新的冰球位置来放置冰球:
// 放置Puck
positionObjectInScene(puckPosition.x, puckPosition.y, puckPosition.z)
添加边界反弹效果
现在我们有了另一个问题:我们的冰球可以移动,但它不会停下来,为了解决这个问题,我们首先必须在冰球上添加边界检查,并在冰球撞击桌子边缘时反弹。
调用puckPosition.translate()
之后,我们可以向onDrawFrame()
添加以下代码:
// 分别判断是否应当反转速度向量的x分量或z分量
if (puckPosition.x < leftBound + puck.radius
|| puckPosition.x > rightBound - puck.radius
) {
puckVector = Vector(-puckVector.x, puckVector.y, puckVector.z)
}
if (puckPosition.z < farBound + puck.radius
|| puckPosition.z > nearBound - puck.radius
) {
puckVector = Vector(puckVector.x, puckVector.y, -puckVector.z)
}
// 保证冰球不超出边界
puckPosition = Point(
clamp(puckPosition.x, leftBound + puck.radius, rightBound - puck.radius),
puckPosition.y,
clamp(puckPosition.z, farBound + puck.radius, nearBound - puck.radius)
)
我们检查冰球是否触碰到桌子的左、右边缘,然后通过反转向量的x分量来反转它的方向。对于z分量的处理与之类似,我们通过反转向量的z分量来反转它的方向。
最后,我们调整冰球的位置,使之不超出桌子的边界。如果我们再运行一次程序,我们的冰球现在应该在桌子里弹来弹去,而不是飞离边缘。
增加摩擦
冰球运动的方式仍然有一个大问题:它从不减速!这看起来不太现实,所以我们将添加模拟摩擦力的代码来减缓冰球的速度。在onDrawFrame()
中与冰球相关的代码末尾,添加以下方法来在每一帧中降低冰球的速度:
// 模拟摩擦力
puckVector = puckVector.scale(0.99F)
如果我们再运行一次,我们会看到冰球速度逐渐变慢,最终停下来。我们可以通过给反弹添加额外的阻尼来使物体的运动表现变得更真实。修改onDrawFrame()
的以下代码:
// 分别判断是否应当反转速度向量的x分量或z分量
if (puckPosition.x < leftBound + puck.radius
|| puckPosition.x > rightBound - puck.radius
) {
puckVector = Vector(-puckVector.x, puckVector.y, puckVector.z).scale(0.8F)
}
if (puckPosition.z < farBound + puck.radius
|| puckPosition.z > nearBound - puck.radius
) {
puckVector = Vector(puckVector.x, puckVector.y, -puckVector.z).scale(0.8F)
}
现在我们将看到冰球被边界反弹时,速度会减慢一些。
回顾和总结
本章我们讨论了一些有趣的话题:首先,我们学习了如何用手指抓住并移动木槌,然后我们学习了如何让冰球在桌子上反弹。你可能已经注意到了冰球与木槌重叠的问题,我们将在以后学习如何使用深度缓冲区移除隐藏表面。
有些数学知识可能超出了你的理解,但重要的是要在较高的层次上理解这些概念,以便我们知道如何使用它们。有很多很棒的库可以让事情变得更简单,比如Bullet physics和JBox2D。
有很多方法可以扩展我们所学的内容:例如,你可以创建一个保龄球游戏,其中一个球被玩家扔出,观察那个球沿着球道向前滚动,并撞到远处的瓶子。触摸式互动是移动设备真正与众不同的地方。
我们的空中曲棍球项目到此结束。花点时间坐下来反思我们所学到的一切,因为我们确实已经走了很长的路。我们距离一个完整的游戏也不远了;我们还差一些游戏中的声音,一个基本的人工智能(AI)对手,一个菜单和一些特效。有一些库可以处理其中的一些工作,比如libgdx。你也可以通过一本书,比如《Beginning Android Games》,更详细地探索游戏开发的这些方面。
在开发过程中,我们学到了很多重要的概念。我们从研究着色器的工作原理开始,通过学习颜色、矩阵和纹理来构建事物,甚至学习如何构建简单物体并用手指移动它们。为你所学到的和已经完成的内容感到自豪吧,因为我们是直接使用底层的OpenGL API,一步步走到这的。
练习
让我们花一些时间完成以下练习:
- 既然我们没有在每一帧上更改视图矩阵,那么您可以做些什么来优化
viewProjectionMatrix
和invertDViewProjectionMatrix
的更新? - 冰球目前的反应是相同的,无论木槌直接击中冰球还是从击中侧面。更新碰撞代码以考虑撞击角度。回想一下之前的单位圆,可能会让您了解如何实现这一点。
- 更新碰撞代码,使冰球在自行移动时也能与木槌相互作用;额外的需求是,请避免冰球的运动速率与帧速率直接相关。提示:将运动矢量存储作为每秒的单位,并计算出每帧之间经过了多少时间来计算该帧的运动增量。