Encapsulating Angles

posted by Craig Gidney on November 13, 2012

This post is about making a simple problem actually simple: dealing with angles in 2d. As part of writing this post, I implemented a library for working with angles. The source code is available on github, or you can reference the nuget package with visual studio’s package manager.

Problems and Obstacles

Lets start with a motivating problem. You have a tank, it has a turret with a restricted range of motion, and you want the tank and turret to rotate towards the mouse pointer. Like this:

Angle Clamping Example

This is not a complicated task, but the angle math makes it tricky. If you approach it in the wrong way then you will end up with code containing a lot of cases, meaning lots of opportunities for sign errors and off-by-one errors. As a result, you’re likely to see bugs isolated to one quadrant or bugs on the transitions between quadrants (particularly -1° to +1° and +179° to +181°).

What is it that makes angle code tricky to write? I can think of three good reasons.

  1. The arithmetic of angles is very close to, but not quite the same as, the arithmetic of real numbers. It’s so tempting to just tack a “modulo 360°” after everything, and expect it to work:
    • |A-B| mod 360° is the distance between two angles… half of the time.
    • In the equation x*3 = 6°, you solve for x by computing (6°/3) mod 360° = 2°… except x might also be 122° or 242°.
    • Increase a rotation of 180° by 10% by computing 180°*1.1 (mod 360°) = 191°… except -180° is the “same” rotation and (-180°)*1.1 (mod 360°) = 169° != 191°.
  2. Angles actually represent two distinct types of thing: directions (where something is facing) and turns (how much to rotate something). These two types support different operations. It makes sense to sum and scale turns, but not directions (What’s going north plus going south? Forwards times two? You can define answers to these questions, typically by representing directions as turns, but the results are meaningless in practical terms.). A very easy way to create a bug is to treat a direction like a turn, or a turn like a direction (The only case I can think of where adding directions makes sense, averaging by computing (A + B) / 2, is wrong because it misses one of the averages [there are two, separated by 180°] and the one it picks isn’t invariant with respect to rotation.).
  3. There’s not a universal agreed-upon system pairing directions and angles. Does 0 point rightward or upward? Is the numeric value of a half rotation equal to 180, pi, or 200? Often, software isn’t even consistent within itself with respect to what angles mean. For example, in .Net radians are expected by System.Math but degrees are expected by System.Windows.Media.MatrixTransform.

Math Trivia: A thing where you distinguish between absolute position and relative deltas is called an Affine Space. Another example of an affine space is the combination of points and vectors. Modular arithmetic is the arithmetic of ‘looping back around numbers’ like angles. Unfortunately, although there’s lots of online resources about integer modular arithmetic, there’s doesn’t seem to be much for real/continuous modular arithmetic.

Encapsulating the Problems

A proper angle library needs to make it natural to solve common tasks correctly. Examples of common tasks are:

  • Are these two angles the same? Are they close?
  • Rotate your facing by this much.
  • Do these different rotations have the same effect? Is it close?
  • Adjust rotation speed by X%. Limit rotation speed to Y.
  • Is this angle close to that one? Make this angle be close to that one.
  • What’s the angle of this vector? The vector pointing along this angle?
  • The file format spec says that 0 degrees is up, and 90 degrees is towards the right, but Math.Sin expects something different.

To make solving these tasks easy, my approach is to divide the space into four types of thing: Basis, Dir, Turn, and Range.

A “Basis” is a system of angles, defining how to convert from raw angle values into encapsulated directions/rotations (and back). For example, the natural angle basis pairs the angle value 0 with the rightward direction and the angle value pi/2 with the upward direction. The natural basis is very common, so it makes sense to have it included by default and even to have specialized methods that use it. The Basis type is also a nice place to define constants like the number of radians/degrees/gradians per rotation. Note that users sometimes want signed angles [-180° to +180°) and sometimes want unsigned angles [0° to 360°), so it's important to support both cases.

A "Dir" is a direction, equivalent to a line starting at the origin and passing through a point on the unit circle. Directions have both exact and approximate equality that respects that 1° = 361° and that 359.999° is close to 0.001°. Being able to convert to and from X/Y vector components allows work independent of any angle basis. Arithmetic operators are also useful: adding/subtracting a turn from a direction rotates the direction, and taking the difference between to directions gives the turn that rotates between them. The Dir type is a great place to define constants for the cardinal directions.

A "Turn" is a non-normalized rotation. The lack of normalization (meaning -180° != +180° != +540°) is a trade-off to allow intuitive comparisons and scaling (normalizing would create ambiguities in those cases, as I mentioned earlier). Instead of automatic normalization, turns have manual normalization as well as specialized method that nicely handle "congruence" (a turn of +15° is congruent to a turn of -345°). Another trade-off made by Turn is encapsulation of the sign of clockwise-ness. Some systems of angles pick counter-clockwise to be the "positive" direction and some pick clockwise, so that information should be part of the angle basis and not redundantly specified in Turn. As a consequence, the user must specify which direction is positive in order to compare turns. Turn arithmetic is almost identical to real arithmetic: summing, scaling, and dividing are all allowed and behave nicely thanks to the lack of automatic normalization. The Turn type is the place for common rotation constants (1°, 1 radian, 360°).

A "Range" is a contiguous set of directions. The Range type is probably the hardest to implement correctly (see the 'example awful code' at the end of the post). That also makes it the most valuable to users. Providing several safe ways to create it, and implementing the all-important clamp method, basically solves the toy problem I opened with. The Range type is a good place to define constants for the four quadrants and four half-planes.

Example Usage

With all of these types implemented, creating a tank with a constraint turret is easy. I spent significantly more time tweaking the art positioning and movement constants than this code:

// --- move the tank ---
// measurements
var dx = this._lastMousePosition.X - tankPos.X;
var dy = this._lastMousePosition.Y - tankPos.Y;
var dist = Math.Sqrt(dx * dx + dy * dy);
var dirTowardsMouseFromTank = Dir.FromVector(dx, dy);

// rotate tank towards target
tankDir +=
    // determine the turn necessary to rotate the tank to face the target
    (dirTowardsMouseFromTank - tankDir) 
    // force the turn to be within the allowed tank rotation rate
    .ClampMagnitude(MaxTankTurnPerSec * dt.TotalSeconds); 

// rotate turret towards target
turretTurn += 
    // determine the amount of turning necessary to rotate the turret to face the target
    (dirTowardsMouseFromTank - tankDir - turretTurn)
    // force the turn to be within the allowed turret rotation rate
    .ClampMagnitude(MaxTurretTurnPerSec * dt.TotalSeconds);

// force turret to stay within its allowed turning radius
turretTurn = turretTurn.ClampMagnitude(MaxTurretTurn);

The example project in the source code on github includes the above code.

Summary

Angles can be tricky to work with, but we can reduce the problem by encapsulating them into a library.

Also, you can make an adequate animated gif by writing an example program, recording it with CamStudio, editing the video with Windows Movie Maker, uploading the video to benderconverter.com, cropping the result with Gimp, then uploading the result to imgur. I wouldn't recommend this process, though.

Bonus: Example Awful Code

What does it look like when a programmer who doesn't really know what they're doing tries to implement angle math? Well, when I started programming (14 years ago) I had only QBasic's built-in documentation to guide me. I didn't know about variables, loops, arrays, indentation. Nothing. In the process of teaching myself, I naturally wrote some truly bad code. Now I get to put that bad code to use as an example of what not to do when trying to clamp an angle:

//[Hindsight: this is an inlined atan2 function]
Horizontal = ABS(StartX – DestX)
Vertical = ABS(StartY – DestY)
IF Horizontal AND Vertical <> 0 THEN
	Diagonal = SQR((Horizontal ^ 2) + (Vertical ^ 2))
	Angle = (Arcsin(Vertical / Diagonal))
	IF DestX > StartX AND DestY > StartY THEN Angle = 0 – Angle: Angle = Angle + 90
	IF DestX > StartX AND DestY < StartY THEN Angle = Angle + 90
	IF DestX < StartX AND DestY > StartY THEN Angle = Angle + 270
	IF DestX < StartX AND DestY < StartY THEN Angle = 180 - Angle: Angle = Angle + 90
ELSE
	IF Horizontal = 0 AND DestY > StartY THEN Vector = 0
	IF Horizontal = 0 AND DestY < StartY THEN Vector = 180
	IF Vertical = 0 AND DestX > StartX THEN Vector = 90
	IF Vertical = 0 AND DestX < StartX THEN Vector = 270
	IF Vertical = 0 AND Horizontal = 0 THEN Vector = INT(RND * 360)
END IF
//[Hindsight: an awkward modulus, forcing the remainder to be in -180 to +180]
DO UNTIL Angle + 180 > Star AND Angle – 180 < Start
	IF Angle + 180 < Start THEN Angle = Angle + 360
	IF Angle - 180 > Start THEN Angle = Angle – 360
LOOP
//[Hindsight: clamping the angle towards the start]‘
IF Angle > Start + Limit THEN Angle = Start + Limit
IF Angle < Start - Limit THEN Angle = Start - Limit
//[Hindsight: another awkward modulus, forcing the remainder to be in 0 to 360]
DO UNTIL Angle <= 360 AND Angle >= 0
	IF Angle < 0 THEN Angle = Angle + 360
	IF Angle > 360 THEN Angle = Angle – 360
LOOP

As you can see: lots of cases, lots of constants, and lots of fiddly bits. I was probably "adjusting" it constantly, because it's a breeding ground for bugs. There's even still some serious ones in there! For starters, I see a mistakenly semi-bitwise comparison, a potentially infinite loop, and a stupid typo.

---

Embarrassing... Although, not quite as brain-dead as this code, from the seems-fine-otherwise HydraMF package, that I came across when looking through other math libraries on NuGet:

public static float Sin(AngleSingle angle) {
    return (float)Math.Sin((int)angle.Degrees) / 1000f;
}
public static float Cos(AngleSingle angle) {
    return (float)Math.Cos((int)angle.Degrees) / 1000f;
}

I'm guessing these two particular methods have never been used. They pass degrees to a function expecting radians, unnecessarily round that input, and then confusingly transform the output to be from -0.001 to +0.001 instead of -1 to +1. Why? Who knows.

---

Discuss on Reddit.

---


Twisted Oak Studios offers consulting and development on high-tech interactive projects. Check out our portfolio, or Give us a shout if you have anything you think some really rad engineers should help you with.

Archive

More interesting posts (11 of 33 articles)

Or check out our Portfolio.