wandres.dev
POR QUÉ WEBGPU · Los límites de WebGL

La máquina de estados global de WebGL y por qué produce bugs

Cómo funciona el modelo de estado de WebGL, qué clase de errores genera de forma estructural y por qué ninguna disciplina de programación los elimina del todo.

⏱ 18 min

La crítica habitual a WebGL es que es verboso o que es antiguo. Ninguna de las dos cosas es el problema. El problema es que WebGL es una única variable global mutable compartida por todo el código gráfico de la página, y que casi todas sus funciones la modifican sin decirlo. Esa propiedad, y no la sintaxis, es la que genera la familia de bugs que hizo falta rediseñar la API para eliminar.

🎯 Al terminar esta lección sabrás
  • Describir con precisión el modelo de estado enlazado de WebGL.
  • Reproducir mentalmente los tres patrones de bug que produce ese modelo.
  • Explicar por qué el patrón de guardar y restaurar estado no escala.
  • Relacionar cada problema con la decisión concreta de WebGPU que lo elimina.

Un contexto es una variable global

En WebGL nada se pasa por parámetro. Se enlaza. El contexto mantiene una tabla de puntos de enlace —el búfer de arrays actual, el búfer de índices actual, el programa actual, la textura actual de cada unidad, el framebuffer actual, el estado de mezcla, el de profundidad, el de recorte, la máscara de color, el viewport— y las funciones operan sobre lo que haya en esa tabla en ese instante.

// WebGL2: nada de esto dice sobre que actua.
gl.bindBuffer(gl.ARRAY_BUFFER, posiciones);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);
gl.useProgram(programa);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, difusa);
gl.uniform1i(locTextura, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLES, 0, 3);

Mira la última línea. drawArrays recibe tres argumentos y ninguno identifica qué se dibuja, con qué shader, con qué textura ni con qué mezcla. Todo eso está implícito en el estado que dejaron las nueve llamadas anteriores. Y lo que es peor: está implícito en el estado que dejó cualquier código que se haya ejecutado antes, incluido el que no escribiste tú.

De esa propiedad salen tres familias de bug, y las tres son estructurales.

El estado heredado. Una función dibuja bien cuando se llama después de otra concreta y mal cuando se llama en otro orden, porque dependía de un estado que la anterior había dejado puesto. El síntoma clásico: un objeto se dibuja con la textura del objeto anterior, o desaparece porque alguien dejó activado el descarte de caras traseras. No hay ningún error, solo una imagen distinta.

El estado filtrado. El inverso. Una función activa la mezcla alfa para dibujar su parte y no la desactiva. Todo lo que se dibuje después queda mezclado. El bug aparece en un componente que no ha cambiado, escrito por otra persona, y su causa está en un archivo que ni siquiera está en la pila de llamadas.

La invalidación en cascada. Ciertas operaciones invalidan estado de forma no obvia. Cambiar el programa activo puede invalidar los uniformes en algunas rutas; enlazar un búfer nuevo al punto de índices afecta al VAO activo; borrar una textura que sigue enlazada deja el punto de enlace en un estado que la especificación define pero casi nadie recuerda.

⚠️
Los VAO ayudan y no resuelven

WebGL2 tiene vertex array objects, que empaquetan la configuración de atributos de vértice en un objeto reutilizable. Es exactamente la idea buena, aplicada a una fracción del estado. El programa, las texturas, la mezcla, la profundidad, el recorte y el viewport siguen siendo globales. Los VAO demuestran que el problema estaba diagnosticado dentro del propio WebGL; lo que no se podía era arreglarlo sin romper la API.

Por qué la disciplina no basta

La respuesta obvia es «pues sé ordenado». Se ha intentado de tres formas y las tres tienen un techo.

La primera es guardar y restaurar: cada función consulta el estado con gl.getParameter, lo modifica y lo devuelve al salir. Falla por rendimiento: gl.getParameter obliga a consultar el estado real, lo que en la arquitectura multiproceso de un navegador implica una comunicación síncrona entre el proceso de la página y el proceso de GPU. Una consulta de esas puede costar más que varias decenas de llamadas de dibujo. Es la razón de que la primera regla de optimización de WebGL sea «nunca consultes nada durante el fotograma».

La segunda es el estado espejo: la aplicación mantiene en JavaScript una copia de lo que cree que está enlazado y solo llama a gl cuando el valor cambia. Es lo que hacen Three.js, Babylon y cualquier motor serio, y funciona bien mientras todo el código gráfico pase por el espejo. En cuanto un fragmento de código toca el contexto directamente —una biblioteca de terceros, una herramienta de depuración, un fragmento copiado de un ejemplo— el espejo miente y los bugs que produce son peores que los originales, porque ahora la aplicación está segura de un estado falso.

La tercera es encapsular el contexto para que nadie lo toque directamente. Funciona dentro de un motor y no funciona entre motores: dos bibliotecas de render en la misma página comparten el contexto de WebGL de un mismo canvas si lo comparten, y ninguna sabe lo que hace la otra.

El techo común de las tres es el mismo: el estado es global, y encapsular una variable global solo funciona si nadie más tiene la referencia. En una plataforma de código de terceros, alguien siempre la tiene.

La misma escena en WebGPU

WebGPU no arregla esto con una recomendación: lo hace imposible por construcción. El estado deja de ser global y pasa a estar en dos sitios, ambos explícitos.

El estado que no cambia entre dibujos vive en un GPURenderPipeline inmutable: shaders, formato de vértices, topología, descarte de caras, prueba de profundidad, mezcla, formatos de salida. El estado que cambia por objeto vive en GPUBindGroups, también inmutables como objetos. Y la orden de dibujo se da dentro de un pase que empieza vacío:

const pase = encoder.beginRenderPass(descriptorDelPase);
pase.setPipeline(pipeline);         // todo el estado grafico, de una vez
pase.setBindGroup(0, recursos);     // todos los recursos, de una vez
pase.setVertexBuffer(0, vertices);
pase.draw(3);
pase.end();

Tres diferencias, y cada una mata una de las tres familias de bug.

Un pase de render empieza con estado vacío. No hay pipeline puesto, no hay bind groups puestos, no hay búferes de vértices puestos. Nada se hereda del pase anterior ni de otro código. El estado heredado desaparece porque no hay nada que heredar.

El estado no se filtra fuera del pase. Lo que hagas dentro de beginRenderPass y end no existe después. La filtración desaparece porque el ámbito es real.

El estado es inmutable una vez creado. No se puede modificar un pipeline ni un bind group. No hay invalidación en cascada porque no hay mutación.

Hay un cuarto efecto menos comentado y muy práctico: dos bibliotecas de WebGPU pueden convivir en la misma página sin coordinarse. Comparten el GPUDevice si quieren, pero cada una crea sus propios pipelines, sus propios recursos y sus propios pases, y ninguna puede corromper el estado de la otra porque no hay estado compartido que corromper. Eso en WebGL era imposible.

El coste real de la máquina de estados no era el bug, era la incapacidad de razonar localmente

Los bugs de estado global se arreglan, uno a uno, con paciencia. Lo que no se arregla es lo que cuestan antes de arreglarse: en WebGL no puedes leer una función de dibujado y saber qué hace. Su comportamiento depende del estado de entrada, el estado de entrada depende del historial completo de ejecución, y el historial incluye código que no controlas. La única forma de saber qué dibuja drawArrays es ejecutarlo y mirar.

Eso tiene una consecuencia que la gente atribuye a otras causas: el código de gráficos en WebGL no se puede revisar en un pull request. Un revisor puede verificar que la sintaxis es correcta y no puede verificar que el estado de entrada asumido es el estado de entrada real, porque esa información no está en el diff ni en el archivo. De ahí la cultura de «lo pruebo y si se ve bien, entra», y de ahí que los motores de render en WebGL acumulen capas de defensa —restaurar por si acaso, reenlazar por si acaso— que cuestan rendimiento y esconden más de lo que arreglan.

WebGPU no es más fácil de escribir que WebGL. Es más fácil de leer, y en un proyecto que dure más de tres meses eso importa bastante más. Un beginRenderPass con su setPipeline y su draw es autocontenido: lo que hace está ahí, en la pantalla, sin contexto oculto.

La máquina de estados es solo la mitad de la historia. La otra mitad es lo que cuesta comprobar que ese estado es coherente, y eso se paga en cada llamada de dibujo.