Skip to content

The Cross Product and Building a Basis

How to get a vector perpendicular to two others, why its magnitude is an area, why the answer flips between Godot and Unity, and how to build a full set of right/up/forward axes from a single direction - which is all look_at does.

The cross product takes two vectors and returns a vector, unlike the dot product which returns a number:

a×b=(aybzazbyazbxaxbzaxbyaybx)\vec{a} \times \vec{b} = \begin{pmatrix} a_y b_z - a_z b_y \\ a_z b_x - a_x b_z \\ a_x b_y - a_y b_x \end{pmatrix}

Each component is built from the other two axes, cycling xyzxx \to y \to z \to x. Nobody memorises this by staring at it; the pattern is easier: for the xx component, skip xx and take yazbzayby_a z_b - z_a y_b, then rotate the letters.

Two properties matter more than the formula:

a×b=absinθ,(a×b)a and b|\vec{a} \times \vec{b}| = |\vec{a}|\,|\vec{b}| \sin\theta, \qquad (\vec{a} \times \vec{b}) \perp \vec{a} \text{ and } \perp \vec{b}

The result is perpendicular to both inputs, and its length is the area of the parallelogram they span.

source The code doing this, and everything else in this section src/lib/gamedev/cross.ts 98 lines
/**
 * The cross product, surface normals, and building a full orientation from one
 * direction.
 *
 * Displayed in the lesson and imported by the figure above it.
 */
import { type Vec, normalize } from "./vectors.ts";

/**
 * A vector perpendicular to both inputs, whose length is the area of the parallelogram
 * they span.
 *
 * Each component is built from the other two axes, cycling x to y to z. Swapping the
 * arguments negates the result, so order matters here in a way it never does for the
 * dot product.
 *
 * The arithmetic is fixed. Where the answer *points* depends on the handedness of the
 * coordinate system, which is why a left/right test written for Unity answers backwards
 * in Three.js or Godot.
 */
export function cross(a: Vec, b: Vec): Vec {
  return [
    a[1] * b[2] - a[2] * b[1],
    a[2] * b[0] - a[0] * b[2],
    a[0] * b[1] - a[1] * b[0],
  ];
}

/**
 * The 2D "cross product": the z component the 3D version would produce.
 *
 * Two dimensions have no third axis to point along, so the result is a single number.
 * Its sign says which side of `a` the vector `b` lies on, which is the piece of
 * information the dot product cannot give you.
 */
export function cross2(a: Vec, b: Vec): number {
  return a[0] * b[1] - a[1] * b[0];
}

/**
 * The outward normal of a triangle, from its winding order.
 *
 * List the corners the other way round and the normal flips. That is the entire reason
 * 3D tools have a "flip normals" button, and why a model can look right in one program
 * and inside-out in another.
 */
export function triangleNormal(p0: Vec, p1: Vec, p2: Vec): Vec | null {
  const e1 = p1.map((c, i) => c - p0[i]);
  const e2 = p2.map((c, i) => c - p0[i]);
  return normalize(cross(e1, e2));
}

/**
 * Three perpendicular unit vectors from a single forward direction. This is what
 * `look_at` does internally.
 *
 * Returns null when `forward` is parallel to `worldUp`, because then there is genuinely
 * no way to decide which way is "right". That is not a bug to fix: looking straight up,
 * every horizontal direction is equally valid. Engines hit the same wall, which is why
 * third-person cameras clamp pitch just short of vertical.
 *
 * The argument order in the first cross product is load-bearing, and getting it wrong is
 * subtle enough to be worth spelling out. With forward as -Z, `cross(forward, worldUp)`
 * gives +X, which is the right. `cross(worldUp, forward)` gives -X - the *left* - and the
 * resulting three vectors are still mutually perpendicular and still unit length, so
 * every orthonormality check passes while the triple is quietly left-handed. Anything
 * oriented by it comes out mirrored. The demo in the lesson checks the determinant for
 * exactly this reason.
 *
 * Note `up` needs no normalizing: the cross product of two perpendicular unit vectors
 * already has length 1.
 */
export function buildBasis(
  forward: Vec,
  worldUp: Vec = [0, 1, 0],
): { right: Vec; up: Vec; forward: Vec } | null {
  const f = normalize(forward);
  if (f === null) return null;
  const right = normalize(cross(f, worldUp));
  if (right === null) return null; // forward was parallel to worldUp
  return { right, up: cross(right, f), forward: f };
}

/**
 * Which side of `forward` does `toTarget` lie on, as a signed number?
 *
 * Cross to get a perpendicular, then dot with up to collapse it to one value. Which
 * sign means "left" depends on handedness, so establish it once by experiment and write
 * it down rather than guessing each time.
 */
export function sideOf(
  forward: Vec,
  toTarget: Vec,
  up: Vec = [0, 1, 0],
): number {
  const c = cross(forward, toTarget);
  return c[0] * up[0] + c[1] * up[1] + c[2] * up[2];
}

The orange arrow in that figure is drawn from cross in the panel, not from Three.js’s built-in crossVectors. Both agree, which is the point: the formula is the formula.

a×b=(b×a)\vec{a} \times \vec{b} = -(\vec{b} \times \vec{a})

Swapping the inputs flips the output. This is unlike the dot product, where order does not matter, and it is a live source of bugs: a normal computed from a triangle’s edges in the wrong order points into the surface instead of out of it, and the face renders black.

Also, the cross product of parallel vectors is zero, because sin0=0\sin 0 = 0. Two vectors pointing the same way span no parallelogram, so there is no unique perpendicular to return. This is the cross product’s version of the zero-length trap from section 2, and it is why look_at fails when you ask an object to look straight up while its up vector is also straight up.

The formula above is fixed arithmetic. Feed it the same six numbers and you get the same three numbers out, in Godot, Unity, or on paper.

What differs is where those three numbers point.

In a right-handed system, x^×y^=+z^\hat{x} \times \hat{y} = +\hat{z}. In a left-handed system, the same components describe a z^\hat{z} that points the other way. The figure above shows exactly this: identical arithmetic, mirrored frame, opposite arrow.

The practical consequence is that any test built on cross-product direction inverts when ported:

  • “Is this point left or right of my facing direction?”
  • “Is this triangle wound clockwise or counter-clockwise?”
  • “Which way does this surface face?”

Recall the table from section 1: Godot and Blender are right-handed; Unity and Unreal are left-handed. When you copy a snippet from a Unity tutorial and the character turns the wrong way, this is usually why, and the fix is a single negation - once you know it is the cause rather than guessing.

In Three.js:

const c = new THREE.Vector3().crossVectors(a, b); // right-handed
const area = c.length();
// Careful: a.cross(b) modifies a in place, like most Three.js methods.
// crossVectors writes into a fresh vector and leaves both inputs alone.

Two dimensions have no perpendicular axis to point along, so there is no true 2D cross product. What you want instead is the scalar axbyaybxa_x b_y - a_y b_x - the zz component the 3D version would produce - which is cross2 in the panel above:

const z = a.x * b.y - a.y * b.x; // a single number, not a vector

That scalar is enormously useful: its sign tells you which side of a\vec{a} the vector b\vec{b} lies on. It is the missing half of the dot product, and section 5 uses it to get a signed angle.

Given a triangle with corners P0,P1,P2P_0, P_1, P_2, two edges are e1=P1P0\vec{e_1} = P_1 - P_0 and e2=P2P0\vec{e_2} = P_2 - P_0, and the normal is:

n^=e1×e2e1×e2\hat{n} = \frac{\vec{e_1} \times \vec{e_2}}{|\vec{e_1} \times \vec{e_2}|}

The winding order of the corners decides which way the normal points. This is why mesh tools have a “flip normals” button, and why a model can look correct in Blender and inside-out in an engine.

For a character with forward f^\hat{f} and up u^\hat{u}, and a direction to a target d^\hat{d}:

s=(f^×d^)u^s = (\hat{f} \times \hat{d}) \cdot \hat{u}

The sign of ss says whether the target is to the left or the right. Cross to get a perpendicular, dot with up to collapse it to a signed number. Which sign means “left” depends on handedness, so determine it once by experiment and write it down.

Here is the payoff, and it is what look_at does internally.

You know where you want something to face. You need a complete orientation - three perpendicular axes. Two cross products get you there.

Start with the desired forward f^\hat{f} and a rough world up u^world\hat{u}_{world}, usually (0,1,0)(0, 1, 0):

r^=f^×u^worldf^×u^worldthenu^=r^×f^\hat{r} = \frac{\hat{f} \times \hat{u}_{world}}{|\hat{f} \times \hat{u}_{world}|} \qquad\text{then}\qquad \hat{u} = \hat{r} \times \hat{f}

The first cross gives a right vector perpendicular to both, which is horizontal because it is perpendicular to world up. The second gives a true up, perpendicular to forward and right - and it needs no normalizing, because the cross product of two perpendicular unit vectors already has length 1.

The three vectors r^,u^,f^\hat{r}, \hat{u}, \hat{f} are now an orthonormal basis: mutually perpendicular, each of length 1. In the next module these become the columns of a rotation matrix, and the whole thing becomes one multiply.

Write the first cross the other way round, as u^world×f^\hat{u}_{world} \times \hat{f}, and you get a vector that is still perpendicular to both and still unit length. Every orthonormality check you can think of still passes. But with forward as Z-Z it points along X-X, which is the left, and the resulting triple is left-handed. Anything you orient with it comes out mirrored.

This is the most dangerous kind of mistake in this section, because the symptom is not an error or a NaN. It is a model that looks fine until you notice its text is backwards. The test that catches it is the determinant, not the lengths and angles - and it runs against the scene below every time this page is built.

If f^\hat{f} is parallel to u^world\hat{u}_{world} - looking straight up or straight down - then the first cross product is zero, normalizing it is a division by zero, and the orientation is undefined. This is not a bug in the maths; there genuinely is no way to decide which way is “right” when you are looking straight up.

Every engine hits this. It is the reason a third-person camera goes haywire at the poles, and the standard fix is to clamp the pitch to something like ±89\pm 89^\circ so the degenerate case never arrives. The camera section comes back to this.

That is buildBasis in the panel above, which returns null for the degenerate case rather than pretending. In Three.js, by hand and then with the built-in:

const f = new THREE.Vector3().subVectors(target, mesh.position).normalize();
if (Math.abs(f.dot(new THREE.Vector3(0, 1, 0))) > 0.999) {
// Looking straight up or down: there is no "right". Bail out.
} else {
const r = new THREE.Vector3()
.crossVectors(f, new THREE.Vector3(0, 1, 0))
.normalize();
const u = new THREE.Vector3().crossVectors(r, f);
// r, u, f are now an orthonormal basis. In Part 2 they become matrix columns.
}
// Or let the library do it, with the same restriction:
mesh.lookAt(target);

Three.js lookAt uses object.up (default (0, 1, 0)) as its reference. Give it a target directly overhead and the result is undefined rather than an error - it fails quietly, which is worse than failing loudly, so guard it yourself.

Compute a×b\vec{a} \times \vec{b} for a=(2,0,0)\vec{a} = (2, 0, 0) and b=(0,3,0)\vec{b} = (0, 3, 0).

x=aybzazby=(0)(0)(0)(3)=0y=azbxaxbz=(0)(0)(2)(0)=0z=axbyaybx=(2)(3)(0)(0)=6\begin{aligned} x &= a_y b_z - a_z b_y = (0)(0) - (0)(3) = 0 \\ y &= a_z b_x - a_x b_z = (0)(0) - (2)(0) = 0 \\ z &= a_x b_y - a_y b_x = (2)(3) - (0)(0) = 6 \end{aligned}

So the result is (0,0,6)(0, 0, 6): perpendicular to both, as expected, with length 6. And 6 is exactly the area of a 2×32 \times 3 rectangle, which is the parallelogram the two vectors span. The area interpretation is not a metaphor.

In a left-handed frame the same (0,0,6)(0, 0, 6) points the opposite way in space.

One triangle, two normals, decided by corner order
The code that draws it src/lib/gamedev/demos/winding.scene.ts
/**
 * A triangle whose normal flips when you list its corners the other way round.
 */
import * as THREE from "three";
import {
  corners,
  edges,
  normalFor,
  eyeFromAzimuth,
  frontFaces,
  CENTROID,
} from "./winding-shared.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(6, 6, 0x30363d, 0x21262d));

  const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
  const origin = new THREE.Vector3(...CENTROID);

  // One geometry, two meshes. Each material draws only the side it owns, so the colour
  // under your eye tells you which face you are looking at.
  const geom = new THREE.BufferGeometry();
  geom.setAttribute(
    "position",
    new THREE.BufferAttribute(new Float32Array(9), 3),
  );
  const face = (colour: number, side: THREE.Side) => {
    const m = new THREE.Mesh(
      geom,
      new THREE.MeshBasicMaterial({
        color: colour,
        side,
        transparent: true,
        opacity: 0.8,
      }),
    );
    m.position.y = 0.01; // off the grid, or the two surfaces flicker against each other
    scene.add(m);
    return m;
  };
  face(0x7ee787, THREE.FrontSide);
  face(0xff7b72, THREE.BackSide);

  const normal = new THREE.ArrowHelper(
    new THREE.Vector3(0, 1, 0),
    origin,
    2.2,
    0xf0883e,
    0.35,
    0.18,
  );
  const e1 = new THREE.ArrowHelper(
    new THREE.Vector3(1, 0, 0),
    new THREE.Vector3(0, 0.03, 0),
    1,
    0x58a6ff,
    0.28,
    0.15,
  );
  const e2 = new THREE.ArrowHelper(
    new THREE.Vector3(0, 0, -1),
    new THREE.Vector3(0, 0.03, 0),
    1,
    0xd2a8ff,
    0.28,
    0.15,
  );
  scene.add(normal, e1, e2);

  const show = addReadout(el);
  const flipped = addCheckbox(
    el,
    "List the corners the other way round",
    false,
    draw,
  );
  const view = addSlider(el, "View angle", 0, 359, 35, draw);

  function draw() {
    const [a, b, c] = corners(flipped());

    // The vertex order in the buffer *is* the winding. Nothing else changes.
    const pos = geom.getAttribute("position") as THREE.BufferAttribute;
    pos.set(new Float32Array([...a, ...b, ...c]));
    pos.needsUpdate = true;
    geom.computeBoundingSphere();

    const n = normalFor(flipped());
    normal.setDirection(new THREE.Vector3(...n));

    const edge = edges(flipped());
    for (const [arrow, v] of [
      [e1, edge.e1],
      [e2, edge.e2],
    ] as const) {
      arrow.setDirection(new THREE.Vector3(...v).normalize());
      arrow.setLength(Math.hypot(...v), 0.28, 0.15);
    }

    const eye = eyeFromAzimuth(view());
    camera.position.set(...(eye as [number, number, number]));
    camera.lookAt(origin);

    show(
      frontFaces(n, eye, CENTROID)
        ? "you are looking at the front (green)"
        : "you are looking at the back (red)",
    );
    renderer.render(scene, camera);
  }

  draw();

  // Both controls are discrete, so a frame per input is enough and nothing animates.
  return () => renderer.dispose();
};

export default mount;

Tick List the corners the other way round. The shape does not move - same three points, same size, same place - but the orange arrow spins to point the other way and the surface changes from green to red.

Green means you are looking at the triangle’s front, red at its back. Nothing about the geometry decided that. Only the order the three corners were listed in.

Now drag the view angle right round. The orange arrow stays perpendicular to both edges from every position, and the colour never changes as you orbit. Perpendicular is a fact about the vectors, not about where you are standing.

This is the entire reason 3D tools have a “flip normals” button, and the reason an imported model sometimes renders inside-out: some exporter, somewhere, listed the corners backwards.

Move the orange target and the teal camera turns to face it. Two cross products are doing all of the turning.

A camera that turns to face whatever you point it at
The code that draws it src/lib/gamedev/demos/lookat.scene.ts
/**
 * A camera on a post that turns to look at a target. This is `lookAt`, built by hand.
 */
import * as THREE from "three";
import { buildBasis } from "../cross.ts";
import { targetAt } from "./lookat-shared.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;
  scene.add(new THREE.GridHelper(8, 8, 0x30363d, 0x21262d));

  const view = new THREE.PerspectiveCamera(44, width / height, 0.1, 100);
  view.position.set(5.2, 3.8, 6);
  view.lookAt(0, 0.4, 0);

  // World up, drawn faintly. It is the second input to the first cross product, and the
  // reason "right" comes out horizontal.
  scene.add(
    new THREE.Line(
      new THREE.BufferGeometry().setFromPoints([
        new THREE.Vector3(0, 0, 0),
        new THREE.Vector3(0, 3.6, 0),
      ]),
      new THREE.LineBasicMaterial({
        color: 0x7ee787,
        transparent: true,
        opacity: 0.25,
      }),
    ),
  );

  // The thing doing the looking. A cone tipped to point along its local -Z.
  const body = new THREE.Mesh(
    new THREE.ConeGeometry(0.3, 1.3, 20),
    new THREE.MeshBasicMaterial({ color: 0x39d3c3 }),
  );
  body.rotation.x = -Math.PI / 2;
  body.position.z = -0.65;
  const camera = new THREE.Group();
  camera.add(body);
  scene.add(camera);

  const arrow = (colour: number) => {
    const a = new THREE.ArrowHelper(
      new THREE.Vector3(1, 0, 0),
      new THREE.Vector3(),
      1.7,
      colour,
      0.26,
      0.14,
    );
    scene.add(a);
    return a;
  };
  const rightArrow = arrow(0x58a6ff);
  const upArrow = arrow(0x7ee787);
  const fwdArrow = arrow(0xffffff);

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

  const sight = new THREE.Line(
    new THREE.BufferGeometry(),
    new THREE.LineDashedMaterial({
      color: 0xf0883e,
      dashSize: 0.2,
      gapSize: 0.16,
    }),
  );
  scene.add(sight);

  const show = addReadout(el);
  const bearing = addSlider(el, "Target bearing", -180, 180, 40, draw);
  const elevation = addSlider(el, "Target height", -90, 90, 20, draw);

  function draw() {
    const p = targetAt(bearing(), elevation());
    target.position.set(p[0], p[1], p[2]);
    sight.geometry.setFromPoints([
      new THREE.Vector3(),
      new THREE.Vector3(...p),
    ]);
    sight.computeLineDistances();

    // Two cross products turn "look there" into a full orientation - or report that
    // there isn't one.
    const b = buildBasis(p);

    if (b === null) {
      // The target is straight up or straight down, so world up and forward are the same
      // line and there is no perpendicular to call "right".
      (body.material as THREE.MeshBasicMaterial).color.setHex(0x6e7681);
      for (const a of [rightArrow, upArrow, fwdArrow]) a.visible = false;
      show("no orientation exists here: every direction is equally 'right'");
      renderer.render(scene, view);
      return;
    }

    (body.material as THREE.MeshBasicMaterial).color.setHex(0x39d3c3);
    for (const a of [rightArrow, upArrow, fwdArrow]) a.visible = true;

    rightArrow.setDirection(new THREE.Vector3(...b.right));
    upArrow.setDirection(new THREE.Vector3(...b.up));
    fwdArrow.setDirection(new THREE.Vector3(...b.forward));

    // The three vectors become the columns of a rotation matrix. Local +Z is backward,
    // which is why forward is negated here.
    camera.quaternion.setFromRotationMatrix(
      new THREE.Matrix4().makeBasis(
        new THREE.Vector3(...b.right),
        new THREE.Vector3(...b.up),
        new THREE.Vector3(...b.forward).negate(),
      ),
    );

    const n = (v: number) => (Math.abs(v) < 5e-3 ? "0.00" : v.toFixed(2));
    show(
      `forward (${b.forward.map(n).join(", ")})` +
        `    right stays level: its height is ${n(b.right[1])}`,
    );
    renderer.render(scene, view);
  }

  draw();

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

export default mount;

Start with the blue arrow. It is the camera’s right, and it stays flat on the ground no matter where you put the target - even with the target high in the air. That is not a coincidence, it is what the first cross product guarantees. Crossing forward with the faint green world-up line gives a vector perpendicular to both, and anything perpendicular to “up” is level.

Then the green arrow. That is the camera’s own up, and it tilts. It is not the world’s up. Cross right with forward and you get the up that belongs to this orientation - the one that keeps the picture level when the camera is looking upward at something.

Three perpendicular arrows, from one direction and two cross products. Those three become the columns of a rotation matrix in Part 2, and then the whole thing is a single multiply.

Now drag the height slider all the way to the top

Section titled “Now drag the height slider all the way to the top”

The arrows vanish and the camera goes grey. It has not crashed - there is genuinely nothing to compute.

With the target directly overhead, forward is world up. The two vectors you were crossing have become the same line, so they span no plane and there is no perpendicular to call “right”. Ask which way is right while looking straight up and the honest answer is that every horizontal direction is equally valid.

So buildBasis returns null instead of inventing one. Skip that guard and you get [NaN, NaN, NaN] handed straight into your camera transform without a complaint, and the view disappears with no error to explain why.

Every engine has this hole. Nobody fills it, because it cannot be filled - they avoid it instead, by clamping camera pitch to about ±89\pm 89^\circ. One degree short of the top still works fine, which you can check by nudging the slider back down. That clamp is the entire reason a third-person camera stops just before looking straight down.

Everything above is plain arrays so you can see the arithmetic, but you will not write it that way for long. The equivalents:

// Three.js. crossVectors writes into the receiver rather than allocating.
const c = new THREE.Vector3().crossVectors(a, b);
c.length(); // the parallelogram area
a.angleTo(b); // radians, already clamped for you
// A whole basis, without building it by hand:
const m = new THREE.Matrix4().lookAt(eye, target, up);
const q = new THREE.Quaternion().setFromRotationMatrix(m);

Matrix4.lookAt is the buildBasis from this section, and it fails the same way at the pole - so the pitch clamp is still yours to write. Knowing what it does inside is what lets you predict that rather than discover it.

  • Lighting and shading, since every surface normal in every mesh came from a cross product at some point.
  • look_at and camera orientation, which is the basis construction above.
  • Turning decisions in AI - the left/right sign test tells a steering behaviour which way to rotate.
  • Torque and angular physics, where τ=r×F\vec{\tau} = \vec{r} \times \vec{F} is literally a cross product.
  • Backface culling and winding order, which decides whether a triangle is drawn at all.
  • Quaternions, in the next module, whose vector part is built from a cross product.