Android 3D球面运动

2,304 阅读8分钟

前言

我们之前的两篇3D文章中,我们对3D的画法进行了一些探索,主要通过两种方式去构建3D效果,最终,我们找到了适合我们自己构建3D物体的方法。 《Android Canvas绘制3D视图探索》是踩坑篇,通过Path构建路径之后选择整体图形,发现事与愿违,Path 路径旋转后依然是坍塌的2D平面。如下图所示,我们需要穿过中心绕Y轴运动,结果整个屏幕都动了,另外由于点直接无法建立连接,导致外部无法形成闭合区域,因此也无法贴图。

fire_29.gif

接着我们《Android Canvas 3D视图构建》中终于找到了我们想要的方法,那就是通过数学模型动态连接Path的方式,先让每个点分布在3D物体的位点,当然点越多精度会越多,就像割圆术求圆周率一样。而且这种方式避免了点无法连接的问题,因此可以形成闭合曲面进行贴图。

fire_35.gif

球面运动公式

x =  mRadius * Math.cos(a) * Math.sin(b);
y =  mRadius * Math.sin(a) * Math.sin(b);
z =  mRadius * Math.cos(b);

好了,现在就以画一个球围绕面旋转了

a = 0度, 绕Y轴

fire_66.gif

a = 90度 ,绕X轴 fire_67.gif

a = -45度,绕x-y平面夹角 fire_68.gif

b=90度 ,绕Z轴旋转

fire_69.gif

从上面三个图我们知道,其实这里的投影完全依赖z轴的运动,因此,3D是不存在的,你看到的3D只不过是2D平面的投影。

核心逻辑

为了大家体验,我把代码贴出来

核心逻辑当然是数学模型,但是绘制方法我们也要理解一下,在绘制的过程中我们需要球缩小。 之前的文章中,我们有提到,x/z和y/z来做透视投影,但这里我们没用,主要原因是其中夹角的关系已经做了类似的换转,如果使用矩阵,那么这种透视除法一般需要手动处理才行。

    canvas.drawCircle(point.x, point.y, 50 + (point.z / radius) * 25 , mCommonPaint);

下面是比较完整的代码,当然公式的角度要转为圆周值

double a = 0;
double b = 10;

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

    int width = getWidth();
    if(width < 1) return;
    int height = getHeight();
    if(height < 1) return;

    float radius = Math.min(width, height) / 2.5f;
    int save = canvas.save();
    canvas.translate(width/2f,height/2f);

    point.x = (float) (radius * Math.cos(Math.toRadians(a)) * Math.sin(Math.toRadians(b)));
    point.y = (float) (radius * Math.sin(Math.toRadians(a)) * Math.sin(Math.toRadians(b)));
    point.z = (float) (radius * Math.cos(Math.toRadians(b)));

    canvas.drawCircle(point.x, point.y, 50 + (point.z / radius) * 25 , mCommonPaint);
    mCommonPaint.setStyle(Paint.Style.FILL);
    mCommonPaint.setColor(0x66ff9922);
    canvas.drawCircle(0,0,radius,mCommonPaint);
    mCommonPaint.setStyle(Paint.Style.FILL);
    mCommonPaint.setColor(0xFF2299ff);
    canvas.restoreToCount(save);

   // a += 1;
    b += 5;

    if(a > 360){
        a = a - 360;
    }
    if(b > 360){
        b = b - 360;
    }
    postInvalidateDelayed(32);

}


static class Point{
    private float Radius;
    private float x;
    private float y;
    private float z;
}

夹角思考

我们在旋转的过程中,角a和角b中有很多时间做圆周运动,圆周运动中有很多推导和公式。如三角函数值:360 + 旋转角度 = 旋转角度;三角函数之中,虽然角度象限不同,但值可能是一样的,另外物体只能绕中心点运动;那么,使用这种方式来确定方向以及不饶中心点呢,显然还需要借助更多的计算,有没有一眼就能直观的方式呢?

其实是有的,那就是使用矩阵,矩阵写对角线的数值的正负数很明确的就能看出点所在的大致区域,但是如何表示这个矩阵呢?当然,在欧拉等数学家的努力下,欧拉角的出现,让这个问题得到了解决。欧拉角可以和矩阵互相转换,优缺点互补,进行缩放平移、方向确定等。(注:当然还有其他表示方法,比如四元素、指数映射、矩阵)

企业微信20231209-062206@2x.png

欧拉角的含义: 欧拉角用了三个角度分量表示(a,b,c),每个角度用来表示绕X,Y,Z轴旋转的角度,和初始角度不同的是(a,b,c为增量角度,不是指定角度,因此不需要累加),我们无需要关注与平面的夹角,只关注旋转角,虽然靠想象依然很难定位方向,但是转换为矩阵结果就能清楚的知道方向。

实战

排列

我们首先定义初始位置 ,为什么要初始位置呢?看一下欧拉角的矩阵变换,其本质是依赖前值的,如果前值都是0,意味着都堆到了原点(0,0,0)位置。


   //随机排列
   float alpha = random.nextFloat() * 360;
   float delta = random.nextFloat() * 180;  //靠正面

定义旋转角

我们在前面说过,欧拉角是增量角度旋转(依赖前值,在前值的基础上旋转),意味角度不需要累加。

double xr = Math.toRadians(5);  //绕x轴旋转
double yr = Math.toRadians(5);  //绕y轴旋转;
double zr = 0;  // 绕z轴旋转

旋转起来

当然我们使用三个角的乘积公式也是可以的,不过这里方便大家理解,就分为三步矩阵相乘。

float x = point.x;
float y = point.y;
float z = point.z;

//绕X轴旋转,乘以X轴的旋转矩阵
float rx1 = x;
float ry1 = (float) (y * Math.cos(xr) + z * -Math.sin(xr));
float rz1 = (float) (y * Math.sin(xr) + z * Math.cos(xr));

// 绕Y轴旋转,乘以Y轴的旋转矩阵
float rx2 = (float) (rx1 * Math.cos(yr) + rz1 * Math.sin(yr));
float ry2 = ry1;
float rz2 = (float) (rx1 * -Math.sin(yr) + rz1 * Math.cos(yr));

// 绕Z轴旋转,乘以Z轴的旋转矩阵
float rx3 = (float) (rx2 * Math.cos(zr) + ry2 * -Math.sin(zr));
float ry3 = (float) (rx2 * Math.sin(zr) + ry2 * Math.cos(zr));
float rz3 = rz2;


point.x = rx3;
point.y = ry3;
point.z = rz3;

预览一下

fire_70.gif

增加透视效果

因为在计算机设备中,无论是手机还是电脑,3D立面是不存在的,存在的是3D模型在2D平面的投影,因此为了更加逼真,我们让原理我们眼睛方向的物体缩小,颜色变浅,反之变大,颜色变深,我们使用透视除法,不过这里做些优化.

// 透视除法,z轴向内
float scale = (2 * radius) / ((2 * radius) + rz3);
mCommonPaint.setColor(point.color);
if(scale > 1) {
    mCommonPaint.setAlpha(255);
}else{
    mCommonPaint.setAlpha((int) (scale * 255));
}

另外要知道,scale是按球面z轴横截面比例计算出来的,意味着所有的运动都是沿着z后运动,当z轴比例缩小时,那么意味着横截面在缩小,那么和横截面半圆上的小球也应该缩小。

添加更多物体,绕X轴旋转

定义旋转角

double xr = Math.toRadians(5);  //绕x轴旋转
double yr = 0;  //绕y轴旋转;
double zr = 0;  // 绕z轴旋转

排列

if(pointList.isEmpty()){

    int max = 20;

    for (int i = 0; i < max; i++) {

        //均匀排列
        float delta = (float) Math.acos(-1.0 + (2.0 * i - 1.0) / max);
        float alpha = (float) (Math.sqrt(max * Math.PI) * delta);

        //随机排列
      //  float alpha = random.nextFloat() * 360;
     //   float delta = random.nextFloat() * 180;  //靠正面

        Point point = new Point();

        point.x = (float) (radius * Math.cos(alpha) * Math.sin(delta));
        point.y = (float) (radius * Math.sin(alpha) * Math.sin(delta));
        point.z = (float) (radius * Math.cos(delta));
        point.color = argb(random.nextFloat(),random.nextFloat(),random.nextFloat());
        pointList.add(point);
    }
}

效果预览

fire_83.gif

增加Shader效果

增加Shader 为了让大圆像太阳一样

if(shader == null){
    shader = new RadialGradient(0, 0, radius,
            new int[]{0xaaec5533, 0x77f9922, 0x11000000},
            new float[]{0.3f, 0.8f, 0.9f},
            Shader.TileMode.CLAMP);
}

最终效果预览

fire_84.gif

总结

到这里本篇就结束了,在本篇,我们可以有一个更加具体的方法去定义三维物体的运动,那就是欧拉角和矩阵,这是一种通用做法,包括在open gl的世界坐标系中,因此,本篇作为一个一篇3D模型构建篇,相比前2篇更加具体。

全部代码

public class Smartian3DView extends View {

    private TextPaint mCommonPaint;
    private DisplayMetrics mDM;


    public Smartian3DView(Context context) {
        this(context, null);
    }

    public Smartian3DView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initPaint();
    }

    private void initPaint() {
        mDM = getResources().getDisplayMetrics();
        //否则提供给外部纹理绘制
        mCommonPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
        mCommonPaint.setAntiAlias(true);
        mCommonPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mCommonPaint.setStrokeCap(Paint.Cap.ROUND);
        mCommonPaint.setFilterBitmap(true);
        mCommonPaint.setDither(true);
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int 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);

    }


    double xr = Math.toRadians(5);  //绕x轴旋转
    double yr = 0;  //绕y轴旋转;
    double zr = 0;

    private List<Point> pointList = new ArrayList<>();

    private Random random = new Random();

    private Shader shader = null;

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

        int width = getWidth();
        if (width < 1) return;
        int height = getHeight();
        if (height < 1) return;

        float radius = Math.min(width, height) / 3f;
        int save = canvas.save();
        canvas.translate(width / 2f, height / 2f);


        if (pointList.isEmpty()) {

            int max = 20;

            for (int i = 0; i < max; i++) {

                //均匀排列
                double v = -1.0 + (2.0 * i - 1.0) / max;
                if (v < -1.0) {
                    v = 1.0f;
                }
                float delta = (float) Math.acos(v);
                float alpha = (float) (Math.sqrt(max * Math.PI) * delta);

                //随机排列
                //  float alpha = random.nextFloat() * 360;
                //   float delta = random.nextFloat() * 180;  //靠正面

                Point point = new Point();

                point.x = (float) (radius * Math.cos(alpha) * Math.sin(delta));
                point.y = (float) (radius * Math.sin(alpha) * Math.sin(delta));
                point.z = (float) (radius * Math.cos(delta));
                point.color = argb(random.nextFloat(), random.nextFloat(), random.nextFloat());
                pointList.add(point);
            }
        }


        for (int i = 0; i < pointList.size(); i++) {

            Point point = pointList.get(i);
            float x = point.x;
            float y = point.y;
            float z = point.z;

            //绕X轴旋转,乘以X轴的旋转矩阵
            float rx1 = x;
            float ry1 = (float) (y * Math.cos(xr) + z * -Math.sin(xr));
            float rz1 = (float) (y * Math.sin(xr) + z * Math.cos(xr));

            // 绕Y轴旋转,乘以Y轴的旋转矩阵
            float rx2 = (float) (rx1 * Math.cos(yr) + rz1 * Math.sin(yr));
            float ry2 = ry1;
            float rz2 = (float) (rx1 * -Math.sin(yr) + rz1 * Math.cos(yr));

            // 绕Z轴旋转,乘以Z轴的旋转矩阵
            float rx3 = (float) (rx2 * Math.cos(zr) + ry2 * -Math.sin(zr));
            float ry3 = (float) (rx2 * Math.sin(zr) + ry2 * Math.cos(zr));
            float rz3 = rz2;


            point.x = rx3;
            point.y = ry3;
            point.z = rz3;

            // 透视除法,z轴向内的方向
            float scale = (2 * radius) / ((2 * radius) + rz3);
            point.scale = scale;

        }

        //排序,先画背面的,再画正面的
        Collections.sort(pointList, comparator);

        for (int i = 0; i < pointList.size(); i++) {
            Point point = pointList.get(i);
            mCommonPaint.setColor(point.color);
            if(point.scale > 1) {
                mCommonPaint.setAlpha(255);
            }else{
                mCommonPaint.setAlpha((int) (point.scale * 255));
            }
            if (point.z > 0) {
                canvas.drawCircle(point.x * point.scale, point.y * point.scale, 5 + 25 * point.scale, mCommonPaint);
                continue;
            }
            break;
        }
        mCommonPaint.setAlpha(255);
        if (shader == null) {
            shader = new RadialGradient(0, 0, radius,
                    new int[]{0xffec7733, 0x77f9922, 0x11000000},
                    new float[]{0.2f, 0.7f, 0.9f},
                    Shader.TileMode.CLAMP);
        }
        mCommonPaint.setShader(shader);
        mCommonPaint.setStyle(Paint.Style.FILL);
        canvas.drawCircle(0, 0, radius, mCommonPaint);
        mCommonPaint.setShader(null);

        //绘制大于
        for (int i = pointList.size() - 1; i >= 0; i--) {
            Point point = pointList.get(i);
            mCommonPaint.setColor(point.color);
            if(point.scale > 1) {
                mCommonPaint.setAlpha(255);
            }else{
                mCommonPaint.setAlpha((int) (point.scale * 255));
            }
            if (point.z <= 0) {
                canvas.drawCircle(point.x * point.scale, point.y * point.scale, 5 + 25 * point.scale, mCommonPaint);
            } else {
                break;
            }
        }
        canvas.restoreToCount(save);
        postInvalidateDelayed(32);

    }

    Comparator comparator = new Comparator<Point>() {
        @Override
        public int compare(Point left, Point right) {
            if (left.z - right.z < 0) {
                return 1;
            }
            if (left.z == right.z) {
                return 0;
            }
            return -1;
        }
    };

    static class Point {
        private int color;
        private float x;
        private float y;
        private float z;

        private float scale = 1f;
    }


    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);
    }

}

参考文章 《地球仪控件