wandres.dev
WGSL III · Atributos y builtins

Los builtins de vértice y de fragmento

El catálogo exacto de valores que el hardware inyecta en las etapas gráficas, de dónde sale cada número, y qué significa cada componente de position en cada etapa.

⏱ 19 min

Un builtin es un valor que no viene de ningún buffer que tú hayas creado: lo pone el hardware. Son la parte del shader que conecta con el rasterizador y con el ensamblador de primitivas, y saber exactamente de dónde sale cada número es lo que separa reconstruir la posición del mundo desde un fragmento en tres líneas de pasarse una tarde con las coordenadas del revés.

🎯 Al terminar esta lección sabrás
  • Enumerar los builtins de cada etapa gráfica con su tipo y su dirección.
  • Explicar el origen exacto de vertex_index e instance_index en cada tipo de dibujado.
  • Interpretar los cuatro componentes de position en el vertex y en el fragment shader.
  • Reconstruir la coordenada de pantalla y la profundidad lineal desde un fragmento.
Builtin Etapa Dirección Tipo
vertex_index vértice entrada u32
instance_index vértice entrada u32
position vértice salida vec4f
clip_distances vértice salida array<f32, N>
position fragmento entrada vec4f
front_facing fragmento entrada bool
sample_index fragmento entrada u32
sample_mask fragmento entrada y salida u32
frag_depth fragmento salida f32

clip_distances es el único que necesita permiso: requiere enable clip_distances; en el shader y la feature clip-distances en el dispositivo. Sirve para recortar geometría contra planos arbitrarios, que es lo que usa un renderer para los reflejos planos y las secciones.

vertex_index e instance_index

Los dos son contadores que produce el ensamblador de primitivas, y de dónde sale su valor depende del comando de dibujado.

En un dibujado no indexado, vertex_index empieza en el firstVertex que pasaste y avanza de uno en uno:

pase.draw(3, 1, 100, 0);    // vertex_index toma los valores 100, 101, 102

En un dibujado indexado, vertex_index es el valor leído del índice más el baseVertex, que puede ser negativo:

pase.drawIndexed(6, 1, 0, 500, 0);   // vertex_index = indice + 500

instance_index empieza siempre en el firstInstance del comando y avanza de uno en uno hasta completar el número de instancias.

La consecuencia útil de que vertex_index exista es que se puede dibujar sin buffers de vértices. El triángulo que cubre la pantalla entera para un pase de post-proceso es el caso canónico y no necesita ninguna geometría:

@vertex
fn vsPantalla(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f {
  // Un unico triangulo grande que cubre todo el cuadrado de clip.
  var p = array<vec2f, 3>(
    vec2f(-1.0, -1.0),
    vec2f( 3.0, -1.0),
    vec2f(-1.0,  3.0),
  );
  return vec4f(p[i], 0.0, 1.0);
}

Y la consecuencia útil de instance_index es que es el índice natural para leer datos por objeto desde un storage buffer, sin atributos por instancia y sin bind groups por objeto.

position, dos builtins con el mismo nombre

Se llaman igual y son cosas distintas. Confundirlos es el error clásico de quien viene de GLSL, donde tenían nombres diferentes.

En la salida del vertex shader es la posición de clip: el resultado de multiplicar la posición del modelo por la matriz combinada, sin dividir por w. El hardware hace después tres cosas con ella: recorta la primitiva contra el volumen de visión, divide los tres primeros componentes por el cuarto, y mapea el resultado al viewport.

En la entrada del fragment shader es la coordenada de framebuffer, y sus cuatro componentes significan lo siguiente:

  • x e y son píxeles, con el origen en la esquina superior izquierda y el centro del píxel en los medios: el primer píxel tiene coordenada (0.5, 0.5).
  • z es la profundidad ya mapeada al rango de profundidad del viewport, normalmente entre 0 y 1. Es exactamente el valor que se compara contra el depth buffer.
  • w es el recíproco de la w de clip, es decir, 1.0 / clip.w.

Ese último componente es el regalo escondido de la etapa. Con una matriz de proyección en perspectiva estándar, clip.w vale la distancia a la cámara a lo largo del eje de vista, así que:

@fragment
fn fs(@builtin(position) frag : vec4f) -> @location(0) vec4f {
  let profundidadLineal = 1.0 / frag.w;     // distancia en unidades de vista
  let niebla = 1.0 - exp(-profundidadLineal * 0.02);
  return vec4f(vec3f(niebla), 1.0);
}

Sin uniforms, sin varyings y sin leer el depth buffer. La profundidad lineal es una división.

Y la coordenada de pantalla normalizada sale igual de barata, siempre que sepas el tamaño del target:

let uv = frag.xy / vec2f(ajustes.tamanoPantalla);      // 0..1, origen arriba a la izquierda
let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);   // -1..1, con la Y dada la vuelta

El cambio de signo de la Y es obligatorio y es la causa de la mitad de los efectos de pantalla completa que salen invertidos: en coordenadas de framebuffer la Y crece hacia abajo, y en coordenadas normalizadas de dispositivo crece hacia arriba.

Los builtins de cobertura y la salida

front_facing es un bool que dice si el triángulo se está viendo por su cara delantera, según el sentido de giro que declaraste en primitive.frontFace'ccw' por defecto—. Su uso canónico es dar la vuelta a la normal para materiales de dos caras:

let n = select(-normalInterpolada, normalInterpolada, entrada.frontal);

sample_index es el índice de la muestra que se está sombreando cuando hay multimuestreo y el shader se ejecuta por muestra. Leerlo tiene una consecuencia importante: fuerza el sombreado por muestra, con lo que el fragment shader pasa de ejecutarse una vez por píxel a ejecutarse una vez por muestra, y en 4x eso es cuatro veces el coste.

sample_mask es una máscara de bits, uno por muestra. Como entrada dice qué muestras cubre la primitiva en ese píxel; como salida permite anular la escritura de muestras concretas, que es la base del recorte suave y del antialiasing de transparencias por cobertura. Si el pipeline tiene alphaToCoverageEnabled, escribir sample_mask desde el shader no está permitido: las dos cosas compiten por lo mismo.

frag_depth sustituye la profundidad interpolada por una calculada por ti. Su valor se acota al rango de profundidad del viewport, y escribirlo tiene un coste real que la lección de fragment shaders desarrolla: desactiva la prueba de profundidad temprana.

struct SalidaFS {
  @location(0) color : vec4f,
  @builtin(frag_depth) profundidad : f32,
};
⚠️
Leer position no cuesta nada, pero sample_index sí

Los builtins de entrada no son gratis por igual. position en fragmento ya lo calcula el rasterizador y leerlo no añade trabajo. front_facing viene del mismo sitio. Pero sample_index y un @interpolate(..., sample) cambian la frecuencia de ejecución del shader entero, y frag_depth desactiva optimizaciones del hardware. Los cuatro se escriben igual de fácil y no cuestan lo mismo ni de lejos.

El medio píxel de position es el origen de casi todos los desalineamientos de post-proceso

Que el centro del primer píxel esté en (0.5, 0.5) y no en (0, 0) parece un detalle sin importancia hasta que produce un desplazamiento de medio texel en una cadena de post-proceso, y entonces se convierte en una de esas cosas que se depuran a base de sumar y restar constantes hasta que la imagen deja de vibrar.

El origen del problema es que hay dos convenciones conviviendo. La coordenada de framebuffer cuenta centros de píxel, así que un target de 1920 de ancho tiene coordenadas de fragmento entre 0.5 y 1919.5. La coordenada de textura cuenta bordes, así que va de 0.0 a 1.0 y el centro del primer texel está en 0.5 / ancho. Convertir entre las dos exige la división correcta y no una parecida.

La conversión buena de fragmento a UV es frag.xy / tamano, sin sumar ni restar nada, porque frag.xy ya trae el medio píxel incorporado. El error clásico es hacer (frag.xy + 0.5) / tamano, que añade medio píxel por segunda vez, o floor(frag.xy) / tamano, que lo quita. Las dos versiones desplazan la imagen medio texel, lo cual con filtrado bilineal no da un salto visible sino un desenfoque leve: la imagen no se rompe, se ensucia, y por eso el bug sobrevive tanto tiempo.

La versión inversa, de UV a coordenada de texel entera para un textureLoad, es vec2i(uv * tamano), y ahí sí hay que tener cuidado con el redondeo. Y si estás encadenando pasadas a resoluciones distintas —un blur a media resolución, un downsample piramidal—, el desplazamiento se acumula en cada nivel y el resultado se va desviando hacia una esquina de forma que parece un problema del kernel.

La forma de no equivocarse nunca es tener una sola función de conversión en el shader común y no volver a escribir la división a mano:

fn uvDeFragmento(frag : vec2f, tam : vec2f) -> vec2f { return frag / tam; }

Parece ridículo encapsular una división. Deja de parecerlo la tercera vez que un efecto sale medio texel movido y tienes un único sitio donde mirar.