Search code examples
androidopengl-esrotationrajawali

How to avoid a sphere from turning upside down when user rotates it, in OpenGL ES 2.0/ Rajawali?


I'm trying to make a 360video player app for Android. So far I've managed to create a sphere and project the video on to it. I also have an Arcball camera that follows the sphere in order to handle user touch-events and object rotations.

I've extended the ArcballCamera class from Rajawali to make some changes on it as I don't like some weird movements the video does when rotating the sphere. I've managed to change vertical and horizontal rotating directions.
The problem is, if the user scrolls up and up, there is a moment when you pass the sphere's pole where the video gets upside down. Also, depending on how the sphere rotates (I'm not sure of what is the reason exactly) the video also is sloping. I want to let the user to look up and down, behind and forward, but I always want the northern video pole to point up, I don't know if I'm explaining myself well.

I can only post two links, so here is a screenshot of the app - This happens either when the user continues scrolling up or down passing the sphere's poles:

upside down capture

I am using quaternions for the rotations and, since they're made with a rotation axis and a rotation angle, I first tried to keep the x-axis or z-axis at 0, but that didn't work. I've searched for similar things but I can't find a way out.

Here's the ArcballCamera class code (only things related to rotation):

public class mArcballCamera extends ArcballCamera{

    /*...*/

    /*with the given x and y coordinates returns a 3D vector with x,y,z sphere coordinates*/
    private void mapToSphere(float x, float y, Vector3 out) {
        float lengthSquared = x * x + y * y;
        if(lengthSquared > 1.0F) {
            out.setAll((double)x, (double)y, 0.0D);
            out.normalize();
        } else {
            out.setAll((double)x, (double)y, Math.sqrt((double)(1.0F - lengthSquared)));
        }

    }

    /**with the given x and y coordinates returns a 2D vector with x,y screen coordinates*/
    private void mapToScreen(float x, float y, Vector2 out) {
        out.setX((double)((2.0F * x - (float)this.mLastWidth) / (float)this.mLastWidth));
        out.setY((double)(-(2.0F * y - (float)this.mLastHeight) / (float)this.mLastHeight));
    }

    /**maps initial x and y touch event coordinates to <mPrevScreenCoord/> and then copies it to
     * mCurrScreenCoord */
    private void startRotation(float x, float y) {
        this.mapToScreen(x, y, this.mPrevScreenCoord);
        this.mCurrScreenCoord.setAll(this.mPrevScreenCoord.getX(), this.mPrevScreenCoord.getY());
        this.mIsRotating = true;
    }


    /**updates <mCurrScreenCoord/> to new screen mapped x and y and then applies rotation*/
    private void updateRotation(float x, float y) {
        this.mapToScreen(x, y, this.mCurrScreenCoord);
        this.applyRotation();
    }
        /** applies the rotation to the target object*/
    private void applyRotation() {
        if(this.mIsRotating) {
            //maps to sphere coordinates previous and current position
            this.mapToSphere((float) this.mPrevScreenCoord.getX(), (float) this.mPrevScreenCoord.getY(), this.mPrevSphereCoord);
            this.mapToSphere((float) this.mCurrScreenCoord.getX(), (float) this.mCurrScreenCoord.getY(), this.mCurrSphereCoord);
            //rotationAxis is the crossproduct between the two resultant vectors (normalized)
            Vector3 rotationAxis = this.mPrevSphereCoord.clone();
            rotationAxis.cross(this.mCurrSphereCoord);
            rotationAxis.normalize();
            //rotationAngle is the acos of the vectors' dot product
            double rotationAngle = Math.acos(Math.min(1.0D, this.mPrevSphereCoord.dot(this.mCurrSphereCoord)));
            //creates a quaternion using rotantionAngle and rotationAxis (normalized)

            this.mCurrentOrientation.fromAngleAxis(rotationAxis, MathUtil.radiansToDegrees(rotationAngle));
            this.mCurrentOrientation.normalize();
            //accumulates start and current orientation in mEmpty object
            Quaternion q = new Quaternion(this.mStartOrientation);
            q.multiply(this.mCurrentOrientation);
            double orientacionX = q.angleBetween(new Quaternion(0f,0f,1f,0f));
            this.mEmpty.setOrientation(q);
        }

    }

        /** adds the basic listeners to the camera*/
    private void addListeners() {
        //runs this on the ui thread
        ((Activity)this.mContext).runOnUiThread(new Runnable() {
            public void run() {
                //sets a gesture detector (touch)
                mArcballCamera.this.mDetector = new GestureDetector(mArcballCamera.this.mContext, mArcballCamera.this.new GestureListener());
                //sets a scale detector (zoom)
                mArcballCamera.this.mScaleDetector = new ScaleGestureDetector(mArcballCamera.this.mContext, mArcballCamera.this.new ScaleListener());
                //sets a touch listener
                mArcballCamera.this.mGestureListener = new View.OnTouchListener() {
                    public boolean onTouch(View v, MotionEvent event) {
                        //sees if it is a scale event
                        mArcballCamera.this.mScaleDetector.onTouchEvent(event);
                        if(!mArcballCamera.this.mIsScaling) {
                            //if not, delivers the event to the movement detector to start rotation
                            mArcballCamera.this.mDetector.onTouchEvent(event);
                            if(event.getAction() == 1 && mArcballCamera.this.mIsRotating) {
                                //ends the rotation if the event ended
                                mArcballCamera.this.endRotation();
                                mArcballCamera.this.mIsRotating = false;
                            }
                        }

                        return true;
                    }
                };
                //sets the touch listener
                mArcballCamera.this.mView.setOnTouchListener(mArcballCamera.this.mGestureListener);
            }
        });
    }

        /*gesture listener*/
    private class GestureListener extends GestureDetector.SimpleOnGestureListener {
        private GestureListener() {
        }

        public boolean onScroll(MotionEvent event1, MotionEvent event2, float distanceX, float distanceY) {
            if(!mArcballCamera.this.mIsRotating) {
                mArcballCamera.this.originalX=event2.getX();
                mArcballCamera.this.originalY=event2.getY();
                mArcballCamera.this.startRotation(mArcballCamera.this.mLastWidth/2, mArcballCamera.this.mLastHeight/2); //0,0 es la esquina superior izquierda. Buscar centro camara en algun lugar
                return false;
            } else {
                float x =  Math.abs((mArcballCamera.this.originalX - event2.getX() + mArcballCamera.this.mLastWidth/2)%mArcballCamera.this.mLastWidth);
                float y = Math.abs((mArcballCamera.this.originalY - event2.getY() + mArcballCamera.this.mLastHeight/2)%mArcballCamera.this.mLastHeight);
                mArcballCamera.this.mIsRotating = true;
                mArcballCamera.this.updateRotation(x, y);
                return false;
            }
        }
    }

If you want to see it on your own, here is the Github repository of the project. I've tested it on a Samsung Galaxy Tab4 and an Xperia Z mostly.

If you see the code, I also tried to change the rotation by moving the start point to the screen center in the onScroll method of the gesture listener, but that didn't worked either (although changed the movement direction, which I also wanted to do) and left some unwanted side effects.

So, is there a way to balance the sphere so that the northern pole always points up while the user can move around? Anything that can help me in the right direction will be welcomed.

I'm working on Android Studio, using mostly Rajawali (a library built up on OpenGL ES 2.0)

If you need some more information tell me and I'll edit the question. Also if the english is bad and didn't explained myself in a good way I'll try to do it better.


Solution

  • Well, I managed to work things out. I'm posting the answer here, and hope someday can be useful to someone.

    Instead of using quaternions to limit the rotation, I'm now using Euler angles(Tait-Bryan ones). These have a problem called the Gimbal lock but are easier to understand.

    To avoid the Gimbal lock and prevent major changes in the old system, I first calculate the Euler angles of each rotation and then create a Quaternion from them. Conversion from quaternion to euler angles is not univoque, so be careful with that. Also, remember that you need to store the accumulated rotations so that the new rotation is always absolute, always around the initial reference system.

    From the distance on X and Y that the user scrolled in the screen, I get the angle that the sphere has to rotate around x and y axis. I set that 3 complete horizontal scrolls in the screen mean a 360º movement in the sphere. To avoid the slipping problem, I set the roll (rotation along the Z-axis, the one the camera is facing) to 0. Avoiding the video to turn upside down is just a matter of clipping the pitch (rotation along X-axis facing right) from -PI/2 to PI/2.

    Notice that using directly the distance scrolled in the screen instead of the sphere's coordinates also solved the weird rotating directions that I got before.

    Here is the updated code:

            private void startRotation(final float x, final float y){
        mapToScreen(x, y, mPrevScreenCoord);
        mCurrScreenCoord.setAll(mPrevScreenCoord.getX(), mPrevScreenCoord.getY());
    
        mIsRotating = true;
        this.xAnterior=x;
        this.yAnterior=y;
    }
    
         private void applyRotation(float x, float y){
        this.gradosxpixelX = gbarridoX/mLastWidth;
        this.gradosxpixelY = gbarridoY/mLastHeight;
        double gradosX = (x - this.xAnterior)*this.gradosxpixelX; //rotation around Y axis - yaw
        double gradosY = (y - this.yAnterior)*this.gradosxpixelY; //rotation around X axis - pitch
        if(this.mIsRotating) {
            this.mCurrentOrientation.fromEuler(gradosX, gradosY, 0);
            this.mCurrentOrientation.normalize();
            Quaternion q = new Quaternion(this.mStartOrientation);
            q.multiply(this.mCurrentOrientation);
            this.mEmpty.setOrientation(q);
        }
    }