wandres.dev
FORMAS Y RUTAS · El modelo de path

Curvas de Bézier: cuadráticas, cúbicas y la continuidad

Entender los puntos de control de las curvas de Bézier, saber colocarlos con intención y encadenar curvas sin que se vean los empalmes.

⏱ 18 min

Las curvas de Bézier son el vocabulario universal del gráfico vectorial: las usan las fuentes tipográficas, los programas de diseño, SVG, PDF y el canvas. Su definición es una interpolación polinómica que casi nadie necesita recordar, pero su comportamiento geométrico —la curva sale tirando hacia el primer control y llega tirando desde el último— es lo único que hace falta para colocarlos con intención.

🎯 Al terminar esta lección sabrás
  • Usar quadraticCurveTo y bezierCurveTo y predecir la forma resultante.
  • Explicar la propiedad de la envolvente convexa y las tangentes en los extremos.
  • Encadenar curvas con continuidad de tangente y saber por qué se ve el empalme si no.
  • Aproximar una circunferencia con Bézier y decir por qué la constante es la que es.

Cuadráticas: un solo control

ctx.quadraticCurveTo(cx, cy, x, y);

Traza una curva desde el punto actual hasta (x, y), usando (cx, cy) como único punto de control. La curva no pasa por el control: sale del punto actual en dirección al control y llega al destino viniendo desde la dirección del control.

Dos propiedades definen todo su comportamiento.

Las tangentes en los extremos apuntan al control. La curva arranca en la dirección exacta de la recta que va del inicio al control, y termina en la dirección de la recta que va del control al final. Esto es lo que permite empalmar curvas sin que se note.

La curva queda dentro del triángulo formado por los tres puntos. Es la propiedad de la envolvente convexa, y sirve para acotar dónde puede estar la curva sin calcularla, lo cual es útil para el recorte y para las cajas envolventes.

ctx.beginPath();
ctx.moveTo(40, 160);
ctx.quadraticCurveTo(150, 20, 260, 160);
ctx.strokeStyle = '#89b4fa'; ctx.lineWidth = 3;
ctx.stroke();

// Visualizar el control y las tangentes: imprescindible mientras aprendes
ctx.beginPath();
ctx.moveTo(40, 160); ctx.lineTo(150, 20); ctx.lineTo(260, 160);
ctx.strokeStyle = '#585b70'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]); ctx.stroke(); ctx.setLineDash([]);

Dibujar el polígono de control mientras diseñas una curva es el mejor hábito posible y cuesta cuatro líneas. Sin él estás ajustando números a ciegas.

Cúbicas: dos controles

ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);

La versión con dos controles es la que se usa en la práctica para casi todo, porque permite formas que la cuadrática no puede: una S, una curva con puntos de inflexión, un lazo.

Las mismas dos propiedades se aplican: la tangente inicial apunta al primer control, la final viene del segundo, y la curva queda dentro del cuadrilátero convexo de los cuatro puntos.

La longitud de cada mango de control determina cuánto “tira” la curva en esa dirección. Un control cerca del extremo produce un giro brusco; uno lejano produce una curva que se estira antes de girar.

ctx.beginPath();
ctx.moveTo(40, 120);
ctx.bezierCurveTo(100, 20, 200, 220, 260, 120);
ctx.strokeStyle = '#cba6f7'; ctx.lineWidth = 3;
ctx.stroke();

Continuidad: por qué se ve el empalme

Al encadenar curvas, el resultado se ve suave o se ve quebrado según qué se conserve en la unión.

Continuidad de posición. El final de una curva coincide con el inicio de la siguiente. Es el mínimo y se cumple automáticamente porque la siguiente curva arranca en el punto actual. Con solo esto, la unión puede tener un pico.

Continuidad de tangente. Además, las direcciones de salida y de llegada son la misma. Se consigue haciendo que el último control de una curva, el punto de unión y el primer control de la siguiente estén alineados. Es lo que hace que un empalme sea invisible.

Continuidad de curvatura. Además de la dirección, coincide el radio de curvatura. Exige que las distancias de los controles al punto de unión sean también proporcionales. Solo se nota en superficies con reflejos o en tipografía de calidad.

En la práctica, la de tangente basta casi siempre, y hay un truco de una línea para conseguirla: el primer control de la curva nueva es la reflexión del último control de la anterior respecto al punto de unión.

function reflejar(punto, centro) {
  return { x: 2 * centro.x - punto.x, y: 2 * centro.y - punto.y };
}

const p0 = { x: 40, y: 150 };
const c1 = { x: 90, y: 40 };
const c2 = { x: 150, y: 40 };
const p1 = { x: 190, y: 150 };
// Reflejamos c2 respecto a p1 para que el empalme sea suave
const c3 = reflejar(c2, p1);
const c4 = { x: 300, y: 250 };
const p2 = { x: 340, y: 150 };

ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p1.x, p1.y);
ctx.bezierCurveTo(c3.x, c3.y, c4.x, c4.y, p2.x, p2.y);
ctx.strokeStyle = '#a6e3a1'; ctx.lineWidth = 4;
ctx.stroke();

Ese cálculo de reflexión es exactamente lo que hacen los comandos S y T del lenguaje de rutas de SVG, que reflejan el control automáticamente. El canvas no tiene equivalente, así que hay que hacerlo a mano, y por eso conviene tener la función escrita.

La constante 0,5523 y por qué no puedes dibujar un círculo con Bézier

Una Bézier es un polinomio y una circunferencia no lo es, así que ninguna curva de Bézier puede ser un arco de círculo exacto. Lo que se hace en todos los formatos vectoriales del mundo es aproximar cada cuarto de círculo con una cúbica cuyos mangos de control miden k = 4/3 · (√2 − 1) ≈ 0.5522847498 veces el radio. Ese valor sale de imponer que la curva pase exactamente por el punto medio del arco, y da un error máximo de unas 2,7 diezmilésimas del radio: invisible en cualquier tamaño razonable. Saberlo tiene tres usos prácticos que compensan la digresión. El primero es que te permite construir círculos y elipses con Bézier cuando necesitas que sean rutas de cuatro segmentos —por ejemplo, para interpolar entre un círculo y otra forma en una animación de morfismo, donde ambas rutas tienen que tener el mismo número de segmentos—. El segundo es que explica por qué exportar un círculo desde un programa de diseño a SVG a veces produce cuatro comandos C en lugar de un circle: el programa trabaja internamente con Bézier. Y el tercero, el más útil: cuando quieras una esquina que se sienta más orgánica que un arco perfecto —lo que en diseño de interfaces se llama esquina continua o squircle— la técnica consiste precisamente en usar un factor distinto de 0,5523. Valores mayores producen esquinas que se funden con los lados sin ese salto de curvatura que delata al arco circular, y es lo que hacen los sistemas de diseño que presumen de esquinas suaves.

const K = 0.5522847498307936;
function circuloBezier(ctx, cx, cy, r) {
  const k = r * K;
  ctx.moveTo(cx, cy - r);
  ctx.bezierCurveTo(cx + k, cy - r, cx + r, cy - k, cx + r, cy);
  ctx.bezierCurveTo(cx + r, cy + k, cx + k, cy + r, cx, cy + r);
  ctx.bezierCurveTo(cx - k, cy + r, cx - r, cy + k, cx - r, cy);
  ctx.bezierCurveTo(cx - r, cy - k, cx - k, cy - r, cx, cy - r);
}

Curvas suaves a partir de puntos

El caso práctico más frecuente es tener una serie de puntos —datos, un trazo capturado del ratón— y querer una curva suave que pase por todos. Las Bézier no pasan por sus controles, así que hay que calcular los controles a partir de los puntos.

La técnica más sencilla y robusta es la Catmull-Rom convertida a Bézier: para cada tramo entre dos puntos, los controles se derivan de los vecinos.

/** Traza una curva suave que pasa por todos los puntos. */
function curvaSuave(ctx, puntos, tension = 0.5) {
  if (puntos.length < 2) return;
  ctx.beginPath();
  ctx.moveTo(puntos[0].x, puntos[0].y);
  for (let i = 0; i < puntos.length - 1; i++) {
    const p0 = puntos[i - 1] ?? puntos[i];
    const p1 = puntos[i];
    const p2 = puntos[i + 1];
    const p3 = puntos[i + 2] ?? p2;
    ctx.bezierCurveTo(
      p1.x + ((p2.x - p0.x) / 6) * tension,
      p1.y + ((p2.y - p0.y) / 6) * tension,
      p2.x - ((p3.x - p1.x) / 6) * tension,
      p2.y - ((p3.y - p1.y) / 6) * tension,
      p2.x, p2.y);
  }
}

Esa función es la base de cualquier gráfico de líneas suavizado y de cualquier herramienta de dibujo a mano alzada decente. El parámetro de tensión controla cuánto se curva: con cero se convierte en una polilínea recta, con uno se curva más y puede sobrepasar los puntos.

Una advertencia sobre suavizar datos: la curva puede salirse del rango de los datos. Un gráfico de valores que nunca son negativos puede mostrar una curva que baja por debajo de cero entre dos puntos. En visualización de datos eso es una mentira visual, y es la razón de que los gráficos serios usen splines monótonas o directamente líneas rectas.