Quaternion Rotation Creates Wobble

Trying to create a 3D first person camera control with yaw controls. Changing the yaw makes it so I cannot just turn the camera in x and y axis. When yaw is changed the direction that x and y inputs change the rotation of the camera changes as well so I refreshed the up, right and forward vectors update with cameras rotation. If made so the “xRotation” “yRotation” and “zRotation” are additive (they show the total amount of rotation rather than the instant) the camera starts to rotate super fast when done 60 degrees of rotation in any direction including z. If it is made so (this is the current version) they aren’t additive but change the rotation and change it more when more rotations are done, the problem of camera freaking out when turned 60 degrees is fixed but the controls are completely lost creating almost wobbly shapes when any two of x, y or z rotations are non-zero. I have been working on this longer than I would want to and have learned much more about Quaternions than I would’ve liked (it didn’t help). Send help.
PS. I also tried changing the order in which values are multiplied


    void HandleFreeLook()
    {
        //inputs
        rotationX = Input.GetAxis("Mouse X") * cameraSens; 
        rotationY = Input.GetAxis("Mouse Y") * -cameraSens;  

        if (Input.GetKey(KeyCode.Q))
        {
            rotationZ = yawSpeed * Time.deltaTime;  
        }

        //create real-time vectors to calculate rotations
        Vector3 right = _camera.transform.right;
        Vector3 up = _camera.transform.up;
        Vector3 forward = _camera.transform.forward;

        //calculate rotations
        Quaternion yQuaternion = Quaternion.AngleAxis(rotationX, up);
        Quaternion xQuaternion = Quaternion.AngleAxis(rotationY, right);
        Quaternion zQuaternion = Quaternion.AngleAxis(rotationZ, forward);

        //multiply and rotate
        _camera.transform.rotation = _camera.transform.rotation * yQuaternion * xQuaternion * zQuaternion;
    }

Camera stuff is pretty tricky… I hear all the Kool Kids are using Cinemachine from the Unity Package Manager.

There’s even a dedicated Camera / Cinemachine area: see left panel.

If you insist on making your own camera controller, do not fiddle with camera rotation.

The simplest way to do it is to think in terms of two Vector3 points in space:

  1. where the camera is LOCATED
  2. what the camera is LOOKING at
private Vector3 WhereMyCameraIsLocated;
private Vector3 WhatMyCameraIsLookingAt;

void LateUpdate()
{
  cam.transform.position = WhereMyCameraIsLocated;
  cam.transform.LookAt( WhatMyCameraIsLookingAt);
}

Then you just need to update the above two points based on your GameObjects, no need to fiddle with rotations. As long as you move those positions smoothly, the camera will be nice and smooth as well, both positionally and rotationally.

The reason rotation is difficult isn’t because… it’s difficult. I managed to create a simple fps camera controller that stores values locally. The problem is I need to control z axle rotation as well and it needs to affect all camera movements. For example: If you were to look at the horizon and try to look up you would look at the sky but if you were to look at the horizon turn 90 degrees in z axle and look up you would look around you while always looking at the horizon. I even managed to make this with continuous rotations but the camera would start to freak out when turning more than 60 degrees in any axis.

This is why I cannot use “LookAt” and I need to keep track of where is up because the Vector.up would make horizon line the only control point for x rotation movement.

I simply don’t understand why my code doesn’t work since all rotation would need would be “where is up, right, and forwards” and with good degrees and rotation it should work but it doesn’t.

This seems to be your main issue here. You’re multiplying in the wrong order. Quaternion multiplication is the other way round. It works like matrix multiplication. So your delta quaternions should come before the one you want to change

_camera.transform.rotation = yQuaternion * xQuaternion * zQuaternion * _camera.transform.rotation;

Another thing that is not clear from your code is where / when your “rotationZ” is actually set back to 0. Those are supposed to all be delta values. So when no input is given they should all be 0

I tried that as well… as I had mentioned. Also rotation z is set to 0 at the start of code. Also also changing the value of z doesn’t cause the problem. With this code when z is 0 it is still very much wrong

I tried a few extra tests but this one baffled me the most. This gives the exact same result as the last function I posted even though I didn’t touch any quaternions and am simply creating a vector that looks at the direction that is specified. When z equals to 0 it should be an extremely simple camera control function but this doesn’t work either. There isn’t even any multiplications. I am losing my mind over this and have spent a total of 9 hours now… increasing.

Edit: Upon even further investigation I realised that in the previous function and this one camera looks EXACTLY where it is supposed to. When moved only in x axle the center of the screen is always at the horizon and movement work perfectly… But the camera is always tilted in some direction. The combination of multiple types of rotation cause the camera to tilt for some reason. If I knew why and calculate it I could counteract it but I don’t. Also separating the rotations do not work either, it causes the same issue.


    void test()
    {

        rotationX = Input.GetAxis("Mouse X") * cameraSens;
        rotationY = Input.GetAxis("Mouse Y") * -cameraSens;
        if (Input.GetKey(KeyCode.Q))
        {
            rotationZ = yawSpeed * Time.deltaTime;
            totalZRotation += rotationZ;
        }

        Vector3 lookAt = new Vector3(rotationY * MathF.Cos(totalZRotation) + rotationX * MathF.Sin(totalZRotation), rotationY * MathF.Sin(totalZRotation) + rotationX * MathF.Cos(totalZRotation), rotationZ);

        _camera.transform.Rotate(lookAt);

    }

I think you should be more clear what behaviour you actually need. Your original code suggests that you want a full 3 axis of freedom rotation on all 3 axis for a first person controller. So essentially an astronaut in space with no relation to any kind of world space. So the astronaut can rotate freely around his local axis. That’s what your code does. With 3 axis of freedom it’s impossible to rotate around two axis and keeping the third somehow fixed. That’s because the rotation around the third axis can always be achieved by a combination of rotations around the other two.

This is the behaviour that you get in games like space engineers when you fly with the jetpack. Q and E rotate around the roll axis.

However if your goal is to create a typical “gravity aligned” hierarchical FPS controller (yaw is always around the gravity vector, followed by pitch which is limited by ±90° around local x and potentially followed by roll for “leaning” animations) then your approach is generally flawed.

I think you should be more specific what you need exactly as your description of the “issues” is not really precise.

Finally the code you posted in your latest post doesn’t make much sense. Here you seem to mix concepts like directions / vectors with two different kinds of angles. The “Rotate” method expects relative euler angles in degrees. Mathf.Cos / Sin expects angles in radians.

Just in case you did not know, radians is THE mathematical way to express angles as the trigonometric functions intrinsically work with radians. Radians represent the arc-length of a certain angle on a unit circle / sphere. So 360° == 2PI or roughly 6.283. The Mathf class has two constant factors to convert between degree and radians. Deg2Rad or Deg2Rad.

As I said, you should start by clarifying the exact behaviour you want. There are many different ways how an FPS controller may work. Even in space engineers you actually get two completely different controllers depending on if you use the jetpack or if you’re in a gravity well without the jetpack.

ps: I should note that for example airplane controllers are often a hybrid between the two, though it may also depend on the exact flying mechanic.

Thanks for your lecturing on forming sentences. I thought “Trying to create a 3D first person camera control with yaw controls” was a sufficient description of what I was trying to do. The reason my first code suggests that is because that is what I want my code to do.

The last code I posted is a test function that I scrambled to try to achieve the same outcome using different methods to make sure my algorithm isn’t the issue which it proved it isn’t. I know what radians are and that cos and sin take radians but it doesn’t matter in this context. Just to humor though i tried it. It’s the same result but multiplied by 0.01 so it is slower.

I really don’t get why you are talking with this patronising tone. I had a rage burst reading this response and wanted to say very bad things with this reply…

Anyway, yes I am trying to create a camera control similar to games like Space Engineers which is why the way I described my code and wrote it is reminiscent of a Space Engineers like camera control. The reason I cannot describe well what the problem is is because I don’t know how or why it is doing what it is doing. I could attach a video file if it is necessary but the code is extremely simple so literally copying and pasting into Update() should suffice.

I am sorry if I seem a little mean but I am a little pissed after that response. (you really explained what radians are… are you serious?)

Oh good lord, just STOP with this Grade A Nonsense. This is not productive. Stay on point. Bunny83 is not being patronizing, YOU are interpreting him as patronizing.

Bunny has no idea what your skill level is and like me, Bunny is trying to be as helpful as possible while pointing out common pitfalls.

I would venture to say that fully 50% of people posting on here, perhaps even 90% of people posting on here, have never heard of a radian before.

Sorry, I went a little overboard and I will look past their actions. Anyway, I looked into even more things to potentially fix my issue and even found another thread that you replied to that was asking for the exact same problem. It was in 2022 and didn’t lead anywhere again.

I tried a little more and decided to see where completely using Euler angles would do… it isn’t and I am not sure if it is a step forward backward or not a step at all


    void test()
    {

        float totRadZ = Mathf.Deg2Rad * totalZRotation;

        rotationX = Input.GetAxis("Mouse X") * cameraSens;
        if ((_camera.transform.eulerAngles.z > 90 && _camera.transform.eulerAngles.z < 180) || (_camera.transform.eulerAngles.z > 270 && _camera.transform.eulerAngles.z < 360)) { rotationX *= -1; }
        totalXRotation += rotationX * MathF.Cos(totRadZ) + rotationY * MathF.Sin(totRadZ);
        rotationY = Input.GetAxis("Mouse Y") * -cameraSens;
        totalYRotation += rotationY * MathF.Cos(totRadZ) + rotationX * MathF.Sin(totRadZ);

        rotationZ = 0;
        if (Input.GetKey(KeyCode.Q))
        {
            rotationZ = yawSpeed * Time.deltaTime;
            totalZRotation += rotationZ;
        }
        if (Input.GetKey(KeyCode.E))
        {
            rotationZ = yawSpeed * Time.deltaTime;
            totalZRotation -= rotationZ;
        }

        _camera.transform.eulerAngles = new Vector3(totalYRotation,totalXRotation, totalZRotation);

    }

Because this completely uses Euler angles there are lots of artifacts. This does the job very well compared to functions I posted before. The wobble is gone and it doesn’t break at certain angles (kind of). The main problem with this one is the cameras up or down is determined by almost a sphere around the camera. The closer you are to the poles the weirder it gets. When z equals zero and you are looking below you the camera rotates around the pole as it does in nearly every traditional first person game. But when z value is changed things almost feel like they are gravitating towards the poles because of the curvature. Now when you look under you, rotate z by 90 degrees and look up you are rotating around that point. Also there are a lot of moments when the direction needs to be reversed because we are using sin cos and apparently Euler angles don’t like that, but they are easily fixed with some if statements (there are still some more fixing required but I decided not to work on them any longer because of the glaringly big issue that is the pole problem).

I don’t think pole problem could be fixed while using Euler angles unfortunately. But this proves again that the algorithm that I wrote in the first two functions was supposed to work but doesn’t for some Quaternion magic reason.

I know some games have implemented this type of camera control which is why I am so confused that nothing I do is working. This small part of the game that takes 20-30 lines have consumed the entire project and made it come to a halt because if this is not possible then the entire project isnt either.

Edit: The algorithm does have a fault apparently but it is easily fixable. When calculating sin cos’ some need to be negative at some points… cant figure out exactly and it is a minor issue in relation so I will leave this at that for now.

Edit 2: I created another test function with “LookAt” in mind. The variable lookAt is serializable field game object. This produced the exact same outcome as the last function but a bit worse. Now when camera tries to go through the poles it gets stuck until an opposite input is given. But yeah… the poles are being a big problem again even though this function doesn’t directly edit Euler angles. I don’t see how rotating around a random axle can make an object get affected by arbitrary points in space, but that is the case.

I will try to figure out the cause and try to find a solution.

void test_2()
{

    rotationX = Input.GetAxis("Mouse X") * cameraSens;
    rotationY = Input.GetAxis("Mouse Y") * -cameraSens;

    rotationZ = 0;
    if (Input.GetKey(KeyCode.Q))
    {
        rotationZ = yawSpeed * Time.deltaTime;
        totalZRotation += rotationZ;
    }
    if (Input.GetKey(KeyCode.E))
    {
        rotationZ = -yawSpeed * Time.deltaTime;
        totalZRotation += rotationZ;
    }

    lookAt.transform.RotateAround(_camera.transform.position, _camera.transform.up, rotationX);
    lookAt.transform.RotateAround(_camera.transform.position, _camera.transform.right, rotationY);

    _camera.transform.LookAt( lookAt.transform.position - _camera.transform.position);
    _camera.transform.Rotate(0, 0, totalZRotation);

}

Since it’s saturday I have some spare time and fired up Unity once again (hadn’t done anything in the last year). I quick and dirty created a space charactercontroller very similar to the one of space engineers. Though, no magnetic boots outside a gravity field and when in jetpack mode gravity is generally off, even when the inertia dampers are off. So gravity only works when the jetpack is off.

SpaceCharacterController.cs
using UnityEngine;

public class SpaceCharacterController : MonoBehaviour
{
    public bool useJetpack = false;
    public bool inertiaDamper = true;
    public Camera cam;
    public float mouseSensitivity = 10f; // 2f for webgl
    public float rollSpeed = 90f;
    public float alignSpeed = 180f;
    public float moveSpeed = 5.0f;
    public float flyAcc = 5.0f;
    public float maxAngle = 80f;

    private Transform player;
    private Transform camT;
    private Rigidbody rb;
    public bool onGround; // for debugging purposes
    void Start()
    {
        player = transform;
        camT = cam.transform;
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        // update gravity based on local gravity wells.
        Physics.gravity = GravityWell.GetClosestGravity(player.position);

        if (Input.GetMouseButtonDown(0))
            Cursor.lockState = CursorLockMode.Locked;

        if (Input.GetKeyDown(KeyCode.X))
            useJetpack = !useJetpack;
        if (Input.GetKeyDown(KeyCode.Z))
            inertiaDamper = !inertiaDamper;
        rb.useGravity = !useJetpack;
        if (useJetpack)
            JetPackBehaviour();
        else
            GravityWellBehaviour();
    }

    void JetPackBehaviour()
    {
        // camera align with player, as jetpack movement will rotate the whole player
        camT.localRotation = Quaternion.RotateTowards(camT.localRotation, Quaternion.identity, 10 * alignSpeed * Time.deltaTime);

        // rotation
        player.rotation = Quaternion.AngleAxis(Input.GetAxis("Mouse X") * mouseSensitivity, player.up) * player.rotation;
        player.rotation = Quaternion.AngleAxis(-Input.GetAxis("Mouse Y") * mouseSensitivity, player.right) * player.rotation;
        if (Input.GetKey(KeyCode.Q))
            player.rotation = Quaternion.AngleAxis(rollSpeed * Time.deltaTime, player.forward) * player.rotation;
        else if (Input.GetKey(KeyCode.E))
            player.rotation = Quaternion.AngleAxis(-rollSpeed * Time.deltaTime, player.forward) * player.rotation;

        // movement
        Vector3 force = Vector3.zero;
        force += player.forward * Input.GetAxis("Vertical") * flyAcc;
        force += player.right * Input.GetAxis("Horizontal") * flyAcc;
        if (Input.GetKey(KeyCode.Space))
            force += player.up * flyAcc;
        if (Input.GetKey(KeyCode.LeftControl))
            force += -player.up * flyAcc;
        rb.AddForce(force);

        if (inertiaDamper)
        {
            Quaternion q = Quaternion.identity;
            Vector3 vel = rb.velocity;
            float f = 0f;
            if (force.sqrMagnitude > 0.01f)
            {
                q = Quaternion.LookRotation(force);
                vel = Quaternion.Inverse(q) * rb.velocity;
                f = vel.z;
                vel.z = 0;
            }
            float acc = Mathf.Clamp(vel.magnitude*0.5f, 1f, 10f)*Mathf.Clamp(vel.magnitude, 0.1f, 1f) * flyAcc;
            vel = Vector3.MoveTowards(vel, Vector3.zero, acc * Time.deltaTime);
            if (force.sqrMagnitude > 0.01f)
                vel.z = f;
            rb.velocity = q * vel;
        }
    }
    void GravityWellBehaviour()
    {
        // player gravity align
        player.rotation = Quaternion.RotateTowards(player.rotation, Quaternion.FromToRotation(player.up, -Physics.gravity) * player.rotation, alignSpeed * Time.deltaTime);

        // rotation
        player.rotation = Quaternion.AngleAxis(Input.GetAxis("Mouse X") * mouseSensitivity, player.up) * player.rotation;
        camT.localRotation = Quaternion.AngleAxis(-Input.GetAxis("Mouse Y") * mouseSensitivity, Vector3.right) * camT.localRotation;

        // limit vertical view
        float angle = Vector3.SignedAngle(camT.forward, player.forward, -player.right);
        if (angle > maxAngle)
            camT.localRotation = Quaternion.AngleAxis(maxAngle, Vector3.right);
        if (angle < -maxAngle)
            camT.localRotation = Quaternion.AngleAxis(-maxAngle, Vector3.right);
        onGround = false;
        // movement
        if (Physics.SphereCast(player.position, 0.5f,Physics.gravity, out var _,0.7f))
        {
            onGround = true;
            Vector3 localVelocity = player.InverseTransformVector(rb.velocity);
            localVelocity.x = Input.GetAxis("Horizontal") * moveSpeed;
            localVelocity.z = Input.GetAxis("Vertical") * moveSpeed;
            if (Input.GetKeyDown(KeyCode.Space))
                localVelocity.y = 5f;
            rb.velocity = player.TransformVector(localVelocity);
        }
    }
    private void OnGUI()
    {
        GUILayout.BeginVertical("box");
        GUILayout.Label("grav: " + Physics.gravity);
        GUILayout.Label("vel: " + rb.velocity.magnitude.ToString("F4"));
        GUI.color = useJetpack ? Color.green : Color.red;
        GUILayout.Label("Jetpack(X):" + useJetpack);
        GUI.color = inertiaDamper ? Color.green : Color.red;
        GUILayout.Label("Inertia(Z):" + inertiaDamper);
        GUILayout.EndVertical();
    }
}

GravityWell.cs
using System.Collections.Generic;
using UnityEngine;

public class GravityWell : MonoBehaviour
{
    public static List<GravityWell> gravityWells = new List<GravityWell>();

    public enum GravityType { Linear, Spherical }
    public GravityType type;

    public Vector3 min;
    public Vector3 max;

    public float radius;

    public float gravity = 9.81f;

    private void Awake()
    {
        gravityWells.Add(this);
    }
    private void OnDestroy()
    {
        gravityWells.Remove(this);
    }

    public Vector3 GetGravity(Vector3 aPos)
    {
        if (type == GravityType.Linear)
        {
            Bounds b = new Bounds((min + max) * 0.5f, max - min);
            if (b.Contains(transform.InverseTransformPoint(aPos)))
                return -transform.up * gravity;
        }
        else if (type == GravityType.Spherical)
        {
            Vector3 d = aPos - transform.position;
            if (d.magnitude < radius)
                return -d.normalized * gravity;
        }
        return Vector3.zero;
    }
    public static Vector3 GetClosestGravity(Vector3 aPos)
    {
        float gravity = 0;
        Vector3 vec = Vector3.zero;
        foreach(var well in gravityWells)
        {
            Vector3 v = well.GetGravity(aPos);
            float g = v.magnitude;
            if (g > gravity)
            {
                gravity = g;
                vec = v;
            }
        }
        return vec;
    }
    private void OnDrawGizmos()
    {
        if (type == GravityType.Linear)
        {
            Gizmos.matrix = transform.localToWorldMatrix;
            Gizmos.DrawWireCube((min + max) * 0.5f, max - min);
        }
        else if (type == GravityType.Spherical)
        {
            Gizmos.matrix = Matrix4x4.identity;
            Gizmos.DrawWireSphere(transform.position,radius);
        }
    }
}

Of course I created a webGL build if you want to try it out. The controls are inspired by SE. So press X to toggle the jetpack, space to jump / move up, CTRL to move down. Press “Z” to toggle the inertia dampers when in jetpack mode. Just for giggles I creates a spherical gravity well as well as some linear gravity fields. They have an effective area, when you leave it there will be no gravity anymore. For fun I managed to speed up with my jetpack around the “planet” and switched off the jetpack and I got into an elliptic orbit :slight_smile: After 30 minutes running in the background I was still orbiting.

Anyways I implemented two different behaviour. The jetpack behaviour will have the camera fixed to the player as you will rotate the player itself freely in space and the camera will just move with the player. In “gravity mode” without the jetpack, you get the usual “down” aligned character controller. So the player only rotates around the y axis which is automatically aligned with the gravity vector. When you look up and down we just rotate the camera inside the player on the x axis within reasonable limits. You can set them to ±90°, I have it at 80°. Greater angles don’t make any sense as you would bend over backwards which would of course somewhat invert the yaw rotaion since you’re looking backwards upside down.

In jetpack mode you can use Q and E to roll. I did launch Space Engineers to make some tests and measurements. The inertia dampers for ships used to be stronger than manual braking, but it seems they fixed it and made it more realistically. Though the jetpack inertia dampers are still up to almost 10 times stronger than manual decelleration ^^. It was quite hard to replicate the behaviour and it’s still not right, but works somewhat. In WebGL everything is a bit off anyways. I had to cut down the mouse sensitivity by a factor of 5, but that’s a known issue. I couldn’t be bothered to create a settings menu ^^. Don’t forget to click once to capture the mouse.

Though the main point was to show the free rotation in action. As I explained earlier, in actual 6 degree of freedom movement, when we just rotate around 2 axis we implicitly rotate around the the third. That’s why we actually need roll controls in the first place. The behaviour is very similar to Space Engineers.

ps: I’m in firefox and I have to be careful with CTRL+W as it closes the current tab :smiley: