wandres.dev
WGSL I · El lenguaje de shading

De GLSL a WGSL: el diccionario y las cinco trampas

La tabla de equivalencias entre GLSL y WGSL, las variables mágicas que desaparecen, y los cinco puntos donde una traducción literal produce código que compila y da mal el resultado.

⏱ 19 min

Traducir un shader de GLSL a WGSL parece un ejercicio de renombrar funciones, y en un noventa por ciento del texto lo es. El diez por ciento restante es donde vive todo el dolor: construcciones que existen en los dos lenguajes con el mismo nombre y semántica distinta, un modelo de recursos que no se corresponde, y un sistema de tipos que rechaza expresiones que llevabas quince años escribiendo.

🎯 Al terminar esta lección sabrás
  • Sustituir cada variable global de GLSL por su builtin declarado en la firma.
  • Reescribir expresiones que dependían de conversiones implícitas sin cambiar su semántica.
  • Traducir declaraciones de recursos al modelo de grupos y bindings.
  • Identificar las cinco construcciones cuya traducción literal compila y da un resultado distinto.

Las variables mágicas desaparecen

En GLSL, un vertex shader escribe en gl_Position porque sí, y lee gl_VertexID porque está ahí. Son variables globales que el lenguaje declara por ti y cuya disponibilidad depende de la etapa, de la versión y del perfil.

En WGSL no existe ninguna. Todo lo que la etapa te da entra como parámetro anotado, y todo lo que la etapa espera sale como valor de retorno anotado. La firma de la función es la lista completa de lo que ese shader consume y produce, sin nada implícito.

// GLSL
void main() {
  gl_Position = uMVP * vec4(aPos, 1.0);
  vColor = aColor;
}
// WGSL
struct SalidaVS {
  @builtin(position) clip  : vec4f,
  @location(0)       color : vec3f,
};

@vertex
fn vs(@location(0) posicion : vec3f, @location(1) color : vec3f) -> SalidaVS {
  var s : SalidaVS;
  s.clip  = camara.mvp * vec4f(posicion, 1.0);
  s.color = color;
  return s;
}

El beneficio no es estético. Que la firma sea completa significa que el compilador conoce exactamente el conjunto de entradas y salidas de cada etapa, que puede comprobar la compatibilidad entre etapas antes de generar código, y que no hay ninguna variable cuyo valor dependa de en qué etapa estés. La misma función auxiliar se puede llamar desde vértice y desde fragmento sin que su significado cambie por debajo.

Tipado sin conversiones implícitas

GLSL convierte int a float en silencio en casi cualquier contexto. WGSL no convierte nada entre tipos concretos: si tienes un i32 y necesitas un f32, escribes f32(x) y punto.

let i : i32 = 3;
let f : f32 = 2.0;

// let mal = i * f;        // ERROR: no hay operador i32 * f32
let bien = f32(i) * f;     // correcto

La excepción, y es una excepción importante que evita que el lenguaje sea insufrible, son los literales numéricos. Un literal sin sufijo no tiene tipo concreto: es un entero abstracto o un flotante abstracto que se materializa al tipo que haga falta en su contexto.

let a : f32 = 1;          // legal: el 1 abstracto se materializa como f32
let b = 1.0;              // f32
let c = 1;                // i32
let d : u32 = 7;          // legal
let e = 1u;               // u32 explicito con sufijo
let v = vec3f(0);         // legal: los tres componentes valen 0.0

Los sufijos son i, u, f y h para i32, u32, f32 y f16. La regla mental que funciona es: los literales se adaptan, las variables no.

La segunda diferencia de tipado que muerde es que las comparaciones de vectores devuelven vectores de booleanos, igual que en GLSL, pero if exige un bool escalar. En GLSL escribías if (all(lessThan(a, b))); aquí es lo mismo con otros nombres:

let dentro = todosPositivos(v);

fn todosPositivos(v : vec3f) -> bool {
  return all(v > vec3f(0.0));    // v > vec3f(0.0) es vec3<bool>; all() lo reduce
}

all y any son las dos reducciones, y los operadores de comparación funcionan componente a componente sobre vectores directamente, sin las funciones lessThan y compañía de GLSL.

El diccionario

La mayor parte de la traducción es mecánica. Estas son las correspondencias que se buscan una y otra vez:

GLSL WGSL
vec3, ivec2, uvec4 vec3f, vec2i, vec4u
mat4 mat4x4f
gl_Position @builtin(position) de salida en vértice
gl_FragCoord @builtin(position) de entrada en fragmento
gl_VertexID, gl_InstanceID @builtin(vertex_index), @builtin(instance_index)
gl_FrontFacing, gl_FragDepth @builtin(front_facing), @builtin(frag_depth)
gl_GlobalInvocationID @builtin(global_invocation_id)
gl_LocalInvocationID, gl_WorkGroupID @builtin(local_invocation_id), @builtin(workgroup_id)
layout(local_size_x = 64) in; @compute @workgroup_size(64)
shared float t[64]; var<workgroup> t : array<f32, 64>;
barrier() workgroupBarrier()
uniform Bloque { ... }; var<uniform> b : Bloque; con @group y @binding
buffer Datos { ... }; var<storage, read_write> d : Datos;
sampler2D s; texture_2d<f32> y sampler por separado
texture(s, uv) textureSample(t, muestreo, uv)
textureLod(s, uv, l) textureSampleLevel(t, muestreo, uv, l)
texelFetch(s, p, l) textureLoad(t, p, l)
dFdx, dFdy, fwidth dpdx, dpdy, fwidth
inversesqrt inverseSqrt
atan(y, x) atan2(y, x)
#define TAM 64 const TAM = 64; o override TAM : u32 = 64;
in y out entre etapas miembros con @location(n)

Lo que no aparece en la tabla porque no tiene equivalente: #include, #ifdef, precision mediump float, #version, varying, attribute, y las funciones lessThan, greaterThan y familia, sustituidas por los operadores normales.

Las cinco trampas

Estas cinco compilan sin quejarse y dan un resultado distinto. Son las que cuestan una tarde.

mod no existe, y % no es mod. En GLSL, mod(x, y) vale x - y * floor(x / y), y el signo del resultado sigue al del divisor. En WGSL, % sobre flotantes vale x - y * trunc(x / y), y el signo sigue al del dividendo. Con valores positivos coinciden; con negativos no. mod(-1.5, 1.0) es 0.5 en GLSL, y -1.5 % 1.0 es -0.5 en WGSL. Si tu shader envuelve coordenadas, hace patrones repetidos o cicla ángulos, esto rompe justo en el lado negativo:

fn modGlsl(x : f32, y : f32) -> f32 {
  return x - y * floor(x / y);
}

Las texturas y los samplers son objetos separados. No hay sampler2D. Hay una texture_2d<f32> y un sampler, cada uno con su binding, y las funciones de muestreo reciben los dos. La misma textura se puede muestrear con dos samplers distintos en el mismo shader, y ese es exactamente el punto: el modelo de WebGPU es el de las APIs modernas y no permite la fusión de GLSL.

Los tipos enteros entre etapas necesitan @interpolate(flat). En GLSL había que escribir flat igualmente, pero muchos shaders funcionaban sin él porque el valor era de hecho constante. Aquí es un error de compilación, y es mejor así.

@builtin(position) en el fragment shader no es lo mismo que en el vertex shader. En vértice es una posición de clip que tú calculas. En fragmento es una coordenada de framebuffer en píxeles, con el centro del píxel en los medios, la profundidad ya mapeada al viewport en el componente z, y el recíproco de la w de clip en el componente w. Es gl_FragCoord, no gl_Position, y usarla como si fuera la otra da coordenadas absurdas.

El eje Z del espacio de clip llega hasta cero, no hasta menos uno. WebGPU usa el rango de profundidad de Direct3D y Metal: después de la división de perspectiva, z va de 0 a 1. Una matriz de proyección copiada de un tutorial de OpenGL produce que la mitad cercana de la escena se recorte. Las librerías de matemáticas actuales tienen las dos variantes; en gl-matrix la correcta es la familia perspectiveZO y su compañera orthoZO.

La traducción literal funciona en el shader y falla en la arquitectura

Si coges un shader GLSL de doscientas líneas y lo traduces función a función, casi seguro que acabas con WGSL que compila y pinta lo mismo. El problema aparece un nivel más arriba, y es que el shader traducido arrastra el modelo de recursos de WebGL sin que te des cuenta.

Los síntomas son reconocibles. Un var<uniform> distinto por cada cosa que antes era una uniform suelta, porque en GLSL las uniforms eran variables independientes y aquí cada una acaba siendo un binding. Un bind group por objeto que se recrea cada fotograma, porque glUniform4fv era barato y createBindGroup no lo es. Texturas ligadas de una en una en el orden en que las declaraste, porque en WebGL el orden lo daba el número de unidad. Y sobre todo, un setBindGroup por draw call para datos que no cambian entre draws.

La consecuencia es un renderer que funciona y va más lento que la versión de WebGL, que es el resultado más desmoralizante posible después de una migración. Y no es culpa de WebGPU: es que el modelo de agrupación por frecuencia de cambio, que es la razón de ser de la API, se ha perdido en la traducción.

La regla práctica es esta: traduce el shader al final, no al principio. Empieza por diseñar los grupos de recursos según lo que cambia por fotograma, por material y por objeto; escribe las structs de datos que corresponden a esa división; y solo entonces adapta el cuerpo del shader, que es la parte fácil. Un shader escrito contra un buen diseño de recursos se parece bastante al GLSL original; un buen diseño de recursos derivado de un shader traducido no se consigue nunca.