Introduction

When animators lay out a camera fly-through or game designers plot a racing line, they need a curve that is smooth and obedient: it must pass exactly through every waypoint they place, with no abrupt kinks at the joins.

That is precisely what Catmull-Rom splines deliver. Invented by Edwin Catmull and Raphael Rom in 1974, they belong to the family of interpolating splines — as opposed to the approximating Bézier curves, which only approach their control points without ever touching them. Every point you place on a Catmull-Rom path is a point the curve actually visits.

The secret ingredient is C1C^1 continuity: not only does the curve flow without breaks, but its tangent direction (the velocity vector) changes smoothly at every join. The resulting motion feels natural because it matches how a physical object — a camera, a car, a roller coaster — would actually travel through those positions.

Try It

Drag the waypoints on the canvas below. The curve passes through every colored dot — not just near them. Add a new point by clicking an empty area, or remove one by right-clicking it.

<!-- Toolbar + canvas for the Catmull-Rom interactive demo -->
<div class="toolbar">
  <button id="btn-tangents" type="button" class="toggle" aria-pressed="false">Show tangents</button>
  <button id="btn-reset" type="button" class="ghost">Reset</button>
  <span class="hint-label">Click to add · right-click to remove · drag to move</span>
</div>
<canvas id="canvas" width="560" height="340" aria-label="Interactive Catmull-Rom spline canvas"></canvas>
<div class="status" id="status">Drag a point or click to add one.</div>
/* Styles for the Catmull-Rom demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fa; color: #1d2b3a; }
.toolbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; padding: .5rem 0 .4rem; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border-radius: 7px;
         border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.toggle.active { background: #2a7d4f; border-color: #2a7d4f; }
.hint-label { font-size: .82rem; color: #556; margin-left: .25rem; }
canvas { display: block; border-radius: 10px; background: #fff;
         border: 1px solid #dde3ea; cursor: crosshair; touch-action: none; max-width: 100%; }
.status { font-size: .88rem; color: #556; margin-top: .4rem; min-height: 1.3em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Catmull-Rom spline interactive demo */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const btnTangents = document.getElementById('btn-tangents');
const btnReset = document.getElementById('btn-reset');

// Default waypoints spread across the canvas
let pts = [
  { x: 80,  y: 220 },
  { x: 170, y: 100 },
  { x: 280, y: 250 },
  { x: 380, y: 90  },
  { x: 480, y: 200 }
];

let showTangents = false;
let dragging = null; // index of the point being dragged, or null
const RADIUS = 9;    // hit-test + draw radius for control points
const STEPS = 60;    // line segments drawn per cubic segment

/* Evaluate the Catmull-Rom cubic at parameter t in [0,1] for four control points */
function catmullRomPoint(p0, p1, p2, p3, t) {
  const t2 = t * t, t3 = t2 * t;
  return {
    x: 0.5 * ((2*p1.x) + (-p0.x + p2.x)*t + (2*p0.x - 5*p1.x + 4*p2.x - p3.x)*t2 + (-p0.x + 3*p1.x - 3*p2.x + p3.x)*t3),
    y: 0.5 * ((2*p1.y) + (-p0.y + p2.y)*t + (2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t2 + (-p0.y + 3*p1.y - 3*p2.y + p3.y)*t3)
  };
}

/* Evaluate the derivative of the Catmull-Rom cubic at parameter t */
function catmullRomTangent(p0, p1, p2, p3, t) {
  const t2 = t * t;
  return {
    x: 0.5 * ((-p0.x + p2.x) + 2*(2*p0.x - 5*p1.x + 4*p2.x - p3.x)*t + 3*(-p0.x + 3*p1.x - 3*p2.x + p3.x)*t2),
    y: 0.5 * ((-p0.y + p2.y) + 2*(2*p0.y - 5*p1.y + 4*p2.y - p3.y)*t + 3*(-p0.y + 3*p1.y - 3*p2.y + p3.y)*t2)
  };
}

/* Add phantom end-points so the curve passes through the first and last real points */
function phantom(pts) {
  const n = pts.length;
  const p0 = { x: 2*pts[0].x - pts[1].x, y: 2*pts[0].y - pts[1].y };
  const pn = { x: 2*pts[n-1].x - pts[n-2].x, y: 2*pts[n-1].y - pts[n-2].y };
  return [p0, ...pts, pn];
}

function draw() {
  const W = canvas.width, H = canvas.height;
  ctx.clearRect(0, 0, W, H);

  if (pts.length < 2) {
    // Not enough points — show a placeholder message
    ctx.fillStyle = '#999';
    ctx.font = '14px system-ui';
    ctx.textAlign = 'center';
    ctx.fillText('Add at least 2 points to draw the spline.', W / 2, H / 2);
    return;
  }

  const ext = phantom(pts);

  /* Trace the full spline by iterating over all segments */
  ctx.beginPath();
  ctx.strokeStyle = '#1d6fba';
  ctx.lineWidth = 2.5;
  ctx.lineJoin = 'round';
  let first = true;
  for (let i = 0; i < ext.length - 3; i++) {
    const p0 = ext[i], p1 = ext[i+1], p2 = ext[i+2], p3 = ext[i+3];
    for (let s = 0; s <= STEPS; s++) {
      const t = s / STEPS;
      const pt = catmullRomPoint(p0, p1, p2, p3, t);
      if (first) { ctx.moveTo(pt.x, pt.y); first = false; }
      else ctx.lineTo(pt.x, pt.y);
    }
  }
  ctx.stroke();

  /* Overlay velocity arrows at t=0, 0.5, 1 for each segment */
  if (showTangents) {
    ctx.strokeStyle = '#e07c2a';
    ctx.lineWidth = 1.4;
    for (let i = 0; i < ext.length - 3; i++) {
      const p0 = ext[i], p1 = ext[i+1], p2 = ext[i+2], p3 = ext[i+3];
      for (const t of [0, 0.5, 1]) {
        const pt = catmullRomPoint(p0, p1, p2, p3, t);
        const tg = catmullRomTangent(p0, p1, p2, p3, t);
        const len = Math.sqrt(tg.x*tg.x + tg.y*tg.y) || 1;
        const scale = 28 / len;
        ctx.beginPath();
        ctx.moveTo(pt.x, pt.y);
        ctx.lineTo(pt.x + tg.x*scale, pt.y + tg.y*scale);
        ctx.stroke();
        // Draw the arrowhead triangle at the tip of each tangent arrow
        const ax = pt.x + tg.x*scale, ay = pt.y + tg.y*scale;
        const angle = Math.atan2(tg.y, tg.x);
        ctx.beginPath();
        ctx.moveTo(ax, ay);
        ctx.lineTo(ax - 6*Math.cos(angle-0.4), ay - 6*Math.sin(angle-0.4));
        ctx.lineTo(ax - 6*Math.cos(angle+0.4), ay - 6*Math.sin(angle+0.4));
        ctx.closePath();
        ctx.fillStyle = '#e07c2a';
        ctx.fill();
      }
    }
  }

  /* Draw colored numbered control-point dots on top */
  const colors = ['#e63946','#2a7d4f','#1d6fba','#9b2dca','#c97b22','#0a7d6e'];
  pts.forEach((p, i) => {
    ctx.beginPath();
    ctx.arc(p.x, p.y, RADIUS, 0, 2*Math.PI);
    ctx.fillStyle = colors[i % colors.length];
    ctx.fill();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    ctx.stroke();
    // White index number inside each dot
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 9px system-ui';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(i + 1, p.x, p.y);
  });

  statusEl.textContent = i18nFmt('%n% waypoints — curve passes through all of them.', { n: pts.length });
}

// Return the index of the point under (mx, my), or -1
function hitTest(mx, my) {
  for (let i = pts.length - 1; i >= 0; i--) {
    const dx = pts[i].x - mx, dy = pts[i].y - my;
    if (dx*dx + dy*dy <= (RADIUS + 4)*(RADIUS + 4)) return i;
  }
  return -1;
}

function getPos(e) {
  const r = canvas.getBoundingClientRect();
  const src = e.touches ? e.touches[0] : e;
  return { x: (src.clientX - r.left) * (canvas.width / r.width),
           y: (src.clientY - r.top)  * (canvas.height / r.height) };
}

// Left-click drag an existing point or add a new one
canvas.addEventListener('mousedown', e => {
  if (e.button === 2) return;
  const pos = getPos(e);
  const hit = hitTest(pos.x, pos.y);
  if (hit >= 0) { dragging = hit; }
  else { pts.push({ x: pos.x, y: pos.y }); draw(); }
});

// Right-click removes the point under the cursor (keep at least 2)
canvas.addEventListener('contextmenu', e => {
  e.preventDefault();
  const pos = getPos(e);
  const hit = hitTest(pos.x, pos.y);
  if (hit >= 0 && pts.length > 2) { pts.splice(hit, 1); draw(); }
});

canvas.addEventListener('mousemove', e => {
  if (dragging === null) return;
  const pos = getPos(e);
  pts[dragging] = { x: pos.x, y: pos.y };
  draw();
});

canvas.addEventListener('mouseup', () => { dragging = null; });
canvas.addEventListener('mouseleave', () => { dragging = null; });

// Mirror mouse interactions for touch screens
canvas.addEventListener('touchstart', e => {
  e.preventDefault();
  const pos = getPos(e);
  const hit = hitTest(pos.x, pos.y);
  if (hit >= 0) dragging = hit;
  else { pts.push({ x: pos.x, y: pos.y }); draw(); }
}, { passive: false });
canvas.addEventListener('touchmove', e => {
  e.preventDefault();
  if (dragging === null) return;
  const pos = getPos(e);
  pts[dragging] = { x: pos.x, y: pos.y };
  draw();
}, { passive: false });
canvas.addEventListener('touchend', () => { dragging = null; });

btnTangents.addEventListener('click', () => {
  showTangents = !showTangents;
  btnTangents.classList.toggle('active', showTangents);
  btnTangents.setAttribute('aria-pressed', showTangents);
  draw();
});

btnReset.addEventListener('click', () => {
  pts = [
    { x: 80,  y: 220 },
    { x: 170, y: 100 },
    { x: 280, y: 250 },
    { x: 380, y: 90  },
    { x: 480, y: 200 }
  ];
  showTangents = false;
  btnTangents.classList.remove('active');
  btnTangents.setAttribute('aria-pressed', false);
  draw();
});

draw();
Result
Console

Notice how the curve never has a sharp corner: each segment flows into the next with a matching slope. That smooth handoff is C1C^1 continuity in action. Toggle the tangent arrows to see the velocity vectors that make it work.

The Real Complexity

Under the hood, each segment of a Catmull-Rom spline is a cubic Hermite curve — a polynomial of degree 3 defined by two endpoints and two tangent vectors.

Given four consecutive control points Pi−1, Pi, Pi+1, Pi+2P_{i-1},\, P_i,\, P_{i+1},\, P_{i+2}, the segment from PiP_i to Pi+1P_{i+1} is drawn by setting the tangent at each end to:

mi=Pi+1−Pi−12,mi+1=Pi+2−Pi2\mathbf{m}_i = \frac{P_{i+1} - P_{i-1}}{2}, \qquad \mathbf{m}_{i+1} = \frac{P_{i+2} - P_i}{2}

With those two tangents locked in, the segment is a single cubic that can be written as a matrix product:

q(t)=[1tt2t3][0100−1201201−522−12−1232−3212][Pi−1PiPi+1Pi+2]\mathbf{q}(t) = \begin{bmatrix}1 & t & t^2 & t^3\end{bmatrix} \begin{bmatrix}0&1&0&0\\-\tfrac{1}{2}&0&\tfrac{1}{2}&0\\1&-\tfrac{5}{2}&2&-\tfrac{1}{2}\\-\tfrac{1}{2}&\tfrac{3}{2}&-\tfrac{3}{2}&\tfrac{1}{2}\end{bmatrix} \begin{bmatrix}P_{i-1}\\P_i\\P_{i+1}\\P_{i+2}\end{bmatrix}

where t∈[0,1]t \in [0, 1] walks from PiP_i to Pi+1P_{i+1}.

Algorithmic cost: for nn control points you get n−3n - 3 interior segments (with two phantom end-points, or n−1n - 1 segments with clamped ends). Each segment needs exactly four multiplications of a 4×44 \times 4 matrix — the total work is O(n)O(n).

Continuity: at every interior point the curve has matching position (C0C^0) and matching first derivative (C1C^1), but the second derivative can jump (G2G^2 is not guaranteed). For smoother results, the centripetal variant re-parameterises tt by ∥Pi+1−Pi∥\sqrt{\lVert P_{i+1} - P_i \rVert}, eliminating cusps and self-intersections that can appear with unevenly spaced points.

Where It Matters

Catmull-Rom splines appear wherever a system needs to move smoothly through a set of prescribed positions:

  • Film and game animation: camera rigs follow Catmull-Rom paths so cinematographers can place keyframes and trust the curve to interpolate a natural motion. Disney, Pixar, and virtually every 3-D game engine use them under the hood.
  • Racing-line AI: in driving games and autonomous vehicles, the optimal path through a series of waypoints is often a Catmull-Rom spline — it guarantees the vehicle passes through each gate without a jerk.
  • Robotics and drone flight: a quadrotor given a list of checkpoints plans its trajectory as a Catmull-Rom path, giving smooth velocity profiles that keep the flight stable.
  • Map routing and GPS: some navigation systems smooth the sequence of road-network nodes into a Catmull-Rom curve before rendering the turn-by-turn polyline, removing the visual staircase effect.
  • Font design and vector graphics: editors like Inkscape allow toggling between Bézier and Catmull-Rom handles so designers can lock points to the curve and let tangents compute themselves.

The common thread: whenever you have a list of positions and need a physically plausible motion through all of them, Catmull-Rom is often the first tool to reach for. See also convex hull and closest pair for related geometric algorithms.

Conclusion

Catmull-Rom splines solve a deceptively simple problem — connect a list of waypoints with a smooth curve — in the most elegant way: derive the tangents automatically from the neighbors. No manual handle-pulling, no unsatisfied points.

The result is a cubic curve that is C1C^1-continuous everywhere, computed in O(n)O(n) time, and visually natural even for novice users. Fifty years after Catmull and Rom published their idea, it still ships in every major game engine, 3-D renderer, and animation tool on the planet.

The next time a camera glides through a scene, or a racing car hugs a series of gates, or a drone threads a sequence of checkpoints, there is a very good chance that four neighbors and a 4×44 \times 4 matrix are quietly doing the work.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/catmull-rom-splines/Content licensed under CC BY-NC 4.0.