Skip to content

Angles, Radians and atan2

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.

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.

TurnRadiansDegrees
none000°0°
an eighthπ/4\pi/445°45°
a quarterπ/2\pi/290°90°
a halfπ\pi180°180°
a full turn2π2\pi360°360°

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.

d^=(cos⁡θ,  sin⁡θ)\hat{d} = (\cos\theta,\; \sin\theta)

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 +X+X 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.

The other way needs atan2:

θ=atan2⁡(vy,  vx)\theta = \operatorname{atan2}(v_y,\; v_x)

Y first. Every language spells it in that order and it still catches people, because it reads backwards from the (x,y)(x, y) you are holding.

A turret aiming at a target, with atan2 and without it
Drag the target or use the sliders, then uncheck atan2 and move it to the left half.
The code that draws it 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 11. 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:

θ=arctan⁡ ⁣(vyvx)\theta = \arctan\!\left(\frac{v_y}{v_x}\right)

The division is where the information goes. (1,1)(1, 1) and (−1,−1)(-1, -1) are opposite directions, and both give a ratio of 11. atan receives one number where there were two, and no amount of care afterwards can recover what the division discarded.

The same four targets, read by atan2 and by plain atan
The code 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;
a target at (3, 2), up and to the right → atan2 says 33.7°, atan says 33.7° // agreeing, because x is positive
a target at (-3, 2), up and to the left → atan2 says 146.3°, atan says -33.7° // half a turn apart, and nothing in the code says which is which
a target at (-3, -2), down and to the left → atan2 says -146.3°, atan says 33.7° // half a turn apart, and nothing in the code says which is which
a target at (3, -2), down and to the right → atan2 says -33.7°, atan says -33.7° // agreeing, because x is positive
aim a barrel with each answer and dot it back against the target → atan2: 1, 1, 1, 1 · atan: 1, -1, -1, 1 // minus one is a barrel pointing exactly away from what it was aiming at
straight left, at (-1, 0) → atan2 says 180.0°, atan says 0.0° // the plainest case there is, and the division threw away the minus sign

The four rows are one target per quadrant. In the two where xx is positive the two functions agree exactly. In the two where xx 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 11 four times. atan scores 1,−1,−1,11, -1, -1, 1. A score of −1-1 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 xx, and exactly correct at all 3,240 with positive xx. The 80 samples sitting on the vertical axis come out right, by luck — the division gives Infinity and Math.atan(Infinity) happens to be π/2\pi/2 — 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: 0/00/0 is NaN, Math.atan(NaN) is NaN, and Section 1.4 covered what a NaN does to every comparison downstream. atan2(0, 0) returns 00 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 0°0° to 180°180° and can never tell you which way to turn. Section 2.1 supplied the missing sign. Put both together:

θ=atan2⁡(a×b,  a⋅b)\theta = \operatorname{atan2}(a \times b,\; a \cdot b)

Both readings, in one expression. The cross product supplies ∣a∣∣b∣sin⁡θ|a||b|\sin\theta, the dot product supplies ∣a∣∣b∣cos⁡θ|a||b|\cos\theta, 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. atan2 accepts any two numbers, so the NaN that rounding forces out of acos about 30% of the time cannot happen here.
  • It is better conditioned near 0°0° and 180°180°, where acos loses 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.

Angles live on a circle, not on a line. 370°370° and 10°10° are the same heading, and −190°-190° is that heading too. Arithmetic does not know this, so you have to tell it:

wrap⁡(θ)=((θ+180°) mod 360°)−180°\operatorname{wrap}(\theta) = \left((\theta + 180°) \bmod 360°\right) - 180°

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 ±900°\pm 900° rather than trusted.

Two facts worth knowing before you write a test against this:

  • 370°370° wraps to 10°10°, as expected.
  • Exactly 180°180° comes back as −180°-180°. Same direction, other spelling. The range is [−180°,180°)[-180°, 180°), 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 src/lib/gamedev2d/angles2d.ts 115 lines
/**
 * 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;
}
  • 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 (x,y)(x, y) 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.