Angles, Radians and atan2
What You’ll Learn
Section titled “What You’ll Learn”How to get from an angle to a direction, from a direction back to an angle, and why the obvious way to do the second one is wrong for exactly half of the plane. Then the wrap that makes two angles comparable, without which a turret turns almost all the way round to face something beside it.
Radians Are Not a Preference
Section titled “Radians Are Not a Preference”An angle in radians is the length of the arc it cuts on a unit circle. That is the definition, not a conversion factor, and it is why every trigonometric function in every language takes radians.
| Turn | Radians | Degrees |
|---|---|---|
| none | ||
| an eighth | ||
| a quarter | ||
| a half | ||
| a full turn |
Degrees are for people: designers, inspector fields, dialogue with your team. So convert at the edges
and keep radians everywhere in between. A codebase that converts in the middle is a codebase where
somebody eventually calls Math.cos on a number of degrees, gets a plausible-looking wrong answer,
and loses an afternoon.
From an Angle to a Direction
Section titled “From an Angle to a Direction”That is the unit circle, and it is already length 1, so nothing needs normalizing. Section 1.1 established the convention this assumes: angles counter-clockwise from the axis, measured in world coordinates with Y up. On the canvas the same angle appears to turn the other way, and that is a drawing concern, fixed with one minus sign at the point of drawing.
From a Direction to an Angle
Section titled “From a Direction to an Angle”The other way needs atan2:
Y first. Every language spells it in that order and it still catches people, because it reads backwards from the you are holding.
src/lib/gamedev/demos/2d/aim.scene.ts /** A turret aiming at a target, with a checkbox that swaps atan2 for atan and breaks half the plane. */
import {
makeCanvas2D,
addDragTargets,
arrow,
dot as fillDot,
label,
line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import { TURRET, UNIT, report, screenOf, worldOf } from "./aim-shared.ts";
import type { MountFn } from "../runner.ts";
const GOOD = "#7ee787";
const BAD = "#ff7b72";
const TARGET = "#d2a8ff";
const GRID = "#252b33";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, canvas, width, height, clear } = makeCanvas2D(el, 320);
const show = addReadout(el);
const note = addReadout(el);
const tx = addSlider(el, "target x", -8, 8, -4, draw, "", 0.1);
const ty = addSlider(el, "target y", -4, 4, 2, draw, "", 0.1);
const useAtan2 = addCheckbox(
el,
"use atan2 (uncheck for plain atan)",
true,
draw,
);
const ox = () => width / 2;
const oy = () => height / 2;
// Dragging is a convenience; the sliders above are the accessible path to the same values.
const stopDragging = addDragTargets(
canvas,
() => [screenOf(report(tx(), ty(), useAtan2()).target, ox(), oy())],
(_index, x, y) => {
const world = worldOf(x, y, ox(), oy());
tx.set(Math.min(Math.max(world.x, -8), 8));
ty.set(Math.min(Math.max(world.y, -4), 4));
draw();
},
);
function draw() {
clear();
const at = (p: { x: number; y: number }) => screenOf(p, ox(), oy());
const centre = at(TURRET);
line(ctx, { x: 0, y: centre.y }, { x: width, y: centre.y }, GRID, {
width: 1,
});
line(ctx, { x: centre.x, y: 0 }, { x: centre.x, y: height }, GRID, {
width: 1,
});
label(ctx, "0\u00B0", centre.x + 14, centre.y - 8, TEXT);
const r = report(tx(), ty(), useAtan2());
// Above 0.999 the barrel is on the target for any purpose a game has.
const aiming = r.alignment > 0.999;
const colour = aiming ? GOOD : BAD;
// The unit circle the angle is measured on, so the arc has something to sit against.
ctx.save();
ctx.strokeStyle = GRID;
ctx.beginPath();
ctx.arc(centre.x, centre.y, UNIT * 2, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
// The angle itself, swept from the +x axis. Counter-clockwise in the world is clockwise here.
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(
centre.x,
centre.y,
UNIT * 1.2,
0,
-r.angle,
r.angle > 0, // counter-clockwise in world means anticlockwise sweep on a flipped canvas
);
ctx.stroke();
ctx.restore();
// Where the target is, and the line the turret should be lying along.
const target = at(r.target);
line(ctx, centre, target, TARGET, { dashed: true, width: 1 });
fillDot(ctx, target.x, target.y, 6, TARGET);
label(
ctx,
`target (${r.target.x.toFixed(1)}, ${r.target.y.toFixed(1)})`,
target.x + 10,
target.y - 8,
TARGET,
);
// The barrel, drawn from the angle rather than from the target: that is what makes the bug visible.
arrow(
ctx,
centre,
at({ x: r.facing.x * 3.4, y: r.facing.y * 3.4 }),
colour,
3,
);
fillDot(ctx, centre.x, centre.y, 7, TEXT);
label(ctx, "turret", centre.x - 6, centre.y + 22, TEXT, "center");
label(
ctx,
`${r.degrees.toFixed(1)}\u00B0`,
centre.x + Math.cos(-r.angle) * UNIT * 1.5,
centre.y + Math.sin(-r.angle) * UNIT * 1.5 + 4,
colour,
"center",
);
show(
`${useAtan2() ? "atan2(y, x)" : "atan(y / x)"} = ${r.degrees.toFixed(1)}\u00B0 ` +
`(${r.angle.toFixed(3)} rad) \u00B7 barrel \u00B7 target = ${r.alignment.toFixed(3)} \u2192 ` +
`${aiming ? "pointing at it" : "not pointing at it"}`,
);
note(
useAtan2()
? "atan2 reads the sign of both components, so every one of the four quadrants comes out right"
: `atan divided y by x and lost the signs: ${
r.target.x < 0
? `atan2 would have said ${r.correctDegrees.toFixed(1)}\u00B0, exactly half a turn from this`
: "with x positive it happens to agree, which is why this bug survives testing"
}`,
);
}
draw();
return stopDragging;
};
export default mount; Drag the target anywhere and the barrel follows, in all four quadrants, with the angle reported in both units.
The readout also shows the number that decides whether this Section is telling the truth: the barrel’s own direction dotted back against the direction to the target. That has to be . It is worth explaining why that particular number is on screen. The scene draws the barrel from the angle it computed, so a barrel pointing exactly the wrong way still looks like a barrel — you get a picture with nothing visibly wrong in it. Aiming and then measuring where you actually ended up pointing turns “it looks right” into something a build can check, and the check sweeps 6,560 target positions.
Now uncheck the box.
Plain atan Cannot Work, and Looks Like It Does
Section titled “Plain atan Cannot Work, and Looks Like It Does”The tempting version is the one from trigonometry class:
The division is where the information goes. and are opposite directions, and
both give a ratio of . atan receives one number where there were two, and no amount of care
afterwards can recover what the division discarded.
src/lib/gamedev/demos/2d/atan.ts /** The same four targets read by atan2 and by plain atan, one quadrant at a time. */
import {
angleOf,
directionFromAngle,
naiveAngleOf,
toDegrees,
} from "../../../gamedev2d/angles2d.ts";
import { dot } from "../../../gamedev2d/dot2d.ts";
import { normalize } from "../../../gamedev2d/length2d.ts";
import type { Vector } from "../../../gamedev2d/vectors2d.ts";
import type { Demo } from "../runner.ts";
const QUADRANTS: Array<{ v: Vector; where: string }> = [
{ v: { x: 3, y: 2 }, where: "up and to the right" },
{ v: { x: -3, y: 2 }, where: "up and to the left" },
{ v: { x: -3, y: -2 }, where: "down and to the left" },
{ v: { x: 3, y: -2 }, where: "down and to the right" },
];
const deg = (radians: number) => `${toDegrees(radians).toFixed(1)}\u00B0`;
/** Point a barrel at the angle, then ask how well it lines up with the target. 1 is right, -1 is backwards. */
const alignment = (v: Vector, angle: number) =>
dot(directionFromAngle(angle), normalize(v)!).toFixed(0);
const demo: Demo = (log) => {
for (const { v, where } of QUADRANTS) {
const agree =
toDegrees(angleOf(v)).toFixed(1) ===
toDegrees(naiveAngleOf(v)).toFixed(1);
log(
`a target at (${v.x}, ${v.y}), ${where}`,
`atan2 says ${deg(angleOf(v))}, atan says ${deg(naiveAngleOf(v))}`,
agree
? "agreeing, because x is positive"
: "half a turn apart, and nothing in the code says which is which",
);
}
log(
"aim a barrel with each answer and dot it back against the target",
`atan2: ${QUADRANTS.map((q) => alignment(q.v, angleOf(q.v))).join(", ")} \u00B7 ` +
`atan: ${QUADRANTS.map((q) => alignment(q.v, naiveAngleOf(q.v))).join(", ")}`,
"minus one is a barrel pointing exactly away from what it was aiming at",
);
log(
"straight left, at (-1, 0)",
`atan2 says ${deg(angleOf({ x: -1, y: 0 }))}, atan says ${deg(naiveAngleOf({ x: -1, y: 0 }))}`,
"the plainest case there is, and the division threw away the minus sign",
);
};
export default demo; The four rows are one target per quadrant. In the two where is positive the two functions agree exactly. In the two where is negative they are half a turn apart — and nothing in the code says which case you are in.
The row underneath is the one that matters: aim a barrel with each answer, then dot it back against the
target. atan2 scores four times. atan scores . A score of is a barrel
pointing exactly away from what it was aiming at.
Over the swept grid the damage is precisely the left half-plane: atan is exactly backwards at all
3,240 sampled targets with negative , and exactly correct at all 3,240 with positive .
The 80 samples sitting on the vertical axis come out right, by luck — the division gives Infinity
and Math.atan(Infinity) happens to be — so straight up and straight down work, which just
concentrates the failure in the half you are least likely to test first.
At the origin it is worse than wrong: is NaN, Math.atan(NaN) is NaN, and Section 1.4
covered what a NaN does to every comparison downstream. atan2(0, 0) returns instead. Not
meaningful, but finite.
The Signed Angle, Which Closes Section 1.4
Section titled “The Signed Angle, Which Closes Section 1.4”Section 1.4 ended on a limitation: acos returns an angle from to and can never tell you
which way to turn. Section 2.1 supplied the missing sign. Put both together:
Both readings, in one expression. The cross product supplies , the dot product
supplies , and atan2 divides out the lengths, leaving the angle with its sign.
Three things this buys over acos:
- It is signed, so it answers “which way should I turn” rather than only “how far off am I”.
- It needs no clamp.
atan2accepts any two numbers, so theNaNthat rounding forces out ofacosabout 30% of the time cannot happen here. - It is better conditioned near and , where
acosloses precision because its slope goes vertical.
The build checks it against angleBetween from Section 1.4 across the full circle: same magnitude
everywhere, sign supplied by the cross product. If you find yourself reaching for acos, this is
almost always the function you actually wanted.
Wrapping
Section titled “Wrapping”Angles live on a circle, not on a line. and are the same heading, and is that heading too. Arithmetic does not know this, so you have to tell it:
with one wrinkle: % in JavaScript keeps the sign of its left operand, so a single modulo leaves
negative inputs negative and outside the range you asked for. Adding a full turn and taking the modulo
again fixes it, and the double modulo is checked over rather than trusted.
Two facts worth knowing before you write a test against this:
- wraps to , as expected.
- Exactly comes back as . Same direction, other spelling. The range is , half-open at the top.
Wrapping never changes the direction an angle describes, which is checked by comparing sines and cosines rather than the numbers themselves — the honest form of the claim.
Section 2.3 is where this stops being housekeeping. Every “turn toward” and every “how far off am I” is a subtraction of two angles, and a subtraction of two angles is wrong without this.
source Angles, both directions, and the wrap
/**
* Angles: the unit they are measured in, the function that recovers one from a direction, and the
* wrap that keeps the answers comparable.
*
* Two things in here account for most angle bugs in 2D games. Using `Math.atan` instead of
* `Math.atan2`, which throws away half the information before you start. And comparing two angles
* without wrapping the difference, so $170°$ and $-170°$ look $340°$ apart when they are $20°$ apart.
*/
import { cross } from "./cross2d.ts";
import { dot } from "./dot2d.ts";
import { length } from "./length2d.ts";
import { displacement, type Point, type Vector } from "./vectors2d.ts";
/** A full turn in radians. Named because `2 * Math.PI` appears in every wrap and every loop. */
export const TAU = Math.PI * 2;
/**
* Radians to degrees.
*
* Radians are not an arbitrary preference: an angle in radians **is** the arc length it cuts on a
* unit circle, which is why every trigonometric function and every derivative in the rest of
* mathematics is written in them. Degrees are for designers, inspectors and dialogue with humans.
* Convert at the edges and keep radians in the middle.
*/
export function toDegrees(radians: number): number {
return (radians * 180) / Math.PI;
}
/** Degrees to radians. */
export function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/**
* A direction from an angle: the unit circle, in code.
*
* Counter-clockwise from the $+X$ axis, in world coordinates with Y up. `screen.ts` owns this
* definition, and it is re-exported here so a Section about angles does not have to send you to a
* Section about pixels to find it.
*/
export { directionFromAngle } from "./screen.ts";
/**
* The angle of a direction, from $-\pi$ to $\pi$. **The function to reach for.**
*
* $$\theta = \operatorname{atan2}(v_y, v_x)$$
*
* Note the argument order: **Y first**. Every language spells it this way and it still catches people,
* because it reads backwards from the $(x, y)$ you have in your hand.
*
* `atan2` looks at the signs of both components, so it knows which of the four quadrants you are in
* and returns an angle covering the whole circle. It also handles a zero `x` without dividing by it.
*/
export function angleOf(v: Vector): number {
return Math.atan2(v.y, v.x);
}
/**
* The angle by way of `Math.atan`, which is wrong for half of the plane. Do not ship this.
*
* $$\theta = \arctan\!\left(\frac{v_y}{v_x}\right)$$
*
* The division is where the information goes. $(1, 1)$ and $(-1, -1)$ are opposite directions and
* both give a ratio of $1$, so `atan` has no way to separate them and returns $45°$ for both. Every
* direction pointing left comes back **exactly half a turn wrong**, and the code looks fine.
*
* It is also undefined when `x` is zero. In JavaScript that happens to work out - the division gives
* `Infinity` and `Math.atan(Infinity)` is $\pi/2$ - so straight up and straight down come out right,
* which just means the failure is concentrated in the half you are least likely to test first.
*/
export function naiveAngleOf(v: Vector): number {
return Math.atan(v.y / v.x);
}
/** The angle you must face to look from `from` at `to`. Aiming, in one line. */
export function angleFromTo(from: Point, to: Point): number {
return angleOf(displacement(from, to));
}
/**
* The **signed** angle from `a` to `b`, from $-\pi$ to $\pi$.
*
* $$\theta = \operatorname{atan2}(a \times b,\; a \cdot b)$$
*
* Both readings of Part 1 and 2.1 in one expression: the cross product supplies $|a||b|\sin\theta$
* and the dot product supplies $|a||b|\cos\theta$, so `atan2` divides out the lengths and recovers
* the angle with its sign intact. This is the thing `acos` could not give you.
*
* It is also better conditioned than `acos` near $0°$ and $180°$, and it needs no clamp, because
* `atan2` accepts any pair of numbers. Returns `null` when either vector has no direction.
*/
export function signedAngleBetween(a: Vector, b: Vector): number | null {
if (length(a) < 1e-9 || length(b) < 1e-9) return null;
return Math.atan2(cross(a, b), dot(a, b));
}
/**
* Bring an angle into $[-\pi, \pi)$, so two angles can be compared.
*
* Angles are not numbers on a line; they live on a circle, where $370°$ and $10°$ are the same
* heading. Wrapping is how you make arithmetic respect that. **Exactly $\pi$ comes back as $-\pi$**,
* which is the same direction written the other way and is worth knowing before you write a test
* expecting $\pi$.
*
* The double modulo is not superstition: `%` in JavaScript keeps the sign of its left operand, so a
* single `%` leaves negative inputs negative and outside the range you asked for.
*/
export function wrapRadians(radians: number): number {
return ((((radians + Math.PI) % TAU) + TAU) % TAU) - Math.PI;
}
/** The same, in degrees, for the numbers a designer will hand you. */
export function wrapDegrees(degrees: number): number {
return ((((degrees + 180) % 360) + 360) % 360) - 180;
} Where This Shows Up
Section titled “Where This Shows Up”- Aiming: turrets, homing projectiles, an enemy facing a player, a sprite’s rotation.
- Turning toward a heading at a limited rate, which is Section 2.3 and needs the wrap.
- Directions from input: converting a stick’s into a heading to compare against.
- Sorting things by bearing, for a radial menu or a minimap arrow.
- Bezier tangents, where a curve’s derivative becomes a facing direction. Section 4.3.