imageSmoothing: cuándo suavizar y cuándo no
Controlar el filtrado del canvas al escalar imágenes, conseguir pixel art nítido de verdad, y aplicar la reducción por pasos para miniaturas de calidad.
Las dos propiedades de suavizado del canvas son una decisión binaria y una pista, y entre ambas cubren los dos extremos del escalado de imagen: el pixel art que exige bloques duros y la fotografía que exige el mejor filtro disponible. Lo que no cubren es el caso intermedio, que es donde está la mayoría del trabajo real y donde hay que aplicar técnica.
- Controlar
imageSmoothingEnabledyimageSmoothingQualityy saber qué operaciones afectan. - Conseguir pixel art nítido incluyendo la corrección por densidad de pantalla.
- Aplicar la reducción por pasos y explicar por qué mejora el resultado.
- Reconocer los casos en los que el suavizado no se aplica aunque lo pidas.
Las dos propiedades
ctx.imageSmoothingEnabled = false; // vecino mas proximo
ctx.imageSmoothingQuality = 'high'; // 'low' | 'medium' | 'high'
imageSmoothingEnabled es una decisión binaria: con false, cualquier escalado usa el vecino más próximo y produce bloques duros; con true, se interpola.
imageSmoothingQuality solo tiene efecto cuando el suavizado está activo, y es una pista: el navegador puede ignorarla y los tres valores no siempre se distinguen. Pedir 'high' en una reducción grande sí produce diferencias visibles en los navegadores actuales.
Ambas forman parte del estado del contexto y las guarda save.
Lo que afectan y lo que no importa mucho:
| Operación | ¿Le afecta el suavizado? |
|---|---|
drawImage con escalado |
Sí |
drawImage sin escalado y en posición entera |
No: es copia directa |
Patrones escalados con createPattern |
Sí |
putImageData |
No, nunca: escribe píxel a píxel |
| Rutas y texto | No: tienen su propio antialiasing, que no se desactiva |
Esa última fila merece énfasis porque es un malentendido frecuente: imageSmoothingEnabled = false no desactiva el antialiasing de las formas. Un círculo trazado sigue teniendo bordes suaves. La propiedad solo gobierna el remuestreo de imágenes. No hay ninguna forma estándar de desactivar el antialiasing de rutas en el canvas 2D.
Pixel art nítido, de verdad
El caso obvio:
ctx.imageSmoothingEnabled = false;
ctx.drawImage(sprite, 0, 0, sprite.width * 4, sprite.height * 4);
Y el caso que falla en la mitad de los dispositivos: si el contexto está escalado por la densidad de pantalla, la escala total hasta el píxel físico es el producto de tu factor por la densidad. Con un factor de 4 y densidad 1,5, la escala real es 6, que es entera y va bien. Con densidad 2,625 —muchos Android— la escala real es 10,5, y aunque el suavizado esté desactivado, el compositor del navegador remuestrea al componer y aparecen filas de píxeles de distinto grosor.
La defensa es calcular la escala en píxeles físicos y redondearla a entero:
function dibujarPixelArt(ctx, sprite, x, y, escalaDeseada, dpr) {
const escalaReal = Math.max(1, Math.round(escalaDeseada * dpr));
const escalaLogica = escalaReal / dpr;
ctx.imageSmoothingEnabled = false;
// Posicion redondeada al pixel FISICO
const px = Math.round(x * dpr) / dpr;
const py = Math.round(y * dpr) / dpr;
ctx.drawImage(sprite, px, py,
sprite.width * escalaLogica, sprite.height * escalaLogica);
}
Con eso, el sprite ocupa un número entero de píxeles físicos por píxel de arte y cada bloque sale idéntico. El precio es que el tamaño en pantalla no es exactamente el que pediste, y hay que diseñar el layout para tolerar ese ajuste. Es exactamente lo que hacen los emuladores y los juegos de píxeles bien hechos: en lugar de rellenar la ventana, eligen el múltiplo entero más grande que cabe y dejan bandas.
// Escalado entero que llena lo maximo posible sin distorsion
function escalaEntera(anchoDisponible, altoDisponible, anchoArte, altoArte) {
return Math.max(1, Math.floor(Math.min(anchoDisponible / anchoArte,
altoDisponible / altoArte)));
}
Reducir bien
En el extremo contrario está la reducción de fotografías, donde el problema no es el filtrado sino el aliasing. Reducir de golpe un factor grande produce moirés y detalle sucio, porque el filtro del navegador muestrea muy pocos píxeles de origen por cada píxel de destino.
La solución es reducir en etapas de un factor dos, cada una de las cuales sí está bien filtrada por la interpolación bilineal:
function reducirPorPasos(origen, anchoFinal, altoFinal) {
let actual = origen;
let w = origen.width ?? origen.naturalWidth;
let h = origen.height ?? origen.naturalHeight;
while (w * 0.5 > anchoFinal && h * 0.5 > altoFinal) {
w = Math.max(anchoFinal, Math.round(w * 0.5));
h = Math.max(altoFinal, Math.round(h * 0.5));
const paso = document.createElement('canvas');
paso.width = w; paso.height = h;
const c = paso.getContext('2d');
c.imageSmoothingEnabled = true;
c.imageSmoothingQuality = 'high';
c.drawImage(actual, 0, 0, w, h);
if (actual !== origen) { actual.width = 0; actual.height = 0; } // liberar
actual = paso;
}
const fin = document.createElement('canvas');
fin.width = anchoFinal; fin.height = altoFinal;
const c = fin.getContext('2d');
c.imageSmoothingEnabled = true;
c.imageSmoothingQuality = 'high';
c.drawImage(actual, 0, 0, anchoFinal, altoFinal);
if (actual !== origen) { actual.width = 0; actual.height = 0; }
return fin;
}
La diferencia frente a un drawImage directo es visible a simple vista en cualquier fotografía con detalle fino: tejidos, follaje, texto pequeño. Es la técnica de las bibliotecas de miniaturas serias.
Existe una alternativa que a menudo es mejor y más barata: createImageBitmap con resizeQuality: 'high', que delega el reescalado al navegador y en varios motores usa un filtro de mejor calidad que el de drawImage. Prueba las dos con tus imágenes antes de escribir la reducción por pasos.
imageSmoothingEnabled y imageSmoothingQuality son las propiedades del canvas donde más divergen las implementaciones, y conviene saber por qué antes de perseguir un bug que no está en tu código. Razón uno: el compositor tiene la última palabra. Tú controlas el filtrado que se aplica al escribir en el búfer del canvas, pero entre el búfer y la pantalla hay otro escalado —el que va del tamaño del búfer al tamaño de la caja del elemento— y ese lo hace el compositor con su propio filtro, que no controlas desde el contexto. Si el búfer no está exactamente alineado con los píxeles físicos, tu pixel art perfectamente nítido se emborrona en ese último paso. La única defensa es la alineación exacta del búfer, que es el tema del nivel de resolución, y además image-rendering: pixelated en el CSS del elemento, que sí influye en ese último escalado. Razón dos: la calidad es una pista sin garantía. No hay ninguna especificación de qué filtro corresponde a cada valor, así que 'high' produce resultados distintos en cada motor y a veces indistinguibles de 'medium'. No construyas nada que dependa de un resultado exacto. Razón tres: el camino de GPU y el de software filtran distinto. Un mismo canvas puede dar resultados sutilmente diferentes según esté acelerado o no, y eso puede cambiar entre dispositivos e incluso durante la vida de una pestaña. Si tu aplicación compara imágenes píxel a píxel —tests visuales, detección de cambios— esta es la razón por la que las capturas no coinciden entre máquinas. La conclusión práctica: para pixel art, combina las tres defensas —suavizado desactivado, escala entera en píxeles físicos y image-rendering: pixelated en CSS— y no confíes en ninguna por separado.
El CSS que acompaña
Para cerrar el círculo, el elemento canvas también admite la propiedad de renderizado de imagen, que gobierna el escalado del búfer a la caja:
canvas.pixel {
image-rendering: pixelated; /* vecino mas proximo en el compositor */
}
Sin esa regla, un canvas con el búfer más pequeño que su caja se suaviza al componerse aunque hayas desactivado el suavizado dentro. Con ella, el escalado final también es de bloques duros.
Esa combinación —búfer pequeño, image-rendering: pixelated, y CSS que agranda el elemento— es en realidad una técnica muy eficiente para pixel art: el búfer tiene el tamaño de la resolución nativa del juego, dibujas con coordenadas de esa resolución sin ninguna escala, y el navegador se encarga de ampliar. Es menos memoria, menos píxeles que dibujar y menos aritmética.
<style>
#pantalla { width: 100%; image-rendering: pixelated; }
</style>
<canvas id="pantalla" width="320" height="180"></canvas>
Trescientos veinte por ciento ochenta píxeles de búfer, mostrados a pantalla completa, con bloques perfectamente nítidos y un coste de dibujo ridículo. Es el reverso exacto del consejo habitual de dimensionar el búfer a los píxeles físicos, y es correcto precisamente porque el arte no tiene más resolución que esa.