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.
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.
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.
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.