wandres.dev
PRODUCCIÓN · Carga, accesibilidad y fallback

Detectar la capacidad del dispositivo y degradar con dignidad

Por qué las señales estáticas del navegador mienten, cómo medir la capacidad real en los primeros segundos, y cómo construir niveles de calidad que se ajusten solos sin oscilar.

⏱ 20 min

La pregunta “qué GPU tiene este usuario” no tiene respuesta fiable en la web, y es mejor así: la cadena que identificaba el modelo exacto de tarjeta era una huella de rastreo excelente y los navegadores la han ido cerrando. Lo que queda son señales indirectas que se equivocan mucho y una técnica que no se equivoca casi nunca: renderizar y medir. Una escena que se adapta bien no clasifica el dispositivo al arrancar, sino que empieza por un nivel razonable y ajusta según lo que mida, con la disciplina suficiente para no oscilar entre dos niveles cada tres segundos.

🎯 Al terminar esta lección sabrás
  • Enumerar las señales estáticas disponibles y explicar en qué se equivoca cada una.
  • Construir un sistema de niveles de calidad con parámetros explícitos y aplicables en caliente.
  • Medir el tiempo de fotograma con una ventana deslizante y decidir sin oscilar.
  • Elegir el nivel inicial con una heurística conservadora que no arruine el primer segundo.

Las señales estáticas y su margen de error

Cuatro señales están disponibles antes de renderizar nada, y las cuatro tienen un margen de error grande.

const senales = {
  // Nucleos logicos. Correlaciona debilmente con la potencia de GPU.
  nucleos: navigator.hardwareConcurrency ?? 4,

  // GB de RAM, redondeado a la baja en potencias de dos. No en todos
  // los navegadores, y en algunos esta limitado a 8 como maximo.
  memoria: navigator.deviceMemory ?? 4,

  // Densidad de pixeles. Alta densidad significa mas pixeles que rellenar,
  // no mas potencia para rellenarlos.
  densidad: window.devicePixelRatio,

  // Tipo de puntero: 'coarse' sugiere movil o tableta.
  tactil: matchMedia('(pointer: coarse)').matches,
};

hardwareConcurrency cuenta hilos de CPU, y en el 3D web el cuello está casi siempre en la GPU. Un portátil de trabajo con dieciséis hilos y gráfica integrada rinde peor en 3D que un móvil de gama alta con ocho.

deviceMemory es RAM del sistema, no memoria de vídeo, y llega redondeada y acotada por privacidad. Sirve para descartar los casos extremos y poco más.

devicePixelRatio es la señal más útil de todas, pero al revés de lo que sugiere la intuición: un valor de tres significa que hay nueve veces más píxeles que rellenar que con un valor de uno. Es un multiplicador de coste, no un indicador de potencia.

pointer: coarse identifica dispositivos táctiles con bastante fiabilidad, y ahí sí hay una correlación razonable con un presupuesto térmico limitado.

Three expone además las capacidades reales del contexto, que son datos duros y no heurísticas:

const cap = renderer.capabilities;
console.log({
  maxTextura: cap.maxTextureSize,        // 4096 en hardware antiguo, 16384 hoy
  maxAtributos: cap.maxAttributes,
  maxMuestras: cap.maxSamples,           // muestras de MSAA disponibles
  anisotropia: cap.getMaxAnisotropy(),   // 1 en hardware muy limitado
  precision: cap.precision,              // 'highp' o 'mediump'
});

maxTextureSize de 4096 y precision de mediump son señales claras de un dispositivo antiguo, y en ese caso sí conviene arrancar directamente en el nivel más bajo. getMaxAnisotropy() devolviendo uno significa que la extensión no está, lo cual es raro hoy pero indica hardware muy modesto.

⚠️
Cuidado

La extensión que revelaba el nombre del chip gráfico está restringida o desactivada en la mayoría de navegadores modernos por motivos de privacidad, y cuando responde puede devolver una cadena genérica. Cualquier lógica basada en buscar palabras dentro de esa cadena está condenada a fallar en los dispositivos que más te importan. No construyas tu estrategia de calidad sobre ella.

Niveles de calidad explícitos

Antes de medir nada hace falta tener qué ajustar. Un sistema de calidad útil declara los parámetros en un objeto y tiene una función que los aplica; nada de condicionales repartidas por el código.

import * as THREE from 'three';

export const NIVELES = {
  bajo: {
    escalaPixel: 1.0,
    sombras: false,
    tamanoSombra: 512,
    anisotropia: 1,
    postproceso: false,
    maxLuces: 2,
    segmentosCurva: 16,
  },
  medio: {
    escalaPixel: 1.5,
    sombras: true,
    tamanoSombra: 1024,
    anisotropia: 4,
    postproceso: false,
    maxLuces: 4,
    segmentosCurva: 32,
  },
  alto: {
    escalaPixel: 2.0,
    sombras: true,
    tamanoSombra: 2048,
    anisotropia: 8,
    postproceso: true,
    maxLuces: 6,
    segmentosCurva: 64,
  },
};

export function aplicarNivel(nombre, { renderer, scene, composer, luces }) {
  const n = NIVELES[nombre];

  renderer.setPixelRatio(Math.min(window.devicePixelRatio, n.escalaPixel));
  renderer.shadowMap.enabled = n.sombras;

  for (const luz of luces) {
    luz.castShadow = n.sombras && luz.userData.puedeProyectar === true;
    if (luz.shadow) {
      luz.shadow.mapSize.setScalar(n.tamanoSombra);
      // Forzar la reconstruccion del mapa con el nuevo tamano
      luz.shadow.map?.dispose();
      luz.shadow.map = null;
    }
  }

  scene.traverse((o) => {
    if (!o.isMesh) return;
    const mats = Array.isArray(o.material) ? o.material : [o.material];
    for (const m of mats) {
      if (m.map) m.map.anisotropy = n.anisotropia;
    }
  });

  if (composer) composer.enabled = n.postproceso;

  return n;
}

Dos puntos técnicos. El primero: cambiar mapSize de una sombra no basta, hay que liberar el mapa existente y ponerlo a null para que Three lo reconstruya con el tamaño nuevo. Sin eso, el cambio no se aplica hasta que algo más fuerce la recreación.

El segundo: cambiar shadowMap.enabled invalida la clave de caché de todos los materiales afectados y provoca una recompilación. Es un cambio caro, así que no debe formar parte de un ajuste automático que se dispare cada pocos segundos. Los parámetros baratos (escala de píxel, anisotropía) se pueden tocar en caliente; los caros (sombras, post-proceso) solo en transiciones deliberadas.

Medir sin oscilar

Medir un fotograma no dice nada: hay picos por recolección de basura, por compilación y por el propio navegador. Lo que sirve es una ventana deslizante y, sobre todo, un percentil en lugar de una media, porque lo que arruina la experiencia son los picos y la media los esconde.

export function crearVigilante({ objetivoMs = 16.7, muestras = 120 } = {}) {
  const buffer = new Float32Array(muestras);
  let indice = 0;
  let llenas = 0;
  let ultimo = performance.now();

  function medir() {
    const ahora = performance.now();
    buffer[indice] = ahora - ultimo;
    indice = (indice + 1) % muestras;
    llenas = Math.min(llenas + 1, muestras);
    ultimo = ahora;
  }

  function percentil(p) {
    if (llenas < muestras) return null;   // aun no hay datos suficientes
    const copia = Array.from(buffer.slice(0, llenas)).sort((a, b) => a - b);
    return copia[Math.floor(copia.length * p)];
  }

  return {
    medir,
    /** Devuelve 'bajar', 'subir' o null */
    veredicto() {
      const p90 = percentil(0.9);
      if (p90 === null) return null;
      if (p90 > objetivoMs * 1.35) return 'bajar';
      if (p90 < objetivoMs * 0.6) return 'subir';
      return null;
    },
    reiniciar() {
      llenas = 0;
      indice = 0;
    },
  };
}

Los dos umbrales asimétricos son lo que evita la oscilación. Bajar cuando el percentil noventa supera el objetivo en un treinta y cinco por ciento; subir solo cuando está claramente por debajo, en el sesenta por ciento del objetivo. La banda muerta entre ambos garantiza que un dispositivo que rinde justo en el límite se quede en un nivel y no salte.

El uso en el bucle, con la disciplina completa:

const vigilante = crearVigilante({ objetivoMs: 16.7 });
const orden = ['bajo', 'medio', 'alto'];
let indiceNivel = 1;
let ultimoCambio = 0;
let vecesQueBajo = 0;

function frame() {
  vigilante.medir();

  const ahora = performance.now();
  if (ahora - ultimoCambio > 4000) {
    const v = vigilante.veredicto();

    if (v === 'bajar' && indiceNivel > 0) {
      indiceNivel--;
      vecesQueBajo++;
      aplicarNivel(orden[indiceNivel], contexto);
      vigilante.reiniciar();
      ultimoCambio = ahora;
    } else if (v === 'subir' && indiceNivel < orden.length - 1 && vecesQueBajo === 0) {
      // Solo sube si nunca ha tenido que bajar: evita el sube y baja
      indiceNivel++;
      aplicarNivel(orden[indiceNivel], contexto);
      vigilante.reiniciar();
      ultimoCambio = ahora;
    }
  }

  renderer.render(scene, camera);
}

Las tres guardas son las que convierten un sistema teóricamente correcto en uno usable. El intervalo mínimo de cuatro segundos entre cambios evita que un pico transitorio degrade la escena. El reinicio del vigilante tras cada cambio descarta las mediciones del nivel anterior. Y el bloqueo de las subidas después de la primera bajada convierte el sistema en monótono: una vez que ha demostrado que el dispositivo no da para más, deja de intentarlo.

Nivel dios

El error de diseño que arruina estos sistemas es medir durante los primeros segundos. El arranque es la peor ventana posible: se están compilando shaders, subiendo texturas, ejecutando la primera recolección de basura y, en un portátil, la GPU todavía está en su estado de bajo consumo y tarda uno o dos segundos en subir de frecuencia. Un sistema que mide desde el primer fotograma degrada la calidad en máquinas perfectamente capaces y deja al usuario con una escena fea que nunca se recupera. La regla es descartar los tres primeros segundos completos y no tomar ninguna decisión antes. Y hay un caso peor que el arranque frío: el térmico. Un móvil rinde a tope dos o tres minutos y luego baja de frecuencia, así que una escena que midió bien al principio se hunde justo cuando el usuario se ha enganchado. Por eso el vigilante no se apaga nunca: tiene que seguir midiendo toda la sesión.

El nivel inicial

Con todo lo anterior, elegir el nivel de arranque es la parte fácil, porque el sistema se va a corregir solo. Lo único que importa es no empezar tan alto que el primer segundo sea horrible ni tan bajo que un dispositivo potente vea una escena fea durante cuatro segundos.

export function nivelInicial(renderer) {
  const cap = renderer.capabilities;

  // Descarte duro: hardware claramente antiguo
  if (cap.maxTextureSize < 8192 || cap.precision !== 'highp') return 'bajo';

  const tactil = matchMedia('(pointer: coarse)').matches;
  const memoria = navigator.deviceMemory ?? 4;
  const nucleos = navigator.hardwareConcurrency ?? 4;

  // Movil: empieza en medio y deja que el vigilante decida
  if (tactil) return memoria >= 6 && nucleos >= 6 ? 'medio' : 'bajo';

  // Escritorio: empieza en medio salvo senales claras de equipo modesto
  if (memoria <= 4 || nucleos <= 4) return 'medio';

  return 'alto';
}

Fíjate en que ningún camino devuelve alto en móvil. No es discriminación arbitraria: es que el presupuesto térmico de un dispositivo sin ventilador no admite el nivel alto de forma sostenida aunque lo aguante los primeros segundos, y arrancar ahí garantiza una degradación visible a los dos minutos. Es preferible empezar en medio y que el usuario nunca note un cambio.

Y una consideración que va más allá del rendimiento: si el usuario ha expresado preferencia por menos movimiento o está en una conexión medida, esa preferencia manda sobre cualquier medición. prefers-reduced-motion y prefers-reduced-data son declaraciones explícitas y tienen prioridad sobre lo que tu vigilante crea, como se trata en la accesibilidad del 3D.

⚔️ Reto práctico

Implementa el vigilante y el sistema de niveles en una escena, y añade un modo de depuración que dibuje en una esquina el percentil noventa actual, el nivel vigente y el número de cambios desde el arranque. Pruébalo forzando carga: abre otras diez pestañas con vídeo, o limita la CPU desde las herramientas de desarrollo. El sistema debería bajar una vez, estabilizarse, y no volver a moverse. Si sube y baja repetidamente, amplía la banda muerta entre los dos umbrales.