Having Trouble Rotating Properly in 3D Space

Hello all,

I’m trying to write code to rotate a character in a space environment, but no matter which implementation I choose I run into the same two problems. The playing field is the space between planets, and so I need the player to be able to rotate on X, Y, and Z axis. Here’s one attempt at that:

// Mouse movement (these are captured in an earlier function within Update)
inputLookX = Input.GetAxis(axisLookHorizontal); // Mouse X
inputLookY = Input.GetAxis(axisLookVertical); // Mouse Y
inputKeyRoll = Input.GetKey(keyRoll); // R

float accMouseX = 0f;
float accMouseY = 0f;

float verticalRotation = 0f;
float horizontalRotation = 0f;
float rollRotation = 0f;

float mouseSensitivity = 5f;
float mouseSnappiness = 20f;

playerTx = transform;

void ProcessLook()
{
        accMouseX = Mathf.Lerp(accMouseX, inputLookX, mouseSnappiness * Time.deltaTime);
        accMouseY = Mathf.Lerp(accMouseY, inputLookY, mouseSnappiness * Time.deltaTime);

        float mouseX = accMouseX * mouseSensitivity * 100f * Time.deltaTime;
        float mouseY = accMouseY * mouseSensitivity * 100f * Time.deltaTime;

        // change rotations by mouse position
        verticalRotation -= mouseY;
        if (inputKeyRoll)
        {
            rollRotation -= mouseX;
        }
        else
        {
            horizontalRotation += mouseX;
        }

        // finally, rotate player
        playerTx.localRotation = Quaternion.Euler(verticalRotation, horizontalRotation, rollRotation);
}

And while this works fine for X and Y, once I rotate on the Z axis the other rotations get funky. From what I’ve researched, this is the “correct” way to do movement with the mouse (storing the values and manually rotating), but I get some strange results.

For example, rotating 90 degrees on the Z axis, then attempting to rotate on the X axis causes a rotation on the world’s axis, not the player’s, so it looks like what a rotation on the Y axis should be from that orientation. Beyond that, when my character is upside down, rotating on the X axis is reversed, because I’m rotating on world space.

So the best solution I could find to that problem was to use Rotate(), like below:

void NewProcessLook()
    {  
        float mouseX = inputLookX * mouseSensitivity * 100f * Time.deltaTime;
        float mouseY = inputLookY * mouseSensitivity * 100f * Time.deltaTime;

        // initialize target rotations
        Vector3 targetRotation = Vector3.zero;

        // switch rotational axis if holding 'R'
        if (inputKeyRoll)
        {
            targetRotation.x = -mouseY;
            targetRotation.z = -mouseX; // moving on z axis
        }
        else
        {
            targetRotation.y = mouseX;
            targetRotation.x = -mouseY;
        }
       
        playerTx.Rotate(targetRotation, Space.Self);
    }

This works perfectly and solves all the above issues. Except, it creates one more: when I move the mouse in a circle, it creates roll rotation. I know this is because rotating on X then Y moves you on the Z axis, and the only suggestion I’ve found is to store the rotations myself like the first solution, but then I’m back to square one.

Is there a better way to do this that I’m not aware of? I’d like to use some sort of local rotation to maintain the character’s roll, but when I’ve tried saving the current x, y, and z rotations I run into all the Quaternion math I’m not quite literate in yet.

One more method I’ve tried is to split the rotations between a quaternion and Rotate(), doing Y and Z through a quaternion and X through Rotate(), but that causes more problems that solves them.

Your first attempt would work better if you‘d taken the current localRotation and then add rotation to it, the put it back. Instead you used fields to keep a reference h/v rotation which assumes a fixed rotation axis.

Nevertheless this approach would get you into Euler angle troubles anyway.
I have a hunch this is why Rotate also doesn‘t work as expected. Or maybe it‘s the self (local) space, since you only add absolute additive rotation values you could apply those from world space in case this is related to rotating an already rotated object.

Note that Quaternion math isn‘t hard. If you have two quaternions and want to add them (eg one being an offset), simply multiply them.

2 Likes

From my experience this is a normal behavior for quaternions. This artifact is present in every game that lets you rotate items in the inventory, or a globe/planet. Basically whenever your 2D mouse motion is interpreted like so

mouse X -> rotation over Y
mouse Y -> rotation over X

you will encounter this rolling precession because the motion is incremental (and not fixed to any absolute frame of reference).

For example equipment screen in Helldivers 2 has this, and planet overview in Dyson Sphere Program (DSP), to name a few games.

To be clear, this isn’t really a bug, the root cause of this is a deep mathematical reality of such behavior and can be compensated with adequate user input. However, I can understand that it looks wrong in someone’s case, in particular when you want to keep some cardinal alignment.

This is exactly why DSP has a ‘north pole lock’ (as a toggle) which makes sure to actively keep the globe oriented so that the motion resembles Eulerian rotation. It simply maintains the polar axis and actively fixes the camera roll such that it stays parallel with the screen Y axis. (I mean if you believe in word “simply” in the context of quaternions.)

All in all, this is a highly advanced topic, and requires you to be ON TOP when it comes to quaternions. We can help you with something specific, but if you’re super-reliant on API methods to tame complex rotations, not only you’ll have a hard time understanding (and fixing) whatever is happening to your solution, but it could be that the API simply doesn’t cover, say “pro league” cases, which means you need to delve deeper into quaternion rotation, beyond the typical Rotate, Euler crutches.

I see that you’re aware of this, and yes you can implement a constant roll correction relatively easily if you use LookRotation, because it allows you specify the vector that is perpendicular to your view direction. Here you can specify that your up vector is in fact whatever world axis you’d like to remain vertical in your view.

Another way would be to describe a world vector relative to the camera (i.e. project it to its far frustum plane) and determine the roll rotation (via FromToRotation) against some other known vector.

Yet another way would be to artificially construct the rotations (based on input) specifically with this behavior in mind, which is what I think DSP does. It’s not that the toggle magically autocorrects a “bad rotation”, they literally deploy two different mathematical strategies, one of which is true to the spherical topology, and the other is translating back to spherical coordinates from which the standard Euler angles can be obtained artificially.

And there are surely other ways, because this issue is open to mathematical description, so naming them this abstractly is getting ridiculous. Find some game you can compare your case with, then try to formally define what is your objective (with a set of images showing the discrepancy), and I’ll try to help as much as I can.

Edit:
From this implementation of an auto-aiming turret (which could be mounted onto the roof of a moving, bumping, rolling car) you can see how to decompose a full on rotation quaternion into two planar ones, essentially turning a singular rotation construct into two separate ones: yaw and pitch, both potentially Euler angles (although I do not use Euler rotation as is). With the resultant rotation decomposed, I’m in strict control over the roll, which is something typical military turrets should never do.

This is what I meant by artificially constructing the rotation. I basically arrive to the answer (with readily available techniques), deconstruct that answer to extract the essentials of it, and then impose my own. As opposed to trying to correct the original thing which would be much much harder.

2 Likes

First of all, thank you for the detailed response! I definitely want to learn Quaternions in and out so this sort of rotational math doesn’t bar me from more complex features down the road, but your mention of Helldivers and DSP got me thinking.

I’m basing much of my initial movement system off of Outer Wilds, and figured (without checking) that it didn’t have that sort of “accidental” z rotation. But upon booting it up, I encountered the same effect. Initially I thought that if my rotations were different than other games, it could get frustrating for the player. But I guess a lot of games run into this same problem.

I’m interested in this idea, because keeping a sort of alignment makes sense to me. But I’m also eager to move on with other features, because this problem has been plaguing me for a few days now. All in all, you’ve already given me a lot to look into and really appreciate it. If I come back to this problem, I might post on this thread again, but for now I think I’ll stick with my Rotate() implementation since that seems to fit.

Thanks!

1 Like

I keep forgetting that I can store the full rotation and don’t have to individually modify the x, y, and z components!

I’d like to give this method a shot, because I remember early on adjusting the existing transform with transform.rotation *= Quaternion.Euler(...) and that might be best. One thing I’ll have to implement later is to clamp vertical rotation when on a planet so the player can’t do flips, but I think I can just use the position of the planet to determine that.

Thanks for your response!

Here are my implementations of AngleAxis and FromToRotation, the most fundamental “constructors”.
Maybe peering into the innards of the quaternions will help you unravel their mysteries.

static public Quaternion angleAxis(float rad, Vector3 axis) {
  const float TOO_SMALL = 3E-8f;
  if(sqrMag(axis) >= TOO_SMALL) { rad *= half; return v4_xyz(mul(sin(rad), axis), w: cos(rad)).AsQuat(); }
  return quat_id;
}

static public Quaternion fromToSafe(Vector3 from, Vector3 to, Quaternion defaultQuat = default) {
  const float EPS = 1E-6f;
  var w = 1f + dot(from, to);
  if(w < EPS) return defaultQuat != default? defaultQuat : angleAxis(pi, cross(from, to));
  return normalize(v4_xyz(cross(from, to), w).AsQuat());
}

Unlike vectors, quaternions must be of unit length at all times. Their normalization works exactly the same as it would for a Vector4. In fact, they are technically vectors of 4 elements, it’s just that their components are realized in the context of two complex planes (hence trigonometry). The rest of the functions should be self-explanatory, just ask if you need help parsing this.

Quaternion-to-quaternion and quaternion-to-vector multiplication look like this

// a * b
static public Quaternion mul(Quaternion a, Quaternion b)
  => new Quaternion(
    a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
    a.w * b.y + a.y * b.w + a.z * b.x - a.x * b.z,
    a.w * b.z + a.z * b.w + a.x * b.y - a.y * b.x,
    a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
  );

// q * v
static public Vector3 mul(Quaternion q, Vector3 v)
  float x = 2f * q.x, y = 2f * q.y, z = 2f * q.z;
  float xx = q.x * x, yy = q.y * y, zz = q.z * z;
  float xy = q.x * y, xz = q.x * z, yz = q.y * z;
  float wx = q.w * x, wy = q.w * y, wz = q.w * z;

  return new Vector3(
    (1f - (yy + zz)) * v.x + (xy - wz) * v.y + (xz + wy) * v.z,
    (xy + wz) * v.x + (1f - (xx + zz)) * v.y + (yz - wx) * v.z,
    (xz - wy) * v.x + (yz + wx) * v.y + (1f - (xx + yy)) * v.z
  );
}

Not that you need these two implemented like this in any shape or form, it’s only for learning purposes.

You can also find Quaternion.Slerp here .

1 Like

WARNING: