Prevent Gravity-based Orbit camera from flipping

So I’ve pretty much run up against the wall with my understanding of rotations.

I’m working on a fun little ball-rolling puzzle platformer, of which has gravity zones of various shapes (spherical, cylindrical, etc), and some of which add to normal gravity, and some that completely replace it.

Expectedly you’d want the camera to re-orient itself so that ‘up’ is with respect to this new gravity. That in itself hasn’t been too difficult, but I’ve had one issue I haven’t been able to overcome: if I go from one gravity direction to that of the opposite orientation (or roughly opposite), the camera flips around, facing ‘backwards’ with respect to the direction you came from.

You can see what I mean here.

Currently the set up is: one game object (GravityPointer) that immediately points ‘up’ to the current gravity direction, and rotates on this axis to player input; plus another game object (CameraOrientor) that rotates to match this one over time with a ‘Quaternion.SmoothDamp’ like implementation, with a cinemachine free-look camera that is locked to said object. Said camera only moves up and down itself, with the horizontal rotation being handled by the GravityPointer.

The hierarchy is as such:
8753038--1186069--upload_2023-1-24_23-23-28.png

In the video you can see GravityPointer snap to the opposite direction.

And this is the present code for the GravityPointer (ignore the Odin stuff):

namespace HIAS.Spheres
{
   using UnityEngine;
   using Cinemachine;
   using Sirenix.OdinInspector;
   using LizardBrainGames;
   using HIAS.Physics;

   [HideMonoScript]
   public class GravityPointer : MonoBehaviour
   {
       #region Inspector Fields

       [BoxGroup("R", LabelText = "References:")]
       [SerializeField]
       private SphereController sphereController;

       [BoxGroup("R")]
       [SerializeField]
       private LocalRigidbody localRigidbody;

       [BoxGroup("R")]
       [SerializeField]
       private CinemachineInputProvider inputProvider;

       [MemberNameBoxGroup("X")]
       [SerializeField, HideLabel]
       private AxisState cameraYAxis = new(minValue: -180, maxValue: 180, wrap: true, rangeLocked: false, maxSpeed: 300f, accelTime: 0.1f, decelTime: 0.1f, name: null, invert: true);

       #endregion

       #region Internal Members

       private Quaternion gravityAlignment = Quaternion.identity;

       private Vector3 gravityForward = Vector3.forward;
       private Quaternion lastFinalRotation = Quaternion.identity;

       #endregion

       #region Unity Callbacks

       private void Awake()
       {
           cameraYAxis.SetInputAxisProvider(axis: 0, inputProvider);
       }

       private void Start()
       {
           UpdatePointerPosition();
           UpdatePointerDirection();
       }

       private void LateUpdate()
       {
           UpdatePointerPosition();
           UpdatePointerDirection();
       }
       #endregion

       #region Gravity Pointer Methods

       private void UpdatePointerPosition()
       {
           transform.position = sphereController.transform.position;
       }

       private void UpdatePointerDirection()
       {
           Vector3 gravityDirection = localRigidbody.GravityDirection;

           float deltaTime = Time.deltaTime;
           cameraYAxis.Update(deltaTime);

           gravityAlignment = Quaternion.FromToRotation(gravityAlignment * Vector3.up, -gravityDirection) * gravityAlignment;
           Quaternion orbitRotation = Quaternion.AngleAxis(cameraYAxis.Value, -gravityDirection);

           Quaternion finalRotation = orbitRotation * gravityAlignment;
           Quaternion difRot = finalRotation * Quaternion.Inverse(lastFinalRotation);

           gravityForward = difRot * gravityForward;
           Debug.DrawRay(transform.position, gravityForward * 3f, Color.blue);

           Quaternion lookRotation = Quaternion.LookRotation(gravityForward, -gravityDirection);

           transform.rotation = lookRotation;
           lastFinalRotation = finalRotation;
       }

       #endregion
   }
}

This is one of many attempts, with this one attempting to maintain a consistent ‘forward’ direction over time by rotating a direction over time by using the difference in overall rotation between frames, though the issue still happens.

I’d appreciate any pointers in the right direction here, as my understanding of Quaternions only covers the basics. Hell I imagine there’s a simpler implementation here. Cheers folks.

1 Like

Hmm…I can’t fully grok the problem at the moment (maybe I need to listen to some more Marble Blast music).

Would the ideal behavior in that video example be for the camera to roll over 180 degrees without moving very much?

Well, since the gravity filps over you always have to flip over as well in some way. Though it depends on what axis. You just use FromToRotation to get the shortest great circle route around the unit sphere. Because the tube is slightly tilted, the rotation you get back it rotating around the local x axis as this is where the shortest path goes. You probably want to prefer a rolling around z if possible (like the game portal does when you go through portals in the floor). So the forward direction mainly keeps the direction if possible. Portal actually had two different approaches. If you originally looked at the portal (so looking straight up or down), they would actually align with the portal and just flip the forward axis. However when you looked sideways when going through the portal they rotate you around your forward axis. If you look at an angle it’s essentially a mix between the two.

It’s actually hard to get such a mechanic consistently the way you want. Even Portal has some issues in some cases, especially when you almost look at a portal the rotation around z can be really confusing, especially in chamber 18 at the end(around 9m). He always looks right at the portal so forward gets flipped 180° as he’s looking up / down. However at the last portal he had a slight angle so he gets the rotation around z happening.

You essentially have a similar situation. You “teleport” from one world into another where gravity is oriented differently. You want to keep your forward axis aligned if possible but have up changing around your forward to the new up. I don’t have a ready to use solution, but I would probably decompose the orienation into forward and up vector and construct a relative rotation around forward that first brings you as close as possible to have your up aligned with your target up and then just do the rest. You should be able to combine those two rotations into one. So the rotation axis would still be “almost” forward but just slightly off in case of the slighly tilted surface. With a perfect 180° filp it would actually be a perfect 180° rotation around forward.

I think you have to flip your thinking around on this and ask a slightly different question for camera orientation.

I encountered this in my tank terrain following system (see below). The reason is when you directly use the normal of the plane as your “up,” any number of directions around the compass can satisfy the math.

Instead, you say “I am facing this way” so make me look that way, but tilt my “up” to be the normal of the ground, or in your case the local gravity.

Granted, the below cannot even handle fully tilting over, but the computation of doing the look is the important part:

            Plane p = new Plane( rchL.point, rchR.point, rchAFT.point);
            Vector3 fwd = (rchL.point + rchR.point) * 0.5f - rchAFT.point;
            Visual.LookAt ( Visual.position + fwd, p.normal);

where L, R and AFT are the three tripod points.

Thanks for all the responses everyone!

Yeah pretty much. Ideally the camera should just roll around its current ‘forward’ direction to right itself.

Which leads me to:

This idea sounds on the money to me. The only thing I’m not entirely certain on is how to do this part:

Off the top of my head, I want to say… get the Vector3.SignedAngle between the current ‘up’ and the direction of gravity around the current forward, rotate that much around said forward with Quaternion.AngleAxis, then Quanternion.FromToRotation the remaining difference (on both directions)? Does that make sense?

After which I can then spin the rotation around the axis of gravity based on input.

Still at my boring normal person job so I can’t try it out just yet.

Well my latest attempt was unsuccessful. Thought I had it all worked out and after some trial and error I came up with this:

private void UpdatePointerDirection()
{
   Vector3 gravityDirection = localRigidbody.GravityDirection;
   Vector3 pointerUp = transform.up;
   Vector3 pointerForward = transform.forward;

   float deltaTime = Time.deltaTime;
   cameraYAxis.Update(deltaTime);

   Quaternion previousOrbitRotation = orbitRotation;
   orbitRotation = Quaternion.AngleAxis(cameraYAxis.Value, -gravityDirection);
   Quaternion orbitDif = orbitRotation * Quaternion.Inverse(previousOrbitRotation);

   // project gravity onto forward's plane to prevent excess rotation
   Vector3 projectedGravity = Vector3.ProjectOnPlane(-gravityDirection, pointerForward);
   float gravityAngle = Vector3.SignedAngle(pointerUp, projectedGravity, pointerForward);

   Quaternion forwardRotation = Quaternion.AngleAxis(gravityAngle, pointerForward);

   // rotate 'up' with the rotation around forward, then find the rotation from that to true 'up'
   Vector3 rotatedPointerUp = forwardRotation * pointerUp;
   Quaternion fromToGravityUp = Quaternion.FromToRotation(rotatedPointerUp, -gravityDirection);

   // rotate both up and forward with the remaining rotation
   rotatedPointerUp = fromToGravityUp * rotatedPointerUp;
   Vector3 rotatedPointedForward = fromToGravityUp * pointerForward;

   Quaternion finalRotation = Quaternion.LookRotation(rotatedPointedForward, rotatedPointerUp);

   finalRotation *= orbitDif;
   transform.rotation = finalRotation;
}

In principle it seems sound. We make a rotation around ‘forward’, rotate ‘up’ with it, then we rotate both forward and up by the remaining required rotation, and finally making a look rotation with those two directions.

However it still seems to flip when coming out of large gravity AND my horizontal rotation inverts when upside-down too.

I have a feeling the ‘real’ solution to this is a bunch of messy edge case handling. Would appreciate any input on my current attempt.

I did something very similar a few years ago for a university project. I used this component to align the up vector of a transform with an arbitrary gravity vector while keeping the forward direction (if possible). I’m not sure if it’ll help you, but I remember it working pretty reliably
Also don’t judge me for my old code please :smile:

public class RotateWithGravity : MonoBehaviour
    {
        public bool interpolate;
        public float adjustmentSpeed;

        // Optional: Can be used to provide an external forward axis, for example a camera
        public Transform forwardAxis;

        private GravityController m_gravityController;
        private Quaternion m_targetRotation;

        private void Awake()
        {
            m_gravityController = FindObjectOfType<GravityController>();
        }

        private void OnEnable()
        {
            m_gravityController.OnGravityChange += AdjustToGravity;
        }

        private void OnDisable()
        {
            m_gravityController.OnGravityChange -= AdjustToGravity;
        }

        private void Update()
        {
            if (interpolate)
            {
                transform.rotation = Quaternion.Slerp(transform.rotation, m_targetRotation, adjustmentSpeed * Time.deltaTime);
            }
            else if (transform.rotation != m_targetRotation)
            {
                transform.rotation = m_targetRotation;
            }
        }

        private void AdjustToGravity()
        {
            Vector3 gravity = m_gravityController.GetGravityVector();

            float angle = 0.0f;

            // Calculate the axis we're going to rotate around
            Vector3 axis = Vector3.Cross(transform.up, -gravity);

            if (Utilities.ApproximatelyEqual(axis, Vector3.zero, 0.001f))
            {
                // If the rotation is approximately 180 degrees, always use the forward vector as rotation axis
                angle = 180.0f;
                axis = forwardAxis == null ? transform.forward : Vector3.ProjectOnPlane(forwardAxis.forward, transform.up);
            }
            else
            {
                angle = Vector3.SignedAngle(transform.up, -gravity, axis);
            }

            m_targetRotation = Quaternion.AngleAxis(angle, axis) * transform.rotation;
        }
    }

For reference, this is the trailer for the final product where you can (briefly) see it in action:

Can only thank you for offering assistance! (Cute game too) I gave the code a shot (had to kinda guess what Utilities.ApproximatelyEqual(axis, Vector3.zero, 0.001f)) was doing), and it worked well though had similar flipping issues.

I think I’m just going to have to be sure not to let the player change gravity at such extreme angles. In all other cases my code in the first post works super nicely.

What about if you put in a boundary gravity provider mechanism, where some other object synthesizes the gravity over a short distance between two widely disparate gravity areas?

It could rotate the gravity vector around a pole that runs between the two gravity area centers perhaps… that way any instantaneous 180 change can be avoided, or rather turned into a “rotate over to” sequence.