wandres.dev
WEBXR · Realidad virtual y aumentada

Rendimiento en visores: dos ojos, noventa hercios y lo que queda

El cálculo del presupuesto real dentro de un visor, qué hacen setFoveation y setFramebufferScaleFactor, qué es multiview, y qué técnicas de escritorio hay que abandonar.

⏱ 21 min

Una escena que va a sesenta fotogramas en un monitor no va a sesenta en un visor: va a la mitad o menos, porque hay que renderizarla dos veces y a una resolución por ojo que suele superar la del monitor. Y el objetivo ya no es sesenta sino noventa o ciento veinte, porque por debajo de la cadencia nativa del visor el usuario no ve tirones: siente mareo. Es el único contexto del desarrollo web donde perder fotogramas tiene consecuencias fisiológicas, y eso cambia todas las prioridades.

🎯 Al terminar esta lección sabrás
  • Calcular el presupuesto real de fragmentos y de milisegundos dentro de una sesión inmersiva.
  • Usar setFramebufferScaleFactor y setFoveation con criterio y conocer sus límites.
  • Explicar qué es multiview y qué ahorra exactamente.
  • Descartar las técnicas de escritorio que no sobreviven al visor.

La aritmética del presupuesto

Empecemos por los números, porque son brutales y aclaran el resto.

Un visor de gama media tiene del orden de dos mil por dos mil doscientos píxeles por ojo. Eso son cuatro millones cuatrocientos mil fragmentos por ojo, ocho millones ochocientos mil por fotograma. Una pantalla de portátil a 1920 por 1080 son dos millones. El visor pide más de cuatro veces el trabajo de relleno de un monitor normal.

A noventa hercios, el presupuesto es de 11,1 milisegundos por fotograma. A ciento veinte, 8,3. En un monitor a sesenta tenías 16,6. Junta las dos cosas: cuatro veces más fragmentos en dos tercios del tiempo. El factor total frente a una escena de escritorio a sesenta hercios está en torno a seis.

Contexto Píxeles por frame Presupuesto
Monitor 1080p a 60 Hz 2,1 M 16,6 ms
Monitor 1440p a 60 Hz 3,7 M 16,6 ms
Visor de gama media a 90 Hz 8,8 M 11,1 ms
Visor de gama alta a 120 Hz 12 M o más 8,3 ms

Y hay una capa más que no aparece en la tabla. El visor tiene que hacer su propio trabajo con tu imagen: corrección de distorsión de las lentes, corrección de aberración cromática, y reproyección temporal, que reajusta la imagen a la última pose de la cabeza justo antes de mostrarla. Todo eso consume parte del presupuesto de la GPU y no lo controlas.

La consecuencia inmediata es que las técnicas caras del escritorio no caben. Un post-procesado con bloom y oclusión ambiental que costaba tres milisegundos en el monitor cuesta doce en el visor, es decir, el fotograma entero. No es cuestión de optimizarlo: es cuestión de no tenerlo.

Los dos mandos: escala y foveación

Three expone dos controles directos sobre el coste de relleno, y son las dos primeras palancas que hay que tocar.

// Escala del framebuffer: multiplica la resolucion recomendada del dispositivo.
// Hay que llamarlo ANTES de entrar en la sesion.
renderer.xr.setFramebufferScaleFactor(0.8);

// Foveacion: 0 = sin foveacion, 1 = maxima.
// Se puede cambiar en cualquier momento, tambien dentro de la sesion.
renderer.xr.setFoveation(1.0);

setFramebufferScaleFactor multiplica la resolución que el dispositivo recomienda. Un valor de 0,8 reduce el número de fragmentos a un 64 por ciento, porque el área escala con el cuadrado. Es la palanca más contundente que existe y la primera que hay que probar. Por debajo de 0,7 el texto empieza a ser difícil de leer, así que ese suele ser el suelo práctico. Y tiene que llamarse antes de la sesión: dentro no tiene efecto.

setFoveation aprovecha que el ojo humano solo tiene alta resolución en el centro del campo visual. Con foveación activa, el dispositivo renderiza la periferia de la imagen a menor resolución y el centro a resolución completa. En visores con foveación fija el ahorro es del orden del veinte al treinta por ciento del coste de relleno, y el usuario no lo percibe porque su periferia visual tampoco resuelve ahí.

El valor por defecto de Three no es el máximo, así que subirlo a uno es dinero gratis en la mayoría de las experiencias. Solo conviene bajarlo si la experiencia tiene contenido importante en la periferia, cosa rara porque el usuario simplemente giraría la cabeza para mirarlo.

// Estrategia adaptativa: mide y ajusta la escala entre sesiones
let escala = 1.0;

renderer.xr.addEventListener('sessionstart', () => {
  renderer.xr.setFoveation(1.0);
});

function medirYAjustar(msMedios) {
  const objetivo = 11.1;   // 90 Hz
  if (msMedios > objetivo * 0.9 && escala > 0.7) {
    escala = Math.max(0.7, escala - 0.1);
    renderer.xr.setFramebufferScaleFactor(escala);
    // Requiere reiniciar la sesion para que surta efecto
  }
}

La limitación de la escala del framebuffer es que solo aplica al crear la capa, así que un ajuste adaptativo dentro de la sesión no es posible por esa vía. Lo que sí se puede ajustar en caliente es la calidad de lo que dibujas: bajar la resolución de las sombras, desactivar efectos, reducir el número de luces.

⚠️
Cuidado

renderer.setPixelRatio no tiene ningún efecto dentro de una sesión inmersiva. La resolución la fija el dispositivo y se modula con setFramebufferScaleFactor. Es un error frecuente ajustar el pixel ratio pensando que se está reduciendo el coste dentro del visor, y no reducir absolutamente nada.

Multiview: renderizar los dos ojos de una vez

El coste doble no es solo de fragmentos: por defecto, cada ojo implica recorrer la escena entera, emitir todas las llamadas de dibujo y ejecutar todos los vertex shaders otra vez. Multiview es la extensión que permite emitir una sola llamada de dibujo que produce las dos vistas, con el vertex shader ejecutándose una vez por vista pero con un solo recorrido de CPU.

En r184, el WebGPURenderer acepta la opción al construirlo:

import * as THREE from 'three/webgpu';

const renderer = new THREE.WebGPURenderer({
  antialias: true,
  multiview: true,
});
renderer.xr.enabled = true;

Lo que ahorra multiview es el trabajo de CPU y el de vértices, no el de fragmentos. Si tu escena está limitada por relleno, no vas a notar nada. Si está limitada por el número de llamadas de dibujo, el ahorro puede acercarse al cuarenta por ciento, porque es justo lo que se duplicaba.

El criterio para saber si te interesa es el de siempre: mide primero. La prueba concreta en XR es bajar la escala del framebuffer a la mitad. Si el frame mejora mucho, estás limitado por relleno y multiview no te va a ayudar. Si apenas cambia, estás limitado por CPU o por vértices y multiview es exactamente tu solución.

Qué hay que abandonar y qué lo sustituye

La lista de técnicas de escritorio que no sobreviven al visor es más larga de lo que gusta, y cada una tiene un sustituto.

El post-procesado con varias pasadas. Cada pasada de pantalla completa cuesta ocho millones y pico de fragmentos. Dos pasadas se comen el presupuesto entero. La regla en XR es cero pasadas de post-proceso salvo que sean muy baratas y muy necesarias. El sustituto es hornear los efectos en los materiales: el bloom se sustituye por materiales emisivos bien iluminados, la corrección de color por texturas ya corregidas, el viñeteado por un plano oscuro pegado a la cámara (que además ayuda contra el mareo).

Las sombras dinámicas de alta calidad. Un mapa de sombras de 2048 con filtrado suave es una pasada de geometría extra más un coste alto por fragmento. El sustituto es hornear la iluminación estática en lightmaps y reservar las sombras dinámicas para lo que se mueve, con mapas pequeños y shadowMap.autoUpdate = false renovado solo cuando algo cambia.

La transparencia generosa. El overdraw en XR se paga cuatro veces. Los efectos de humo y partículas grandes que en escritorio costaban dos milisegundos aquí cuestan ocho. El sustituto es reducir el área y el número de capas, y aceptar que un efecto de partículas en VR tiene que ser mucho más discreto.

Los materiales físicos completos. MeshPhysicalMaterial con transmisión obliga a dibujar la escena entera una segunda vez sobre un render target. En escritorio esa pasada es una sola y la comparten todos los objetos transmisivos, así que lo caro es el primer cristal; en XR la cámara es una ArrayCamera y el renderer repite la pasada una vez por ojo, de modo que un solo objeto de vidrio duplica el coste de toda la escena por partida doble. En un visor eso es inviable. El sustituto es MeshStandardMaterial con un buen entorno, que da el noventa por ciento del resultado por una fracción del coste.

La resolución nativa por defecto. Ya está dicho, pero merece repetirse porque es lo que más se ignora: bajar la escala del framebuffer a 0,8 es más eficaz que casi cualquier otra optimización y casi nadie lo prueba antes de reescribir shaders.

Nivel dios

El fotograma perdido en XR no es un problema estético. Cuando la imagen no llega a tiempo, el sistema aplica reproyección para compensar el movimiento de la cabeza, y esa reproyección corrige la rotación pero no puede corregir la paralaje: los objetos cercanos se quedan atrás durante un instante. El cerebro detecta la incoherencia entre lo que el oído interno dice que se ha movido y lo que los ojos ven, y esa incoherencia es exactamente el mecanismo del mareo por movimiento. Por eso en XR el objetivo no es “un buen framerate medio” sino cero fotogramas perdidos: una escena que va a noventa el noventa y cinco por ciento del tiempo y baja a cuarenta y cinco el cinco por ciento restante es peor experiencia que una que va a setenta y dos estable con la mitad de calidad visual. Presupuesta para el peor caso y déjate margen, porque en el visor la degradación no se ve, se siente.

El presupuesto que queda, en cifras concretas

Con todo lo anterior aplicado, estos son los órdenes de magnitud con los que trabajar en un visor autónomo de gama media. No son límites duros sino puntos de partida razonables para presupuestar antes de escribir la escena.

Entre cincuenta y ciento cincuenta llamadas de dibujo por ojo, dependiendo de cuánto multiview te ahorre. Entre trescientos mil y un millón de triángulos visibles. Menos de doscientos megabytes de texturas, con compresión de bloque obligatoria. Cuatro luces dinámicas como mucho, y solo una proyectando sombras. Cero pasadas de post-procesado, o una muy barata. Overdraw por debajo de dos.

Y una última cifra que rara vez se menciona: el presupuesto de CPU en JavaScript es de unos tres o cuatro milisegundos por fotograma. A noventa hercios eso deja muy poco margen para lógica de juego, física o recorridos del grafo. Todo lo dicho en el nivel anterior sobre no reconciliar en el bucle deja de ser una buena práctica y pasa a ser una condición de funcionamiento.

La forma de comprobar que vas bien no es el contador de fotogramas del navegador, que dentro de la sesión no es fiable. Los visores tienen herramientas propias de perfilado que muestran el tiempo de GPU real por fotograma y, sobre todo, cuántos fotogramas se han perdido. Ese es el número que hay que vigilar, y es el único que se correlaciona con la sensación del usuario.

⚔️ Reto práctico

Coge una escena que funcione bien en escritorio y llévala al visor. Antes de tocar nada, anota el tiempo de fotograma. Después aplica en este orden y mide entre cada paso: foveación al máximo, escala de framebuffer a 0,8, eliminar el post-procesado, y reducir el mapa de sombras a la mitad. La sorpresa habitual es que los dos primeros pasos, que cuestan dos líneas, dan más que los dos últimos, que cuestan reescribir la escena.