Por qué WebGPU inventó un lenguaje en vez de adoptar SPIR-V
La decisión de diseño que produjo WGSL, qué garantiza un lenguaje textual con semántica definida, y el recorrido completo de un shader mínimo línea a línea.
Cuando el grupo de trabajo empezó, la opción evidente era aceptar SPIR-V: ya existía, ya tenía compiladores desde GLSL y HLSL, y ya era el formato de entrada de Vulkan. Después de dos años de discusión se descartó y se escribió un lenguaje desde cero. Entender por qué no es arqueología: la razón explica casi todas las restricciones de WGSL que al principio parecen burocracia.
- Explicar los tres argumentos que descartaron un formato binario como entrada de la web.
- Leer un módulo WGSL completo e identificar cada elemento sintáctico.
- Distinguir qué toma WGSL de Rust en sintaxis y qué no toma en semántica.
- Enumerar las construcciones que el lenguaje omite deliberadamente y qué gana con cada omisión.
Por qué no un binario
El primer argumento es de auditabilidad. Todo lo que la web ejecuta llega como texto que el usuario puede leer: HTML, CSS, JavaScript. Un formato binario rompe esa propiedad, y con ella rompe el «ver código fuente», las herramientas de desarrollo y la capacidad de un usuario o un investigador de seguridad de saber qué está ejecutando su GPU. El grupo consideró que una excepción a esa regla necesitaba una justificación muy fuerte, y no la encontró.
El segundo es de superficie de ataque. Un consumidor de SPIR-V en el navegador tiene que validar un grafo binario arbitrario antes de pasárselo al driver, y ese validador es código C++ que procesa datos hostiles. La historia de los drivers gráficos está llena de fallos de memoria alcanzables desde shaders malformados. Un lenguaje textual con una gramática pequeña y un analizador escrito para la ocasión es una superficie más pequeña y más fácil de razonar.
El tercero, y el que más peso tuvo, es la semántica definida. SPIR-V hereda de la tradición de OpenGL y Vulkan un catálogo largo de comportamiento indefinido: leer fuera de un array, dividir por cero, usar una variable sin inicializar. En una API nativa eso es aceptable porque el binario lo firma el desarrollador de la aplicación. En la web, el binario lo escribe cualquiera, y comportamiento indefinido significa filtración de memoria de otra pestaña en el peor caso. WGSL define qué pasa en cada una de esas situaciones, y esa definición es lo que hace que sea seguro ejecutar un shader que has descargado de un tercero.
El resultado es que WGSL es el único formato de shader que WebGPU acepta. El navegador lo traduce después a lo que el sistema necesite: SPIR-V en Vulkan, MSL en Metal, HLSL o DXIL en Direct3D 12. Esa traducción la hacen compiladores propios de cada motor —Tint en Chromium, naga en Firefox, el compilador de WebKit en Safari— y es donde vive el trabajo pesado de portabilidad que tú ya no tienes que hacer.
Un módulo completo, línea a línea
Este es un módulo que dibuja un triángulo con color interpolado, sin buffers de vértices ni recursos de ningún tipo. Cabe entero y no le falta nada.
struct SalidaVS {
@builtin(position) posicion : vec4f,
@location(0) color : vec3f,
};
@vertex
fn vs(@builtin(vertex_index) indice : u32) -> SalidaVS {
var puntos = array<vec2f, 3>(
vec2f( 0.0, 0.5),
vec2f(-0.5, -0.5),
vec2f( 0.5, -0.5),
);
var colores = array<vec3f, 3>(
vec3f(1.0, 0.0, 0.0),
vec3f(0.0, 1.0, 0.0),
vec3f(0.0, 0.0, 1.0),
);
var salida : SalidaVS;
salida.posicion = vec4f(puntos[indice], 0.0, 1.0);
salida.color = colores[indice];
return salida;
}
@fragment
fn fs(entrada : SalidaVS) -> @location(0) vec4f {
return vec4f(entrada.color, 1.0);
}
Hay siete cosas que mirar. struct declara un tipo compuesto a nivel de módulo; sus miembros llevan el tipo detrás de dos puntos y admiten coma final. Los atributos empiezan por @ y se colocan delante del elemento que decoran: @builtin(position) marca ese miembro como la posición de clip que consume el rasterizador, y @location(0) lo marca como una variable de usuario que viaja entre etapas por la ranura 0.
@vertex y @fragment convierten una función normal en un punto de entrada. Una función sin atributo de etapa es una función auxiliar y no la puede invocar la API. fn nombre(params) -> Tipo es la firma completa; el tipo de retorno va detrás de la flecha y puede llevar atributos, como en -> @location(0) vec4f.
var declara una variable mutable y let declararía un valor inmutable; la distinción es de Rust y en WGSL además arrastra consecuencias de espacio de memoria. vec4f y vec2f son alias predeclarados de vec4<f32> y vec2<f32>; el lenguaje trae una familia entera de ellos. Y la salida del vertex shader es la entrada del fragment shader: la misma struct sirve en los dos lados porque la correspondencia se establece por @location, no por nombre ni por orden.
Fíjate en lo que no hay: ni una sola declaración de versión, ni precision mediump float, ni variables globales mágicas tipo gl_Position, ni #include. Todo lo que el shader usa está escrito en el shader.
Sintaxis de Rust, semántica propia
La familiaridad es real pero superficial, y conviene tener claro dónde acaba.
De Rust vienen la forma fn, los tipos pospuestos con dos puntos, la flecha del retorno, let frente a var, los atributos entre corchetes convertidos aquí en @, la coma final permitida en listas, y los parámetros de tipo entre ángulos como en array<vec2f, 3> o vec3<f32>.
No viene nada más. WGSL no es orientado a expresiones: un if no devuelve valor, no hay match, no hay bloques que valgan lo que valga su última expresión. No hay traits, ni genéricos de usuario, ni ownership, ni borrow checker; los parámetros de tipo solo existen en los tipos predeclarados del lenguaje. No hay Option ni ningún tipo suma: no hay uniones ni enumeraciones. Y no hay macros.
Lo que sí comparte con Rust es la actitud: pocas conversiones implícitas, errores en compilación antes que sorpresas en ejecución, y un compilador que prefiere rechazar código dudoso a generar algo que quizá funcione. Si vienes de GLSL, esa actitud es el cambio grande, mucho más que la sintaxis.
Un detalle pequeño con consecuencias diarias: en WGSL los comentarios /* ... */ anidan correctamente. Comentar un bloque que ya contenía comentarios de bloque no rompe nada, al contrario que en C, C++, GLSL o JavaScript. Es una decisión heredada de Rust y es de las pocas cosas del lenguaje que se agradecen depurando.
Lo que el lenguaje no tiene, y por qué
Cada omisión de WGSL responde a una de tres razones: seguridad, portabilidad o el hecho de que el hardware nunca lo tuvo.
No hay recursión. El grafo de llamadas de un módulo no puede contener ciclos, y es un error de compilación si lo contiene. La razón es de hardware: una GPU no tiene pila por invocación en el sentido en que la tiene una CPU, y el tamaño de pila necesario tendría que conocerse estáticamente. Los algoritmos recursivos se escriben con una pila explícita en un array, y eso vale igual para recorrer un árbol que para trazar rayos.
No hay asignación dinámica. Ni new, ni malloc, ni arrays que crezcan. Toda la memoria que un shader toca viene de recursos declarados por la API o de variables de tamaño conocido. El único tamaño que se decide en ejecución es el de un array de tamaño variable dentro de un storage buffer, y ese tamaño lo fija la API antes del lanzamiento.
No hay preprocesador. Ni #define, ni #ifdef, ni #include. Lo que en GLSL se resolvía con macros se resuelve aquí con const, con override y con generación de texto desde JavaScript, que al fin y al cabo es donde vives.
No hay acceso fuera de límites con consecuencias impredecibles. Un índice fuera de rango no lee memoria ajena: la especificación obliga a la implementación a acotarlo dentro del recurso o a devolver un valor de un conjunto restringido. Eso no significa que un índice mal calculado sea inofensivo —te dará el dato equivocado— pero sí que nunca será un fallo de seguridad ni un cuelgue del sistema.
No hay sobrecarga de funciones de usuario ni parámetros por defecto. Las funciones integradas sí están sobrecargadas —max acepta enteros y flotantes de cualquier anchura—, pero tú no puedes declarar dos fn con el mismo nombre. Simplifica el compilador y elimina una clase de ambigüedad que en GLSL producía errores difíciles de leer.
En un shader de Vulkan escrito a mano, la diferencia entre un bug y una vulnerabilidad es una cuestión de suerte. Leer un array[i] con i mal calculado puede devolver basura, puede leer la textura del vecino, puede colgar el driver o puede no hacer nada visible durante meses. Ese abanico existe porque la especificación dice «comportamiento indefinido» y cada driver hace lo que le conviene.
WGSL elimina la categoría entera. Cada operación tiene un resultado especificado, incluidas las que en otros lenguajes son territorio de nadie: la división entera por cero, el desplazamiento por más bits de los que tiene el tipo, la conversión de un flotante fuera de rango a entero, el acceso fuera de límites. Ninguna de ellas es correcta en el sentido de darte el número que querías, pero todas son predecibles, y esa es la propiedad que permite depurar.
El precio se paga en dos monedas. La primera es rendimiento: las comprobaciones de límites que el compilador inserta cuando no puede demostrar que un índice es seguro cuestan instrucciones, y en un bucle apretado se notan. La segunda es expresividad: hay patrones legítimos de programación de GPU —punteros a memoria arbitraria, aliasing controlado, saltos calculados— que sencillamente no se pueden escribir.
La conclusión práctica que sacas de esto es que cuando algo va mal en WGSL, la culpa es tuya y el fallo es reproducible. Si el mismo shader da resultados distintos en dos máquinas, casi nunca es comportamiento indefinido: es una diferencia de precisión de coma flotante, un límite del dispositivo, o una condición de carrera que tú escribiste. Esa certeza cambia por completo cómo se depura, y es el motivo de que las sesiones de «funciona en mi GPU» sean mucho más cortas aquí que en WebGL.
Con el mapa del lenguaje en la cabeza, lo siguiente es la anatomía formal de un módulo: directivas, declaraciones y el orden que no importa.