wandres.dev
WGSL III · Atributos y builtins

@location: un número con tres significados distintos

Qué designa una localización en la entrada de vértice, entre etapas y en la salida de fragmento, qué tipos admite, con qué límites choca y cómo se empaquetan las variables interpoladas.

⏱ 17 min

@location(0) aparece tres veces en un pipeline completo y designa tres cosas que no tienen nada que ver entre sí: un atributo de un buffer de vértices, una ranura de interpolación, y un attachment de color. Los tres espacios de numeración son independientes, no colisionan, y confundirlos produce errores de validación que hablan de localizaciones sin decir de cuál de las tres.

🎯 Al terminar esta lección sabrás
  • Identificar a qué se refiere una localización según dónde aparezca.
  • Declarar interfaces entre etapas que casen sin depender de nombres ni de orden.
  • Enumerar los tipos válidos en una localización y los límites que acotan cada interfaz.
  • Empaquetar variables interpoladas para no agotar las ranuras disponibles.

Tres espacios de numeración

Estos tres @location(0) conviven en el mismo pipeline sin conflicto:

struct EntradaVS {
  @location(0) posicion : vec3f,      // 1: atributo del vertex buffer
};

struct SalidaVS {
  @builtin(position) clip : vec4f,
  @location(0) color : vec3f,         // 2: ranura entre etapas
};

@fragment
fn fs(entrada : SalidaVS) -> @location(0) vec4f {   // 3: attachment de color
  return vec4f(entrada.color, 1.0);
}

El primero se corresponde con un shaderLocation de vertex.buffers. El segundo solo existe entre el vertex y el fragment, y no aparece en ningún descriptor de la API. El tercero es el índice dentro de fragment.targets y del array colorAttachments del render pass.

La consecuencia práctica es que puedes numerar cada interfaz desde cero y compacta, sin dejar huecos para no chocar con las otras. Es lo que hace todo el mundo y es lo correcto.

Los tipos admitidos son los mismos en los tres casos: escalares y vectores numéricos. Nada de matrices, arrays, structs ni bool. Una matriz se descompone en columnas; un array, en elementos numerados.

La entrada de vértice

Aquí la localización es una coordenada en el layout de buffers de vértices, y hay que tener claro qué depende de qué:

vertex: {
  module, entryPoint: 'vs',
  buffers: [{
    arrayStride: 20,
    attributes: [
      { shaderLocation: 0, offset: 0,  format: 'float32x3' },
      { shaderLocation: 1, offset: 12, format: 'float32x2' },
    ],
  }],
}

El tipo que declaras en WGSL tiene que ser compatible con el formato del atributo: los formatos que acaban en unorm, snorm, float producen flotantes y se declaran como f32 o vecNf; los uint y sint producen enteros y se declaran como u32 o i32. Declarar vec4u sobre un unorm8x4 es un error de creación de pipeline.

El número de componentes no tiene que coincidir. Si el formato tiene menos componentes que la variable, los que faltan se rellenan con ceros salvo el último, que se rellena con uno; si tiene más, sobran y se descartan. Declarar vec4f sobre un float32x3 es legal y te da w igual a 1.

El límite es maxVertexAttributes, con un mínimo garantizado de 16 atributos repartidos entre todos los buffers.

Un vertex shader no está obligado a declarar todos los atributos del layout. Al revés sí: pedir una localización que el layout no proporciona es un error.

El tramo entre etapas

Este es el único de los tres que no aparece en la API, y sus reglas son las del compilador:

  • Cada localización del fragment shader tiene que existir en la salida del vertex shader.
  • Los tipos tienen que coincidir exactamente: no hay conversión ni relleno como en la entrada de vértice.
  • Los atributos @interpolate tienen que coincidir también.
  • El vertex puede producir localizaciones que el fragment no consume.
  • Los nombres y el orden de declaración son irrelevantes.

El límite aquí es maxInterStageShaderVariables, cuyo mínimo garantizado son 16 variables. @builtin(position) no cuenta contra ese número; las localizaciones sí, y cuenta cada @location completa independientemente de si es un f32 o un vec4f.

Ese último detalle es importante y va en tu contra: un @location con un solo f32 consume una ranura entera, igual que un vec4f. Cuatro flotantes sueltos en cuatro localizaciones gastan cuatro ranuras; los mismos cuatro flotantes en un vec4f gastan una.

// Cuatro ranuras para dieciseis bytes.
struct Derrochador {
  @builtin(position) clip : vec4f,
  @location(0) profundidad : f32,
  @location(1) niebla      : f32,
  @location(2) oclusion    : f32,
  @location(3) borde       : f32,
};

// Una ranura para los mismos dieciseis bytes.
struct Apretado {
  @builtin(position) clip : vec4f,
  @location(0) varios : vec4f,   // x profundidad, y niebla, z oclusion, w borde
};

La salida de fragmento

La localización es el índice del color target, y el tipo tiene que ser compatible con el formato de ese target: un formato flotante o normalizado espera vec4f, un formato uint espera vec4u, y un sint, vec4i.

struct SalidaGBuffer {
  @location(0) albedo   : vec4f,   // rgba8unorm
  @location(1) normal   : vec4f,   // rgba16float
  @location(2) material : vec4f,   // rgba8unorm
};

@fragment
fn fs(entrada : SalidaVS) -> SalidaGBuffer {
  var s : SalidaGBuffer;
  s.albedo   = vec4f(color, 1.0);
  s.normal   = vec4f(normalize(entrada.normal) * 0.5 + 0.5, 0.0);
  s.material = vec4f(rugosidad, metalico, 0.0, 1.0);
  return s;
}

Escribir menos componentes de los que el target tiene es legal —un vec2f a un target rg16float—, pero si el estado de mezcla usa el canal alfa de la fuente, la salida tiene que ser un vec4. El límite es maxColorAttachments, con mínimo garantizado de 8.

En la misma struct de salida caben también @builtin(frag_depth) y @builtin(sample_mask), que no llevan localización porque no van a ningún attachment de color.

💡
El fragment puede declarar menos de lo que recibe

Si el vertex shader produce ocho localizaciones y un fragment shader concreto solo usa dos, declara solo esas dos en su struct de entrada. El pipeline se crea igual, el código queda más claro, y algunas implementaciones aprovechan para no interpolar lo que nadie lee. Es la razón de que compartir una única struct enorme entre el vertex y todos los fragment shaders sea cómodo pero no óptimo.

Las variables interpoladas se pagan por fragmento, y en móvil se pagan dos veces

Cuesta ver el coste de una variable entre etapas porque no aparece en ningún contador. Aparece en dos sitios reales.

En el hardware de interpolación. Cada componente de cada localización tiene que interpolarse para cada fragmento, con corrección de perspectiva, que es una división. En una GPU de escritorio moderna eso lo hace la unidad de fragmento en el prólogo del shader y es barato, pero no es gratis: dieciséis variables son sesenta y cuatro componentes interpolados por fragmento, y a resolución 4K con algo de sobredibujado eso son miles de millones de interpolaciones por segundo.

En la memoria, y esta es la que duele. Las GPUs de arquitectura por tiles —todos los móviles, todos los Apple Silicon— separan la etapa de geometría de la de fragmentos en el tiempo: procesan todos los vértices de la escena, escriben sus salidas a memoria, y solo después empiezan a sombrear fragmentos leyéndolas. Cada variable entre etapas es memoria escrita y leída por vértice. Un vertex shader con dieciséis varyings sobre una malla de doscientos mil vértices escribe unos trece megabytes por fotograma que nadie contabiliza, y en un dispositivo alimentado por batería ese tráfico es directamente consumo.

Las tres técnicas que lo reducen, por orden de rentabilidad. Empaquetar: cuatro escalares sueltos en un vec4f ahorran tres ranuras y ningún dato. Reconstruir en vez de transportar: la coordenada de pantalla ya la tienes en @builtin(position), y de ahí sale la UV de pantalla completa, la dirección del rayo de cámara y la posición de vista si tienes la profundidad; pasarlas como varyings es transportar información que ya viaja. Codificar: una normal de superficie cabe en dos flotantes con codificación octaédrica en vez de tres, un color en un u32 con unpack4x8unorm en el fragment, y un identificador de material en un u32 plano en lugar de en cuatro flotantes.

La señal de que hay que hacerlo es concreta: si tu struct entre etapas pasa de seis o siete localizaciones, casi seguro que hay ahí un vec3 que se puede codificar y dos escalares que se pueden fusionar. Y si vas a desplegar en móvil, esa struct es de las primeras cosas que hay que mirar cuando el rendimiento no sale.