OpenGL.ES在Android上的简单实践(9-曲棍球(交互、相交测试))

OpenGL.ES在Android上的简单实践:9-曲棍球(交互、相交测试)
0、开始之前,我想说些话。
桌面曲棍球项目从开篇1,到上篇8其实基本完成了OpenGL-3D部分的基础学习了,曲棍球项目也快接近尾声了。从这篇文章开始,还有下篇文章的内容,我自己感觉有点偏,准确来说不是OpenGL的必学内容,是三维游戏的基础知识。而且和文章7、8(构建物体对象)的有一丝相似点,就是在实际商业应用开发中是一般不会用到的。(除非是自研的游戏引擎)

在本篇文章开始之前,我们先来个大总结,归纳下1~8的知识,温故而知新:
1、我们从环境初始化开始,认识了笛卡尔坐标系下 顶点 的定义。
2、然后就是认识着色器程序,分为两个部分,顶点着色器和片段着色器,基于共性我们封装成一个类ShaderProgram。着色器定义属性关键字attribute,定义变量关键字uniform。注意事项查看这里的一些基本的glsl概念。
3、之后我们进入三维世界,分别认识了什么是透视除法、归一化坐标等。
4、接着学习纹理,通过着色器渲染纹理并显示到屏幕上。还有纹理的MIP层级图生成,纹理放大缩小的过滤参数等学习。
5、接着我们把复杂的几何图形分解成基础的OpenGL三角形扇、三角形带,最终拼接成一个可以辨别的三维模型,最终通过VAO传进到OpenGL的ShaderProgram当中构建出来。
6、最后,我们学习三大矩阵->投影矩阵、视图(观察)矩阵、模型矩阵。
以上都是基础的知识重点,希望大家能基本掌握,第一次看可能有点懵逼,先跟着敲代码,把流程走一遍有所理解之后重看,就会慢慢进入道上了。

1、为渲染器增加触控支持
在本篇文章,我们就会通过添加触控支持使这个程序更具有交互性,我们学习如何使用三维相交测试和碰撞检测,以便我们可以抓住木槌并在桌子范围内来回拖动它。
事不宜迟,我们修改HockeyActivity的代码:

@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ... ... glSurfaceView.setOnTouchListener(this); setContentView(glSurfaceView); }@Override public boolean onTouch(View view, MotionEvent motionEvent) { return false; }

我们先为Activity增加监听触控事件,通过调用setOnTouchListener监听glSurfaceView的触控事件。当用户触碰了glSurfaceView,我们就会收到一个onTouch的回调 。
在Android里,触控事件是发送在视图的坐标空间中的,举个例子,如果glSurfaceView这个视图被定义成480个像素宽、800个像素高,那么左上角映射到(0,0)右下角映射到(480,800)。接着我们需要做的是检查是否有我们需要的事件要处理。我们如下更新onTouch接口:
@Override public boolean onTouch(View view, MotionEvent event) { if(event == null) return false; final float normalizedX = (event.getX() / view.getWidth()) * 2 - 1; final float normalizedY = -((event.getY() / view.getHeight()) * 2 - 1); if(event.getAction() == MotionEvent.ACTION_DOWN) { glSurfaceView.queueEvent(new Runnable() { @Override public void run() { hockeyRenderer.handleTouchDown(normalizedX,normalizedY); } }); }else if(event.getAction() == MotionEvent.ACTION_MOVE) { glSurfaceView.queueEvent(new Runnable() { @Override public void run() { hockeyRenderer.handleTouchMove(normalizedX,normalizedY); } }); }else { return false; } return true; }

首先,我们在着色器中需要使用归一化设备坐标,因此我们需要把触控事件坐标转换回归一化设备坐标。因为安卓设备的屏幕竖直向下为y的正方向,这和OpenGL向上为正相反,这需要把y轴反转,并把每个坐标按比例映射到范围[-1,1]内。(把实际触碰横坐标 event.getX 除以 视图的宽 得出 量化后的值 再乘以2 表示-1~0 和 0~1的两端范围,减1是把值控制在少于1的范围)
我们用不同的方法分别处理按压(ANCTION_DOWN)和拖拽(ACTION_MOVE)事件,重要的是,要记住,Android的UI运行在主线程,而OpenGL的GLSurfaceView运行在一个单独的线程中,因此我们需要使用线程安全的技术在两个线程之间通信,我们使用queueEvent给OpenGL线程分发调用,我们在hockeyRenderer分别创建handleTouchDown和handleTouchMove两个方法,并加入打印:
package org.zzrblog.blogapp.hockey.HockeyRenderer2.javapublic void handleTouchDown(float normalizedX, float normalizedY) { Log.d(TAG, "handleTouchDown normalizedX*normalizedY == " +normalizedX+" "+normalizedY); }public void handleTouchMove(float normalizedX, float normalizedY) { Log.d(TAG, "handleTouchMove normalizedX*normalizedY == " +normalizedX+" "+normalizedY); }

现在可以运行程序,尝试触碰屏幕时,看看日志打印了什么。

2、增加相交测试
我们已经在设备坐标里得到了屏幕的被触碰的区域了,接下来我们需要决定这个被触碰的屏幕区域是否指向木槌所在的三维世界位置上。这就需要我们增加相交测试了,这是开发三维游戏和应用是一个非常重要的操作。以下是我们需要做的:
1、首先,我们要把二维屏幕坐标转换到三维空间,并看看我们触碰到了什么。要做到这点,我们要把被触碰的点投射到一条射线上,这条线从我们的视点跨越那个三维场景。
2、然后,我们需要检查看看这条射线是否与木槌相交,为了是事情简单些,我们假定那个木槌实际上是一个差不多同样大小的球,包裹着物体整体,然后用射线和包围球进行相交测试。
我们从视觉上看看这些东西,可能更加直观。让我们想象一个桌面曲棍球的场景,有桌子、冰球和木槌,并想象在下图两个圆圈的位置触摸屏幕(图有点丑,请大家别介意)
OpenGL.ES在Android上的简单实践(9-曲棍球(交互、相交测试))
文章图片

黑色圆圈相当于包围球,灰色水彩带相当于我们触碰的特定区域,这一特定区域是在二维屏幕上,而木槌的包围球是在三维空间中。要测试这个,我们首先要把那个二维的点转换为两个三维的点:一个在三维视锥体的近端,另一个在三维视锥体的远端(如果不明白视椎体这个单词,请查看这篇文章)然后,我们在这两个点之间画一条直线来创建一条射线。我们从侧面看一下这个场景,下面的示意图展示了这条射线怎样与那个三维场景相交。
OpenGL.ES在Android上的简单实践(9-曲棍球(交互、相交测试))
文章图片

为了使相交测试的数学计算简便化,我们在木槌生成的位置点假定一个包围球代表木槌的被触碰的位置。
准备代码之前,我们先备份之前的劳动成果,复制黏贴HockeyRenderer2,改名为HockeyRenderer3,HockeyActivity使用HockeyRenderer3为渲染器对象。准备好后我们列出需求,以下基本含括了整个任务的需求,我们从实际需求出发,一个个解决。
1、木槌位置以及包围球的表现形式
2、二维屏幕触碰点转换成三维世界的近平面点。
3、从近平面的点发射到远平面的射线
4、射线与包围球的相交测试

下面我们以需求分成四个小节,逐一讲解。

· 2.1木槌位置 & 包围球
对于第一个需求,木槌位置,我们可以用自定义的Geometry.Point代表,在HockeyRenderer3添加如下代码:
private Geometry.Point malletPosition ; ... ...@Override public void onSurfaceChanged(GL10 gl10, int width, int height) { GLES20.glViewport(0,0,width,height); ... ... Matrix.rotateM(table.modelMatrix,0, -90f, 1f,0f,0f); Matrix.translateM(mallet.modelMatrix,0, 0f, mallet.height, 0.5f); Matrix.translateM(puck.modelMatrix,0, 0f, puck.height, 0f ); malletPosition = new Geometry.Point(0f, mallet.height, 0.5f); }

我们根据mallet在z上的偏移初始化其所在位置,然后就到包围球了,我们在Geometry增加球的定义Sphere:
package org.zzrblog.blogapp.utils.Geometry.java; ... ... // 球 public static class Sphere { public final Point center; //中心远点 public final float radius; //球半径public Sphere(Point center, float radius){ this.center = center; this.radius = radius; } }

有了以上球的定义,木槌的包围球就能这样定义了
public void handleTouchDown(float normalizedX, float normalizedY) { Log.d(TAG, "handleTouchDown normalizedX*normalizedY == " +normalizedX+" "+normalizedY); // 我们为啥不直接拿malletPosition当Sphere的中心点? // 因为按照原计划木槌位置是跟着手指滑动而改变,所以单纯用初始化的malletPosition不够严谨准确。 Geometry.Sphere malletBoundingSphere = new Geometry.Sphere( new Geometry.Point(malletPosition.x, malletPosition.y, malletPosition.z), mallet.raduis); }

噢yeah~就这样我们解决了第一个需求。

· 2.2二维转三维? 三维转二维!
现在我们还不懂2D->3D的理论知识,但我们已经分析过3D->2D,我们来回顾一下,当我们把一个三维场景投递到二维屏幕的时候,我们使用透视投影和透视除法,把顶点坐标变换为归一化设备坐标。
现在我们按相反方向变换:我们有被触碰点的归一化设备坐标,我们要计算出在三维世界里那个被触碰的点与哪里相对应,为了把被触碰的点转换为一个三维射线,实质上我们需要取消透视投影和透视除法。
我们当前有被触碰的二维屏幕坐标,但我们还不知道它应该在多远或者多近的地方。要解决这个模糊性问题,我们把被触碰的点映射到三维空间的一条直线:直线的近端映射到我们在投影矩阵中定义的视椎体的近平面,直线的远端映射到视椎体的远平面
要实现这个转换,我们需要一个转置矩阵,它用于取消视图矩阵和投影矩阵的效果。其实就是视图矩阵和投影矩阵相乘之后的转置矩阵,让我们添加如下代码:
private final float[] invertViewProjectionMatrix = new float[16]; public HockeyRenderer3(Context context) { ... ... 记得初始化为单位矩阵 Matrix.setIdentityM(invertViewProjectionMatrix,0); }@Override public void onSurfaceChanged(GL10 gl10, int width, int height) { ... ... Matrix.multiplyMM(viewProjectionMatrix,0,projectionMatrix,0, viewMatrix,0); Matrix.invertM(invertViewProjectionMatrix, 0,viewProjectionMatrix, 0); ... ... // 得到视图和投影矩阵的乘积后,随即我们获取它的转置矩阵。 }

Matrix.invertM(invertViewProjectionMatrix, 0,viewProjectionMatrix, 0); 这个调用会创建一个转置矩阵,我们可以用它把那个二维被触碰的点转换为两个三维坐标。如果场景可以移来移去,它就会影响场景的哪一部分在手指下面,因此我们也把视图矩阵考虑在内,所以获取的是 视图和投影矩阵合并后的结果。
接下来我们可以定义方法convertNormalized2DPointToRay()。从方法名字来看就知道了,通过这个方法,我们把触碰屏幕的二维归一化坐标转换成三维的射线,有了这些基础,我们正式开始2D->3D的方法 convertNormalized2DPointToRay
private void convertNormalized2DPointToRay( float normalizedX, float normalizedY) {final float[] nearPointNdc = {normalizedX, normalizedY, -1, 1}; final float[] farPointNdc = {normalizedX, normalizedY, 1, 1}; final float[] nearPointWorld = new float[4]; final float[] farPointWorld = new float[4]; Matrix.multiplyMV(nearPointWorld,0, invertViewProjectionMatrix,0, nearPointNdc,0); Matrix.multiplyMV(farPointWorld,0, invertViewProjectionMatrix,0, farPointNdc,0); ... ... }

我们在归一化设备坐标里设置两个点:其中一个点z值为-1,而另外一个点z值为+1。我们分别把这两个点存储在nearPointNdc 和 farPointNdc。同时我们把w分量设为1,代表他们在坐标意义上是一个点(0代表的是方向,1代表的是点,没为什么,OpenGL就是这样定义  ̄△ ̄;)接下来,我们把每个点都与invertViewProjectionMatrix相乘,这样我就取消了视图和投影矩阵的作用了,得到世界空间中的坐标。
接下来,我们还需要撤销透视除法的影响,不清除透视除法为何物的同学,可以到这里回顾一下。转置的视图和投影矩阵有一个有趣的属性:把顶点和转置的视图和投影矩阵相乘以后,nearPointWorld和farPointWorld实际上含有了反转的w值。这是因为,通常情况下,投影矩阵的主要意义就是创建不同的w值,以便透视除法可以施加效果。因此,如果我们使用一个反转的投影矩阵,我们就会得到一个反转的w。我们所需要做的就是把x、y和z除以反转的w,这样就可以撤销了透视除法的影响了。
so,更新代码如下:
private void convertNormalized2DPointToRay( float normalizedX, float normalizedY) {final float[] nearPointNdc = {normalizedX, normalizedY, -1, 1}; final float[] farPointNdc = {normalizedX, normalizedY, 1, 1}; final float[] nearPointWorld = new float[4]; final float[] farPointWorld = new float[4]; Matrix.multiplyMV(nearPointWorld,0, invertViewProjectionMatrix,0, nearPointNdc,0); Matrix.multiplyMV(farPointWorld,0, invertViewProjectionMatrix,0, farPointNdc,0); divideByW(nearPointWorld); divideByW(farPointWorld); }private void divideByW(float[] vector) { vector[0] /= vector[3]; vector[1] /= vector[3]; vector[2] /= vector[3]; }

就这样,我们就成功的把二维屏幕的触碰点,转化成三维视椎体上近平面和远平面的一组点了。ヾ(??▽?)ノ

· 2.3 定义一条射线
我们现在已经成功地把一个被触碰的点转换为世界空间中的两个点了。我们现在可以用这两个点定义一个跨越那个三维场景的射线了,在此之前,我们回想一下高中所学的知识,射线的定义是从一个点出发,沿着一个方向发出的直线。而在几何坐标中,默认情况下从坐标O出发,指向特定坐标A,我们称为向量OA,然后向量OA-向量OB=向量BA。这样我们就能得到从B点指向A的方向射线了。
package org.zzrblog.blogapp.utils.Geometry.java; public static class Vector { public final float x,y,z; public Vector(float x, float y, float z) { this.x = x; this.y = y; this.z = z; } }public static class Ray { public final Point point; public final Vector vector; public Ray(Point point, Vector vector) { this.point = point; this.vector = vector; } }public static Vector vectorBetween(Point from, Point to) { return new Vector( to.x-from.x, to.y-from.y, to.z-from.z); }

有了这些定义,我们就可以产生近平面到远平面的射线了。更新convertNormalized2DPointToRay如下代码:
private Geometry.Ray convertNormalized2DPointToRay( float normalizedX, float normalizedY) {final float[] nearPointNdc = {normalizedX, normalizedY, -1, 1}; final float[] farPointNdc = {normalizedX, normalizedY, 1, 1}; final float[] nearPointWorld = new float[4]; final float[] farPointWorld = new float[4]; Matrix.multiplyMV(nearPointWorld,0, invertViewProjectionMatrix,0, nearPointNdc,0); Matrix.multiplyMV(farPointWorld,0, invertViewProjectionMatrix,0, farPointNdc,0); divideByW(nearPointWorld); divideByW(farPointWorld); Geometry.Point nearPointRay = new Geometry.Point(nearPointWorld[0],nearPointWorld[1],nearPointWorld[2]); Geometry.Point farPointRay = new Geometry.Point(farPointWorld[0],farPointWorld[1],farPointWorld[2]); // 从nearPointRay出发,方向从nearPointRay指向farPointRay的射线 return new Geometry.Ray(nearPointRay, Geometry.vectorBetween(nearPointRay,farPointRay)); }

噢yeah,我们现在已经完成了4个需求的3个了,我们立马更新方法 handleTouchDown
public void handleTouchDown(float normalizedX, float normalizedY) { Log.d(TAG, "handleTouchDown normalizedX*normalizedY == " +normalizedX+" "+normalizedY); Geometry.Sphere malletBoundingSphere = new Geometry.Sphere( new Geometry.Point(malletPosition.x, malletPosition.y, malletPosition.z), mallet.raduis); Geometry.Ray ray = convertNormalized2DPointToRay(normalizedX, normalizedY); }


· 2.4 线与圆之间的相交测试
【OpenGL.ES在Android上的简单实践(9-曲棍球(交互、相交测试))】我们之前提及过,我们假定木槌是一个球体,相交测试就会相当容易。实际上,我已经定义了一个与木槌大小相当的包围球malletBoundingSphere;接下来,我们还是要用高中时期所学的基本几何知识,来进行相交测试。
在我们编写代码之前,让我们把这个相交测试可视化(如下图示),因为这会使得它更容易理解:
1、我们需要计算出球体与射线之间的距离。要得到这个距离,我们首先定义射线上的两个点:起点和终点,终点是由起点与射线向量相加而得到。接下来,在这两个点与球体的中心点之间创建一个虚拟三角形,最后,通过计算三角形的高就得到这个距离
2、接下来,我们比较那个距离和球体的半径。如果那个距离比半径小,那么射线就与球体相交了。
OpenGL.ES在Android上的简单实践(9-曲棍球(交互、相交测试))
文章图片

按照以上原理,我们在几何工具类Geometry.java添加如下方法:
package org.zzrblog.blogapp.utils.Geometry.java; // 包围球 与 射线 的相交测试接口 public static boolean intersects(Ray ray, Sphere sphere) { return sphere.radius > distanceBetween(shpere.center, ray); } // 求出包围球中心点 与 射线的距离 public static float distanceBetween(Point centerPoint, Ray ray) { ... ... }

接下来,又要上几何数学课了。
如上图所示,我们可以定义两个向量:一个从射线的第一个开始点到球心,另一个是从结束点到球心。这两个向量一起能定义一个三角形。要得到这个三角形的面积,我们首先需要计算这两个向量的交叉乘积(cross product)计算这个交叉乘积会得到第三个向量,它是垂直前两个向量构成的平面,但是对我们更重要的特性是,这个向量的长度的大小恰好是前两个向量定义的三角形的面积的两倍。详尽的数学原理和矩阵运算的公式,请参考这里的矩阵形式。(特别需要理清 叉积 与 点积 的区别)
一旦得到了三角形面积,就可以使用三角形公式计算三角形的高了,这个高就是射线与球体中心的距离。这个高就等于(area*2)/ lengthOfRay。我们先存储两个向量的叉积(area*2),并利用ray.vector的长度(lengthOfRay)计算三角形底边的长度,这样我们就可以把三角形的高求出来了。之后,我们把它与球体的半径作比较,看看这个射线是否与球体相交。
通过以上分析,我们需要增加一些数学运算方法:点+方向=另外一个点;向量的长度值;向量之间的叉积;
public static class Point { public final float x,y,z; public Point(float x,float y,float z){ this.x = x; this.y = y; this.z = z; } public Point translateX(float value) { return new Point(x+value,y,z); } public Point translateY(float value) { return new Point(x,y+value,z); } public Point translateZ(float value) { return new Point(x,y,z+value); } // 点+方向=另外一个点 public Point translate(Vector vector) { return new Point(x+vector.x,y+vector.y,z+vector.z); } }

public static class Vector { public final float x,y,z; public Vector(float x, float y, float z) { this.x = x; this.y = y; this.z = z; } // 求标量长度值 public float length() { return (float) Math.sqrt(x*x + y*y + z*z); } // 叉积 矩阵形式运算 public Vector crossProduct(Vector other) { return new Vector( (y*other.z) - (z*other.y), (x*other.z) - (z*other.x), (x*other.y) - (y*other.x) ); } }

好啦,我们按照上面的原理,正式编写distanceBetween:
// 求出包围球中心点 与 射线的距离 public static float distanceBetween(Point centerPoint, Ray ray) { // 第一个向量:开始点到球心 Vector vStart2Center = vectorBetween(ray.point, centerPoint); // 第二个向量:结束点到球心 Vector vEnd2Center = vectorBetween( ray.point.translate(ray.vector), // 结束点 = 开始点 + 方向向量。 centerPoint); // 两个向量的叉积 Vector crossProduct = vStart2Center.crossProduct(vEnd2Center); // 两个向量叉积的值大小 = 三角形面积 * 2 float areaOf2 = crossProduct.length(); // 求出射线的长度 = 三角形的底边长 float lengthOfRay = ray.vector.length(); // 高 = 面积*2 / 底边长 float distanceFromSphereCenterToRay = areaOf2 / lengthOfRay; return distanceFromSphereCenterToRay; }

万事俱备,我们回头完成handleTouchDown方法:
package org.zzrblog.blogapp.hockey.HockeyRenderer3.java; private boolean malletPressed; public void handleTouchDown(float normalizedX, float normalizedY) { Log.d(TAG, "handleTouchDown normalizedX*normalizedY == " +normalizedX+" "+normalizedY); // 我们为啥不直接拿malletPosition当Sphere的中心点? // 因为按照原计划木槌位置是跟着手指滑动而改变,所以单纯用初始化的malletPosition不够准确。 Geometry.Sphere malletBoundingSphere = new Geometry.Sphere( new Geometry.Point(malletPosition.x, malletPosition.y, malletPosition.z), mallet.raduis); Geometry.Ray ray = convertNormalized2DPointToRay(normalizedX, normalizedY); malletPressed = Geometry.intersects(ray, malletBoundingSphere); Log.d(TAG, "malletPressed : "+malletPressed); }

现在,我们终于完成了相交测试的所有部分。运行工程,并加入一些调试语句看看,当你的手指碰到木槌时会发生什么?

小结:对于本章节,如同我一开始所说的,并不是OpenGL标准里面的内容,更多是一丝很基础很简陋的游戏引擎部分理论,但是2D到3D的转变也是比较常用到的,我们这里简述一下其中步骤思路:
1、把屏幕坐标转换为归一化设备坐标,通过归一化设备坐标x,y构造两个4分量的坐标点:近平面(z=-1)和远平面(z=+1)(w都设置为1),然后再通过 视图x投影的矩阵的转置矩阵,把这两个坐标点放置在准确的Z平面上。此时的w分量的值是=之前3D转2D透视除法作用后的w的转置,我们把xyz除以这个w的转置,取消透视除法的效果,真正达到OpenGL的3D世界坐标上。
2、通过这两个点,构造一条射线。向量OA-向量OB = 向量BA;然后我们还以木槌的中心为球心,创建一个测试包围球。
3、利用几何向量叉积知识,求出两点一直线 与 包围球的距离,通过判断距离与球的半径的大小,以完成相交测试。
希望大家能明白其中的原理。

    推荐阅读