El iPhone que juraba tener 4 núcleos
WebKit capa hardwareConcurrency a 4 por privacidad. Cómo ese valor mandó un A17 al póster estático, y cinco lecciones para gates de capacidad WebGL.
Pasé tres rondas de optimización arreglando una escena WebGL que el teléfono de las quejas jamás llegó a ejecutar. Esta es la historia del bug más caro de la portada de Vansol, y de por qué tu iPhone te miente sobre su propio hardware — a propósito, y con permiso.
El síntoma: «no ha cambiado nada»
La portada de esta web es una escena 3D en tiempo real: una «V» de vidrio que se llena de agua, con refracción, cáusticas y una intro coreografiada. En escritorio iba fina. En móvil, no: la primera versión táctil salió borrosa y a tirones en un iPhone con chip A17.
Primera ronda de arreglos: post-procesado recortado, resolución adaptativa, texturas a la mitad. Respuesta desde el sofá: «sigue igual».
Segunda ronda: HDRI también en táctil, escalera de calidad que degrada por FPS medidos, modo arranque conservador. Respuesta: «no ha cambiado nada».
Lo primero que piensas es caché. Comprobado: recarga dura, red distinta, ventana privada. No era caché. Y aquí viene el detalle que me tuvo dos rondas persiguiendo fantasmas: el teléfono no estaba ejecutando la escena que yo optimizaba. Estaba viendo un JPEG.
El culpable: un gate de capacidad razonable
Como casi todo el mundo que hace WebGL en producción, la portada tiene un gate: antes de descargar los ~142 KB del chunk de three.js, decide si el aparato puede con la escena. El original era de manual:
// gate de capacidad v1 — parece sensato, es una trampa
const flojo =
navigator.hardwareConcurrency <= 4 ||
(navigator as any).deviceMemory < 4;
if (flojo) {
montaPoster(); // imagen estática + una gota mínima en canvas 2D
} else {
montaEscena3D(); // la escena real
}
Cuatro núcleos o menos: póster. Más de cuatro: escena. En 2026, «4 núcleos» suena a gama baja de hace años, así que el umbral parecía conservador y seguro.
El iPhone del problema lleva un A17 Pro: 6 núcleos de CPU y una GPU que come esta escena con las luces apagadas. ¿Sabéis qué devuelve navigator.hardwareConcurrency en ese teléfono?
4.
WebKit miente. Deliberadamente. Y hace bien
No es un bug de Safari: es una decisión de privacidad documentada. El número exacto de núcleos es una pieza más de fingerprinting — combinada con pantalla, fuentes, GPU y demás, ayuda a identificarte entre millones de usuarios. WebKit decidió capar el valor: en iOS, hardwareConcurrency devuelve 4 con independencia del chip que haya debajo. Un iPhone SE y un iPhone Pro del año contestan lo mismo. deviceMemory directamente no existe en Safari, por el mismo motivo.
Resultado: mi gate mandaba todos los iPhone del mundo al póster. Los tres primeros peldaños de mi flamante escalera de calidad adaptativa eran código muerto en la mitad del parque móvil.
Y aún faltaba la guinda. Para que el póster no pareciera un pantallazo pobre, lo había horneado desde el propio motor: mismo encuadre, misma malla tenue de fondo, mismo look. Tan parecido que, mirando capturas de WhatsApp a dos metros, el póster pasaba por «la escena con la calidad baja». Mi propio fallback digno me estuvo escondiendo el bug dos rondas enteras.
El arreglo: mide lo que puedas medir, no te creas lo que te cuenten
La conclusión operativa no es «súbete el umbral a 2». Es que en iOS la heurística de hardware del navegador no es una señal: es ruido con cara de señal. El arreglo tuvo tres patas:
1. La capacidad se pregunta a la API que no puede mentir. WebGL2 sí está obligado a decir la verdad, porque o está o no está:
// gate v2 — en táctil, la heurística de navigator solo marca el SUELO
const gl = canvas.getContext('webgl2');
if (!gl) return montaPoster(); // sin WebGL2 no hay debate
const esTactil = matchMedia('(pointer: coarse)').matches;
const flojo = esTactil
? (navigator as any).deviceMemory < 3 || navigator.hardwareConcurrency <= 2
: navigator.hardwareConcurrency <= 4; // en desktop el dato es fiable
En táctil, el umbral ya solo caza gama baja real (los aparatos que declaran 1–2 núcleos son emuladores o teléfonos de hace una década). El trabajo fino no lo hace la heurística: lo hace el siguiente punto.
2. La calidad se decide midiendo, no adivinando. La escena arranca en un peldaño intermedio y una escalera sube o baja según los FPS reales: resolución de render (dpr 2 → 0,85), tamaño máximo del buffer (2048 → 768) y resolución del pase de transmisión del vidrio. Dos detalles que costaron sangre:
- La banda muerta importa más que el umbral. Con «baja de peldaño a menos de 48 fps», un iPad clavado a ~50 fps vivía feliz en el peldaño caro — y 50 fps se sienten a tirones aunque el número parezca decente. La banda quedó en 53/58: por debajo de 53 baja, no sube hasta 58 sostenidos.
- Degrada resolución, jamás identidad. El último escalón de la v1 apagaba el post-procesado entero… y sin bloom ni tonemapping la escena perdía la cara: fondo negro, malla invisible. El look degradado tiene que seguir siendo el look. Ahora el post no se apaga nunca en táctil; solo pierde píxeles.
3. El teléfono mentiroso vive en el arnés de pruebas. Si WebKit finge tener 4 núcleos, mi CI también:
// puppeteer: un «iphone-webkit» que miente como el de verdad
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 });
});
Ese perfil espera a que el 3D real monte — no el póster — y falla la build si algún día otro gate ingenuo vuelve a colarse. Junto a él, la otra lección del arnés: las pruebas de GPU corren con --use-angle=metal, GPU de verdad. Con SwiftShader (el render por software con el que Chrome headless te obsequia en silencio) el primer frame tardaba más de un minuto y cualquier medición de FPS era ficción.
Lo que me llevo
- En iOS,
navigatores un testigo hostil. Núcleos y memoria están capados por privacidad. No construyas decisiones de calidad sobre ellos en táctil. - Capacidad = lo que la API garantiza (WebGL2 sí/no) + lo que tú mides (FPS reales). La heurística de hardware, solo como suelo grosero.
- Tu fallback bonito puede esconder tu bug. Si el póster imita la escena, añade un chivato (
?debugque estampa qué camino montó). Cuesta una línea; me habría ahorrado dos rondas. - La banda muerta define qué significa «fluido», no el umbral. Y 50 fps no es fluido, por mucho que el número lo parezca.
- Reproduce las mentiras del navegador en CI. Un bug que depende de un valor capado solo se caza con un perfil que también lo cape.
La escena vive en la portada de esta misma web — 67 KB de JS inicial, el chunk 3D diferido, y la escalera completa funcionando en cualquier iPhone que jure tener 4 núcleos.