Skip to content

Bezier Curves

That a Bezier curve is repeated lerp and nothing else - no new machinery, just Section 4.2’s smallest function applied over and over. That the construction hands you the tangent for free, with no calculus. What the control points actually control, and the one property that makes them safe to hand to a designer. Then the three grades of smooth when you join two curves, only two of which are any good. And a jump arc, which is a quadratic with one control point in a slightly surprising place.

Take three points. Lerp between the first pair, lerp between the second pair, then lerp between those two results. That final point is on a quadratic Bezier.

Take four points and do it again - three lerps, then two, then one. That is a cubic Bezier, the one used almost everywhere.

n points    n1        1n \text{ points} \;\longrightarrow\; n-1 \;\longrightarrow\; \cdots \;\longrightarrow\; 1

This is de Casteljau’s algorithm, and it is worth taking as the definition rather than the polynomial that follows from it. Everything useful about Bezier curves is easier to see this way.

Below, the four orange dots are the control points. The dashed line joins them, the blue segments are the first round of lerps, and the purple segment is the second. The teal dot is where they end up.

A cubic Bezier, and the repeated lerps that build it
Drag a control point, or pick one with the buttons and use the x and y sliders.
The code that draws it src/lib/gamedev/demos/bezier.scene.ts
/**
 * A cubic Bezier with movable control points, and de Casteljau's repeated lerps drawn live.
 */
import * as THREE from "three";
import { bezierAt, deCasteljauLevels, tangentFromLevels } from "../bezier.ts";
import type { Vec2 } from "../matrices.ts";
import { makeCanvas, addSlider, addReadout, addButtonRow } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const HALF_W = 2.6;
const LIMIT = 2.4;

const CONTROL = 0xf0883e;
const LEVEL1 = 0x58a6ff;
const LEVEL2 = 0xd2a8ff;
const CURVE = 0x39d3c3;

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

  const halfH = (HALF_W * height) / width;
  const scene = new THREE.Scene();
  scene.background = background;
  const camera = new THREE.OrthographicCamera(
    -HALF_W,
    HALF_W,
    halfH,
    -halfH,
    0.1,
    10,
  );
  camera.position.z = 5;

  const points: Vec2[] = [
    { x: -2, y: -1 },
    { x: -1.2, y: 1.6 },
    { x: 1.1, y: -1.4 },
    { x: 2, y: 0.9 },
  ];

  const addLine = (color: number, dashed = false) => {
    const geom = new THREE.BufferGeometry();
    const mesh = new THREE.Line(
      geom,
      dashed
        ? new THREE.LineDashedMaterial({ color, dashSize: 0.09, gapSize: 0.07 })
        : new THREE.LineBasicMaterial({ color }),
    );
    scene.add(mesh);
    return (pts: Vec2[]) => {
      geom.setFromPoints(pts.map((p) => new THREE.Vector3(p.x, p.y, 0)));
      if (dashed) mesh.computeLineDistances();
    };
  };

  const addDots = (count: number, color: number, r: number) => {
    const dots: THREE.Mesh[] = [];
    for (let i = 0; i < count; i += 1) {
      const m = new THREE.Mesh(
        new THREE.CircleGeometry(r, 16),
        new THREE.MeshBasicMaterial({ color }),
      );
      scene.add(m);
      dots.push(m);
    }
    return (pts: Vec2[]) =>
      dots.forEach((d, i) => {
        d.visible = i < pts.length;
        if (i < pts.length) d.position.set(pts[i].x, pts[i].y, 0);
      });
  };

  const hull = addLine(0x545d68, true);
  const level1 = addLine(LEVEL1);
  const level2 = addLine(LEVEL2);
  const curveLine = addLine(CURVE);
  const tangentLine = addLine(LEVEL2);

  const controlDots = addDots(4, CONTROL, 0.075);
  const level1Dots = addDots(3, LEVEL1, 0.05);
  const level2Dots = addDots(2, LEVEL2, 0.05);
  const onCurve = addDots(1, CURVE, 0.09);
  const selectedRing = new THREE.Mesh(
    new THREE.RingGeometry(0.11, 0.14, 20),
    new THREE.MeshBasicMaterial({ color: CONTROL }),
  );
  scene.add(selectedRing);

  let selected = 1;
  const show = addReadout(el);

  const setActive = addButtonRow(
    el,
    points.map((_, i) => ({
      label: `P${i}`,
      apply: () => {
        selected = i;
        xs.set(points[i].x);
        ys.set(points[i].y);
        draw();
      },
    })),
  );

  const xs = addSlider(
    el,
    "selected point x",
    -LIMIT,
    LIMIT,
    points[1].x,
    moveSelected,
    "",
    0.1,
  );
  const ys = addSlider(
    el,
    "selected point y",
    -LIMIT,
    LIMIT,
    points[1].y,
    moveSelected,
    "",
    0.1,
  );
  const t = addSlider(el, "t along the curve", 0, 1, 0.42, draw, "", 0.01);

  function moveSelected() {
    points[selected] = { x: xs(), y: ys() };
    draw();
  }

  // Pointer dragging, which is the direct way to feel what a control point does.
  const canvas = renderer.domElement;
  let dragging = false;

  const toWorld = (event: PointerEvent): Vec2 => {
    const rect = canvas.getBoundingClientRect();
    return {
      x: ((event.clientX - rect.left) / rect.width) * 2 * HALF_W - HALF_W,
      y: -(((event.clientY - rect.top) / rect.height) * 2 * halfH - halfH),
    };
  };

  const onDown = (event: PointerEvent) => {
    const w = toWorld(event);
    let best = 0;
    let bestDist = Infinity;
    points.forEach((p, i) => {
      const d = Math.hypot(p.x - w.x, p.y - w.y);
      if (d < bestDist) {
        bestDist = d;
        best = i;
      }
    });
    if (bestDist > 0.6) return;
    selected = best;
    dragging = true;
    canvas.setPointerCapture(event.pointerId);
    onMove(event);
  };

  const onMove = (event: PointerEvent) => {
    if (!dragging) return;
    const w = toWorld(event);
    points[selected] = {
      x: Math.max(-LIMIT, Math.min(LIMIT, w.x)),
      y: Math.max(-LIMIT, Math.min(LIMIT, w.y)),
    };
    xs.set(points[selected].x);
    ys.set(points[selected].y);
    draw();
  };

  const onUp = () => {
    dragging = false;
  };

  canvas.addEventListener("pointerdown", onDown);
  canvas.addEventListener("pointermove", onMove);
  canvas.addEventListener("pointerup", onUp);
  canvas.addEventListener("pointercancel", onUp);

  function draw() {
    const levels = deCasteljauLevels(points, t());

    hull(points);
    controlDots(points);
    level1([...levels[1]]);
    level1Dots(levels[1]);
    level2([...levels[2]]);
    level2Dots(levels[2]);

    const path: Vec2[] = [];
    for (let i = 0; i <= 120; i += 1) path.push(bezierAt(points, i / 120));
    curveLine(path);

    const here = levels[3][0];
    onCurve([here]);
    selectedRing.position.set(points[selected].x, points[selected].y, 0);

    // The purple segment above is already the tangent direction. Draw it from the curve too.
    const tan = tangentFromLevels(levels);
    const len = Math.hypot(tan.x, tan.y) || 1;
    tangentLine([
      here,
      { x: here.x + (tan.x / len) * 1.1, y: here.y + (tan.y / len) * 1.1 },
    ]);

    show(
      `t ${t().toFixed(2)}  \u00B7  moving P${selected}  \u00B7  ` +
        `the purple segment is the tangent, times ${points.length - 1}`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => {
    canvas.removeEventListener("pointerdown", onDown);
    canvas.removeEventListener("pointermove", onMove);
    canvas.removeEventListener("pointerup", onUp);
    canvas.removeEventListener("pointercancel", onUp);
    renderer.dispose();
  };
};

export default mount;

Drag a control point and watch. Then drag the t slider and watch the construction slide along.

Three things worth noticing while you do.

The curve touches the first and last control point exactly, and no others. The middle two pull the curve towards themselves without ever being reached. That is why editors call them handles rather than points on the path.

The curve never leaves the region its control points span. This is the convex hull property, and it is the reason a Bezier is safe: you can hand the handles to a designer knowing the path cannot wander somewhere unplanned. The build check samples a thousand points and asserts none of them escapes the control points’ box.

The purple segment is the tangent. Not approximately - exactly, up to a factor of the degree. More on that in a moment.

Expand all that lerping algebraically and the lerps collapse into four weights on the four control points:

B(t)=(1t)3P0+3(1t)2tP1+3(1t)t2P2+t3P3B(t) = \underbrace{(1-t)^3}_{P_0} + \underbrace{3(1-t)^2 t}_{P_1} + \underbrace{3(1-t)t^2}_{P_2} + \underbrace{t^3}_{P_3}

These are the Bernstein basis functions, and the useful fact about them is that they always sum to 1 and are never negative on [0,1][0, 1]. Which means every point on the curve is a weighted average of the control points - and that is precisely why the convex hull property holds. It is not a separate fact, it is the same fact stated twice.

Both forms are in the code, and the build check runs 401 values of tt through each and asserts they agree to 101210^{-12}. The polynomial is faster; de Casteljau is clearer and gives you more.

Here is the part that seems too convenient.

The last segment of the de Casteljau construction - the purple one - points along the curve’s direction of travel. Multiply its length by the degree and you have the derivative exactly:

B(t)=n(the final segment)B'(t) = n \cdot \left(\text{the final segment}\right)

So you can get the tangent without differentiating anything. Just run the construction you were already running and read off the last step.

This is checked three ways, because a claim that convenient deserves it: tangentFromLevels reads it off the construction, cubicTangent differentiates the polynomial, and a finite difference approximates it numerically. All three agree across 400 values of tt.

The derivative has a nice shape of its own. Differentiating a cubic Bezier gives a quadratic Bezier on the gaps between control points:

B(t)=Bezier(3(P1P0),  3(P2P1),  3(P3P2))B'(t) = \text{Bezier}\Big(3(P_1 - P_0),\; 3(P_2 - P_1),\; 3(P_3 - P_2)\Big)

One consequence you can see in the scene: at t=0t = 0 the tangent is 3(P1P0)3(P_1 - P_0), so the curve leaves P0P_0 heading straight at P1P_1. That is why dragging a handle rotates the end of the curve the way it does.

source Everything built on lerp2 src/lib/gamedev/bezier.ts 157 lines
/**
 * Bezier curves, built from nothing but repeated `lerp`.
 *
 * That is the whole idea and it is worth saying before any polynomials appear: take the control
 * points, lerp between each neighbouring pair to get one fewer point, and repeat until a single
 * point is left. That point is on the curve. The polynomial form is what falls out if you expand
 * the algebra, but the repeated lerp - de Casteljau's algorithm - is the definition worth carrying.
 */
import { lerp } from "./interpolation.ts";
import type { Vec2 } from "./matrices.ts";

/** Lerp two points. Every function below is built on this and nothing else. */
export const lerp2 = (a: Vec2, b: Vec2, t: number): Vec2 => ({
  x: lerp(a.x, b.x, t),
  y: lerp(a.y, b.y, t),
});

/** One round of de Casteljau: `n` points become the `n - 1` points between them. */
export function deCasteljauStep(points: readonly Vec2[], t: number): Vec2[] {
  const out: Vec2[] = [];
  for (let i = 0; i + 1 < points.length; i += 1) {
    out.push(lerp2(points[i], points[i + 1], t));
  }
  return out;
}

/**
 * Every level of the construction, from the control points down to the single point on the curve.
 *
 * The scene draws the middle levels, because those lines are the algorithm made visible.
 */
export function deCasteljauLevels(
  points: readonly Vec2[],
  t: number,
): Vec2[][] {
  const levels: Vec2[][] = [points.slice()];
  while (levels[levels.length - 1].length > 1) {
    levels.push(deCasteljauStep(levels[levels.length - 1], t));
  }
  return levels;
}

/** The point on the curve, for any number of control points. */
export function bezierAt(points: readonly Vec2[], t: number): Vec2 {
  const levels = deCasteljauLevels(points, t);
  return levels[levels.length - 1][0];
}

/**
 * The last segment of the construction is **tangent to the curve**, which the scene draws.
 *
 * So de Casteljau hands you the direction of travel for free, with no derivative taken. For a
 * curve of degree `n` the tangent is `n` times that final segment.
 */
export function tangentFromLevels(levels: Vec2[][]): Vec2 {
  const degree = levels[0].length - 1;
  const last = levels[levels.length - 2];
  return {
    x: degree * (last[1].x - last[0].x),
    y: degree * (last[1].y - last[0].y),
  };
}

// ---- The polynomial form -----------------------------------------------------------------

/**
 * The four weights a cubic puts on its control points, known as the Bernstein basis.
 *
 * They always sum to 1, which is what makes the curve stay inside the shape its control points
 * span - it is a weighted average of them at every `t`.
 */
export function cubicWeights(t: number): [number, number, number, number] {
  const u = 1 - t;
  return [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
}

/** The same point as `bezierAt` on four control points, reached by expanding the algebra. */
export function cubicAt(p: readonly Vec2[], t: number): Vec2 {
  const w = cubicWeights(t);
  return {
    x: w[0] * p[0].x + w[1] * p[1].x + w[2] * p[2].x + w[3] * p[3].x,
    y: w[0] * p[0].y + w[1] * p[1].y + w[2] * p[2].y + w[3] * p[3].y,
  };
}

/**
 * The derivative of a cubic Bezier is a **quadratic Bezier** on the gaps between control points.
 *
 * Which is a pleasant fact rather than a coincidence: differentiating drops the degree by one and
 * leaves the same construction behind, so the tangent is a Bezier curve in its own right.
 */
export function cubicTangent(p: readonly Vec2[], t: number): Vec2 {
  const gap = (a: Vec2, b: Vec2): Vec2 => ({
    x: 3 * (b.x - a.x),
    y: 3 * (b.y - a.y),
  });
  return bezierAt([gap(p[0], p[1]), gap(p[1], p[2]), gap(p[2], p[3])], t);
}

// ---- Chaining ----------------------------------------------------------------------------

export type Cubic = readonly [Vec2, Vec2, Vec2, Vec2];

const near2 = (a: Vec2, b: Vec2, tol: number) =>
  Math.abs(a.x - b.x) < tol && Math.abs(a.y - b.y) < tol;

/** C0: the two halves actually touch. Without this there is a visible gap. */
export function meets(a: Cubic, b: Cubic, tol = 1e-9): boolean {
  return near2(a[3], b[0], tol);
}

/**
 * G1: the tangents point the same way, so there is no visible corner.
 *
 * Enough for something that only has to *look* smooth, and cheaper to author by hand.
 */
export function sameDirection(a: Cubic, b: Cubic, tol = 1e-9): boolean {
  const u = { x: a[3].x - a[2].x, y: a[3].y - a[2].y };
  const v = { x: b[1].x - b[0].x, y: b[1].y - b[0].y };
  const lu = Math.hypot(u.x, u.y);
  const lv = Math.hypot(v.x, v.y);
  if (lu < tol || lv < tol) return false;
  return (
    Math.abs((u.x * v.y - u.y * v.x) / (lu * lv)) < tol &&
    u.x * v.x + u.y * v.y > 0
  );
}

/**
 * C1: the tangents are identical, length included, so **speed** matches across the joint too.
 *
 * The difference matters for anything travelling the path rather than looking at it. A camera on a
 * G1-but-not-C1 join changes pace at the seam, which reads as a stumble.
 */
export function sameTangent(a: Cubic, b: Cubic, tol = 1e-9): boolean {
  return near2(
    { x: a[3].x - a[2].x, y: a[3].y - a[2].y },
    { x: b[1].x - b[0].x, y: b[1].y - b[0].y },
    tol,
  );
}

/**
 * A jump as a quadratic Bezier: launch, one control point, landing.
 *
 * The control point sits at **twice** the apex height, because a quadratic at its midpoint is
 * `(P0 + 2*P1 + P2) / 4` - the middle point only gets half the vote, so it has to reach twice as
 * high to pull the curve to the height you asked for. Section 7.1 derives the same arc from
 * gravity instead.
 */
export function jumpArc(distance: number, height: number): [Vec2, Vec2, Vec2] {
  return [
    { x: 0, y: 0 },
    { x: distance / 2, y: 2 * height },
    { x: distance, y: 0 },
  ];
}

One cubic can only bend so much. Real paths are several joined end to end, and the joint is where it goes wrong.

There are three grades, and the middle one is the trap.

One seam, three grades of smooth
The code that draws it src/lib/gamedev/demos/bezierjoin.scene.ts
/**
 * Two cubics joined three ways, with a dot sweeping across the seam.
 */
import * as THREE from "three";
import { bezierAt } from "../bezier.ts";
import type { Vec2 } from "../matrices.ts";
import {
  FIRST,
  JOINS,
  chainAt,
  seamSpeeds,
  secondFor,
  type Join,
} from "./join-shared.ts";
import { makeCanvas, addSlider, addReadout, addButtonRow } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const HALF_W = 2.7;
const FIRST_COLOR = 0x39d3c3;
const SECOND_COLOR = 0xf0883e;

const LABELS: Record<Join, string> = {
  broken: "Corner",
  g1: "Looks smooth",
  c1: "Actually smooth",
};

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

  const halfH = (HALF_W * height) / width;
  const scene = new THREE.Scene();
  scene.background = background;
  const camera = new THREE.OrthographicCamera(
    -HALF_W,
    HALF_W,
    halfH,
    -halfH,
    0.1,
    10,
  );
  camera.position.z = 5;

  const addLine = (color: number, dashed = false) => {
    const geom = new THREE.BufferGeometry();
    const mesh = new THREE.Line(
      geom,
      dashed
        ? new THREE.LineDashedMaterial({ color, dashSize: 0.08, gapSize: 0.06 })
        : new THREE.LineBasicMaterial({ color }),
    );
    scene.add(mesh);
    return (pts: Vec2[]) => {
      geom.setFromPoints(pts.map((p) => new THREE.Vector3(p.x, p.y, 0)));
      if (dashed) mesh.computeLineDistances();
    };
  };

  const dot = (color: number, r: number) => {
    const m = new THREE.Mesh(
      new THREE.CircleGeometry(r, 18),
      new THREE.MeshBasicMaterial({ color }),
    );
    scene.add(m);
    return m;
  };

  const firstLine = addLine(FIRST_COLOR);
  const secondLine = addLine(SECOND_COLOR);
  const handle = addLine(0x545d68, true);
  const seam = dot(0xd2a8ff, 0.07);
  const rider = dot(0x58a6ff, 0.1);

  // The first curve never changes, so draw it once.
  const firstPts: Vec2[] = [];
  for (let i = 0; i <= 100; i += 1) firstPts.push(bezierAt(FIRST, i / 100));
  firstLine(firstPts);
  seam.position.set(FIRST[3].x, FIRST[3].y, 0);

  let join: Join = "broken";
  const show = addReadout(el);
  const setActive = addButtonRow(
    el,
    JOINS.map((j, i) => ({
      label: LABELS[j],
      apply: () => {
        join = j;
        setActive(i);
        draw();
      },
    })),
  );
  const t = addSlider(
    el,
    "travel along both curves",
    0,
    1,
    0.5,
    draw,
    "",
    0.005,
  );

  function draw() {
    const second = secondFor(join);
    const pts: Vec2[] = [];
    for (let i = 0; i <= 100; i += 1) pts.push(bezierAt(second, i / 100));
    secondLine(pts);

    // The one control point that differs between the three cases.
    handle([FIRST[2], FIRST[3], second[1]]);

    const here = chainAt(join, t());
    rider.position.set(here.x, here.y, 0);
    setActive(JOINS.indexOf(join));

    const s = seamSpeeds(join);
    show(
      `${LABELS[join]}  \u00B7  speed into the seam ${s.leaving.toFixed(2)}, ` +
        `out of it ${s.entering.toFixed(2)}` +
        (Math.abs(s.entering - s.leaving) < 1e-9
          ? "  \u00B7  no jump"
          : "  \u00B7  jumps"),
    );
    renderer.render(scene, camera);
  }

  draw();

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

export default mount;

Only one control point differs between the three buttons - the second curve’s first handle. Everything else is held fixed.

GradeRequirementLooksTravels
C0the curves touchvisible cornerbadly
G1tangents point the same directionsmoothchanges pace
C1tangents are the same vector, length includedsmoothsmoothly

Switch between Looks smooth and Actually smooth. The curves are nearly indistinguishable - and the readout is not. The G1 join arrives at the seam with a speed of 3.193.19 and leaves it at 0.960.96, a drop to under a third, while looking perfectly continuous.

That is the whole point of the distinction. G1 is enough for something you look at. C1 is what you need for something you travel along. A camera on a G1 path visibly stumbles at every seam, and the geometry gives you no clue why.

The requirement is easy to state: for the tangents to match in length as well as direction, the handle leaving the joint must be the mirror of the handle arriving at it. Which is exactly what the “unify handles” button does in every vector editor and animation curve editor you have used.

A quadratic Bezier with the two ends on the ground is a parabola, which is the shape of a jump. So you can author a jump by placing three points rather than by tuning gravity.

There is one wrinkle worth knowing, and it is the reason this gets a value list.

A jump arc, and why its control point sits twice as high
The code src/lib/gamedev/demos/jumparc.ts
/** A jump as a quadratic Bezier, and where its one control point has to sit. */
import { bezierAt, jumpArc } from "../bezier.ts";
import type { Demo } from "./runner.ts";

const DISTANCE = 6;
const HEIGHT = 2;

const demo: Demo = (log) => {
  const arc = jumpArc(DISTANCE, HEIGHT);

  log(
    `jumpArc(${DISTANCE}, ${HEIGHT}) control point`,
    `(${arc[1].x}, ${arc[1].y})`,
    "twice the height you asked for",
  );
  log(
    "height at the midpoint",
    bezierAt(arc, 0.5).y,
    "which is the height you asked for",
  );
  log("launch point", `(${bezierAt(arc, 0).x}, ${bezierAt(arc, 0).y})`);
  log("landing point", `(${bezierAt(arc, 1).x}, ${bezierAt(arc, 1).y})`);

  // Where the peak actually is, found by sampling rather than assumed.
  let peak = -Infinity;
  let peakAt = 0;
  for (let i = 0; i <= 2000; i += 1) {
    const p = bezierAt(arc, i / 2000);
    if (p.y > peak) {
      peak = p.y;
      peakAt = i / 2000;
    }
  }
  log("highest point found by sampling", peak, `at t = ${peakAt}`);
};

export default demo;
jumpArc(6, 2) control point (3, 4) // twice the height you asked for
height at the midpoint 2 // which is the height you asked for
launch point (0, 0)
landing point (6, 0)
highest point found by sampling 2 // at t = 0.5

To get an apex of 2, the control point goes at height 4. Because at the midpoint a quadratic evaluates to

B(0.5)=P0+2P1+P24B(0.5) = \frac{P_0 + 2P_1 + P_2}{4}

so the middle point only gets half the vote. It has to reach twice as high to pull the curve where you asked.

The check confirms it for three different arcs, and finds the peak by sampling rather than assuming: exactly the requested height, at exactly t=0.5t = 0.5.

Section 7.1 builds the same arc from the other end - given a jump height and an airtime, it solves backwards for the gravity and launch velocity that produce them. Same curve, two ways in, depending on whether a designer is placing points or tuning feel.

  • Camera rails and cutscene paths, where the convex hull property is what stops a camera clipping through a wall you never expected it near.
  • Animation curve editors, which are cubic Beziers with the handles exposed - and the C1 pairing is the “smooth” toggle on each keyframe.
  • Fonts and vector art, which are almost entirely cubic Beziers with C1 joints.
  • UI motion, where CSS cubic-bezier(...) is a cubic with P0P_0 and P3P_3 pinned to the corners, so you only supply the two handles.
  • Jump arcs and thrown-object trajectories, either authored as here or derived from physics in Part 7.
  • Aiming reticles and grenade indicators, which draw the arc the projectile will follow.