Android如何实现3D效果

前言

前段时间读到一篇文章,作者通过自定义View实现了一个高仿小米时钟,其中的3D效果很是吸引我,于是抽时间学习了一下,现在总结出来,和大家分享。

正文

  1. 想要在Android上实现3D效果,其实并没有想象中那么复杂,我们需要运用两样东西:Camera和Matrix,这里的Camera可不是我们平常拍照用的Camera,这里的Camera是位于android.graphics包下的Camera:

android.graphics.Camera
我们可以看到它的作用是:计算3D变换,并且生成一个Matrix,可以应用到Canvas上,这句话其实就是实现3D效果的核心原理。
Camera的坐标系是左手坐标系。当手机平整的放在桌面上,X轴是手机的水平方向,Y轴是手机的竖直方向,Z轴是垂直于手机向里的那个方向。

Camera坐标系

Camera位于坐标点(0,0),也就是视图的左上角。

我们再来了解一下Matrix,Android中的Matrix是一个3 x 3的矩阵,其内容如下:


Matrix

从字面上来看, MSCALE用于处理缩放变换,MTRANS用于处理平移变换,MSKEW用于处理错切变换。最后一行的MPERSP用于处理透视变换,关于透视变换,官方文档中并没有具体的说明,这里也就不再赘述。另外,矩阵是支持旋转变换的,旋转变换是通过同时设置MSCALE和MSKEW来实现的(这里边就是一些数学原理了,笔者也是半壶水,就不在这丢人了,感兴趣的同学可以自己研究一下)。另外有同学可能对错切变换也不是特别理解,笔者当时也是自己查了下才明白,这里简单说明一下,就免得大家再去百度了:

错切变换(skew)在数学上又称为Shear mapping(可译为“剪切变换”)或者Transvection(缩并),它是一种比较特殊的线性变换。错切变换的效果就是让所有点的x坐标(或者y坐标)保持不变,而对应的y坐标(或者x坐标)则按比例发生平移,且平移的大小和该点到x轴(或y轴)的垂直距离成正比。错切变换,属于等面积变换,即一个形状在错切变换的前后,其面积是相等的。

X轴错切变换

上图中,各点的y坐标保持不变,但其x坐标则按比例发生了平移。


Y轴错切变换

上图中,各点的x坐标保持不变,但其y坐标则按比例发生了平移。

还有一个不容易理解的地方,Matrix针对每种变换,都提供了set、pre和post三种操作方式。

pre方法表示矩阵前乘,如果变换矩阵为A,原始矩阵为M,pre方法即是 M x A

post方法表示矩阵后乘,如果变换矩阵为A,原始矩阵为M,post方法即是 A x M

之所以需要区分前乘和后乘,是因为矩阵的乘法不满足交换率,即 A x M != M x A

另外还有比较重要的一点, 在图像处理中,越靠近右边的矩阵越先执行

调用一系列set、pre、post方法时,可以理解为将这些操作插入一个队列:set是清空队列再添加,pre是在队首插入,post是在队尾插入。

举个栗子:
Matrix m = new Matrix();
m.postTranslate(20, 20);
m.preScale(0.2f, 0.5f);
m.setScale(0.8f, 0.8f);
m.postScale(3f, 3f);
m.preTranslate(0.5f, 0.5f);

执行顺序为:preTranslate(0.5f, 0.5f) → setScale(0.8f, 0.8f) → postScale(3f, 3f)
因为setScale(0.8f, 0.8f)会将前面的postTranslate(20, 20)和preScale(0.2f, 0.5f)清除掉,然后再将postScale(3f, 3f)插入队尾,preTranslate(0.5f, 0.5f)插入队首。

2018.4.25补充---Canvas的几何变换:
Canvas的几何变换方法包括 translate、rotate、scale、skew 几种,其原理也是运用matrix做几何变换。
我们以 canvas.rotate(float degrees) 方法举例:

canvas.rotate(float degrees).png
通过官方文档我们可以了解到,rotate方法其实就是用matrix进行前乘,然后再将matrix应用到当前画布上。
以下两段代码其实是等价的:

canvas.rotate(-degreeZ);  // 1

Matrix matrix = new Matrix();  // 2
matrix.reset();
matrix.preRotate(-degreeZ);
canvas.concat(matrix);

由于是前乘,所以canvas的几何变换方法是倒序的,需要把变换的代码倒着写,举个栗子:

// 如果想要让canvas先移动 (-centerX, -centerY) 距离,再移动 (centerX, centerY) 距离进行恢复
// 代码需要倒着写
canvas.translate(centerX, centerY);
canvas.translate(-centerX, -centerY);

这里再补充一下 canvas.concat(matrix) 方法:
用 Canvas 当前的变换矩阵和 Matrix 相乘,即基于 Canvas 当前的变换,叠加上 Matrix 中的变换。

  1. 熟悉了基本的工具,我们就可以开工了,我们先来看一下最终的效果:


    3D效果.gif

    圆盘跟随手指的移动而变换角度,呈现出3D的效果,看起来还是很不错的,我们看看如何来实现这个效果吧:
    首先我们需要做一些初始化的工作:

    private Paint mWhitePaint;
    private Paint mCirclePaint;
    private float mCircleStrokeWidth = 2;
    private float mMaxRadius = 300;

    /* Camera旋转的最大角度 */
    private float mMaxCameraRotate = 15;

    /* 我们今天的主角 */
    private Matrix mMatrix;
    private Camera mCamera;

    /* Camera绕X轴旋转的角度 */
    private float mCameraRotateX;
    /* Camera绕Y轴旋转的角度 */
    private float mCameraRotateY;

        private void init(){
        mMatrix = new Matrix();
        mCamera = new Camera();

        //白色大圆的画笔
        mWhitePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mWhitePaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mWhitePaint.setStrokeWidth(mCircleStrokeWidth);
        mWhitePaint.setColor(Color.WHITE);

        //内部蓝色圆环的画笔
        mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mCirclePaint.setStyle(Paint.Style.STROKE);
        mCirclePaint.setStrokeWidth(mCircleStrokeWidth);
        mCirclePaint.setColor(Color.WHITE);
        mCirclePaint.setColor(0xff237EAD);
    }

这是重写的onDraw方法,做了两件事情:
1.将canvas传入setCameraRotate方法中
2.再画几个圈圈

@Override
    protected void onDraw(Canvas canvas) {
        setCameraRotate(canvas);
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius, mWhitePaint);
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 5, mCirclePaint);
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 4, mCirclePaint);
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 3, mCirclePaint);
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, mMaxRadius / 6 * 2, mCirclePaint);
    }

接下来我们看看setCameraRotate方法里面做了什么

    private void setCameraRotate(Canvas mCanvas) {
        mMatrix.reset();
        mCamera.save();
        mCamera.rotateX(mCameraRotateX);//绕x轴旋转
        mCamera.rotateY(mCameraRotateY);//绕y轴旋转
        mCamera.getMatrix(mMatrix);//计算对于当前变换的矩阵,并将其复制到传入的mMatrix中
        mCamera.restore();
       /**
         * Camera默认位于视图的左上角,故生成的矩阵默认也是以其左上角为旋转中心,
         * 所以在动作之前调用preTranslate将mMatrix向左移动getWidth()/2个长度,
         * 向上移动getHeight()/2个长度,
         * 使旋转中心位于矩阵的中心位置,动作之后再post回到原位
         */
        mMatrix.preTranslate(-getWidth() / 2, -getHeight() / 2);
        mMatrix.postTranslate(getWidth() / 2, getHeight() / 2);
        mCanvas.concat(mMatrix);//将mMatrix与canvas中当前的Matrix相关联
    }

以上这段代码,除了旋转操作以外,其余的基本属于固定写法,这样写的原因都在注释里写清楚了,可能有点不太好理解,多看几遍或者自己试着写一下就明白了。

上面这段代码中的mCameraRotateX和mCameraRotateY这两个全局变量的值应该与此时手指触摸坐标相关联,所以我们在onTouchEvent方法中动态设置:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //根据手指坐标计算Camera应该旋转的角度
                getCameraRotate(event);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                getCameraRotate(event);
                invalidate();
                break;
        }
        return true;
    }

我们最后来看看getCameraRotate方法中是如何处理的:

    private void getCameraRotate(MotionEvent event) {
        float rotateX = -(event.getY() - getHeight() / 2);
        float rotateY = (event.getX() - getWidth() / 2);
        /**
         *为什么旋转角度要这样计算:
         * 当Camera.rotateX(x)的x为正时,图像围绕X轴,上半部分向里下半部分向外,进行旋转,
         * 也就是手指触摸点要往上移。这个x就会与event.getY()的值有关,x越大,绕X轴旋转角度越大,
         * 以圆心为基准,手指往上移动,event.getY() - getHeight() / 2的值为负,
         * 故 float rotateX = -(event.getY() - getHeight() / 2)
         * 同理,
         * 当Camera.rotateY(y)的y为正时,图像围绕Y轴,右半部分向里左半部分向外,进行旋转,
         * 也就是手指触摸点要往右移。这个y就会与event.getX()的值有关,y越大,绕Y轴旋转角度越大,
         * 以圆心为基准,手指往右移动,event.getX() - getWidth() / 2的值为正,
         * 故 float rotateY = event.getX() - getWidth() / 2
         */

        /**
         * 此时得到的rotateX、rotateY 其实是以圆心为基准,手指移动的距离,
         * 这个值很大,不能用来作为旋转的角度,
         * 所以还需要继续处理
         */

        //求出移动距离与半径之比。mMaxRadius为白色大圆的半径
        float percentX = rotateX / mMaxRadius;
        float percentY = rotateY / mMaxRadius;

        if (percentX > 1) {
            percentX = 1;
        } else if (percentX < -1) {
            percentX = -1;
        }

        if (percentY > 1) {
            percentY = 1;
        } else if (percentY < -1) {
            percentY = -1;
        }

        //将最终的旋转角度控制在一定的范围内,这里mMaxCameraRotate的值为15,效果比较好
        mCameraRotateX = percentX * mMaxCameraRotate;
        mCameraRotateY = percentY * mMaxCameraRotate;
    }

到这里,我们要的3D效果就已经实现了。也是费了一番功夫。

结语

写这篇文章的起因是读到了猴菇先生的博客高仿小米时钟 - 使用Camera和Matrix实现3D效果对文中实现的3D效果产生了兴趣,但是文中主要的篇幅还是介绍如何自定义View,关于3D效果的实现只有主要代码和简单的注释,所以我又自己从Camera和Matrix的定义开始,将3D效果作为主体重新学习了一遍,便是有了这篇文章。文中的部分代码也是从猴菇先生的代码中借鉴的,将代码进行了简化,只保留了3D效果的部分,将注释和说明进行了丰富,从头开始讲解,更加易于学习。

从开始研究到写完这篇文章,断断续续加起来差不多花了2天时间,发现写文章确实很锻炼人,以前自己遇到问题,上网随便搜搜,看个大概,就完事儿了。现在想要写出来,必须要弄明白、透彻,才敢动手写,也算是对自己的一种监督吧,我可不愿意误人子弟,所以经常写到一半,发现某些地方不是特别清楚,又回过头去弄明白了再继续写,写完之后,收获也是大大的。而且之前写的文章还收到了点赞、关注还有打赏,真的特别开心。

最后,如有错误,欢迎指正。

推荐阅读更多精彩内容