Skip to content

Points, Vectors and Coordinate Conventions

The difference between a point and a vector and why storing both as (x, y, z) hides a real distinction. What basis vectors are. Which way is up and which way is forward in Godot, Unity and Blender, and why that single table prevents most sign errors. Degrees versus radians, and why every engine function wants radians.

In 2D you needed two numbers to place something on screen - how far right and how far up. A game world that has depth needs a third number: how far in front of or behind the screen. That is what “3D” means. Three independent directions, each measured by one number.

Those directions are called axes, and they are labelled X, Y and Z. Each axis is a straight line running through the origin - the single point in space where all three numbers are zero.

  • X typically runs left to right.
  • Y typically runs down to up.
  • Z runs into or out of the screen, depending on the engine. More on this shortly.

Any location in 3D space is written as three numbers in that order: (x, y, z). The numbers say “go this far along X, this far along Y, and this far along Z, starting from the origin.”

Drag the sliders below and watch the orange dot move. Each slider controls exactly one axis, so you can feel what each number does independently.

Three axes, and where a number puts you
The code that draws it src/lib/gamedev/demos/axes-intro.scene.ts
/**
 * Three labelled axes, a grid, and one point you can move with sliders.
 */
import * as THREE from "three";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

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

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

  const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
  camera.position.set(5, 4.5, 7);
  camera.lookAt(0, 0, 0);

  scene.add(new THREE.GridHelper(8, 8, 0x30363d, 0x21262d));

  // Three coloured arrows from the origin, one per axis.
  const axis = (dir: THREE.Vector3, colour: number, label: string) => {
    const arrow = new THREE.ArrowHelper(
      dir,
      new THREE.Vector3(),
      3.4,
      colour,
      0.3,
      0.16,
    );
    scene.add(arrow);

    // A text sprite for the label, so it always faces the camera.
    const canvas = document.createElement("canvas");
    canvas.width = 64;
    canvas.height = 64;
    const ctx = canvas.getContext("2d")!;
    ctx.font = "bold 48px sans-serif";
    ctx.fillStyle = "#" + colour.toString(16).padStart(6, "0");
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(label, 32, 32);
    const tex = new THREE.CanvasTexture(canvas);
    const sprite = new THREE.Sprite(
      new THREE.SpriteMaterial({ map: tex, transparent: true }),
    );
    sprite.scale.set(0.6, 0.6, 1);
    sprite.position.copy(dir).multiplyScalar(3.8);
    scene.add(sprite);
  };

  axis(new THREE.Vector3(1, 0, 0), 0xff7b72, "X");
  axis(new THREE.Vector3(0, 1, 0), 0x7ee787, "Y");
  axis(new THREE.Vector3(0, 0, 1), 0x58a6ff, "Z");

  // A movable point, so you can feel what each number does.
  const dot = new THREE.Mesh(
    new THREE.SphereGeometry(0.18, 16, 12),
    new THREE.MeshBasicMaterial({ color: 0xf0883e }),
  );
  scene.add(dot);

  const show = addReadout(el);
  const xSlider = addSlider(el, "X (red, right)", -3, 3, 2, draw, "");
  const ySlider = addSlider(el, "Y (green, up)", -3, 3, 1, draw, "");
  const zSlider = addSlider(el, "Z (blue, forward)", -3, 3, -1, draw, "");

  function draw() {
    const x = xSlider();
    const y = ySlider();
    const z = zSlider();
    dot.position.set(x, y, z);
    show(`position: (${x}, ${y}, ${z})`);
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

A few things to notice while you drag:

  • Setting all three to zero puts the dot at the origin, where the arrows meet.
  • Changing X moves the dot along the red arrow only. Y and Z do not change.
  • Negative values move the dot in the opposite direction along that axis.
  • The grid lies on the X-Z plane (where Y is zero). Raising Y lifts the dot above it.

That grid, those three arrows and that origin are the coordinate system. Everything in a 3D game - every mesh, every camera, every collision shape - is placed by three numbers measured against the same set of axes. What differs between engines is which axis points which way, and the rest of this section is about that.

Both are three numbers. They mean different things.

A point is a location. (3,2,0)(3, 2, 0) as a point is a specific spot - 3 along the X axis, 2 along the Y axis, and 0 along the Z axis. The numbers are always written in that order: X first, Y second, Z third. It only means anything once you have agreed where the origin is.

A vector is a displacement - a direction with a length, and no location at all. 3,2,0\langle 3, 2, 0 \rangle as a vector means three right and two up, starting from wherever you happen to be.

All three arrows in that figure are the same vector. They are drawn in different places because a vector does not have a place. Their tips are three different points.

This is not pedantry, and here is the practical test:

  • Point minus point is a vector. Enemy position minus player position gives the displacement from you to the enemy. Subtracting two places gives a journey.
  • Point plus vector is a point. Your position plus your velocity times delta time gives your new position. Applying a journey to a place gives a place.
  • Point plus point is nothing. There is no meaningful sum of two locations. If you find yourself adding positions, something is wrong - except when you are averaging them, which is really a weighted sum of displacements from the origin.

Nearly every library uses one type for both. Three.js has Vector3 and so does Godot; you decide per variable whether it holds a place or a displacement. The compiler will not help you, which is exactly why the distinction is worth holding in your head.

// Three.js. The naming is the only thing keeping the two ideas apart, so name
// displacements after what they do and points after what they are.
const toTarget = new THREE.Vector3().subVectors(target.position, mesh.position);
const nextPos = mesh.position.clone().addScaledVector(velocity, dt);

Note subVectors(a, b) rather than a.sub(b): the second form modifies a in place, which is a common early surprise. Three.js methods mutate by default, and .clone() is how you opt out.

Later, in the transforms module, the point-versus-direction distinction becomes mechanical rather than a matter of discipline: points carry a fourth component of 1 and directions carry 0.

Basis Vectors: What the Numbers Are Counting

Section titled “Basis Vectors: What the Numbers Are Counting”

Writing a vector as (3, 2) is shorthand. In full it means:

(3,2)=3ı^+2ȷ^(3, 2) = 3\,\hat{\imath} + 2\,\hat{\jmath}

where ı^\hat{\imath} and ȷ^\hat{\jmath} are the basis vectors - one unit along x and one unit along y. The numbers are counts of basis vectors, not the vector itself.

That sounds like a technicality until you realise the basis can be anything. A character’s own right, up and forward directions form a basis, and the same physical arrow has different numbers in that basis than in the world’s. “Move me one meter to my right” and “move me one meter along world x” are different instructions that happen to coincide only when the character faces down the world axis. Everything the transforms module does is bookkeeping for changing which basis the numbers refer to.

Here is where engines genuinely disagree, and where a wrong assumption produces a model lying on its side or a character walking backwards.

source The code doing this, and the source of the table below src/lib/gamedev/conventions.ts 101 lines
/**
 * Coordinate conventions, and the maths for reading a direction out of a basis.
 *
 * This file is displayed to readers in the lesson and imported by the figure that
 * runs above it, so the code on the page is the code doing the work. If you change
 * something here, the lesson updates with it.
 */

/** Which way is up and which way is forward, per tool. */
export interface Convention {
  name: string;
  /** false means a left-handed coordinate system. */
  rightHanded: boolean;
  /** Signed axis for "up": index 0/1/2 is x/y/z, sign is +1 or -1. */
  up: [axis: number, sign: number];
  /** Signed axis for "forward". */
  forward: [axis: number, sign: number];
}

/**
 * Three.js agrees with Godot on every count: right-handed, +Y up, and a camera that
 * looks down -Z. That is why the maths in this track ports between them unchanged.
 */
export const CONVENTIONS: Convention[] = [
  { name: "Three.js", rightHanded: true, up: [1, 1], forward: [2, -1] },
  { name: "Godot 4", rightHanded: true, up: [1, 1], forward: [2, -1] },
  { name: "Unity", rightHanded: false, up: [1, 1], forward: [2, 1] },
  { name: "Unreal", rightHanded: false, up: [2, 1], forward: [0, 1] },
  { name: "Blender", rightHanded: true, up: [2, 1], forward: [1, -1] },
];

/** A unit vector along a signed axis, as a plain [x, y, z] triple. */
export function axisVector([axis, sign]: [number, number]): [
  number,
  number,
  number,
] {
  const v: [number, number, number] = [0, 0, 0];
  v[axis] = sign;
  return v;
}

/**
 * The forward direction of an object, given the three columns of its basis.
 *
 * A basis is just the object's own right, up and back directions written in world
 * coordinates. In a -Z-forward convention, "forward" is the negated third column -
 * which is the entire content of the minus sign you see in engine code.
 */
export function forwardFromBasis(
  basisX: [number, number, number],
  basisY: [number, number, number],
  basisZ: [number, number, number],
  convention: Convention,
): [number, number, number] {
  const columns = [basisX, basisY, basisZ];
  const [axis, sign] = convention.forward;
  const col = columns[axis];
  return [col[0] * sign, col[1] * sign, col[2] * sign];
}

/**
 * The basis of an object rotated by `yawRadians` about the +Y axis.
 *
 * Rotating about Y sends +X toward -Z and +Z toward +X, which is why the columns
 * below look the way they do. Try yaw = 90 degrees: basisZ becomes (1, 0, 0), so
 * forward becomes (-1, 0, 0) and the object faces down negative X.
 */
export function basisFromYaw(yawRadians: number): {
  x: [number, number, number];
  y: [number, number, number];
  z: [number, number, number];
} {
  const c = Math.cos(yawRadians);
  const s = Math.sin(yawRadians);
  return {
    x: [c, 0, -s],
    y: [0, 1, 0],
    z: [s, 0, c],
  };
}

/** Degrees to radians. Every trig function in every engine wants radians. */
export function degToRad(degrees: number): number {
  return (degrees * Math.PI) / 180;
}

/** Radians to degrees, for showing a number to a human. */
export function radToDeg(radians: number): number {
  return (radians * 180) / Math.PI;
}

/**
 * The arc length swept on a circle of radius r by an angle in radians.
 *
 * On the unit circle this returns the angle itself, which is the definition of a
 * radian rather than a coincidence.
 */
export function arcLength(radians: number, radius = 1): number {
  return radians * radius;
}

That panel is not a transcription. The figure above imports CONVENTIONS and axisVector from that exact file, so the triads you just rotated were drawn from the same data the table below reports. If one were wrong, both would be.

Two independent choices are being made. Which axis points up, and whether the coordinate system is left- or right-handed.

HandednessUp axisForward axisVector convention
Three.jsright-handed+Y+YZ-Zcolumn vectors, MvM\vec{v}
Godot 4right-handed+Y+YZ-Zcolumn vectors, MvM\vec{v}
Unityleft-handed+Y+Y+Z+Zcolumn vectors, MvM\vec{v}
Unrealleft-handed+Z+Z+X+Xrow vectors, vM\vec{v}M
Blenderright-handed+Z+ZY-Ycolumn vectors, MvM\vec{v}
OpenGL / glTFright-handed+Y+YZ-Zcolumn vectors, MvM\vec{v}

Bookmark this. Six later sections refer back to it.

Handedness, and why it flips your cross products

Section titled “Handedness, and why it flips your cross products”

Point the fingers of your right hand along +X+X, curl them toward +Y+Y, and your thumb points along +Z+Z. If that matches the engine, it is right-handed. Do the same with your left hand and you have the left-handed convention.

The consequence is concrete: the cross product changes sign. In a right-handed system x^×y^=+z^\hat{x} \times \hat{y} = +\hat{z}; in a left-handed one the same formula applied to the same numbers gives a vector pointing the other way. So a “which side of this wall am I on” test written for Unity gives the opposite answer in Godot unless you account for it. This is dealt with properly in the cross product section; for now, just know the two are not interchangeable.

Godot and Unity treat +Y+Y as up. Blender treats +Z+Z as up. So a model that stands correctly in Blender arrives in Godot rotated 90 degrees about the x axis, face-planted on the floor.

Both tools know this and will convert on export. The reason to understand it anyway is that the conversion sometimes fails silently, and when a rig arrives rotated it is much faster to recognise a known axis swap than to hunt for a broken bone.

Handedness and up-axis are mathematical facts about the coordinate system. Forward is pure convention - somebody decided which way a camera looks by default.

Three.js, Godot and OpenGL all use Z-Z as forward, which surprises people: a camera at the origin looks toward negative z, so things in front of it have negative z coordinates. Unity picked +Z+Z. Neither is wrong; they simply disagree.

Rather than writing axis constants by hand, ask the object for its own axes. Every library exposes them as the three columns of its rotation matrix:

// Three.js. matrixWorld holds the object's basis; extract the columns.
const right = new THREE.Vector3();
const up = new THREE.Vector3();
const back = new THREE.Vector3();
mesh.matrixWorld.extractBasis(right, up, back);
const forward = back.clone().negate(); // the minus IS the -Z convention

That negation is the whole convention in one operator. Three.js also gives you the shortcut mesh.getWorldDirection(v), which fills v with the forward direction and applies the negation for you.

Humans think in degrees. Every trigonometric function in every engine expects radians.

A radian is not an arbitrary unit. It is defined so that the angle equals the arc length it sweeps on a circle of radius 1:

Sweep the slider and watch the bottom two rows. The radian measure and the arc length are always the same number, because the radius is 1. That is the whole definition.

Since a full circle has circumference 2πr2\pi r, a full turn is 2π2\pi radians:

360=2π rad,1 rad=180π57.296360^\circ = 2\pi \text{ rad}, \qquad 1 \text{ rad} = \frac{180^\circ}{\pi} \approx 57.296^\circ

The conversions are worth memorising in this direction, because this is the direction you need when a designer hands you a number in degrees:

radians=degrees×π180\text{radians} = \text{degrees} \times \frac{\pi}{180}

The values worth knowing on sight: π/6=30\pi/6 = 30^\circ, π/4=45\pi/4 = 45^\circ, π/3=60\pi/3 = 60^\circ, π/2=90\pi/2 = 90^\circ, π=180\pi = 180^\circ.

JavaScript has no degree-to-radian helper, so the conversion is one you write once:

// Also in src/lib/gamedev/conventions.ts, shown in the panel above.
const degToRad = (deg) => (deg * Math.PI) / 180;
const radToDeg = (rad) => (rad * 180) / Math.PI;
Math.sin(degToRad(90)); // 1
Math.sin(90); // 0.8939966636005579 <-- the bug

Math.sin, Math.cos, Math.atan2 and every rotation value in Three.js are in radians. The bug this causes is passing a degree value straight in and getting a plausible-looking wrong answer. Math.sin(90) is not 1; it is about 0.894, because 90 radians is roughly fourteen full turns plus a bit. Nothing warns you.

Drag the slider and the cube turns.

An object's own axes, and where forward points
The code that draws it src/lib/gamedev/demos/basis.scene.ts
/**
 * A cube with its own axes drawn on it, turned by a slider.
 */
import * as THREE from "three";
import { basisFromYaw, degToRad } from "../conventions.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

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

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

  const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
  camera.position.set(4.2, 3.4, 5.6);
  camera.lookAt(0, 0, 0);

  // The world's axes: faint, and they never move.
  const world = new THREE.AxesHelper(3.4);
  (world.material as THREE.Material).transparent = true;
  (world.material as THREE.Material).opacity = 0.22;
  scene.add(world, new THREE.GridHelper(8, 8, 0x30363d, 0x21262d));

  // The cube, plus the axes that belong to it and move with it.
  const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1.5, 1.5, 1.5),
    new THREE.MeshNormalMaterial({ flatShading: true }),
  );
  cube.add(new THREE.AxesHelper(2.2));

  // Forward is local -Z. White, so it reads as a label rather than a fourth axis.
  cube.add(
    new THREE.ArrowHelper(
      new THREE.Vector3(0, 0, -1),
      new THREE.Vector3(),
      2.6,
      0xffffff,
      0.35,
      0.18,
    ),
  );
  scene.add(cube);

  const show = addReadout(el);
  const yaw = addSlider(el, "Yaw", 0, 360, 0, draw);

  const fmt = (v: number[]) =>
    v
      .map((n) => (Math.abs(n) < 1e-4 ? 0 : Math.round(n * 100) / 100))
      .join(", ");

  function draw() {
    const radians = degToRad(yaw());
    cube.rotation.y = radians;

    // The same function the lesson shows, so the numbers match the picture.
    const b = basisFromYaw(radians);
    const forward = b.z.map((n) => -n);
    show(`cube's own X (${fmt(b.x)})    forward (${fmt(forward)})`);

    renderer.render(scene, camera);
  }

  draw();

  // Nothing animates on its own, so there is no motion to suppress for readers who
  // have asked for less of it. The slider is the only thing that changes anything.
  return () => renderer.dispose();
};

export default mount;

Two sets of axes are drawn. The faint ones on the grid are the world, and they never move. The bright ones belong to the cube and turn with it - that is its basis. The white arrow is its forward direction, which is local Z-Z.

At 00^\circ the two sets sit on top of each other, which is exactly why local and world space are easy to confuse before anything has been rotated. Move the slider and they come apart. The cube’s red X stops pointing along the world’s red X immediately, so “one meter along world x” and “one meter to my right” stop being the same instruction.

The line under the scene shows forward as you drag. At 00^\circ it reads (0,0,1)(0, 0, -1) - the convention from the table above, on screen instead of on paper. At 9090^\circ it is (1,0,0)(-1, 0, 0), facing down negative world X. At 180180^\circ it is (0,0,1)(0, 0, 1), pointing back the way it started.

The teal ball is the player. Move the orange one - the enemy - and watch the white arrow.

Subtracting one place from another
The code that draws it src/lib/gamedev/demos/displacement.scene.ts
/**
 * Two places, and the arrow you get by subtracting one from the other.
 */
import * as THREE from "three";
import { PLAYER, displacement } from "./displacement-shared.ts";
import { length } from "../vectors.ts";
import { makeCanvas, addSlider, addCheckbox, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

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

  const scene = new THREE.Scene();
  scene.background = background;
  scene.add(new THREE.GridHelper(10, 10, 0x30363d, 0x21262d));

  const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
  camera.position.set(5, 6.5, 8);
  camera.lookAt(0, 0, 0);

  const marker = (colour: number, at: number[]) => {
    const m = new THREE.Mesh(
      new THREE.SphereGeometry(0.28, 16, 12),
      new THREE.MeshBasicMaterial({ color: colour }),
    );
    m.position.set(at[0], at[1], at[2]);
    scene.add(m);
    return m;
  };

  const player = marker(0x39d3c3, PLAYER);
  const enemy = marker(0xf0883e, [2, 0, -2]);

  // The displacement, drawn from the player. This is what point minus point gives you.
  const arrow = new THREE.ArrowHelper(
    new THREE.Vector3(1, 0, 0),
    player.position,
    1,
    0xffffff,
    0.3,
    0.16,
  );
  scene.add(arrow);

  // The same vector drawn from the origin, to make the point that it has no location.
  const ghost = new THREE.ArrowHelper(
    new THREE.Vector3(1, 0, 0),
    new THREE.Vector3(0, 0.02, 0),
    1,
    0xffffff,
    0.3,
    0.16,
  );
  (ghost.line.material as THREE.Material).transparent = true;
  (ghost.line.material as THREE.Material).opacity = 0.35;
  (ghost.cone.material as THREE.Material).transparent = true;
  (ghost.cone.material as THREE.Material).opacity = 0.35;
  scene.add(ghost);

  const show = addReadout(el);
  const ex = addSlider(el, "Enemy X", -4, 4, 2, draw, "");
  const ez = addSlider(el, "Enemy Z", -4, 4, -2, draw, "");
  const showGhost = addCheckbox(
    el,
    "draw the same vector from the origin",
    true,
    draw,
  );

  function draw() {
    const target = [ex(), 0, ez()];
    enemy.position.set(target[0], target[1], target[2]);

    // Point minus point. The whole lesson is this one line.
    const toEnemy = displacement(PLAYER, target);
    const dist = length(toEnemy);

    if (dist > 1e-6) {
      const dir = new THREE.Vector3(...toEnemy).normalize();
      arrow.setDirection(dir);
      arrow.setLength(dist, 0.3, 0.16);
      ghost.setDirection(dir);
      ghost.setLength(dist, 0.3, 0.16);
    }
    arrow.visible = dist > 1e-6;
    ghost.visible = showGhost() && dist > 1e-6;

    show(
      `enemy - player = (${toEnemy.map((n) => n.toFixed(0)).join(", ")})` +
        `    distance ${dist.toFixed(2)}`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

That arrow is what “point minus point” gives you. Two places went in, and what came out was a journey: a direction to travel and a distance to travel it. It is the single most common calculation in gameplay code, because “which way is the enemy, and how far?” is the question everything else is built on.

Watch the readout as you drag. The three numbers are just the enemy’s coordinates minus the player’s, one axis at a time. Put the enemy on the same spot as the player and the arrow vanishes: there is no journey to make, and no direction to report.

Now untick the checkbox and tick it again. The faint second arrow is the same vector drawn starting at the origin instead of at the player. Same three numbers, same length, same direction - just drawn somewhere else. That is the thing that makes it a vector rather than a point. It has a direction and a length, but no home.

And the rule runs in reverse. If you add that journey back onto the player’s position, you land exactly on the enemy. Point plus vector is a point. Point minus point is a vector. Those two sentences are the whole of this section, and every direction you compute for the rest of the module is one of them.

  • Every gameplay script, whenever you compute a direction from one thing to another and have to decide whether you are holding a place or a displacement.
  • Asset pipelines, where the up-axis mismatch between Blender and a game engine is the single most common import problem.
  • Porting and tutorials. Most game-math material online is written for Unity. Being able to spot which parts are convention rather than mathematics is what makes it usable in Godot.
  • Networking, where sending a position and sending a velocity have different precision and prediction requirements, and conflating them causes rubber-banding.