Android Canvas绘制3D视图探索

1,338 阅读4分钟

一、前言

使用Canvas绘制3D效果是老生常谈的问题,但是Canvas是2D坐标体系,实际上很难做到3D效果,即便是使用Camera,也只是投影效果,而真正的坐标体系本质上是不存在的,比如绕X、Y轴旋是不现实的事,Canvas只能绕Z轴旋转。另外还有3D投影固化为2D平面图,也会导致PathMeasure无法生效等问题,学完本篇,最终结论难道只能好好学open gl或者vulkan ?当然不是,经过一系列的探索,最终还是找到了新方法《Android Canvas 3D视图构建》,虽然本篇的方法是失败的,但也是成功之母,另外一些方法显然也可以用来绘制一些特殊效果。

二、效果

球体运动图

绕Y轴旋转的轨道图

星系图

三、问题处理

  • 3D投影固化为2D平面

创建的Path如绕Y轴旋转的轨道图,本身应该绕Y轴旋转,那么作为观察者,理论上看不到整体旋转,实际效果如上图所展示的那样。因此我尝试了星系图,这个看似绕Y轴旋转了,实际上是把平面上的圆旋转了,如果旋转90,都看不到小圆本身。

  • 观察点在球体中心导致视角错位

默认观察点是(0,0,0)点,这个会导致你看到的线条半径超过了球体半径,这个问题本篇克服了,原因是由于Camera的Z轴为0导致的,做以下修改即可,按球体半径一定距离即可

camera.setLocation(0, 0, -radius/2);
  • PathMeasure 无效或者轨迹错位

原因是PathMeasure对变换后的Path没有建立起投影映射,因此使用PatheMeasure会有很多问题

  • Canvas 无法绕X、Y旋转

通过投影方式,Canvas也无法旋转,当然可能我的方法也有问题

  • 球体无法填充

球体表面没有合适的纹理贴图方法

四、案例代码

4.1 球体运动

public class MatrixPathView extends View {
    private static final String TAG = "MatrixLearningView";
    private final DisplayMetrics mDM;
    private TextPaint mCommonPaint;
    Matrix matrix = new Matrix();
    Camera camera = new Camera();
    private float[] vertex = new float[9];
    private int mLineCount = 20;
    private TimeInterpolator interpolator;
    private float padding = 20F;

    private Random random = new Random();

    private List<CPath> paths = new ArrayList<CPath>();
    private int row = 5;
    private int col = 5;

    private float rotate = 0f;

    public MatrixPathView(Context context) {
        this(context, null);
    }
    public MatrixPathView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public MatrixPathView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
    }


    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
    }

    public float sp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        if (width <= padding || height <= padding || mLineCount <= 0) {
            return;
        }

        float radius = Math.min(width, height) / 2F - padding * 2;
        int count = canvas.save();

        vertex[0] = 1;
        vertex[1] = 0;
        vertex[2] = width / 2F;

        vertex[3] = 0;
        vertex[4] = 1;
        vertex[5] = height / 2F;

        vertex[6] = 0;
        vertex[7] = 0;
        vertex[8] = 1;
        matrix.reset();
        matrix.setValues(vertex);
        canvas.concat(matrix);

        float degree = 180F / row;

        if (paths.isEmpty()) {
            for (int i = 0; i < row; i++) {
               // camera.save();
                Matrix circleMatrix = new Matrix();

              //  camera.rotateX(i * degree);
              //  camera.setLocation(0,0,-radius/2F);
              //  camera.getMatrix(circleMatrix);

                CPath path = new CPath(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()),circleMatrix,i * degree,true);
                //因为本身就在0,0点,因此不需要preTranslate和postTranslate
            //    path.addCircle(0, 0, radius, Path.Direction.CCW);
             //   path.transform(circleMatrix);
            //    camera.restore();
                paths.add(path);
            }
            for (int i = 0; i < col; i++) {
                camera.save();
                Matrix circleMatrix = new Matrix();
                CPath path = new CPath(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()),circleMatrix,i * degree,false);
                //因为本身就在0,0点,因此不需要preTranslate和postTranslare
           //     path.addCircle(0, 0, radius, Path.Direction.CCW);

            //    camera.rotateY(i * degree);
            //    camera.setLocation(0,0,-radius/2F);  //注意Camera贴在0,0,0点看,会看到转转的东西很大
              //  camera.getMatrix(circleMatrix);
              //  path.transform(circleMatrix);
              //  camera.restore();
                paths.add(path);
            }
        }
        for (int i = 0; i < paths.size(); i++) {
            CPath cPath = paths.get(i);
            mCommonPaint.setColor(cPath.getColor());
            Matrix pathMatrix = cPath.getMatrix();
            pathMatrix.reset();
            cPath.reset();
            cPath.addCircle(0, 0, radius, Path.Direction.CCW);

            camera.save();
            if (cPath.isRotateX) {
                camera.rotateX(i * degree + rotate);
            }else{
                camera.rotateY(i * degree + rotate);
            }
            camera.getMatrix(pathMatrix);
            camera.setLocation(0, 0, -radius / 2F);
            //注意Camera贴在0,0,0点看,那么坐标轴上的东西就会很大,所以眼睛远离屏幕
            cPath.transform(pathMatrix);
            camera.restore();
            canvas.drawPath(cPath,mCommonPaint);
        }
        if (rotate > 360f) {
            rotate = rotate - 360;
        }
        rotate += 5;
        canvas.restoreToCount(count);
        postInvalidateDelayed(32);
    }

    private void initPaint() {
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        mCommonPaint.setStrokeWidth(dp2px(1));
        interpolator = new AccelerateDecelerateInterpolator();
    }



    public static int argb(float red, float green, float blue) {
        return ((int) (1 * 255.0f + 0.5f) << 24) |
                ((int) (red * 255.0f + 0.5f) << 16) |
                ((int) (green * 255.0f + 0.5f) << 8) |
                (int) (blue * 255.0f + 0.5f);
    }


    static class CPath extends Path{
        private final Matrix matrix;
        private final float degree;
        private final boolean isRotateX;
        int color;
        public CPath(int color, Matrix matrix, float degree, boolean isRotateX) {
            this.color = color;
            this.matrix = matrix;
            this.degree = degree;
            this.isRotateX = isRotateX;
        }

        public Matrix getMatrix() {
            return matrix;
        }

        public int getColor() {
            return color;
        }
    }

}

4.2 绕Y轴旋转的轨道图

public class MatrixOribitPathView extends View {
    private static final String TAG = "MatrixLearningView";
    private final DisplayMetrics mDM;
    private TextPaint mCommonPaint;
    Matrix matrix = new Matrix();

    Camera camera = new Camera();

    private float[] vertex = new float[9];
    private int mLineCount = 20;

    private TimeInterpolator interpolator;
    private float padding = 20F;

    private Random random = new Random();

    private List<CPath> paths = new ArrayList<CPath>();
    private int num = 5;
    private float rotate = 0f;

    public MatrixOribitPathView(Context context) {
        this(context, null);
    }
    public MatrixOribitPathView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public MatrixOribitPathView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
    }


    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
    }

    public float sp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
    }


    Path path = new Path();

    PathMeasure pathMeasure = new PathMeasure();


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        if (width <= padding || height <= padding || mLineCount <= 0) {
            return;
        }

        float radius = Math.min(width, height) / 2F - padding * 2;
        int count = canvas.save();

        vertex[0] = 1;
        vertex[1] = 0;
        vertex[2] = width / 2F;

        vertex[3] = 0;
        vertex[4] = 1;
        vertex[5] = height / 2F;

        vertex[6] = 0;
        vertex[7] = 0;
        vertex[8] = 1;
        matrix.reset();
        matrix.setValues(vertex);
        canvas.concat(matrix);


        if (paths.isEmpty()) {
            for (int i = 0; i < num; i++) {
                Matrix circleMatrix = new Matrix();
                CPath path = new CPath(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()),circleMatrix);
                paths.add(path);
            }
        }

        path.reset();
        float padding = radius / (num + 1);

        for (int i = 0; i < paths.size(); i++) {
            CPath cPath = paths.get(i);
            mCommonPaint.setColor(cPath.getColor());

            Matrix pathMatrix = cPath.getMatrix();
            pathMatrix.reset();
            cPath.reset();
            cPath.addCircle(0, 0, radius - padding * i, Path.Direction.CCW);

            camera.save();
            camera.rotate(90 + 45,rotate,0);
            camera.getMatrix(pathMatrix);
            camera.setLocation(0, 0, -radius / 2F);
            //注意Camera贴在0,0,0点看,那么坐标轴上的东西就会很大,所以眼睛远离屏幕
            cPath.transform(pathMatrix);
            camera.restore();

            canvas.drawPath(cPath, mCommonPaint);

        }

        if (rotate > 360f) {
            rotate = rotate - 360;
        }
        rotate += 5;
        canvas.restoreToCount(count);
        postInvalidateDelayed(32);
    }

    private void initPaint() {
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        mCommonPaint.setStrokeWidth(dp2px(1));
        interpolator = new AccelerateDecelerateInterpolator();
    }



    public static int argb(float red, float green, float blue) {
        return ((int) (1 * 255.0f + 0.5f) << 24) |
                ((int) (red * 255.0f + 0.5f) << 16) |
                ((int) (green * 255.0f + 0.5f) << 8) |
                (int) (blue * 255.0f + 0.5f);
    }


    static class CPath extends Path{
        private final Matrix matrix;
        int color;

        public CPath(int color, Matrix matrix) {
            this.color = color;
            this.matrix = matrix;
        }

        public Matrix getMatrix() {
            return matrix;
        }

        public int getColor() {
            return color;
        }

        @Override
        public void reset() {
            super.reset();
        }
    }

}

4.3 星系图

public class MatrixStarPathView extends View {
    private static final String TAG = "MatrixLearningView";
    private final DisplayMetrics mDM;
    private TextPaint mCommonPaint;
    Matrix matrix = new Matrix();

    Camera camera = new Camera();

    private float[] vertex = new float[9];
    private int mLineCount = 20;

    private TimeInterpolator interpolator;
    private Random random = new Random();

    private List<CPath> paths = new ArrayList<CPath>();
    private int num = 5;
    private float rotate = 0f;

    public MatrixStarPathView(Context context) {
        this(context, null);
    }
    public MatrixStarPathView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public MatrixStarPathView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mDM = getResources().getDisplayMetrics();
        initPaint();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = mDM.widthPixels / 2;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = widthSize / 2;
        }
        setMeasuredDimension(widthSize, heightSize);
    }


    public float dp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, mDM);
    }

    public float sp2px(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, mDM);
    }


    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
    }


    Path path = new Path();

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int height = getHeight();
        if (width <= 10 || height <= 10 || mLineCount <= 0) {
            return;
        }

        float radius = Math.min(width, height) / 2F -  20;
        int count = canvas.save();

        vertex[0] = 1;
        vertex[1] = 0;
        vertex[2] = width / 2F;

        vertex[3] = 0;
        vertex[4] = 1;
        vertex[5] = height / 2F;

        vertex[6] = 0;
        vertex[7] = 0;
        vertex[8] = 1;
        matrix.reset();
        matrix.setValues(vertex);
        canvas.concat(matrix);

        float degree = 180F / num;

        if (paths.isEmpty()) {
            for (int i = 0; i < num; i++) {
                Matrix circleMatrix = new Matrix();
                CPath path = new CPath(argb(random.nextFloat(),random.nextFloat(),random.nextFloat()),circleMatrix,i * degree,true);
                paths.add(path);
            }
        }

        path.reset();
        float padding = radius / (num + 1);

        for (int i = 0; i < paths.size(); i++) {
            CPath cPath = paths.get(i);
            mCommonPaint.setColor(cPath.getColor());

            Matrix pathMatrix = cPath.getMatrix();
            pathMatrix.reset();
            cPath.reset();

            float circleRadius = radius - padding * i;
            cPath.addCircle(0, 0, radius - padding * i, Path.Direction.CCW);

            cPath.addCircle((float) (circleRadius*Math.cos(Math.toRadians(rotate))), (float) (circleRadius*Math.sin(Math.toRadians(rotate))),10, Path.Direction.CCW);

            camera.save();
            camera.rotateX(90 - 45);
            camera.getMatrix(pathMatrix);
         //   pathMatrix.postTranslate(0, padding * i);
            camera.setLocation(0, 0, -radius / 2F);
            //注意Camera贴在0,0,0点看,那么坐标轴上的东西就会很大,所以眼睛远离屏幕
            cPath.transform(pathMatrix);
            camera.restore();

            canvas.drawPath(cPath, mCommonPaint);

        }

        if (rotate > 360f) {
            rotate = rotate - 360;
        }
        rotate += 1;
        canvas.restoreToCount(count);
        postInvalidateDelayed(32);
    }

    private void initPaint() {
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        mCommonPaint.setStrokeWidth(dp2px(1));
        interpolator = new AccelerateDecelerateInterpolator();
    }



    public static int argb(float red, float green, float blue) {
        return ((int) (1 * 255.0f + 0.5f) << 24) |
                ((int) (red * 255.0f + 0.5f) << 16) |
                ((int) (green * 255.0f + 0.5f) << 8) |
                (int) (blue * 255.0f + 0.5f);
    }


    static class CPath extends Path{
        private final Matrix matrix;
        private final float degree;
        private final boolean isRotateX;
        int color;
        public CPath(int color, Matrix matrix, float degree, boolean isRotateX) {
            this.color = color;
            this.matrix = matrix;
            this.degree = degree;
            this.isRotateX = isRotateX;
        }

        public Matrix getMatrix() {
            return matrix;
        }

        public int getColor() {
            return color;
        }
    }

}

五、总结

在Canvas 上绘制3D做简单的图片转是可以的,但是对于其他构图显然不太合适,虽然本篇大量使用了Path,但Path也有很多缺陷,其中之一是性能,其次是本篇说到的纹理贴图问题,无法在球体表面形成闭合空间(当然也可能是我的方法问题)。

意外收获:圆弧最大的旋转角度不能超过起始角,因此最大的旋转角度359.99983999