wandres.dev
WGSL II · Tipos y espacios de dirección

Vectores y matrices: constructores, swizzles y la columna que manda

Cómo se construyen y se acceden los vectores, qué hace de verdad cada operador, por qué matCxR significa C columnas, y qué hacer con la función inverse que WGSL no tiene.

⏱ 18 min

Los vectores y las matrices son los tipos por los que existe un lenguaje de shading, y son también donde más se cuela la intuición equivocada. El asterisco entre dos vectores no hace lo que hace en tu librería de matemáticas de JavaScript, la primera dimensión de una matriz no es la que crees si vienes de HLSL, y la función que más vas a echar de menos no está en el lenguaje.

🎯 Al terminar esta lección sabrás
  • Construir vectores con difusión, concatenación y conversión de tipo.
  • Usar swizzles en lectura y saber cuáles son asignables.
  • Predecir el resultado de cada operador entre escalares, vectores y matrices.
  • Escribir la inversa de una matriz 3x3 y decidir si debe calcularse en la GPU.

Vectores

vec2, vec3 y vec4 son los únicos tamaños, y su parámetro de tipo puede ser cualquier escalar: vec3<f32>, vec4<u32>, vec2<bool>. Los alias predeclarados ahorran mucha escritura: vec2f, vec3f, vec4f para flotantes, vec2i a vec4i para enteros con signo, vec2u a vec4u para sin signo, y vec2h a vec4h cuando f16 está activo.

El constructor admite cuatro formas, y se pueden combinar:

let a = vec3f(1.0, 2.0, 3.0);     // componente a componente
let b = vec3f(0.5);               // difusion: 0.5, 0.5, 0.5
let c = vec4f(a, 1.0);            // concatenacion de un vec3 y un escalar
let d = vec4f(vec2f(1.0), vec2f(2.0));  // dos vec2
let e = vec3f(vec3i(1, 2, 3));    // conversion de tipo componente a componente

Los componentes se leen con dos juegos de nombres, xyzw y rgba, que no se pueden mezclar en el mismo swizzle: v.xy vale, v.rg vale, v.xg no. Un swizzle puede repetir componentes y reordenarlos, y su longitud decide el tipo del resultado:

let v = vec4f(1.0, 2.0, 3.0, 4.0);
let s1 = v.x;        // f32
let s2 = v.wz;       // vec2f(4.0, 3.0)
let s3 = v.xxx;      // vec3f(1.0, 1.0, 1.0)
let s4 = v.bgra;     // vec4f(3.0, 2.0, 1.0, 4.0)

Hay una asimetría que conviene tener presente: solo el acceso a un componente individual de una variable es asignable. v.x = 1.0; es legal si v es un var; v.xy = vec2f(1.0, 2.0); no lo es. Un swizzle de más de un componente produce un valor, no una posición de memoria, y a un valor no se le asigna nada. Cuando necesites escribir dos componentes, o los escribes por separado o reconstruyes el vector entero.

Los operadores, uno por uno

Casi todos los operadores aritméticos funcionan componente a componente y con difusión automática del escalar:

let a = vec3f(1.0, 2.0, 3.0);
let b = vec3f(4.0, 5.0, 6.0);

let suma  = a + b;        // vec3f(5, 7, 9)
let prod  = a * b;        // vec3f(4, 10, 18)  <- NO es el producto escalar
let escal = a * 2.0;      // vec3f(2, 4, 6)
let punto = dot(a, b);    // 32.0, esto si es el producto escalar
let cruz  = cross(a, b);  // vec3f(-3, 6, -3)

El * entre vectores es el producto de Hadamard, componente a componente. Es el mismo comportamiento que GLSL y que HLSL, pero es la primera fuente de confusión de quien viene de escribir matemáticas en papel o de una librería donde mul significa producto escalar.

Las comparaciones también son componente a componente y devuelven un vector de booleanos, que if no acepta:

let mascara = a > b;              // vec3<bool>
if (all(mascara)) { /* ... */ }   // all reduce a bool
if (any(mascara)) { /* ... */ }
let elegido = select(a, b, mascara);  // por componente, segun la mascara

Con matrices, el asterisco cambia de significado y hace álgebra lineal de verdad:

let m : mat4x4f = camara.proyeccion * camara.vista;   // producto de matrices
let p : vec4f   = m * vec4f(posicion, 1.0);           // matriz por vector columna
let q : vec4f   = vec4f(posicion, 1.0) * m;           // vector fila por matriz: la traspuesta
let s : mat4x4f = m * 0.5;                            // escalar, componente a componente

Las dos formas m * v y v * m son válidas y dan resultados distintos: la segunda equivale a multiplicar por la traspuesta. Si tu escena aparece rotada de una forma que no tiene sentido, comprueba el orden antes que las matrices.

Matrices: C columnas, R filas

matCxR significa C columnas y R filas, en ese orden, y la matriz se almacena por columnas. mat4x3f tiene cuatro columnas de tres elementos cada una: es la matriz que transforma un vector de cuatro componentes en uno de tres.

La convención coincide con GLSL. No coincide con HLSL, donde float3x4 significa tres filas y cuatro columnas y el almacenamiento por defecto es por filas. Si estás portando desde HLSL, cada matriz no cuadrada hay que darle la vuelta al nombre y pensar la traspuesta.

El constructor acepta columnas o escalares en orden de columnas:

let m = mat3x3f(
  vec3f(1.0, 0.0, 0.0),   // columna 0
  vec3f(0.0, 1.0, 0.0),   // columna 1
  vec3f(0.0, 0.0, 1.0),   // columna 2
);

let n = mat2x2f(1.0, 2.0,    // columna 0: x = 1, y = 2
                3.0, 4.0);   // columna 1

Y el acceso con corchetes devuelve una columna, no una fila:

let columna : vec3f = m[1];        // vec3f(0, 1, 0)
let elemento : f32  = m[1][2];     // fila 2 de la columna 1

De ahí sale un idioma que aparece en todos los shaders con transformaciones: la traslación de una matriz de modelo 4x4 es su cuarta columna, modelo[3].xyz, y la parte lineal 3x3 son las tres primeras columnas recortadas. Y de ahí sale también el aviso de disposición en memoria que el nivel dedicado a la alineación desarrolla: una mat3x3f ocupa 48 bytes, no 36, porque cada columna es un vec3f alineado a 16.

Lo que falta: no hay inverse

WGSL trae transpose y determinant, pero no trae inverse. GLSL sí la tiene desde la versión 1.40 y su ausencia sorprende a todo el mundo la primera vez.

La omisión es deliberada y tiene dos motivos. El primero es que invertir una matriz es caro y numéricamente delicado, y que las implementaciones de GLSL variaban en precisión. El segundo, y el bueno, es que casi ningún shader necesita invertir una matriz: necesita una matriz que la CPU ya tiene invertida. La matriz de vista es la inversa de la de cámara y se calcula una vez por fotograma en JavaScript; la matriz normal es la traspuesta de la inversa de la parte 3x3 del modelo y se calcula una vez por objeto.

Cuando de verdad la necesitas dentro del shader —porque la matriz se genera en la GPU, típicamente en un compute shader— la versión 3x3 cabe en seis líneas y es rápida:

fn inversa3x3(m : mat3x3f) -> mat3x3f {
  // Los cofactores son productos vectoriales de las columnas.
  let r0 = cross(m[1], m[2]);
  let r1 = cross(m[2], m[0]);
  let r2 = cross(m[0], m[1]);
  let invDet = 1.0 / dot(m[0], r0);
  // La adjunta se forma con esos vectores como FILAS, de ahi la traspuesta.
  return transpose(mat3x3f(r0, r1, r2)) * invDet;
}

Para una 4x4 afín —rotación, escala y traslación, sin proyección— no hace falta el caso general: se invierte la parte lineal y se transforma la traslación.

fn inversaAfin(m : mat4x4f) -> mat4x4f {
  let lineal = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
  let invLineal = inversa3x3(lineal);
  let t = -(invLineal * m[3].xyz);
  return mat4x4f(
    vec4f(invLineal[0], 0.0),
    vec4f(invLineal[1], 0.0),
    vec4f(invLineal[2], 0.0),
    vec4f(t, 1.0),
  );
}
La matriz normal es el ejemplo perfecto de trabajo que no debe estar en el shader

Para transformar normales hace falta la traspuesta de la inversa de la parte 3x3 del modelo, y hay una cantidad asombrosa de código por ahí que la calcula dentro del vertex shader, con la inversa escrita a mano porque el lenguaje no la trae. Es un caso de estudio de dónde poner el cálculo.

Piensa en los números. Una malla de cien mil vértices con un vertex shader que invierte una 3x3 hace cien mil inversiones idénticas, porque la matriz de modelo es la misma para todos los vértices del objeto. El mismo resultado se obtiene con una inversión en JavaScript por objeto y por fotograma, subida en el mismo uniform buffer donde ya viaja la matriz de modelo. La proporción es de cien mil a uno.

El razonamiento generaliza a una regla de tres escalones que decide dónde va cada cálculo. Si el valor es igual para todo el objeto, va en la CPU y viaja en un uniform. Si es igual para todos los vértices de una instancia pero distinto entre instancias, va en un storage buffer indexado por instance_index. Solo si depende del vértice, o del fragmento, tiene que estar en el shader.

El error contrario también existe y es más raro pero más caro: calcular en la CPU algo que depende del fragmento y subirlo en una textura. Pero el error habitual, con diferencia, es el primero, y su síntoma es un vertex shader que hace álgebra lineal que podría estar hecha. Cuando revises un shader ajeno, la primera pregunta útil no es si el código es eficiente: es si ese código tenía que estar ahí.

Con los tipos numéricos cubiertos, quedan los compuestos: arrays y structs.