wandres.dev
PATH2D Y OFFSCREENCANVAS · Reutilizar y salir del hilo

Path2D: la ruta como objeto reutilizable

Sacar la geometría del estado del contexto y guardarla en objetos que se construyen una vez, se combinan entre sí y se dibujan miles de veces.

⏱ 16 min

Path2D es la única concesión del canvas al modo retenido: un objeto que guarda geometría y que puedes reutilizar. No guarda estilo, no guarda posición en la escena y no sabe dibujarse solo, pero elimina la reconstrucción de la ruta en cada llamada, que en escenas densas es una parte enorme del coste. Y además permite combinar rutas y hacer hit testing sin tocar el contexto.

🎯 Al terminar esta lección sabrás
  • Construir un Path2D con la misma API que el contexto y dibujarlo.
  • Combinar rutas con addPath y una matriz de transformación.
  • Medir el ahorro real de reutilizar geometría en un bucle denso.
  • Usar Path2D para hit testing sin depender de la ruta actual del contexto.

Construir y dibujar

const ruta = new Path2D();
ruta.moveTo(0, -30);
ruta.lineTo(26, 15);
ruta.lineTo(-26, 15);
ruta.closePath();

Tiene los mismos métodos de construcción que el contexto: moveTo, lineTo, arc, arcTo, bezierCurveTo, quadraticCurveTo, rect, roundRect, ellipse, closePath. Lo que no tiene es nada de estilo ni de dibujo: no hay fillStyle, no hay stroke().

Para pintarla se le pasa al contexto:

ctx.fillStyle = '#a6e3a1';
ctx.fill(ruta);
ctx.stroke(ruta);
ctx.fill(ruta, 'evenodd');
ctx.clip(ruta);
ctx.isPointInPath(ruta, x, y);
ctx.isPointInStroke(ruta, x, y);

Fíjate en un detalle crucial: usar un Path2D no toca la ruta actual del contexto. Puedes tener una ruta en construcción y dibujar diez Path2D en medio sin que interfieran. Eso elimina toda una familia de errores por acumulación.

Las coordenadas son locales, la posición la pone la matriz

Un Path2D guarda coordenadas fijas. Para dibujar el mismo objeto en veinte sitios, no se modifica la ruta: se mueve el sistema de coordenadas.

const triangulo = new Path2D();
triangulo.moveTo(0, -30); triangulo.lineTo(26, 15);
triangulo.lineTo(-26, 15); triangulo.closePath();

for (const t of elementos) {
  ctx.save();
  ctx.translate(t.x, t.y);
  ctx.rotate(t.angulo);
  ctx.fillStyle = t.color;
  ctx.fill(triangulo);        // la MISMA geometria, sin reconstruir
  ctx.restore();
}

Ese patrón encaja perfectamente con la disciplina de espacios locales: la ruta describe la forma en su origen, y la matriz la coloca.

En bucles muy densos, el par save/restore empieza a pesar, y la alternativa es setTransform con una matriz calculada:

const dpr = window.devicePixelRatio || 1;
for (const t of elementos) {
  ctx.setTransform(dpr * Math.cos(t.angulo), dpr * Math.sin(t.angulo),
                   -dpr * Math.sin(t.angulo), dpr * Math.cos(t.angulo),
                   t.x * dpr, t.y * dpr);
  ctx.fillStyle = t.color;
  ctx.fill(triangulo);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

Combinar rutas

addPath añade el contenido de otra ruta, opcionalmente transformado por una matriz:

const rueda = new Path2D();
rueda.arc(0, 0, 18, 0, Math.PI * 2);

const chasis = new Path2D();
chasis.roundRect(-60, -22, 120, 34, 8);

const coche = new Path2D();
coche.addPath(chasis);
coche.addPath(rueda, new DOMMatrix().translate(-34, 14));
coche.addPath(rueda, new DOMMatrix().translate(34, 14));

ctx.fillStyle = '#89b4fa';
ctx.fill(coche);

Ese ensamblaje ocurre una vez, al inicializar. A partir de ahí, dibujar un coche completo es un solo fill con una sola ruta, en lugar de tres construcciones y tres rellenos.

El constructor de copia también existe:

const copia = new Path2D(otraRuta);   // clona

Modificar la copia no afecta al original, lo que permite tener una biblioteca de formas base y derivar variantes.

Lo que no existe es forma de leer, modificar o borrar el contenido de un Path2D. Solo se puede añadir. Si necesitas geometría editable, la fuente de verdad tiene que ser tu propio modelo de datos y el Path2D se reconstruye cuando cambia.

Hit testing sin contexto activo

isPointInPath con un Path2D es la forma correcta de hacer hit testing en canvas, y sustituye tanto al cálculo geométrico a mano como a la técnica del canvas oculto en la mayoría de los casos.

function figuraEn(ctx, escena, px, py) {
  // De atras hacia delante: lo ultimo pintado esta encima
  for (let i = escena.length - 1; i >= 0; i--) {
    const f = escena[i];
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);   // consultar en coordenadas del buffer
    ctx.translate(f.x, f.y);
    ctx.rotate(f.angulo);
    const dentro = ctx.isPointInPath(f.ruta, px, py);
    ctx.restore();
    if (dentro) return f;
  }
  return null;
}

El detalle que hace correcta esa función es que isPointInPath interpreta el punto en el espacio de usuario vigente, así que hay que establecer exactamente la misma matriz con la que se dibujó. Consultar con una matriz distinta da resultados incorrectos sin ningún aviso.

Para el trazo existe el equivalente, que tiene en cuenta el lineWidth actual:

ctx.lineWidth = 12;
ctx.isPointInStroke(ruta, px, py);   // dentro de la banda del trazo

Es lo que permite seleccionar una línea fina pinchando cerca de ella: subes temporalmente el grosor a un valor de tolerancia y consultas.

El ahorro de Path2D no está donde parece: está en el parseo, no en el rasterizado

Es fácil suponer que Path2D es más rápido porque el navegador guarda la geometría ya rasterizada. No es así: la rasterización ocurre igual en cada fill, y su coste es idéntico al de una ruta construida al vuelo. Lo que ahorras está en otro sitio y es más interesante. Uno: las llamadas desde JavaScript. Cada lineTo es un cruce de la frontera entre el motor de JavaScript y el código nativo, con su validación de argumentos y su conversión de tipos. Una forma de cuarenta segmentos dibujada diez mil veces son cuatrocientas mil de esas llamadas por fotograma, y ahí se va un porcentaje del presupuesto que sorprende. Con Path2D son diez mil llamadas a fill y ninguna de construcción. Dos: la construcción de la estructura interna. El navegador mantiene la ruta en una representación propia que tiene que asignar y hacer crecer; reutilizar el objeto evita esa asignación y la presión sobre el recolector que genera. Tres, y es el que casi nadie considera: la posibilidad de cachear la teselación. Algunos motores guardan resultados intermedios asociados al objeto de ruta cuando detectan que se reutiliza, lo cual solo es posible si el objeto persiste. Los números en escenas reales: con formas simples de cuatro o cinco segmentos, la mejora es marginal y no compensa la complejidad. Con formas de veinte segmentos o más dibujadas miles de veces, la mejora está entre el treinta y el setenta por ciento. Y hay un caso donde Path2D gana siempre, independientemente de la complejidad: cuando la ruta viene de una cadena de datos SVG, porque entonces el ahorro incluye el análisis sintáctico de esa cadena, que es con diferencia lo más caro de todo.

Una biblioteca de formas

El patrón que emerge en un proyecto serio es un módulo de formas construidas una vez al arrancar:

export const FORMAS = (() => {
  const marcador = new Path2D();
  marcador.moveTo(0, 0);
  marcador.bezierCurveTo(-12, -14, -14, -24, -8, -30);
  marcador.arc(0, -32, 8, Math.PI, 0);
  marcador.bezierCurveTo(14, -24, 12, -14, 0, 0);
  marcador.closePath();

  const estrella = new Path2D();
  for (let i = 0; i < 10; i++) {
    const r = i % 2 ? 7 : 16;
    const a = -Math.PI / 2 + (i * Math.PI) / 5;
    const x = r * Math.cos(a), y = r * Math.sin(a);
    i === 0 ? estrella.moveTo(x, y) : estrella.lineTo(x, y);
  }
  estrella.closePath();

  const cuadrado = new Path2D();
  cuadrado.roundRect(-12, -12, 24, 24, 5);

  return { marcador, estrella, cuadrado };
})();

A partir de ahí, dibujar cincuenta mil marcadores en un mapa son cincuenta mil transformaciones y cincuenta mil rellenos, sin ninguna construcción de geometría. Es la diferencia entre un mapa que responde al zoom y uno que no.

⚔️ Reto práctico

Genera veinte mil elementos con una forma de treinta segmentos. Dibújalos de tres maneras: construyendo la ruta en cada iteración, con un Path2D reutilizado y save/restore, y con un Path2D reutilizado y setTransform. Los tres tiempos separan el coste de construir la geometría del coste de gestionar la pila de estado.