Saltar al contenido

devicePixelRatio: la decisión de rendimiento más rentable

Por qué el coste de dibujar crece con el cuadrado de la relación de píxeles, dónde está el tope práctico, y cómo detectar que ha cambiado sin recargar.

Dos unidades con el mismo nombre

Un píxel de CSS es una unidad de maquetación: una referencia de tamaño angular pensada para que un texto de dieciséis píxeles se vea igual de grande en un monitor y en un teléfono. Un píxel de dispositivo es un punto físico de la pantalla.

window.devicePixelRatio es el cociente entre ambos. Vale uno en un monitor clásico, dos en una pantalla de alta densidad, tres en la mayoría de móviles de gama alta, y valores fraccionarios cuando el usuario ha aplicado zoom o cuando el sistema escala la interfaz.

Para el canvas, ese número decide cuántos píxeles reales tiene el búfer de dibujo. Un canvas de mil por seiscientos píxeles de CSS con una relación de tres tiene un búfer de tres mil por mil ochocientos: cinco millones cuatrocientos mil píxeles en lugar de seiscientos mil, es decir, nueve veces más.

Y ahí está el punto: el coste crece con el cuadrado de la relación, porque afecta a las dos dimensiones. Pasar de uno a dos multiplica por cuatro el trabajo del fragment shader, del acceso a texturas, del ancho de banda de escritura y de la memoria de todos los búferes intermedios. Pasar de dos a tres lo multiplica por otro factor de dos y cuarto.

JS
import * as THREE from 'three';const renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(1000, 600);const tamano = new THREE.Vector2();const buffer = new THREE.Vector2();for (const dpr of [1, 2, 3]) {  renderer.setPixelRatio(dpr);  renderer.getSize(tamano);  renderer.getDrawingBufferSize(buffer);  const pixeles = buffer.x * buffer.y;  console.log(    `dpr ${dpr}: presentacion ${tamano.x}x${tamano.y}, ` +    `buffer ${buffer.x}x${buffer.y}, ${(pixeles / 1e6).toFixed(2)} megapixeles`  );}

getSize devuelve el tamaño de presentación en píxeles de CSS y getDrawingBufferSize el número real de píxeles que va a sombrear la GPU. La segunda cifra es la que importa para el rendimiento y la primera es la que importa para la maquetación; confundirlas es el origen de bastantes cálculos erróneos al convertir coordenadas.

Dónde está el tope y por qué

La recomendación estándar es limitar la relación a dos:

JS
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

El razonamiento tiene dos mitades. La primera es perceptual: la mejora de nitidez al pasar de uno a dos es evidente para cualquiera; la de dos a tres es sutil incluso en una pantalla de móvil sostenida cerca, y para geometría 3D con suavizado de bordes es prácticamente indistinguible. La segunda es de coste: ese salto poco perceptible cuesta un ciento veinticinco por ciento más de trabajo por fragmento.

Es la definición de mal negocio, y por eso el tope de dos aparece en prácticamente todos los proyectos serios.

Hay tres situaciones en las que conviene desviarse. Baja a uno y medio o a uno cuando la escena tiene mucho sombreado por fragmento —post-procesado, transparencias apiladas, materiales físicos con muchos mapas— o cuando el público objetivo es móvil de gama media; la pérdida de nitidez es mucho menos molesta que la pérdida de fluidez. Sube a tres solo cuando la escena es muy simple y la nitidez es el producto, por ejemplo un visor de tipografía o de líneas finas, donde el aliasing sí se nota. Y no lo toques si vas a implementar escalado dinámico, que es la solución completa al problema.

Escalado dinámico: la versión adulta

En lugar de fijar un número, se mide el tiempo de fotograma y se ajusta la resolución para mantener el objetivo. Es lo que hacen las consolas desde hace más de una década y lo que permite que una escena pesada siga siendo fluida en hardware que no da para tanto.

JS
import * as THREE from 'three';const OBJETIVO_MS = 16.7;const MIN = 0.6;const MAX = 2;let escala = Math.min(window.devicePixelRatio, MAX);let acumulado = 0;let cuenta = 0;export function ajustarResolucion(renderer, camera, dtMs) {  acumulado += dtMs;  cuenta += 1;  if (cuenta < 30) return;                    // decidir con medio segundo de datos  const medio = acumulado / cuenta;  acumulado = 0;  cuenta = 0;  const anterior = escala;  if (medio > OBJETIVO_MS * 1.2) escala = Math.max(MIN, escala - 0.1);  else if (medio < OBJETIVO_MS * 0.7) escala = Math.min(MAX, escala + 0.1);  if (escala !== anterior) {    renderer.setPixelRatio(escala);    const canvas = renderer.domElement;    renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);    camera.updateProjectionMatrix();  }}

Dos decisiones de ese código merecen explicación. Se promedia sobre treinta fotogramas en lugar de reaccionar al instante, porque un solo fotograma lento —un recolector de basura, una compilación de shader— no significa que el sistema no dé abasto, y reaccionar a cada pico produce una resolución que oscila de forma visible. Y las bandas de subida y bajada son asimétricas: se baja con un veinte por ciento de exceso y se sube solo con un treinta por ciento de margen, para que el sistema no entre en un ciclo de subir y bajar alrededor del umbral.

La relación cambia sin recargar

Un detalle que rompe aplicaciones y que casi nadie contempla: devicePixelRatio no es constante durante la vida de la página. Cambia al arrastrar la ventana de un monitor a otro con densidad distinta, al hacer zoom con el teclado, y al modificar la escala de la interfaz del sistema.

No hay ningún evento dedicado, pero hay una técnica estándar: una consulta de medios sobre la resolución actual que deja de cumplirse en cuanto la relación cambia.

JS
export function vigilarRelacionDePixeles(alCambiar) {  let consulta = null;  function suscribir() {    if (consulta) consulta.removeEventListener('change', alCambiarInterno);    consulta = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);    consulta.addEventListener('change', alCambiarInterno, { once: true });  }  function alCambiarInterno() {    alCambiar(window.devicePixelRatio);    suscribir();                 // la consulta anterior ya no sirve  }  suscribir();  return () => consulta && consulta.removeEventListener('change', alCambiarInterno);}

La consulta hay que reconstruirla en cada cambio porque está escrita contra el valor concreto que había: una vez que deja de cumplirse, no volverá a hacerlo. Es un patrón poco elegante y es el que funciona.

Si tu redimensionado sigue el patrón de comprobar el tamaño dentro del bucle que vimos en la lección anterior, este problema se resuelve solo, porque la comparación entre el tamaño de presentación y el del búfer detecta la discrepancia en el siguiente fotograma. Es una razón más para preferir ese patrón a los eventos.