Swizzling: reordenar componentes sin coste
Los tres juegos de nombres, las reglas de lectura y de escritura, por qué el hardware no ejecuta ninguna instrucción al hacerlo, y los idiomas que aparecen en todo shader real.
El swizzling parece una comodidad sintáctica y es en realidad una consecuencia directa de cómo funciona el hardware: los operandos de las instrucciones vectoriales llevan un campo que dice de qué componente del registro tomar cada carril. Reordenar componentes no emite ninguna instrucción porque se codifica en el operando de la instrucción que ya ibas a ejecutar. Eso lo convierte en la operación verdaderamente gratuita de GLSL, y en el vocabulario básico de cualquier shader.
- Usar los tres juegos de nombres de componente y saber cuándo cada uno comunica mejor.
- Aplicar las reglas de lectura y escritura, y explicar por qué son distintas.
- Reconocer los idiomas de swizzle que aparecen una y otra vez.
- Situar el único coste real: la alineación de un
vec3en un bloque de uniforms.
Tres juegos de nombres para lo mismo
Cualquier vector se puede indexar por componente con tres conjuntos equivalentes:
| Conjunto | Componentes | Uso previsto |
|---|---|---|
x y z w |
posiciones y direcciones | geometría |
r g b a |
color y alfa | color |
s t p q |
coordenadas de textura | muestreo |
Los tres acceden exactamente a los mismos cuatro carriles del registro y el compilador los trata igual. Lo único prohibido es mezclarlos en un mismo swizzle: v.xg es un error, v.xy y v.rg no.
Elegir el conjunto adecuado es documentación gratis. Un vec4 que representa color se lee mejor como .rgb que como .xyz, y una coordenada de textura como .st. En un shader donde conviven posiciones y UV, la distinción evita confundir una cosa con la otra al leer.
Leer y escribir tienen reglas distintas
Al leer, un swizzle puede repetir componentes y ponerlas en cualquier orden, y produce un vector del tamaño del swizzle:
vec4 v = vec4( 1.0, 2.0, 3.0, 4.0 );
vec3 a = v.xyz; // (1, 2, 3)
vec3 b = v.zyx; // (3, 2, 1)
vec3 c = v.xxx; // (1, 1, 1) repetir esta permitido
vec2 d = v.wz; // (4, 3)
float e = v.y; // 2.0, un swizzle de una componente es un escalar
vec4 f = v.xxyy; // (1, 1, 2, 2)
Al escribir, cada componente de destino puede aparecer como mucho una vez, porque el hardware no sabe qué valor poner si le pides escribir dos cosas en el mismo carril:
vec4 v;
v.xy = vec2( 1.0, 2.0 ); // correcto
v.zw = vec2( 3.0, 4.0 ); // correcto
v.wx = vec2( 9.0, 8.0 ); // correcto: destino desordenado, sin repetir
v.xx = vec2( 1.0, 2.0 ); // ERROR: x aparece dos veces
La restricción es sobre el destino, nunca sobre la fuente. Por eso v.xy = v.yy; es perfectamente válido.
Los vectores también admiten índice numérico, y ahí sí hay diferencia de coste: v[ i ] con i variable puede obligar al compilador a materializar el vector en memoria en vez de mantenerlo en registros. v.x es gratis, v[ 0 ] normalmente también porque es constante, y v[ i ] con i calculado puede no serlo.
Los idiomas que aparecen siempre
Coordenadas de pantalla normalizadas, el primer renglón de casi cualquier shader de pantalla completa:
vec2 uv = gl_FragCoord.xy / uResolucion.xy;
Perspectiva a mano, la división por w cuando has calculado tú la posición en clip space:
vec3 ndc = clipPos.xyz / clipPos.w;
Extraer la parte de rotación de una matriz de transformación, o su traslación:
mat3 rotacion = mat3( uModelo ); // esquina 3x3
vec3 traslacion = uModelo[ 3 ].xyz; // cuarta columna
Componer y descomponer color:
vec3 color = textura.rgb;
float alfa = textura.a;
gl_FragColor = vec4( color * alfa, alfa ); // premultiplicado
Escalar en dos ejes con un valor calculado en uno:
vec2 aspecto = vec2( uResolucion.x / uResolucion.y, 1.0 );
vec2 p = ( uv - 0.5 ) * aspecto;
Empaquetar dos parejas en un vec4 para ahorrar varyings o atributos, y desempaquetarlas al otro lado:
// Vertex
varying vec4 vDatos;
vDatos = vec4( uv, aSemilla, aRetardo );
// Fragment
vec2 uvRecuperada = vDatos.xy;
float semilla = vDatos.z;
float retardo = vDatos.w;
Ese último es el uso que más rendimiento aporta: cuatro varyings de una componente cuestan cuatro slots de interpolación, y una de cuatro componentes cuesta uno.
Y un idioma menos evidente: usar un swizzle repetido para difundir un escalar sin llamar al constructor.
vec3 gris = vec3( luminancia ); // constructor
vec3 gris2 = luminancia * vec3( 1.0 );
// Con un vector ya existente, .xxx difunde su primera componente
vec3 desdeVector = valores.xxx;
El único coste real: la alineación en un bloque de uniforms
El swizzling es gratis, pero hay un sitio donde el tamaño de un vector sí cuesta, y como está relacionado conviene cerrarlo aquí. En un uniform buffer object —lo que Three.js expone como UniformsGroup— la disposición de memoria sigue las reglas std140, y esas reglas dicen que un vec3 se alinea a dieciséis bytes, igual que un vec4.
Es decir: un vec3 en un bloque de uniforms ocupa doce bytes útiles y cuatro de relleno. Una estructura con tres vec3 consecutivos ocupa cuarenta y ocho bytes, no treinta y seis. Y un array de vec3 es especialmente malo: cada elemento se alinea a dieciséis bytes, así que un array de cien vec3 gasta 1600 bytes para almacenar 1200.
// Malo en std140: 48 bytes, 12 de relleno
layout( std140 ) uniform Malo {
vec3 posicion;
vec3 color;
vec3 direccion;
};
// Bueno: 48 bytes, 0 de relleno, y un escalar extra gratis en cada hueco
layout( std140 ) uniform Bueno {
vec4 posicionYRadio; // xyz posicion, w radio
vec4 colorEIntensidad; // rgb color, a intensidad
vec4 direccionYAngulo; // xyz direccion, w angulo
};
Y una vez dentro del shader, recuperar cada campo es un swizzle, o sea gratis. Ése es el patrón que usan todos los motores para sus datos de luces.
La afirmación de que el swizzling es gratis es cierta y a la vez incompleta, y el matiz decide algunos casos reales. Lo que es gratis es reordenar: el campo de swizzle viaja codificado en el operando y no hay instrucción. Lo que no siempre es gratis es cambiar el ancho de la operación. En una arquitectura vectorial clásica, a.xyz * b.xyz y a * b sobre vec4 cuestan exactamente lo mismo, porque los cuatro carriles se ejecutan siempre. En una arquitectura escalar —que es lo que hacen todas las GPU modernas desde hace más de una década— cada componente es una instrucción independiente, así que vec3 cuesta tres cuartas partes de vec4 y float cuesta una cuarta parte. Ahí es donde el swizzle sí cambia el coste, pero al revés de como suele pensarse: la lección no es “evita los swizzles” sino “no calcules en cuatro componentes lo que solo necesitas en una”. El caso concreto que aparece en shaders reales todo el tiempo es leer una textura de la que solo te importa un canal: texture2D( uMapa, vUv ).r no ahorra nada, porque el muestreo trae los cuatro canales de todas formas y el filtrado se hace sobre los cuatro. Lo que sí ahorra es toda la aritmética posterior: si vas a comparar, escalar y suavizar una máscara, hazlo en float y no arrastres un vec4 hasta el final para quedarte con una componente. Y si la textura es realmente de un canal, declararla con formato RedFormat reduce el ancho de banda de lectura a la cuarta parte, que en un shader que muestrea muchas veces es una ganancia mucho mayor que cualquier ahorro de instrucciones.