Skip to content

Angles, atan2 and Shortest Rotation

Why atan2 is the function you want and plain atan almost never is. How to get a signed angle, since acos can only ever give you a positive one. How to wrap an angle difference so a turret turns 10 degrees instead of 350. this section closes out Part 1 by turning the dot and cross products into a working “turn toward that” routine.

sin, cos and tan go from an angle to a ratio. The three functions in this section go the other way, from a ratio back to an angle, which is why they are called the inverse trigonometric functions. The a prefix is short for arc, and each one reads as “the angle whose … is this”.

The output range of each is the whole story of this section. acos can only return 0° to 180°, so it can measure how far apart two directions are but never which side. atan can only return −90° to 90°, so it covers half the circle. atan2 returns the full −180° to 180°, which is why it is the one you want.

The same three appear in every engine and language under these names, occasionally written arccos, arctan and arctan2 in mathematical notation. They mean the same things.

Given a vector, its angle from the positive xx axis is:

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

Note the argument order: y first. Almost every language and engine agrees on this, and almost everybody gets it wrong the first time.

Why not arctan(vy/vx)\arctan(v_y / v_x)? Two reasons, both fatal.

It loses the quadrant. The vectors (1,1)(1, 1) and (1,1)(-1, -1) point in opposite directions, but y/xy/x is 11 for both, so plain atan returns 4545^\circ for each. It cannot distinguish them, because the division threw the information away.

It divides by zero. A vector pointing straight up is (0,1)(0, 1), and 1/01/0 is infinity.

atan2 takes the two components separately, so it keeps the signs and handles zero. It returns a value in (180,180](-180^\circ, 180^\circ], covering the full circle.

That is angleOf in the panel above:

const angle = Math.atan2(v.y, v.x); // radians, y first
// In 3D there is no single angle, so pick a plane. For a yaw on the ground
// plane, feed atan2 the x and z components:
const toTarget = new THREE.Vector3().subVectors(target.position, mesh.position);
const yaw = Math.atan2(toTarget.x, toTarget.z);

Note the ground-plane version passes (x, z), not (z, x). Which order you want depends on where you measure yaw from; check the sign once against a known direction and write it down rather than guessing.

The previous section used acos for the angle between two vectors. It has a limitation worth stating plainly: acos always returns a value between 0 and 180 degrees. It tells you how far apart two directions are, never which side.

That is useless for turning. “The target is 40 degrees away” does not say whether to turn left or right.

The fix combines both products from the last two sections. The dot product gives the cosine, the cross product gives the sine, and atan2 of the two gives a signed angle:

θ=atan2((a×b)n^,  ab)\theta = \operatorname{atan2}\big((\vec{a} \times \vec{b}) \cdot \hat{n},\; \vec{a} \cdot \vec{b}\big)

In 2D it is simpler, because the cross product is already a scalar:

θ=atan2(axbyaybx,  axbx+ayby)\theta = \operatorname{atan2}(a_x b_y - a_y b_x,\; a_x b_x + a_y b_y)

Now the sign carries the direction. The dot product answers “how aligned”, the cross answers “which side”, and atan2 combines them into one number that says both.

Those are signedAngle2 and signedAngle3 in the panel above. Three.js has no built-in for either, so this is one you genuinely write yourself:

// 2D: the 2D cross gives the sine, the dot gives the cosine.
const signed2 = Math.atan2(a.x * b.y - a.y * b.x, a.dot(b));
// 3D: measured about an axis, because "which side" needs a plane.
const c = new THREE.Vector3().crossVectors(a, b);
const axis = new THREE.Vector3(0, 1, 0);
const signed3 = Math.atan2(c.dot(axis), a.dot(b));

Three.js’s a.angleTo(b) is unsigned - it is the clamped arccosine from the previous section, and it cannot tell you which way to turn.

Here is the bug this section exists to prevent.

An enemy faces 350350^\circ. The player is at 1010^\circ. The difference is:

10350=34010 - 350 = -340^\circ

So the enemy rotates 340 degrees clockwise, spinning almost all the way round, to look at something 20 degrees away.

source The code doing this, and everything else in this section src/lib/gamedev/angles.ts 82 lines
/**
 * Signed angles, wrapping, and turning the short way.
 *
 * Displayed in the lesson and imported by the figure above it.
 */
import { type Vec } from "./vectors.ts";
import { dot } from "./dot.ts";
import { cross, cross2 } from "./cross.ts";

export const TAU = Math.PI * 2;

/**
 * The angle of a 2D vector, measured from the +x axis, in radians from -PI to PI.
 *
 * Note the argument order: y first. `Math.atan2` takes the components separately rather
 * than their ratio, which is what lets it keep the quadrant and survive a zero x. Plain
 * `Math.atan(y / x)` throws both of those away.
 */
export function angleOf(v: Vec): number {
  return Math.atan2(v[1], v[0]);
}

/**
 * Fold any angle into [-PI, PI).
 *
 * An angle difference has infinitely many representations, all describing the same final
 * heading, and plain subtraction hands you an arbitrary one. This picks the shortest.
 *
 * At exactly half a turn the two directions are equally short, so there is no correct
 * answer. This implementation consistently returns -PI, i.e. clockwise. Consistency is
 * the property that matters; without it a turret facing exactly away from its target can
 * flip direction every frame and judder.
 */
export function wrapRad(radians: number): number {
  const m = (radians + Math.PI) % TAU;
  return (m < 0 ? m + TAU : m) - Math.PI;
}

/** The same fold, in degrees, to [-180, 180). */
export function wrapDeg(degrees: number): number {
  const m = (degrees + 180) % 360;
  return (m < 0 ? m + 360 : m) - 180;
}

/**
 * The signed angle from `a` to `b` in 2D, in radians.
 *
 * The dot product supplies the cosine and the 2D cross the sine, so atan2 of the pair
 * recovers both magnitude and direction. `Math.acos` alone cannot do this: it only ever
 * returns 0 to PI, so it can say how far apart two directions are but never which side.
 */
export function signedAngle2(a: Vec, b: Vec): number {
  return Math.atan2(cross2(a, b), dot(a, b));
}

/**
 * The signed angle from `a` to `b` measured about `axis`, in radians.
 *
 * In 3D "which side" is meaningless until you name the plane you are measuring in, which
 * is what the axis argument is for.
 */
export function signedAngle3(a: Vec, b: Vec, axis: Vec = [0, 1, 0]): number {
  const c = cross(a, b);
  const sine = c[0] * axis[0] + c[1] * axis[1] + c[2] * axis[2];
  return Math.atan2(sine, dot(a, b));
}

/**
 * Step `current` toward `target` by at most `maxStep`, taking the short way.
 *
 * The wrap is what stops a turret rotating 340 degrees to reach something 20 degrees
 * away. The clamp is what makes the movement look mechanical rather than instant.
 */
export function rotateToward(
  current: number,
  target: number,
  maxStep: number,
): number {
  const delta = wrapRad(target - current);
  const step = Math.min(maxStep, Math.max(-maxStep, delta));
  return current + step;
}

The figure imports wrapDeg from that file. That matters at exactly half a turn, where clockwise and counter-clockwise are equally short and there is no right answer - the figure and the code you are told to write resolve that tie the same way because they are the same function.

The problem is that 340-340^\circ and +20+20^\circ describe the same final heading. Angles are only defined up to full turns, so an angle difference has infinitely many representations, and subtraction hands you an arbitrary one.

You want the representative in (180,180](-180^\circ, 180^\circ], which is always the short way. Add half a turn, take the remainder, subtract half a turn:

wrap(d)=((d+180)mod360)180\text{wrap}(d) = \big((d + 180) \bmod 360\big) - 180

There is a trap in writing that directly. JavaScript’s % is a remainder, not a modulo, so it keeps the sign of the left operand: -160 % 360 is -160, not 200. Add 360 and take the remainder again to force a non-negative result:

// wrapDeg from the panel above.
const wrapDeg = (degrees) => {
const m = (degrees + 180) % 360;
return (m < 0 ? m + 360 : m) - 180;
};
wrapDeg(-340); // 20 not -340
wrapDeg(370); // 10
wrapDeg(180); // -180 the tie, resolved clockwise

The radian version replaces 180 with Math.PI and 360 with TAU, and is wrapRad in the panel.

A turret that tracks a target, at a limited turn rate, using every idea from this module:

const TURN_RATE = 2.0; // radians per second
const RANGE = 30;
const rangeSq = RANGE * RANGE; // section 2: compare squares, computed once
const UP = new THREE.Vector3(0, 1, 0);
const toTarget = new THREE.Vector3();
const forward = new THREE.Vector3();
const perp = new THREE.Vector3();
function updateTurret(turret, target, dt) {
toTarget.subVectors(target.position, turret.position);
// section 2: cheap range check, no square root taken
if (toTarget.lengthSq() > rangeSq) return;
// Flatten to the ground plane so the turret yaws without tilting.
toTarget.y = 0;
// section 2: a zero-length direction has no answer, so bail out
if (toTarget.lengthSq() < 1e-6) return;
toTarget.normalize();
// section 1: -Z is forward, which getWorldDirection already accounts for
turret.getWorldDirection(forward);
forward.y = 0;
if (forward.lengthSq() < 1e-6) return;
forward.normalize();
// sections 3, 4 and 5 together: dot gives the cosine, cross gives the sine,
// atan2 of the pair gives a signed angle that says how far AND which way.
perp.crossVectors(forward, toTarget);
const signed = Math.atan2(perp.dot(UP), forward.dot(toTarget));
// Turn the short way, capped by the turn rate so it looks mechanical.
const maxStep = TURN_RATE * dt;
turret.rotateY(Math.min(maxStep, Math.max(-maxStep, signed)));
}

Every line traces back to a section. The squared-distance check is section 2, the zero guard is section 2, the forward vector convention is section 1, the signed angle is sections 3 through 5, and the clamp is the turn-rate limit that makes it look mechanical instead of instant.

A guard faces 170170^\circ. A noise comes from 200200^\circ. Which way, and how far?

Raw difference. 200170=30200 - 170 = 30^\circ. Already inside ±180\pm 180, so no wrapping needed. Turn 30 degrees counter-clockwise.

Now the noise comes from 170-170^\circ (equivalently 190190^\circ, but suppose your code produced the negative form).

Raw difference. 170170=340-170 - 170 = -340^\circ.

Wrapped. (340+180)mod360180(-340 + 180) \bmod 360 - 180. Using a non-negative modulo, 160mod360=200-160 \bmod 360 = 200, so the result is 200180=20200 - 180 = 20^\circ.

Verdict. Turn 20 degrees counter-clockwise, not 340 clockwise. Same destination, seventeen times less rotation.

Move your pointer and the turret follows, turning at a limited rate. Then untick the box and sweep the pointer across the screen behind it.

A turret that turns the short way
Move your pointer over the scene.
The code that draws it src/lib/gamedev/demos/turret.scene.ts
/**
 * A turret that turns to follow your pointer, with the fix on a switch.
 */
import * as THREE from "three";
import { wrapRad, yawToFace } from "./turret-shared.ts";
import { makeCanvas, addCheckbox, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const TURN_RATE = 2.5; // radians per second

const mount: MountFn = (el, { reduced }) => {
  const { renderer, width, height, background, isDark } = makeCanvas(el);

  const scene = new THREE.Scene();
  scene.background = background;

  // Looking straight down, so the scene reads as a plan view.
  const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
  camera.position.set(0, 9, 0.01);
  camera.lookAt(0, 0, 0);

  // A cone points along +Y by default, so tip it forward onto -Z.
  const barrel = new THREE.Mesh(
    new THREE.ConeGeometry(0.34, 2.2, 20),
    new THREE.MeshBasicMaterial({ color: 0x39d3c3 }),
  );
  barrel.rotation.x = -Math.PI / 2;
  barrel.position.z = -1.1;

  const base = new THREE.Mesh(
    new THREE.CircleGeometry(0.55, 24),
    new THREE.MeshBasicMaterial({ color: isDark ? 0x30363d : 0xd0d7de }),
  );
  base.rotation.x = -Math.PI / 2;

  const turret = new THREE.Group();
  turret.add(barrel, base);

  const marker = new THREE.Mesh(
    new THREE.SphereGeometry(0.22, 16, 12),
    new THREE.MeshBasicMaterial({ color: 0xf0883e }),
  );
  scene.add(turret, marker);

  const target = new THREE.Vector3(3, 0, 0);
  marker.position.copy(target);

  renderer.domElement.addEventListener("pointermove", (e) => {
    const r = renderer.domElement.getBoundingClientRect();
    target.set(
      (((e.clientX - r.left) / r.width) * 2 - 1) * 7,
      0,
      (((e.clientY - r.top) / r.height) * 2 - 1) * 3.6,
    );
    marker.position.copy(target);
    if (reduced) tick(1 / 60);
  });

  const show = addReadout(el);
  const useWrap = addCheckbox(
    el,
    "wrap the angle difference (the fix)",
    true,
    () => {},
  );

  const deg = (r: number) => `${((r * 180) / Math.PI).toFixed(0)}\u00B0`;

  // This is the whole lesson, and it runs once per frame.
  function step(dt: number) {
    // Where should it face? Forward is local -Z, so both components are negated.
    const desired = yawToFace(target.x, target.z);

    // How far is that from where it is now, and which way is shorter?
    const raw = desired - turret.rotation.y;
    const delta = useWrap() ? wrapRad(raw) : raw;

    // Turn by at most this much, so it looks mechanical instead of instant.
    const limit = TURN_RATE * dt;
    turret.rotation.y += Math.min(limit, Math.max(-limit, delta));

    show(`turning by ${deg(delta)}${useWrap() ? "" : "  \u2190 the long way"}`);
  }

  let animId = 0;
  let last = performance.now();

  function tick(dt: number) {
    step(dt);
    renderer.render(scene, camera);
  }

  function frame() {
    animId = requestAnimationFrame(frame);
    const now = performance.now();
    const dt = Math.min((now - last) / 1000, 0.05);
    last = now;
    tick(dt);
  }

  // One static frame for readers who asked for reduced motion; pointer moves still
  // advance it, so the figure stays usable.
  if (reduced) tick(0);
  else frame();

  return () => {
    if (animId) cancelAnimationFrame(animId);
    renderer.dispose();
  };
};

export default mount;

With wrapping on it always takes the short way, however fast you move. With it off, crossing behind makes it swing almost all the way round, because the raw difference is telling it to rotate 350 degrees instead of 10. The line under the scene shows the difference actually being used - watch it jump past 180 the moment the box is unticked.

That is the entire bug, with an on/off switch.

Raw versus wrapped angle differences
The code src/lib/gamedev/demos/wrapping.ts
/** A turret facing 350 degrees, and what the raw difference tells it to do. */
import { wrapDeg } from "../angles.ts";
import { HEADING, type Demo } from "./runner.ts";

const FACING = 350;

const demo: Demo = (log) => {
  log(`facing ${FACING} degrees, target at...`, HEADING);

  for (const target of [10, 90, 180, 270]) {
    const raw = target - FACING;
    log(`${String(target).padStart(3)} degrees`, {
      raw,
      wrapped: wrapDeg(raw),
    });
  }

  log("the tie, and beyond one turn", HEADING);
  log("wrapDeg(180)", wrapDeg(180));
  log("wrapDeg(-180)", wrapDeg(-180), "same answer, so it never judders");
  log("wrapDeg(540)", wrapDeg(540), "540 is one and a half turns");
};

export default demo;
facing 350 degrees, target at...
10 degrees { raw: -340, wrapped: 20 }
90 degrees { raw: -260, wrapped: 100 }
180 degrees { raw: -170, wrapped: -170 }
270 degrees { raw: -80, wrapped: -80 }
the tie, and beyond one turn
wrapDeg(180) -180
wrapDeg(-180) -180 // same answer, so it never judders
wrapDeg(540) -180 // 540 is one and a half turns

Read the middle rows first: raw and wrapped agree, so code using the raw difference works fine there. Now the top row - raw says 340-340^\circ where the honest answer is +20+20^\circ. Same destination, seventeen times the rotation, wrong direction.

That is why this bug survives testing. It behaves perfectly across most of the circle and misbehaves only in a region you may never have walked through.

The last rows cover the awkward cases. wrapDeg(180) and wrapDeg(-180) return the same value, which matters more than which value it is: half a turn is equally short either way, so a function that answered inconsistently would make a turret facing directly away from its target flip direction every frame and judder.

Interpolating an angle the long way and the short way
The code src/lib/gamedev/demos/lerp-angles.ts
/** Interpolating 350 degrees to 10 degrees, blending numbers versus blending angles. */
import { wrapDeg } from "../angles.ts";
import { lerp } from "../interpolation.ts";
import { HEADING, type Demo } from "./runner.ts";

const FROM = 350;
const TO = 10;

/** Plain lerp walks straight through the numbers. This one walks through the angles. */
const lerpAngle = (a: number, b: number, t: number) => a + wrapDeg(b - a) * t;

const demo: Demo = (log) => {
  for (const t of [0, 0.5, 1]) {
    log(`t = ${t.toFixed(1)}`, {
      lerp: lerp(FROM, TO, t),
      lerpAngle: lerpAngle(FROM, TO, t),
    });
  }

  log("degrees travelled", HEADING);
  log("lerp", Math.abs(TO - FROM), "the long way round");
  log("lerpAngle", Math.abs(wrapDeg(TO - FROM)));
};

export default demo;
t = 0.0 { lerp: 350, lerpAngle: 350 }
t = 0.5 { lerp: 180, lerpAngle: 360 }
t = 1.0 { lerp: 10, lerpAngle: 370 }
degrees travelled
lerp 340 // the long way round
lerpAngle 20

Both journeys start at 350 and end on the same heading, but look at the middle. Plain lerp sits at 180 halfway through - it is blending the numbers 350 and 10 and has no idea they are angles, so it sweeps 340 degrees the long way. lerpAngle wraps the difference first and travels 20.

Note lerpAngle ends on 370, not 10. Those are the same heading, and the difference is deliberate: snapping to 10 on the last step would jump backwards a full turn, which is exactly the discontinuity the function exists to prevent. Wrap an angle when you display it, not while you are interpolating it.

  • Turrets, enemies and NPC heads, every time something tracks something else.
  • Steering behaviours, where the sign of the angle decides the direction of the correction.
  • Vehicle steering and boids, whose whole model is “turn toward this by at most that”.
  • Compass and minimap arrows, which are atan2 of a relative position, wrapped for display.
  • Animation blending, choosing a turn-left or turn-right animation from the sign of the difference.
  • Any rotation that occasionally spins the wrong way, which is nearly always an unwrapped angle difference.