wandres.dev
PRIMER PROYECTO · Escena, cámara, renderer

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.

⏱ 16 min

La relación de píxeles del dispositivo es el único parámetro de una escena 3D que puede triplicar el coste de dibujado sin cambiar absolutamente nada de lo que se ve en ella. En un móvil moderno vale tres, lo que significa nueve veces más fragmentos que a uno. Ninguna otra decisión que tomes en el primer día del proyecto tiene ese efecto, y sin embargo la mayoría de los ejemplos la copian sin explicarla o la ignoran por completo.

🎯 Al terminar esta lección sabrás
  • Explicar la diferencia entre píxel de CSS y píxel de dispositivo.
  • Calcular el coste de fragmentos en función de la relación de píxeles.
  • Justificar el tope práctico de dos y cuándo conviene bajarlo o subirlo.
  • Detectar los cambios de relación de píxeles en tiempo de ejecución.

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.

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:

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.

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.

Suavizado de bordes y densidad alta son dos formas de pagar por lo mismo, y a partir de cierto punto una sobra

El suavizado por hardware que activa antialias: true funciona tomando varias muestras de cobertura por píxel para calcular los bordes con precisión subpíxel. Es una técnica excelente y tiene un coste real: memoria adicional para el búfer con muestras múltiples, ancho de banda al escribirlo y una operación de resolución al final del fotograma. En una pantalla con relación de píxeles uno, ese coste está más que justificado, porque los bordes escalonados se ven claramente. Ahora observa qué pasa con relación tres: cada píxel de CSS ya está compuesto por nueve píxeles físicos, y el propio proceso de mostrar la imagen en la pantalla ya promedia esos nueve. Estás pagando dos veces por resolver el mismo problema: una con más píxeles y otra con más muestras dentro de cada píxel. La consecuencia práctica es una optimización que casi nadie aplica y que en móvil se nota mucho: decide el suavizado en función de la densidad de la pantalla, activándolo cuando la relación es menor que dos y desactivándolo cuando es igual o mayor, con la resolución alta haciendo el trabajo. En un móvil típico eso libera memoria y ancho de banda con una pérdida de calidad que hay que buscar con lupa. El matiz que hay que conocer antes de aplicarlo: el suavizado no se puede cambiar en caliente, porque forma parte de la creación del contexto, así que la decisión se toma al construir el renderer y no se puede revisar después sin recrearlo entero. Y si usas post-procesado, el suavizado del contexto no se aplica a los objetivos intermedios, con lo que el debate cambia por completo y hay que resolverlo con una pasada de suavizado en el propio encadenado de efectos.

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.

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.