OpenGL.ES在Android上的简单实践:10-曲棍球(拖动物体、碰撞测试)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/a360940265a/article/details/79509436

OpenGL.ES在Android上的简单实践:10-曲棍球(拖动物体、碰撞测试)

1、让木槌跟随手指移动

继续上一篇文章9的内容。既然可以测试木槌是否被触碰了,我们将继续努力下去:当我们来回拖动木槌的时候,它要去哪里?我们可以用这种方式思考:木槌平放在桌面上,当我们来回移动手指的时候,木槌应该随着手指移动并继续平放在桌子上。我们可以通过执行 射线-平面(Ray-Plane) 相交测试计算出它的正确位置。

以上的理论分析,我们又多出了一个几何概念——平面。在我们的认知中,两点相连成一直线,两线相交成一平面。所以,我们是否为平面(Plane)定义两个相交的向量?此时我们引入另外一个新的数学概念 法向量。简单介绍一下法向量:法向量是空间解析几何的一个概念,垂直于平面的直线所表示的向量为该平面的法向量。由于空间内有无数个直线垂直于已知平面,因此一个平面都存在无数个法向量(包括两个单位法向量)。法向量也成为法线。而且不单止光滑平面才有法向量,曲面在某点P处的法线为垂直于该点切平面的向量。也就是说,一个光滑的球,它的表面也是有无限个法向量。

有了以上知识,我们在Geometry.java更新代码,我们定义的平面就非常简单了:它包含一个法向向量(normal vector)和平面上的一个点:法向向量仅仅是一个垂直于那个平面的一个向量。平面也有其可能的定义(两个相交的向量),但这个是简单的,易于使用的定义。

public static class Plane {
	public final Point point;
	public final Vector normal;
	
	public Plane(Point point,Vector normal) {
		this.point = point;
		this.normal = normal;
	}
}

下面是一个平面与射线相交的栗子。一个平面位于(0,0,0),法向量(0,1,0);这里还有一条射线,位于(-2,1,0),且向量为(1,-1,0)。我们要使用这个平面和射线解释相交测试,并分析如何计算出这个交点。

                                          

接下来我们开始搬砖写代码了。首先我们从handleTouchMove开始:

private void handleTouchMove(float normalizedX, float normalizedY) {
	if(malletPressed) { 
		// 根据屏幕触碰点 和 视图投影矩阵 产生三维射线
		Geometry.Ray ray = convertNormalized2DPointToRay(normalizedX, normalizedY);
		// 定义的桌子平面,观察平面的点为(0,0,0)
		Geometry.Plane tablePlane = new Geometry.Plane(new Geometry.Point(0,0,0), new Geometry.Vector(0,1,0));
		// 进行射线-平面 相交测试
		Geometry.Point touchedPoint = Geometry.intersectionPoint(ray, tablePlane);
                // 根据相交点 更新木槌位置
                malletPosition = new Geometry.Point(touchedPoint.x, touchedPoint.y, touchedPoint.z);
	}
}

只有当我们开始使用手指按住那个木槌时,我们才想要拖动它,如果是,那我们就做射线转换,它与我们上一篇文章9中的转换理论一样。一旦我们有了表示被触碰点的射线,我们就要找出这条射线与表示曲棍球桌子的平面在哪里相交了,随即就能更新木槌的位置。

要想计算这个交点,首先需要把射线方向向量放大(缩小)到一定情况,才能和平面想接触;这个放大缩小倍数就是缩放因子(scale factor)接下来我们用这个被缩放的方向向量 平移射线的起点来找出这个相交的点。

要计算这个缩放因子(scale factor)我们首先创建一个向量,它在射线的起点和平面的一个点之间。然后计算那个向量与平面法向向量之间的点积(dot product)(上文我们提及过叉积,叉积与点积的区别,请参考这里的向量积与数量积的区别)

之后我们计算这个缩放量:我们用  射线起点到平面的向量 与 平面法向向量的点积 除以 射线方向向量 与 平面法向向量的点积。这就得到了我们需要的缩放因子。

其中更详尽的数学原理,请参考wikipedia,要想更深入的探索,可以到YouTube或B站搜索3blue1brown,能更好的探究线性代数的本质与几何意义。有了以上理论分析,我们开始编写 Geometry.intersectionPoint :

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 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)
            );
        }
        public float dotProduct(Vector other) {
            return x * other.x +
                    y * other.y +
                    z * other.z ;
        }
        public Vector scale(float f) {
            return new Vector(x*f, y*f, z*f);
        }
    }

    public static Point intersectionPoint(Ray ray, Plane plane) {
	// 产生 射线起点 到 平面视点的向量
	Vector rayToPlaneVector = vectorBetween(ray.point, plane.point);
	// 射线起点到平面的向量 与 法向量的点积 / 射线向量 与 法向量的点积 = 缩放因子
	float scaleFactor = rayToPlaneVector.dotProduct(plane.normal) 
	                  / ray.vector.dotProduct(plane.normal) ;
	//根据缩放因子,缩放射线向量,再从射线起点开始 沿着 缩放后的射线向量,得出与平面的交点
	Point intersectionPoint = ray.point.translate(ray.vector.scale(scaleFactor));
	return intersectionPoint;
    }	// 产生 射线起点 到 平面视点的向量
	Vector rayToPlaneVector = vectorBetween(ray.point, plane.point);
	// 射线起点到平面的向量 与 法向量的点积 / 射线向量 与 法向量的点积 = 缩放因子
	float scaleFactor = rayToPlaneVector.dotProduct(plane.normal) 
	                  / ray.vector.dotProduct(plane.normal) ;
	//根据缩放因子,缩放射线向量,再从射线起点开始 沿着 缩放后的射线向量,得出与平面的交点
	Point intersectionPoint = ray.point.translate(ray.vector.scale(scaleFactor));
	return intersectionPoint;
    }

单纯的代码可能不能容易理解,我们继续使用上面的栗子:一个平面位于(0,0,0),法向量(0,1,0);这里还有一条射线,位于(-2,1,0),且向量为(1,-1,0)。如果我们把这个向量扩展得足够远,这个射线会在哪里碰到平面呢?让我们运用以上数学公式找出答案吧。

首先,我们需要把平面与射线之间的向量赋值给rayToPlaneVector。它应该被设为(0,0,0)-(-2,1,0)=(2,-1,0)

然后,下一步是计算scaleFactor。我们先计算 rayToPlaneVector与normal的点积 = (2,-1,0).(0,1,0)= -1;接着就是计算射线方向向量与normal的点积 = (1,-1,0).(0,1,0)= -1;所以缩放因子为 -1 / -1 = 1;

得到缩放因子后,我们先把方向向量进行缩放=(1,-1,0)* 1 = (1,-1,0);然后把射线点按缩放后的方向向量进行平移,即:(-2,1,0)+(1,-1,0)=(-1,0,0)。这就是射线与平面相交的位置。

既然相交点已经找出来了,我们就可以直接利用相交点更新木槌的位置变量,但有一点要注意,我们要保持y轴的值落在桌子固定高度,进而更新木槌的模型矩阵:

    public void handleTouchMove(float normalizedX, float normalizedY) {
        if(malletPressed) {
            // 根据屏幕触碰点 和 视图投影矩阵 产生三维射线
            Geometry.Ray ray = convertNormalized2DPointToRay(normalizedX, normalizedY);
            // 定义的桌子平面,观察平面的点为(0,0,0)
            Geometry.Plane tablePlane = new Geometry.Plane(new Geometry.Point(0,0,0), new Geometry.Vector(0,1,0));
            // 进行射线-平面 相交测试
            Geometry.Point touchedPoint = Geometry.intersectionPoint(ray, tablePlane);
            // 根据相交点 更新木槌位置
            malletPosition = new Geometry.Point(touchedPoint.x, mallet.height, touchedPoint.z);
            // 更新mallet.modelMatrix
            Matrix.setIdentityM(mallet.modelMatrix, 0);
            Matrix.translateM(mallet.modelMatrix,0, malletPosition.x, malletPosition.y, malletPosition.z);
        }
    }

现在,加上调试日志,把应用程序跑起来,看看实际效果?

2、边界碰撞检测

我们如愿以偿能来回拖动木槌了,但你可能已经注意到问题了:木槌可以走到桌子边界外面去,如下图所示。我们要添加一些基本的碰撞检测,让木槌待在它应该在的地方。我们也会运用一些基本的物理原理,让我们的木槌可以在桌子上击打曲棍球。

边缘的检测比较简单,我们打开objects下的Table.java查看桌子的顶点数据定义。

private static final float[] VERTEX_DATA = {
            //x,    y,      s,      t
            0f,     0f,     0.5f,   0.5f,
            -0.5f,  -0.8f,  0f,     0.9f,
            0.5f,   -0.8f,  1f,     0.9f,
            0.5f,   0.8f,   1f,     0.1f,
            -0.5f,  0.8f,   0f,     0.1f,
            -0.5f,  -0.8f,  0f,     0.9f,
    };

我们定义的桌子的顶点数据是定义在x-y的平面上的,然后我们用模型矩阵把桌子平面沿x轴旋转了-90°,让桌子的平面落在x-z的平面上,所以我们可以定义四个静态变量,用来表示桌子的边缘范围的最大值

package org.zzrblog.blogapp.objects.Table.java;

public static final float leftBound = -0.5f; // 左边缘
public static final float rightBound = 0.5f; // 右边缘
public static final float farBound = -0.8f; // 远平面边缘
public static final float nearBound = 0.8f; // 近平面边缘

这些定义与空气曲棍球桌子的四边相对应。现在我们可以更新handleTouchMove(),并用下面的代码代替malletPosition的赋值

     malletPosition = new Geometry.Point(
                    clamp(touchedPoint.x, Table.leftBound+mallet.raduis, Table.rightBound-mallet.raduis),
                    mallet.height ,
                    clamp(touchedPoint.z, Table.farBound+mallet.raduis, Table.nearBound-mallet.raduis) 
            );
    // 把触碰值 控制在 指定的最大值与最小值之间。
    private float clamp(float value, float max, float min) {
        return Math.min(max, Math.max(value, min));
    }

继续并再次运行这个应用,你现在应该发现木槌拒绝移动边界外了。在往下内容之前,我们把 木槌位置 和 木槌是否被按压 这两个属性封装到木槌类Mallet当中,方便管理。

    public Geometry.Point position ;
    public volatile boolean isPressed;

    public Mallet(float radius, float height, int numPointsAroundMallet){
        ... ...
    }

3、木槌冰球碰撞测试

现在,我们准备着手加入最有乐趣的部分,另木槌和冰球产生激情四射的碰撞。按照惯例,我们提出必须的需求:

1、木槌碰撞冰球后,朝着哪个方向移动?

2、冰球移动的速度多快,怎么量化?

回到以上问题,我们需要随着时间变化持续跟踪木槌是如何移动的。我们要做的第一件事就是给Mallet加入一个新的变量previousPosition,用于记录前一刻的position,在handleTouchMove中加入它的赋值代码:

public void handleTouchMove(float normalizedX, float normalizedY) {
        if(mallet.isPressed) {
            // 保存前一刻木槌的位置信息
            mallet.previousPosition = mallet.position;
            ... ...
        }
}

下一步,我们在冰球Puck类中创建 位置、移动方向等变量

    private Geometry.Vector speedVector;
    private Geometry.Point position;
    
    public Puck(float radius, float height, int numPointsAroundPuck) {
        ... ...
    }

按照之前Mallet.position的方式,在onSurfaceChanged接口里面初始化Puck.position,如下:

    @Override
    public void onSurfaceChanged(GL10 gl10, int width, int height) {
        ... ...
        mallet.position = new Geometry.Point(0f, mallet.height/2f, 0.5f);
        puck.position = new Geometry.Point(0f, puck.height/2f, 0f);
        puck.speedVector = new Geometry.Vector(0f, 0f, 0f);
    }

接下来,按照逻辑分析。当木槌与冰球的距离小于两者半径之和,就会产生碰撞,并更新方向向量,我们在handleTouchMove最后添加代码,要确保这段代码是在木槌被按压后(mallet.isPressed == true)产生的:

        if(mallet.isPressed) {
            // 保存前一刻木槌的位置信息
            mallet.previousPosition = mallet.position;
            ... ...
            // 检查木槌和冰球是否碰撞,更新冰球移动的方向向量
            float distance = Geometry.vectorBetween(mallet.position, puck.position).length();
            if(distance < mallet.radius + puck.radius) {
                puck.speedVector = Geometry.vectorBetween(mallet.previousPosition, mallet.position);
                puck.position = puck.position.translate(puck.speedVector);
            }
        }

当木槌击中冰球,并且我们用前一个木槌位置和当前木槌的位置给冰球创建一个方向向量。木槌移动得越快,那个向量就会越大,冰球也会移动得越快。随后利用这个方向向量,更新puck的position。(此时这里不用固定y值,因为mallet已经固定了,冰球的速度向量的y值其实每时每刻都等于0)碰撞发生致使冰球沿着速度向量移动,这个移动不需要在任何条件下,所以我们把需要时刻更新puck的position到puck的模型矩阵,实时渲染到画面上,(为了统一,我把mallet/puck的模型矩阵相关操作放到onDrawFrame接口中)最后的代码如下:

    public void handleTouchMove(float normalizedX, float normalizedY) {
        if(mallet.isPressed) {
            // 保存前一刻木槌的位置信息
            mallet.previousPosition = mallet.position;
            // 根据屏幕触碰点 和 视图投影矩阵 产生三维射线
            Geometry.Ray ray = convertNormalized2DPointToRay(normalizedX, normalizedY);
            // 定义的桌子平面,观察平面的点为(0,0,0)
            Geometry.Plane tablePlane = new Geometry.Plane(new Geometry.Point(0,0,0), new Geometry.Vector(0,1,0));
            // 进行射线-平面 相交测试
            Geometry.Point touchedPoint = Geometry.intersectionPoint(ray, tablePlane);
            // 根据相交点 更新木槌位置
            //malletPosition = new Geometry.Point(touchedPoint.x, mallet.height/2f, touchedPoint.z);
            mallet.position = new Geometry.Point(
                    clamp(touchedPoint.x, Table.leftBound+mallet.radius, Table.rightBound-mallet.radius),
                    mallet.height/2f, //touchedPoint.y,
                    clamp(touchedPoint.z, Table.farBound+mallet.radius, Table.nearBound-mallet.radius)
            );

            // 检查木槌和冰球是否碰撞,更新冰球移动的方向向量
            float distance = Geometry.vectorBetween(mallet.position, puck.position).length();
            if(distance < mallet.radius + puck.radius) {
                puck.speedVector = Geometry.vectorBetween(mallet.previousPosition, mallet.position);
            }
        }
    }

    @Override
    public void onDrawFrame(GL10 gl10) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

        Matrix.multiplyMM(modelViewProjectionMatrix,0, viewProjectionMatrix,0, table.modelMatrix,0);
        textureShaderProgram.userProgram();
        textureShaderProgram.setUniforms(modelViewProjectionMatrix, textureId);
        table.bindData(textureShaderProgram);
        table.draw();

        Matrix.setIdentityM(mallet.modelMatrix, 0);
        Matrix.translateM(mallet.modelMatrix,0, mallet.position.x, mallet.position.y, mallet.position.z);
        Matrix.multiplyMM(modelViewProjectionMatrix,0, viewProjectionMatrix,0, mallet.modelMatrix,0);
        colorShaderProgram.userProgram();
        colorShaderProgram.setUniforms(modelViewProjectionMatrix, 0f, 0f, 1f);
        mallet.bindData(colorShaderProgram);
        mallet.draw();

        puck.position = puck.position.translate(puck.speedVector);
        Matrix.setIdentityM(puck.modelMatrix, 0);
        Matrix.translateM(puck.modelMatrix,0, puck.position.x, puck.position.y, puck.position.z);
        Matrix.multiplyMM(modelViewProjectionMatrix,0, viewProjectionMatrix,0, puck.modelMatrix,0);
        colorShaderProgram.userProgram();
        colorShaderProgram.setUniforms(modelViewProjectionMatrix, 0f, 1f, 0f);
        puck.bindData(colorShaderProgram);
        puck.draw();
    }

再次运行这个项目应用,当你用木槌击打冰球时,看看会发生什么?

4、最后的策略优化

可能你会发现老问题,冰球飞出了桌子外了,怎么办?为冰球也加上边界测试吧。但这个边界的测试比之前的木槌又复杂了一些。因为,我们要先控制速度向量,我们设定无论何时当冰球碰撞到桌子边缘,它都会从桌子边缘弹开,而且,速度不可能一直不变,我们加入必要的缩小操作,使得速度向量有阻尼效果,最终使得冰球减速停下。

    @Override
    public void onDrawFrame(GL10 gl10) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

        Matrix.multiplyMM(modelViewProjectionMatrix,0, viewProjectionMatrix,0, table.modelMatrix,0);
        textureShaderProgram.userProgram();
        textureShaderProgram.setUniforms(modelViewProjectionMatrix, textureId);
        table.bindData(textureShaderProgram);
        table.draw();

        Matrix.setIdentityM(mallet.modelMatrix, 0);
        Matrix.translateM(mallet.modelMatrix,0, mallet.position.x, mallet.position.y, mallet.position.z);
        Matrix.multiplyMM(modelViewProjectionMatrix,0, viewProjectionMatrix,0, mallet.modelMatrix,0);
        colorShaderProgram.userProgram();
        colorShaderProgram.setUniforms(modelViewProjectionMatrix, 0f, 0f, 1f);
        mallet.bindData(colorShaderProgram);
        mallet.draw();

        updatePuckCollisionTest();
        Matrix.setIdentityM(puck.modelMatrix, 0);
        Matrix.translateM(puck.modelMatrix,0, puck.position.x, puck.position.y, puck.position.z);
        Matrix.multiplyMM(modelViewProjectionMatrix,0, viewProjectionMatrix,0, puck.modelMatrix,0);
        colorShaderProgram.userProgram();
        colorShaderProgram.setUniforms(modelViewProjectionMatrix, 0f, 1f, 0f);
        puck.bindData(colorShaderProgram);
        puck.draw();
    }
    // 根据冰球的移动速度,改变冰球位置。还时刻检测冰球的边缘碰撞,当发生边缘碰撞时,速度会比正常状态缩小得更多。
    private void updatePuckCollisionTest() {
        puck.position = puck.position.translate(puck.speedVector);
        if(puck.position.x < Table.leftBound + puck.radius
                || puck.position.x > Table.rightBound - puck.radius) {
            puck.speedVector = new Geometry.Vector(-puck.speedVector.x,
                    puck.speedVector.y, puck.speedVector.z);
            puck.speedVector = puck.speedVector.scale(0.9f);
        }
        if(puck.position.z < Table.farBound + puck.radius
                || puck.position.z > Table.nearBound - puck.radius) {
            puck.speedVector = new Geometry.Vector(puck.speedVector.x,
                    puck.speedVector.y, -puck.speedVector.z);
            puck.speedVector = puck.speedVector.scale(0.9f);
        }
        puck.position = new Geometry.Point(
                clamp(puck.position.x, Table.leftBound + puck.radius, Table.rightBound - puck.radius),
                puck.position.y,
                clamp(puck.position.z, Table.farBound + puck.radius, Table.nearBound - puck.radius)
        );
        puck.speedVector = puck.speedVector.scale(0.99f);
    }

现在,效果如何?

小结:我们现在已经到了曲棍球项目的结尾了,花点时间回忆下我们学过的所有内容,因为我们确实走了很长的路。在写系列文章的1~10的时,我自己确实也感受到了温故而知新;在整个过程,我们学习了很多重要概念,首先我们搞清楚了着色器是如何工作的,以及通过学习顶点、矩阵和纹理构建事物,我们甚至还接触了最简单的游戏引擎知识,想想我们只是依靠OpenGL直接在底层走到现在的。

有了这一基础系列的学习,下一系列,我将会开展全景视野方面的总结学习。

项目码云Url:https://github.com/MrZhaozhirong/BlogApp;参考hockey/HockeyActivity

同时就在今天,伟大的物理学家 斯蒂芬·霍金 教授尘归星辰,离开了地球,缅怀一代伟人。

猜你喜欢

转载自blog.csdn.net/a360940265a/article/details/79509436