Angles, atan2 and Shortest Rotation
What You’ll Learn
Section titled “What You’ll Learn”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.
First, What These Functions Are
Section titled “First, What These Functions Are”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.
atan2, Not atan
Section titled “atan2, Not atan”Given a vector, its angle from the positive axis is:
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 ? Two reasons, both fatal.
It loses the quadrant. The vectors and point in opposite
directions, but is for both, so plain atan returns for each. It
cannot distinguish them, because the division threw the information away.
It divides by zero. A vector pointing straight up is , and is infinity.
atan2 takes the two components separately, so it keeps the signs and handles zero. It
returns a value in , 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.
Signed Angles
Section titled “Signed Angles”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:
In 2D it is simpler, because the cross product is already a scalar:
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.
Wrapping, and the Long Way Round
Section titled “Wrapping, and the Long Way Round”Here is the bug this section exists to prevent.
An enemy faces . The player is at . The difference is:
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
/**
* 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 and 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 , which is always the short way. Add half a turn, take the remainder, subtract half a turn:
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 -340wrapDeg(370); // 10wrapDeg(180); // -180 the tie, resolved clockwiseThe radian version replaces 180 with Math.PI and 360 with TAU, and is wrapRad in the
panel.
Putting Part 1 Together
Section titled “Putting Part 1 Together”A turret that tracks a target, at a limited turn rate, using every idea from this module:
const TURN_RATE = 2.0; // radians per secondconst 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.
Worked Example
Section titled “Worked Example”A guard faces . A noise comes from . Which way, and how far?
Raw difference. . Already inside , so no wrapping needed. Turn 30 degrees counter-clockwise.
Now the noise comes from (equivalently , but suppose your code produced the negative form).
Raw difference. .
Wrapped. . Using a non-negative modulo, , so the result is .
Verdict. Turn 20 degrees counter-clockwise, not 340 clockwise. Same destination, seventeen times less rotation.
See It Work
Section titled “See It Work”A turret that turns the short way
Section titled “A turret that turns the short way”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.
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, in numbers
Section titled “Raw versus wrapped, in numbers”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; Read the middle rows first: raw and wrapped agree, so code using the raw difference works fine there. Now the top row - raw says where the honest answer is . 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
Section titled “Interpolating an angle”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; 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.
Where This Shows Up
Section titled “Where This Shows Up”- 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
atan2of 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.