Cuando no hay WebGL: detección, contexto perdido y fallback
Cómo comprobar el soporte antes de construir nada, qué hacer cuando el navegador te quita el contexto en mitad de la sesión, y cómo diseñar un sustituto que no sea un rectángulo gris.
Que WebGL 2 esté disponible en todos los motores no significa que esté disponible en todas las visitas. Hay usuarios con la aceleración por hardware desactivada, hay perfiles corporativos que la bloquean, hay navegadores en modo de ahorro extremo, hay controladores en lista negra, y hay una situación que le ocurre a todo el mundo tarde o temprano: el navegador decide quitarte el contexto en mitad de la sesión porque otra pestaña lo necesita o porque la GPU se ha reiniciado. Ninguno de esos casos es un fallo de tu código, y todos se manifiestan igual si no los tratas: un rectángulo negro sin explicación.
- Comprobar el soporte de WebGL 2 antes de construir el renderer y sin consumir un contexto.
- Gestionar la pérdida y la restauración del contexto sin recargar la página.
- Diseñar un sustituto que transmita el contenido real, no la ausencia de tecnología.
- Decidir cuándo
failIfMajorPerformanceCaveatayuda y cuándo estorba.
Comprobar antes de construir
El addon de capacidades trae la comprobación hecha, y hace exactamente lo mínimo necesario: crea un canvas suelto, intenta obtener un contexto de WebGL 2 y devuelve un booleano.
import WebGL from 'three/addons/capabilities/WebGL.js';
if (!WebGL.isWebGL2Available()) {
const aviso = WebGL.getWebGL2ErrorMessage();
contenedor.appendChild(aviso);
} else {
arrancarEscena();
}
getWebGL2ErrorMessage devuelve un div con un mensaje en inglés y estilos en línea. Sirve para una demo; en producción querrás tu propio mensaje, en tu idioma y con tu diseño, y sobre todo con contenido alternativo real en lugar de una disculpa.
Un matiz sobre el coste: el canvas de prueba crea un contexto que cuenta para el límite de contextos simultáneos del navegador hasta que se recoge. En la práctica es intrascendente si compruebas una vez, pero comprobar en un bucle o en cada componente es una forma sorprendentemente eficaz de agotar el límite.
Si construyes el renderer directamente sin comprobar, el constructor lanza cuando no puede obtener contexto. Envolver en un try es una alternativa válida y evita el contexto de prueba:
let renderer = null;
try {
renderer = new THREE.WebGLRenderer({ antialias: true });
} catch (e) {
mostrarAlternativa();
}
failIfMajorPerformanceCaveat
Hay una opción del contexto que merece una decisión consciente. Con failIfMajorPerformanceCaveat: true, el navegador rechaza crear el contexto si va a usar un renderizador por software en lugar de la GPU.
const renderer = new THREE.WebGLRenderer({
antialias: true,
failIfMajorPerformanceCaveat: true,
});
Un renderizador por software funciona: dibuja correctamente. Lo que no hace es ir rápido. Una escena que va a sesenta fotogramas con GPU va a dos con SwiftShader, y ese es un resultado peor que no mostrar nada, porque además calienta la máquina y bloquea la interfaz.
El criterio es el tipo de contenido. Si tu 3D es la funcionalidad principal y no tiene sentido a dos fotogramas, activa la opción y ofrece la alternativa. Si tu 3D es un adorno que puede ir lento sin arruinar la página, déjala desactivada.
Hay una advertencia importante: el comportamiento de esta bandera no es idéntico entre navegadores, y algunos la ignoran en ciertas configuraciones. No la uses como única defensa; combínala con la medición de rendimiento de detectar la capacidad, que detecta el caso de todos modos.
El contexto perdido
Esta es la parte que casi ningún proyecto trata y que le ocurre a todos los usuarios. El navegador puede retirar el contexto WebGL en cualquier momento, y las causas son variadas y ninguna es culpa tuya: la GPU se ha reiniciado tras un cuelgue del controlador, el sistema ha entrado en suspensión, otra pestaña ha agotado la memoria de vídeo, o el usuario ha superado el límite de contextos simultáneos abriendo más pestañas con 3D.
Cuando ocurre, el canvas se queda como estaba en el último fotograma o en negro, y todas las llamadas de WebGL fallan silenciosamente. Sin manejadores, el usuario ve una escena congelada sin ninguna explicación.
const lienzo = renderer.domElement;
lienzo.addEventListener(
'webglcontextlost',
(evento) => {
// Sin preventDefault, el navegador NO intentara restaurar el contexto
evento.preventDefault();
renderer.setAnimationLoop(null);
mostrarAviso('Se ha interrumpido la aceleración gráfica. Restaurando…');
},
false,
);
lienzo.addEventListener(
'webglcontextrestored',
() => {
ocultarAviso();
// Los recursos de GPU se han perdido. Three vuelve a subir geometrias,
// texturas y programas la proxima vez que se usen, pero el estado
// del renderer hay que reponerlo.
reponerEstadoDelRenderer();
renderer.setAnimationLoop(frame);
},
false,
);
La llamada a preventDefault en el evento de pérdida es obligatoria y contraintuitiva. Sin ella, el navegador da el contexto por perdido definitivamente y nunca emite el evento de restauración. Con ella, el navegador se compromete a restaurarlo cuando pueda.
Lo que hay que reponer tras la restauración es el estado que vive en el renderer y no en los objetos de la escena:
function reponerEstadoDelRenderer() {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(contenedor.clientWidth, contenedor.clientHeight, false);
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
// Los render targets del post-proceso hay que redimensionarlos
composer?.setSize(contenedor.clientWidth, contenedor.clientHeight);
}
Las geometrías, materiales y texturas se vuelven a subir solos: Three detecta que el recurso no está en la GPU y lo sube en el siguiente uso, porque los datos siguen en la memoria de JavaScript. Lo que no vuelve solo es lo que se generó en la GPU y no tiene copia en CPU: los mapas de entorno prefiltrados con PMREM, las texturas escritas por un render a textura, y cualquier simulación cuyo estado viva en un render target. Esos hay que regenerarlos explícitamente.
La forma más rápida de probar este flujo es forzarlo. La extensión WEBGL_lose_context expone loseContext() y restoreContext(), y renderer.forceContextLoss() usa la primera internamente. Prueba la pérdida durante el desarrollo: es la única manera de descubrir qué recursos de tu escena no se reponen solos, y descubrirlo en producción sale mucho más caro.
El sustituto que sí sirve
Un mensaje que dice “tu navegador no soporta WebGL” es un callejón sin salida. El usuario no puede hacer nada al respecto y se va. Lo que hay que ofrecer es el contenido, no una explicación de por qué falta.
Piensa en qué comunica tu escena 3D y cuál es la forma más simple de comunicar lo mismo. Un configurador de producto comunica cómo se ve el producto en cada combinación: la alternativa es una galería de fotos, una por combinación, generadas de antemano renderizando la misma escena. Una visualización de datos comunica una relación entre magnitudes: la alternativa es un gráfico plano o una tabla. Una experiencia narrativa comunica una historia: la alternativa es el texto y las imágenes.
<div class="visor" data-modo="alternativa">
<!-- Esto existe SIEMPRE en el HTML. El 3D lo sustituye si puede. -->
<picture>
<source srcset="/producto/frontal.avif" type="image/avif" />
<img
src="/producto/frontal.jpg"
alt="Silla Kompas en roble claro, vista de tres cuartos, con el asiento tapizado en lana gris."
width="1200" height="900"
/>
</picture>
<div class="controles-alternativos">
<button data-vista="frontal" aria-pressed="true">Frontal</button>
<button data-vista="lateral" aria-pressed="false">Lateral</button>
<button data-vista="cenital" aria-pressed="false">Cenital</button>
</div>
</div>
import WebGL from 'three/addons/capabilities/WebGL.js';
const visor = document.querySelector('.visor');
if (WebGL.isWebGL2Available()) {
// Solo entonces se sustituye la alternativa por el canvas
visor.dataset.modo = '3d';
arrancarEscena(visor);
}
Este orden (alternativa primero, mejora después) es mejora progresiva aplicada al 3D, y tiene tres ventajas que van más allá del caso sin WebGL. Funciona sin JavaScript. Es lo que se ve mientras el motor descarga, así que elimina el hueco vacío durante la carga. Y es lo que indexan los buscadores y lo que leen los lectores de pantalla, que es lo que hace de esto una cuestión de accesibilidad además de robustez.
Las tres vistas del ejemplo se generan renderizando tu propia escena desde tres ángulos en el paso de construcción, con la misma iluminación y los mismos materiales. Un script de Node con Three.js sobre un canvas fuera de pantalla lo hace, o simplemente capturas manuales si el catálogo es pequeño. El resultado es coherente con el 3D porque es el 3D, congelado.
Hay un caso de fallo que no se detecta con ninguna comprobación previa y que es más frecuente de lo que parece: el contexto se crea correctamente y el primer render tarda cuatro segundos porque el controlador está compilando shaders en software o porque la GPU virtualizada de una máquina remota no da abasto. isWebGL2Available responde que sí, failIfMajorPerformanceCaveat no salta porque técnicamente hay aceleración, y el usuario ve la página congelada. La única defensa es cronometrar el primer render con un límite: si el primer renderer.render tarda más de un segundo y medio, la escena no va a ser usable y conviene cambiar a la alternativa antes de que el usuario abandone. Un performance.now() antes y después del primer render, y una decisión, son diez líneas que salvan la visita en toda una clase de entornos que nunca vas a poder reproducir.
Un envoltorio que junta las tres defensas
import * as THREE from 'three';
import WebGL from 'three/addons/capabilities/WebGL.js';
export function montarVisor(contenedor, { limitePrimerRenderMs = 1500 } = {}) {
if (!WebGL.isWebGL2Available()) {
contenedor.dataset.modo = 'alternativa';
return null;
}
let renderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true });
} catch {
contenedor.dataset.modo = 'alternativa';
return null;
}
const escena = construirEscena(renderer, contenedor);
const t0 = performance.now();
renderer.render(escena.scene, escena.camera);
const coste = performance.now() - t0;
if (coste > limitePrimerRenderMs) {
escena.destruir();
contenedor.dataset.modo = 'alternativa';
return null;
}
contenedor.dataset.modo = '3d';
conectarPerdidaDeContexto(renderer, escena);
renderer.setAnimationLoop(escena.frame);
return escena;
}
Tres defensas encadenadas, cada una para un modo de fallo distinto: no hay contexto, el contexto falla al crearse, el contexto existe pero es inservible. Y en los tres casos el resultado es el mismo: el atributo del contenedor vuelve a alternativa y el CSS muestra el contenido que ya estaba ahí.
Que la degradación termine siempre en el mismo estado conocido es lo que hace el sistema mantenible. No hay tres caminos de error con tres mensajes distintos: hay un camino de éxito y un estado de reserva.
Monta el visor con la alternativa en HTML y comprueba los tres caminos. Para el primero, desactiva la aceleración por hardware en la configuración del navegador. Para el segundo, abre veinte pestañas con escenas 3D hasta agotar el límite de contextos. Para el tercero, limita la CPU al máximo desde las herramientas de desarrollo y comprueba que el cronómetro del primer render dispara la alternativa. En los tres casos el usuario debería acabar viendo la imagen con sus controles, sin ningún mensaje de error.