OpenGL.ES在Android上的简单实践:
14-全景(惯性滑动球体)
1、整理封装全景球
现在,我们的地球已经能正确的显示出来,我们来增加必要的交互,使得我们左右滑动屏幕的时候,地球能旋转起来,而且是像一个地球仪一样,手指离开屏幕后,能随着惯性的操作延后旋转。
第一步,我们现在测试页面PanoramaActivity添加对GLSurfaceView的触摸事件监听,并设置可以点击状态。
public class PanoramaActivity extends Activity{
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
glSurfaceView = new GLSurfaceView(this);
renderer = new PanoramaRenderer(PanoramaActivity.this);
... ...
glSurfaceView.setClickable(true);
glSurfaceView.setOnTouchListener(new GLViewTouchListener());
setContentView(glSurfaceView);
}
private class GLViewTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View view, MotionEvent event) {
if(event.getAction() == MotionEvent.ACTION_DOWN){
... ...
}else if(event.getAction() == MotionEvent.ACTION_MOVE){
... ...
}else if(event.getAction() == MotionEvent.ACTION_UP){
... ...
}else {
return false;
}
return true;
}
}
}
第二步,我们让渲染器PanoramaRenderer响应手指“滑动”这系列操作。这系列操作分别经过按下,滑动,抬起三个事件。所以我们这三个响应的事件中添加renderer的接口:
if(event.getAction() == MotionEvent.ACTION_DOWN){
final float x = event.getX();
final float y = event.getY();
glSurfaceView.queueEvent(new Runnable() {
@Override
public void run() {
renderer.handleTouchUp(x, y);
}
});
}else if(event.getAction() ==MotionEvent.ACTION_MOVE){
final float x = event.getX();
final float y = event.getY();
glSurfaceView.queueEvent(new Runnable() {
@Override
public void run() {
renderer.handleTouchDrag(x,y);
}
});
}else if(event.getAction() == MotionEvent.ACTION_UP){
final float x = event.getX();
final float y = event.getY();
glSurfaceView.queueEvent(new Runnable() {
@Override
public void run() {
renderer.handleTouchUp(x,y);
}
});
}
进入PanoramaRenderer 我们把响应请求传递給ball,让ball执行对应的操作。对于渲染器renderer,我们不好做太多逻辑的操作,把逻辑操作交给对应的模型内部维护,方便模型对象的管理。
// PanoramaRenderer.java
... ...
public void handleTouchUp(float x, float y) {
if(ball!=null){
ball.handleTouchUp( x, y);
}
}
public void handleTouchDrag(float x, float y) {
if(ball!=null){
ball.handleTouchDrag( x, y);
}
}
public void handleTouchDown(float x, float y) {
if(ball!=null){
ball.handleTouchDown( x, y);
}
}
到这里,其实Renderer的三大glsurfaceview回调,我们也应该封装在模型对象中响应。下面我们更新PanoramaRenderer和Ball->PanoramaRenderer2和PanoramaBall。
public class PanoramaRenderer2 implements GLSurfaceView.Renderer{
private final Context context;
private PanoramaBall ball;
public PanoramaRenderer(Context context) {
this.context = context
ball = new PanoramaBall(context);
}
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
if(ball!=null) ball.onSurfaceCreated(eglConfig);
}
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
if(ball!=null) ball.onSurfaceChanged(width, height);
}
@Override
public void onDrawFrame(GL10 gl10) {
if(ball!=null) ball.onDrawFrame();
}
public void handleTouchUp(float x, float y) {
if(ball!=null) ball.handleTouchUp( x, y);
}
public void handleTouchDrag(float x, float y) {
if(ball!=null) ball.handleTouchDrag( x, y);
}
public void handleTouchDown(float x, float y) {
if(ball!=null) ball.handleTouchDown( x, y);
}
}
我们从渲染器入手,更新后的渲染器相当简单。只起到一个渲染管理者的作用,实际的操作我们交给模型对象PanoramaBall。还有一点注意,我们是在PanoramaRenderer构造函数中调用PanoramaBall的构造,还记得之前我们说过,所以GL的接口方法必须在glsurfaceview的三大回调中调用吗?记住这点,下面我正式改造PanoramaBall:
public class PanoramaBall {
... ...
private float[] mProjectionMatrix = new float[16];// 投影矩阵
private float[] mViewMatrix = new float[16]; // 摄像机位置朝向9参数矩阵
private float[] mModelMatrix = new float[16];// 模型变换矩阵
private float[] mMVPMatrix = new float[16];// 获取具体物体对象的MVP矩阵
private float[] getFinalMatrix() {
Matrix.multiplyMM(mMVPMatrix, 0, mViewMatrix, 0, mModelMatrix, 0);
Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mMVPMatrix, 0);
return mMVPMatrix;
}
public PanoramaBall(Context context){
this.context = context;
Matrix.setIdentityM(mProjectionMatrix, 0);
Matrix.setIdentityM(mViewMatrix, 0);
Matrix.setIdentityM(mModelMatrix, 0);
Matrix.setIdentityM(mMVPMatrix, 0);
}
... ...
}
第一步,我们抽取MVP矩阵的方法,记住三大矩阵是左乘的,具体原理分析请参考这里的第4节。接下来继续查看新增的扩展函数接口onSurfaceCreated / onSurfaceChanged / onDrawFrame。
// 紧接着上面部分 ...
public void onSurfaceCreated(EGLConfig eglConfig) {
initVertexData();
initTexture();
buildProgram();
setAttributeStatus();
// 这些方法跟之前的一样,直接copy就ok
}
public void onSurfaceChanged(int width, int height) {
GLES20.glViewport(0,0,width,height);
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
MatrixHelper.perspectiveM(mProjectionMatrix, 45, (float)width/(float)height, 1f, 100f);
Matrix.setLookAtM(mViewMatrix, 0,
0f, 0f, 4f,
0f, 0f, 0f,
0f, 1f, 0f);
}
public void onDrawFrame() {
GLES20.glClear( GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
ballShaderProgram.userProgram();
ballShaderProgram.setUniforms(getFinalMatrix(),textureId);
setAttributeStatus();
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBuffer.getIndexBufferId());
GLES20.glDrawElements(GLES20.GL_TRIANGLES, numElements, GLES20.GL_UNSIGNED_SHORT, 0);
GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0);
}
其实就是当初我们在renderer写的代码,只是我们封装在PanoramaBall中。一切都按照正确的方向前进中,现在我们可以测试页面PanoramaActivity替换渲染器看看是否和之前的一样效果?
2、滑动全景球
当我们用手指滑动屏幕的时候,全景球需要知道是向左还是向右滑动,以此作出正确的旋转。所以当全景球接受按压事件的时候,先记录当前的屏幕按压位置;之后在滑动的事件求出正确的移动步伐并旋转等量的角度;抬起事件则重置位置。
private float mLastX;
private float mLastY;
public void handleTouchUp(float x, float y) {
this.mLastX = 0;
this.mLastY = 0;
}
public void handleTouchDown(float x, float y) {
this.mLastX = x;
this.mLastY = y;
}
public void handleTouchMove(float x, float y) {
float offsetX = this.mLastX - x;
float offsetY = this.mLastY - y;
.. .. ..
}
up和down都so easy。那么move的响应事件,我们该怎样令球旋转起来呢?我们自然想到android.opengl.Matrix的rotateM(float[] m, int mOffset, float a, float x, float y, float z)函数,调用这个函数我们需要什么?我们需要偏转角度a。得到偏转角度后,我们调用rotateM接口,得出偏转角度的旋转矩阵,然后把旋转的矩阵和模型矩阵运算求值,就得出了新的模型矩阵了,最后模型矩阵更新到MVP。搞定!
private float mLastX;
private float mLastY;
private float rotationX = 0;
private float rotationY = 0;
private float[] mMatrixRotationX = new float[16];
private float[] mMatrixRotationY = new float[16];
public void handleTouchUp(float x, float y) {
this.mLastX = 0;
this.mLastY = 0;
}
public void handleTouchDown(float x, float y) {
this.mLastX = x;
this.mLastY = y;
}
public void handleTouchMove(float x, float y) {
float offsetX = this.mLastX - x;
float offsetY = this.mLastY - y;
this.rotationY -= offsetX/10 ; // 注意! 屏幕横坐标的步伐,球应该是绕着Y轴旋转
this.rotationX -= offsetY/10 ; // 注意! 屏幕纵坐标的步伐,球应该是绕着X轴旋转
// 这部分代码是对球模型矩阵的操作,可以抽取成方法 updateBallMatrix,在onDrawFrame调用
Matrix.setIdentityM(this.mModelMatrix, 0);
Matrix.setIdentityM(mMatrixRotationX, 0);
Matrix.setIdentityM(mMatrixRotationY, 0);
Matrix.rotateM(mMatrixRotationY, 0, this.rotationY, 0, 1, 0);
Matrix.rotateM(mMatrixRotationX, 0, this.rotationX, 1, 0, 0);
Matrix.multiplyMM(this.mModelMatrix,0, mMatrixRotationX,0, mMatrixRotationY,0 );
this.mLastX = x;
this.mLastY = y;
}
运行工程项目,是不是如下图所示能够动起来了?!
但是大家有没发现,我们可以直接绕过顶部和底部... 要不大家加个角度的限制?
3、添加手指操作的惯性效果
现在我们是能够滑动球体了,接下来我们来实现惯性滑动,想象我们以前小时候玩地球仪那样大力滑动产生惯性的流畅自转。所以第一件事就是增加滑动事件的速度。这时我们用到Android的API——VelocityTracker。我们在测试页面PanoramaActivity增加VelocityTracker的使用:
public class PanoramaActivity extends Activity{
private VelocityTracker mVelocityTracker = null;
... ...
protected void onResume() {
... ...
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
} else {
mVelocityTracker.clear();
}
}
protected void onPause() {
if(null != mVelocityTracker) {
mVelocityTracker.clear();
mVelocityTracker.recycle();
mVelocityTracker = null;
}
}
private class GLViewTouchListener implements View.OnTouchListener {
@Override
public boolean onTouch(View view, MotionEvent event) {
if(event.getAction() == MotionEvent.ACTION_DOWN){
// 增加速度
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
} else {
mVelocityTracker.clear();
}
mVelocityTracker.addMovement(event);
... ...
}else if(event.getAction() ==MotionEvent.ACTION_MOVE){
mVelocityTracker.addMovement(event);
mVelocityTracker.computeCurrentVelocity(1000);
// 在获取速度之前总要进行以上两步
... ...
}else if(event.getAction() == MotionEvent.ACTION_UP){
final float x = event.getX();
final float y = event.getY();
final float xVelocity = mVelocityTracker.getXVelocity();
final float yVelocity = mVelocityTracker.getYVelocity();
glSurfaceView.queueEvent(new Runnable() {
@Override
public void run() {
renderer.handleTouchUp(x, y, xVelocity, yVelocity);
// 把速度当成参数传递
}
});
}else {
return false;
}
return true;
}
}
}
当我们手抬起的时候,速度变量传递到渲染器,触发全景球的惯性操作。那么怎样实现惯性这一操作?其实就是使用这个速度变量通过一个线程逐渐递减,并逐渐增加球体的旋转分量,是不是很简单?下面我们继续更新代码:
public void handleTouchUp(final float x, final float y,
final float xVelocity, final float yVelocity) {
this.mLastX = 0;
this.mLastY = 0;
new Thread(new Runnable() {
@Override
public void run() {
try {
handleGestureInertia(x, y, xVelocity, yVelocity);
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
private void handleGestureInertia(float upX, float upY, float xVelocity, float yVelocity)
throws InterruptedException {
boolean gestureInertia_isStop = false;
float mXVelocity = xVelocity;
float mYVelocity = yVelocity;
while(!this.gestureInertia_isStop){
//--------------------------------------------------------------------------------
float offsetY = mYVelocity / 2000;
this.rotationX = this.rotationX + offsetY;
float offsetX = mXVelocity / 2000;
this.rotationY = this.rotationY + offsetX;
if(rotationX%360 > 90 ){
this.rotationX = 90;
}
if(rotationX%360 < -90 ){
this.rotationX = -90;
}
//--------------------------------------------------------------------------------
if(Math.abs(mYVelocity - 0.975f*mYVelocity) < 0.00001f
|| Math.abs(mXVelocity - 0.975f*mXVelocity) < 0.00001f){
this.gestureInertia_isStop = true;
}
mYVelocity = 0.975f*mYVelocity;
mXVelocity = 0.975f*mXVelocity;
Thread.sleep(5);
}
}
我们分析一下这个惯性操作方法,首先我们拿到速度变量,把它们先缩放指定倍数当做步伐(2000倍的缩放),然后把步伐增量添加到旋转角度上;我们还限制绕x轴的旋转角度不能超过顶部和底部。随后我们衰减速度变量,并加一个判断条件,衰减前后的差值小于0.00001f就可以视为惯性已经可以停止了。不要忘记惯性线程每一步伐要睡眠5~10(大家可以试下不睡眠的效果)这个和硬件显示的帧率是相关的,但不会相差很大。
由于篇幅关系,以上方法的具体数值都是我经过调试之后总结出来的,大家可以随便修改成适合自己的大小,加上自己的日志。
接下来,运行工程项目,看看以下效果是否如意?
工程代码:https://github.com/MrZhaozhirong/BlogApp