devicePixelRatio: densidad, zoom y sus cambios
Entender qué mide exactamente devicePixelRatio, por qué cambia sin que el usuario cambie de pantalla, y cómo enterarte de esos cambios en el momento en que ocurren.
devicePixelRatio parece una constante del dispositivo y no lo es. Cambia cuando el usuario hace zoom, cuando arrastra la ventana a otro monitor, cuando el sistema cambia la escala de la interfaz y, en algunos navegadores, cuando cambia la orientación. Un canvas que lee ese valor una vez al arrancar se degrada en cuanto ocurre cualquiera de esas cosas, y el usuario ve un lienzo borroso sin saber por qué.
- Definir con precisión qué relación mide
devicePixelRatio. - Enumerar los eventos que hacen que su valor cambie durante la vida de la página.
- Detectar los cambios de densidad con
matchMediay una consulta de resolución. - Decidir si conviene limitar el ratio y con qué criterio.
Qué mide exactamente
window.devicePixelRatio es el cociente entre el tamaño de un píxel CSS y el tamaño de un píxel del dispositivo:
devicePixelRatio = pixeles fisicos / pixeles CSS
Un valor de 2 significa que cada píxel CSS se dibuja con una rejilla de 2 por 2 píxeles físicos, es decir, cuatro veces más muestras por unidad de área. Un valor de 1 es la correspondencia uno a uno de los monitores clásicos.
Los valores que se encuentran en la práctica son variados y no siempre enteros. Los portátiles de gama alta suelen dar 2. Muchos Android dan 2,625, 2,75 o 3. Un monitor 4K de 27 pulgadas con escala del sistema al 150 % da 1,5. Y el zoom del navegador multiplica todo lo anterior: un zoom al 110 % sobre una pantalla de ratio 2 da 2,2.
Esa última observación es la que cambia el modelo mental: devicePixelRatio no describe el hardware, describe la relación actual entre el sistema de coordenadas CSS y los píxeles reales, incluyendo todos los factores que la alteran.
Qué lo hace cambiar
Hay al menos cinco causas, y cuatro de ellas ocurren durante la vida de la página sin recargarla.
El zoom del navegador. Es la más frecuente con diferencia. Cada pulsación de acercar o alejar cambia el valor.
Mover la ventana a otro monitor. En un escritorio con un portátil de alta densidad y un monitor externo estándar, arrastrar la ventana de uno a otro cambia el ratio a mitad de sesión.
Cambiar la escala del sistema operativo. Menos frecuente pero posible sin cerrar el navegador.
El zoom por gestos en móvil, en algunos navegadores y bajo ciertas configuraciones del viewport.
La rotación del dispositivo, en casos concretos donde la densidad efectiva cambia.
Un canvas que lee el valor una sola vez sobrevive a ninguna de las cinco.
Detectar el cambio
No existe un evento devicepixelratiochange. La técnica canónica usa matchMedia con una consulta de resolución construida a partir del valor actual, que dejará de coincidir en cuanto el valor cambie.
function alCambiarDensidad(callback) {
let mql;
function suscribir() {
const dpr = window.devicePixelRatio;
// Consulta que solo es cierta mientras el ratio siga siendo exactamente este
mql = matchMedia(`(resolution: ${dpr}dppx)`);
mql.addEventListener('change', alCambiar, { once: true });
}
function alCambiar() {
callback(window.devicePixelRatio);
suscribir(); // hay que reconstruir la consulta con el valor nuevo
}
suscribir();
return () => mql.removeEventListener('change', alCambiar);
}
alCambiarDensidad(dpr => {
console.log('Nueva densidad:', dpr);
redimensionar();
});
La parte que se olvida siempre es la resuscripción. La consulta (resolution: 2dppx) deja de coincidir cuando el ratio pasa a 2,2 y dispara el evento, pero a partir de ese momento esa consulta ya no sirve para detectar nada: hay que construir una nueva con el valor actual. Por eso el listener es once y se vuelve a suscribir dentro del manejador.
La unidad dppx significa “puntos por píxel CSS” y es exactamente la unidad de devicePixelRatio. Usar dpi con conversiones a mano es innecesario y propenso a errores de redondeo.
Hay una alternativa, más robusta en la práctica, que es no detectar la densidad por separado sino observar el tamaño real del elemento en píxeles del dispositivo, porque cualquier cambio de densidad cambia también esa medida. Es lo que hace ResizeObserver con la caja de píxeles del dispositivo, y tiene su propia lección.
Es común encontrar código que hace Math.min(devicePixelRatio, 2) y lo justifica como optimización. La idea tiene fundamento —la memoria crece con el cuadrado del ratio, así que un móvil con ratio 3 pide un búfer nueve veces mayor que uno con ratio 1— pero aplicarla sin pensar tiene efectos que nadie mide. El primero: en una pantalla con ratio 3, limitar a 2 significa mostrar un búfer de 2× en un espacio de 3×, es decir, un escalado de 1,5, que es no entero. Y como ya sabemos, los escalados no enteros producen líneas desiguales y texto sucio, con lo que la imagen puede acabar viéndose peor que si hubieras limitado a 1,5 o a 1. Si vas a limitar, limita a un valor que divida bien: en un dispositivo de ratio 3 limitar a 1,5 da un escalado de 2, que es entero y limpio. El segundo efecto: el contenido más sensible al ratio es el texto, y el texto es lo que el usuario lee. Un juego de partículas se ve igual de bien a 1,5 que a 3; una interfaz llena de etiquetas, no. La estrategia que funciona en aplicaciones grandes es separar por capa: la capa de contenido denso se dibuja a ratio limitado y la capa de texto e interfaz a ratio completo, en canvas distintos. Y el tercer efecto, el que nadie ve venir: si limitas el ratio pero no ajustas el tamaño del búfer al múltiplo exacto, tus líneas de un píxel dejarán de caer en la rejilla y el problema del medio píxel volverá con una escala fraccionaria imposible de compensar.
Cuándo importa cada decimal
No todos los contenidos sufren igual una densidad mal resuelta, y saberlo permite priorizar.
El texto es el más sensible. El rasterizador de fuentes usa toda la resolución disponible y el ojo detecta la diferencia inmediatamente. Un canvas con etiquetas a ratio 1 en una pantalla de ratio 2 se ve claramente peor que la interfaz que lo rodea, lo que resulta especialmente evidente porque está al lado de texto del DOM que sí está nítido.
Las líneas finas son las segundas. Una línea de un píxel en un búfer a la mitad de resolución se convierte en dos píxeles borrosos. Las rejillas, los ejes y los bordes son donde primero se nota.
Las formas rellenas grandes casi no sufren. Un rectángulo de color plano se ve igual a cualquier ratio salvo en sus bordes, y si están alineados a la rejilla ni siquiera ahí.
Las imágenes fotográficas dependen de la fuente. Si la imagen original ya tenía menos resolución que el búfer, subir el ratio no aporta nada.
De ahí sale un criterio útil: la pregunta no es “¿qué ratio uso?” sino “¿qué hay en este canvas?”. Un mapa de calor puede permitirse ratio 1; un gráfico con ejes etiquetados, no.
Una utilidad completa
Juntando todo, esta pequeña utilidad centraliza la lectura de la densidad y el aviso de sus cambios, y es la pieza que las dos lecciones siguientes van a usar.
export function crearDensidad({ maximo = Infinity } = {}) {
const oyentes = new Set();
let mql = null;
const valor = () => Math.min(window.devicePixelRatio || 1, maximo);
function suscribir() {
mql = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
mql.addEventListener('change', notificar, { once: true });
}
function notificar() {
for (const f of oyentes) f(valor());
suscribir();
}
suscribir();
return {
get valor() { return valor(); },
alCambiar(f) { oyentes.add(f); return () => oyentes.delete(f); },
};
}
Fíjate en un detalle: la consulta de matchMedia usa window.devicePixelRatio sin limitar, porque queremos enterarnos de cualquier cambio real de densidad aunque nuestro valor efectivo esté limitado y no vaya a cambiar. Confundir esos dos valores hace que el aviso no llegue nunca cuando el usuario pasa de ratio 3 a ratio 4.