Rotating 2d objects properly

I’ve been trying to dig around for a proper way to make my object face the direction it’s going in a top-down space shooter. I’m an absolute beginner and have yet to find my way around the nuances of the API.

Best I could find relevant to what I’m asking is this thread: Calculating angles using Atan2 - but it discusses more on the math around it rather than API.

I have the following snippet on the OnUpdate of my Ship Control ISystem:

        foreach (var (transform, input, momentum) in SystemAPI
                     .Query<RefRW<LocalTransform>, RefRO<PlayerMoveInput>, RefRW<PlayerMomentum>>()
                     .WithAll<Ship>())
        {
            if (!input.ValueRO.Value.Equals(float2.zero))
            {
                momentum.ValueRW.Value += input.ValueRO.Value * SystemAPI.Time.DeltaTime;
                var momentumDirection = momentum.ValueRW.Value / math.length(momentum.ValueRW.Value);
                
                
                //Is there a better way to do this?
                transform.ValueRW.Rotation = quaternion.AxisAngle(math.forward(), math.atan2(-momentumDirection.x, momentumDirection.y));
            }
            else
            {
                momentum.ValueRW.Value = math.lerp(momentum.ValueRW.Value, float2.zero, 0.5f * SystemAPI.Time.DeltaTime);
            }

            transform.ValueRW.Position.xy += momentum.ValueRW.Value * SystemAPI.Time.DeltaTime;
        }

What I’m not sure about here is whether there’s a more elegant way to rotate the object properly aside from transposing the parameters of atan2(). If I do it normally like math.atan2(momentumDirection.y, momentumDirection.x) the ship goes into the direction using its “right” side rather than pointing with its nose (“up” side). I know this is because of how the axes are oriented with respect to the object but I just feel like there would have been a more intuitive way to do it.

In 2D you can equally use basic trigonometry (atan2 et al) or quaternions to achieve full control over rotation. It’s just that, if you already know trigonometry, quaternions are a computational overkill because they work as matrix multiplication intended for 3D and when applied to 2D space it just happens that a lot of that computation ends up being zeroed anyway.

Now because Unity doesn’t really care whether your project is in 2D or 3D, transforms are made in 3D only, and thus you have to feed a quaternion anyway. And I believe that’s what you’re concerned about, how can you optimally come up with a quaternion, given that you don’t really care about the 3rd dimension.

Well, here’s a bit of know-how regarding quaternions. You can start with building your own AngleAxis method and see what can be optimized away. Here’s for example an angle-axis implementation that works in radians

// assumes axis to be a unit vector
static public Quaternion angleAxis(float rad, Vector3 axis) {
  rad *= half;
  return quat(v4_xyz(mul(sin(rad), axis), w: cos(rad)));
}

This is in my own shader-like functional lingo, so here’s a translation

static public Quaternion angleAxis(float rad, Vector3 axis) {
  rad /= 2f;
  var sin = MathF.Sin(rad);
  return new Quaternion(
    sin * axis.x,
    sin * axis.y,
    sin * axis.z,
    MathF.Cos(rad)
  );
}

Now when it comes to rotations in 2D you need to adopt some kind of convention regarding the orientation of the absolute 0. Mathematically speaking, 0 degrees should point to the right (X axis on the XY plane), but you might want to change this convention if you care about the nose of the ship moving upward. However do not fall in the trap of mixing up the absolute and relative orientation. You might still keep the rotations relative to the nose pointing up while staying true to the mathematical conventions, however you need to remember that the positive angles are counter-clockwise. This is true to Unity’s left-handed system in 3D just as well, whenever the axis direction points away from you.

That said, if you want a proper 2D rotation on the XY plane, then you want a quaternion rotation over the positive Z axis. For the XZ plane, a quaternion rotation would be over the positive Y axis.

Here’s how you can simplify the previous method to produce angle-axis representation of the 2D rotation on the XY plane.

static public Quaternion angleAxisXY(float rad)
  => new Quaternion(
       0f,
       0f,
       MathF.Sin(rad / 2f),
       MathF.Cos(rad / 2f)
     );

This is because quat(sin * x, sin * y, sin * z, cos) = quat(sin * 0, sin * 0, sin * 1, cos) due to axis being forward or (0, 0, 1).

Because we only rely on radians, now you can straightforwardly do

transform.rotation = angleAxisXY(MathF.Atan2(y, x));

Likewise if this appears to be oriented toward the right-hand side, you can just add \frac{\pi}{2} to rotate everything by 90 degrees.

transform.rotation = angleAxisXY(MathF.Atan2(y, x) + MathF.PI / 2f);

This very much depends on how you oriented your ship originally.

And yes (as a sidenote) please make sure you’re not using the double precision trigonometry that’s used in System.Math and UnityEngine.Mathf, but use System.MathF instead.

Edit:
Btw, I normally call such a method rotationZ rather than angleAxisXY – it’s much nicer to look at.

Edit2:
Finished some sentences and fixed some typos (incl. adding Pi/2 instead of Pi)

Thanks for showing me the thought process. In retrospect, it looks like what’s pushing me to make this “intuitive” is my misguided desire to reduce the mental load of having to not think about quaternions in the first place. That said, how you described your angleAxisXY is a huge leap in my understanding of quaternions; I think I’ll spend my afternoon reading up on it more.

On another note, what you said about making sure to not mix up the absolute and relative might be something I need to watch out for. It looks like it would be best to hide these conventions somehow on the objects themselves (i.e., have the system take the object’s rotation offset?). How this would translate to DOP though would be a nice exercise.

Don’t mention it. My “afternoon” took me around 5 years. It’s a deep topic and if you’re serious about game dev, quaternions will pester you constantly until you get a good grip on them.

Once you get them though, they tend to be as intuitive as RGB colors. And I’m serious about this analogy. However you do want to get to a good understanding of linear algebra, trigonometry, and matrices to really connect the dots in a way that wouldn’t just evaporate as soon as you start doing something else. This is what lets me recite angle axis function from my head.

You can also find my examples of FromToRotation and Slerp (both Quaternion and Vector3) on the forum. I like my methods to work in radians, and as a plus, you can make Vector3.Slerp work with unit vectors, and it’s much lighter.

Yes, why not, you can make a system that works with angles and then always bake in some orientation bias before producing the final quaternion. Or you can simply reorient all your visuals (i.e. prefabs) in such a way the regular rotation would make them appear rotated by one quarter. Etc.

Here’s what quaternion multiplication looks like in 2D (XY)

You start with this monster

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

But then, because you can substitute every x and y component with 0

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

This is what you end up with

static public Quaternion mul_xy(Quaternion a, Quaternion b)
  => new Quaternion(
       0f,
       0f,
       a.w * b.z + a.z * b.w,
       a.w * b.w - a.z * b.z
     );

Now you have a much lighter alternative to

var finalRotation = transform.rotation * appliedRotation;

Likewise, Quaternion-Vector3 multiplication looks like this

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

Substitutions lead us to

static public Vector2 mul_xy(Quaternion q, Vector2 v) {
  float z = 2f * q.z;
  float zz = q.z * z;
  float wz = q.w * z;

  return new Vector2(
    (1f - zz) * v.x - wz * v.y,
    wz * v.x + (1f - zz) * v.y
  );
}

(I did this manually, expect errors.)
After cleanup

static public Vector2 mul_xy(Quaternion q, Vector2 v) {
  var zz = 2f * q.z * q.z;
  var wz = 2f * q.w * q.z;
  return new(
    (1f - zz) * v.x - wz * v.y,
    wz * v.x + (1f - zz) * v.y
  );
}

I don’t know if you can see it, but there is 2D rotation baked in there
Here’s how rotation in 2D normally looks

static public Vector2 rotateVector(Vector2 v, float rad) {
  var trig = polar(rad);
  return new(
    trig.x * v.x - trig.y * v.y,
    trig.y * v.x + trig.x * v.y
  );
}

// where
static public Vector2 polar(float rad) => new(cos(rad), sin(rad));

(Pythagorean identities help with ironing this out)

This is basically a weird case of a perp-dot product in 2D, which is something like a cross product in 3D. In the end it boils down to the simple unit circle trigonometry, but hopefully I’ve made it a bit more transparent.