wandres.dev
CANVAS: EL CONTEXTO 2D · Empezar a pintar

El sistema de coordenadas: origen, ejes y el medio píxel

Fijar el modelo espacial del canvas: dónde está el origen, hacia dónde crecen los ejes, qué significan las coordenadas fraccionarias y qué queda fuera del lienzo.

⏱ 16 min

El canvas usa un sistema de coordenadas que viene directamente de la tradición de los gráficos por ordenador: origen arriba a la izquierda, eje Y creciendo hacia abajo, y unidades que no son píxeles sino unidades de usuario que la matriz de transformación puede reinterpretar. Cada una de esas tres decisiones tiene consecuencias, y la tercera es la que más gente descubre tarde.

🎯 Al terminar esta lección sabrás
  • Situar el origen y la dirección de los ejes del canvas y explicar de dónde viene esa convención.
  • Distinguir unidades de usuario de píxeles del búfer.
  • Interpretar correctamente una coordenada fraccionaria en términos de muestras.
  • Predecir qué ocurre al dibujar fuera de los límites del lienzo.

Origen arriba, Y hacia abajo

El punto (0, 0) está en la esquina superior izquierda, x crece hacia la derecha e y crece hacia abajo. Es la convención opuesta a la de las matemáticas de secundaria y la misma que usan prácticamente todos los sistemas de ventanas, los formatos de imagen y las APIs de dibujo 2D.

El motivo es histórico y sigue vigente: los monitores de rayos catódicos dibujaban de arriba abajo y de izquierda a derecha, así que la memoria de vídeo se organizó en ese orden. La primera fila de la imagen es la primera fila de bytes. Cualquier API que exponga un búfer lineal hereda esa organización, y el canvas la expone en ImageData.

La consecuencia práctica es que los ángulos giran en el sentido de las agujas del reloj. Un ángulo positivo en rotate o en arc lleva del eje X positivo hacia el eje Y positivo, que apunta hacia abajo, con lo que visualmente gira en sentido horario. Esto confunde a quien viene de la trigonometría estándar, donde un ángulo positivo gira en sentido antihorario. No hay ningún error: el sistema es de mano izquierda y el resultado es coherente.

// El angulo 0 apunta a la derecha; PI/2 apunta hacia ABAJO
ctx.beginPath();
ctx.arc(150, 150, 80, 0, Math.PI / 2);   // cuarto inferior derecho
ctx.strokeStyle = '#a6e3a1';
ctx.lineWidth = 6;
ctx.stroke();

Si tu modelo mental exige Y hacia arriba —porque vienes de un dominio matemático o físico—, no calcules las coordenadas al revés a mano: invierte el eje una vez con una transformación y trabaja después en tu sistema natural.

ctx.translate(0, canvas.height);
ctx.scale(1, -1);
// A partir de aqui, y crece hacia arriba y el origen esta abajo a la izquierda.
// Ojo: el texto tambien sale invertido, hay que compensarlo al dibujarlo.

Unidades de usuario, no píxeles

Las coordenadas que le pasas al contexto son unidades de usuario del espacio actual, no píxeles del búfer. Mientras la matriz de transformación sea la identidad, una unidad equivale a un píxel del búfer y la distinción parece pedante. En cuanto aplicas scale, translate o rotate, deja de serlo.

ctx.scale(2, 2);
ctx.fillRect(10, 10, 50, 50);
// Ocupa desde el pixel 20 hasta el 120 del buffer: 100 pixeles de lado.

Esto es lo que permite el patrón fundamental para pantallas de alta densidad: escalar el contexto por el ratio de píxeles del dispositivo una sola vez y seguir escribiendo el resto del código en unidades lógicas, sin multiplicar nada a mano.

También es lo que permite trabajar en las unidades del dominio del problema. Una gráfica puede dibujarse directamente en euros y meses si defines la transformación adecuada, en lugar de convertir cada valor a píxeles en cada línea.

Hay una excepción importante y a menudo olvidada: getImageData y putImageData ignoran la matriz. Sus coordenadas son siempre píxeles del búfer. Es coherente —operan sobre el búfer, no sobre el espacio de dibujo— pero rompe la ilusión y produce bugs cuando mezclas ambos mundos.

El medio píxel, en el sentido correcto

Las coordenadas enteras caen entre píxeles, no en su centro. El píxel superior izquierdo del búfer ocupa el área que va de (0, 0) a (1, 1), y su centro está en (0.5, 0.5).

Eso explica los dos comportamientos que más desconciertan al principio.

Un rectángulo relleno en coordenadas enteras sale nítido. fillRect(10, 10, 100, 50) cubre exactamente los píxeles del 10 al 109 en X. No hay ninguna cobertura parcial, así que no hay antialiasing y el borde es duro.

Una línea de grosor 1 en coordenadas enteras sale borrosa. El trazo se centra en la ruta, así que una línea vertical en x = 10 con grosor 1 va de 9,5 a 10,5: cubre la mitad del píxel 9 y la mitad del 10. El rasterizador pinta ambos al 50 %. El resultado es una línea gris de dos píxeles en lugar de una negra de uno.

ctx.strokeStyle = '#cdd6f4';
ctx.lineWidth = 1;

ctx.beginPath();
ctx.moveTo(20, 10); ctx.lineTo(20, 90);      // borrosa
ctx.stroke();

ctx.beginPath();
ctx.moveTo(60.5, 10); ctx.lineTo(60.5, 90);  // nitida
ctx.stroke();

Este es el problema del medio píxel y tiene bastante más miga de la que cabe aquí, porque interactúa con el grosor de línea y con la escala del contexto. Por ahora quédate con el mecanismo: el trazo se centra en la ruta, y la nitidez depende de dónde caiga el borde del trazo respecto a la rejilla.

Las coordenadas fraccionarias tienen un coste que no es solo visual

Todo el mundo aprende que las coordenadas fraccionarias producen antialiasing, y ahí se queda. Lo que casi nadie sabe es que también producen un coste de rendimiento medible, y en un sitio muy concreto: drawImage. Cuando copias una imagen en una posición entera, con escala 1 y sin rotación, el navegador puede usar un camino de copia de bloques directa —una transferencia de memoria, esencialmente— que es lo más rápido que puede hacer. En cuanto la posición de destino tiene decimales, ese camino desaparece: cada píxel de destino cae entre cuatro píxeles de origen y hay que interpolar, lo que multiplica el coste por un factor que en canvas grandes es fácil que sea de cinco o diez veces. En un juego con cien sprites por fotograma, la diferencia entre drawImage(s, x, y) y drawImage(s, Math.round(x), Math.round(y)) puede ser la diferencia entre sesenta y cuarenta fotogramas por segundo, y encima la versión redondeada se ve mejor, porque los sprites salen nítidos en lugar de emborronados. El precio es que el movimiento pierde suavidad subpíxel, lo que se nota en desplazamientos muy lentos. La solución habitual en motores serios es redondear la posición de la cámara pero no la del mundo, o redondear solo cuando la velocidad supera un umbral. Y hay un corolario que se olvida siempre: si el contexto está escalado por el ratio de píxeles del dispositivo, redondear en unidades lógicas no garantiza una posición entera en el búfer. Hay que redondear en el espacio del dispositivo.

Fuera del lienzo

Dibujar fuera de los límites del canvas no es un error y no produce ningún aviso: simplemente se recorta. Puedes trazar una línea de (-5000, -5000) a (20000, 30000) y solo verás el tramo que atraviesa el lienzo.

Ese recorte es gratuito desde el punto de vista del resultado, pero no lo es del todo desde el del rendimiento. El navegador tiene que procesar la geometría antes de descartarla, y con formas complejas o muchas llamadas el coste se acumula. En una escena con desplazamiento, descartar los objetos que quedan fuera del área visible antes de llamar al contexto —el culling— es la optimización de mayor relación beneficio-esfuerzo que existe en canvas.

function visible(o, vista) {
  return o.x + o.w >= vista.x && o.x <= vista.x + vista.w &&
         o.y + o.h >= vista.y && o.y <= vista.y + vista.h;
}

for (const o of escena) {
  if (!visible(o, vista)) continue;   // ni siquiera lo intentamos
  ctx.fillStyle = o.color;
  ctx.fillRect(o.x - vista.x, o.y - vista.y, o.w, o.h);
}

Un detalle final sobre los límites: las coordenadas del canvas son números de doble precisión, así que puedes usar valores enormes o minúsculos sin errores de tipo. Pero los rasterizadores internos trabajan con precisión fija en varios navegadores, y valores extremos —del orden de millones de unidades— producen artefactos de redondeo visibles. Si tu escena tiene un rango de coordenadas gigantesco, la solución es la misma que en gráficos 3D: trabajar en coordenadas relativas a la vista, no absolutas al mundo.