wandres.dev
WGSL V · La librería estándar

Las funciones geométricas: dot, cross, reflect, refract y faceForward

Las definiciones exactas de las funciones vectoriales de WGSL, qué convención de dirección espera cada una, y los idiomas que evitan raíces cuadradas y NaN.

⏱ 18 min

Estas ocho funciones son el vocabulario entero de la geometría en un shader, y siete de ellas se escriben en una línea si hiciera falta. Lo que importa de ellas no es lo que calculan, que está en cualquier referencia, sino la convención de dirección que asumen: reflect espera el vector incidente y no el que apunta a la luz, y equivocarse ahí produce un brillo especular que se mueve al revés y que nadie sabe explicar.

🎯 Al terminar esta lección sabrás
  • Enunciar la definición exacta de cada función geométrica integrada.
  • Usar reflect y refract con la convención de dirección correcta.
  • Sustituir raíces cuadradas por comparaciones de cuadrados donde sea equivalente.
  • Escribir una normalización robusta ante el vector nulo.

Longitud, distancia y normalización

let l = length(v);          // sqrt(dot(v, v))
let d = distance(a, b);     // length(a - b)
let u = normalize(v);       // v / length(v)

Las tres se apoyan en la misma raíz cuadrada, y de ahí sale el primer idioma que conviene tener automatizado: comparar cuadrados en lugar de longitudes. Si lo único que necesitas es saber cuál de dos distancias es mayor, o si algo está dentro de un radio, la raíz sobra:

// En vez de esto:
if distance(p, centro) < radio { /* ... */ }

// Esto, sin raiz cuadrada:
let dif = p - centro;
if dot(dif, dif) < radio * radio { /* ... */ }

En un bucle sobre cien luces, esa sustitución quita cien raíces cuadradas por fragmento.

normalize de un vector nulo es indeterminado: divide por cero. Y a partir de ahí el resultado contamina todo lo que toque, porque cualquier operación con un valor no numérico produce otro valor no numérico. La versión robusta cuesta una comparación:

fn normalizarSeguro(v : vec3f, porDefecto : vec3f) -> vec3f {
  let l2 = dot(v, v);
  return select(porDefecto, v * inverseSqrt(l2), l2 > 1e-12);
}

Fíjate en inverseSqrt en lugar de dividir por sqrt: es una sola instrucción en prácticamente todas las GPUs, mientras que sqrt seguida de división son dos.

Producto escalar y producto vectorial

let c = dot(a, b);       // escalar, para vectores de cualquier tamaño
let n = cross(a, b);     // solo vec3 de coma flotante

dot es la función más usada de todo el lenguaje y tiene tres papeles distintos que conviene tener separados en la cabeza: coseno del ángulo entre dos vectores unitarios, proyección de uno sobre otro, y evaluación de un plano cuando uno de los dos es una normal.

cross solo existe para vec3 de coma flotante, y su resultado sigue la regla de la mano derecha. Su longitud es el área del paralelogramo, lo cual da el idioma para calcular la normal de un triángulo y su área de una vez:

let arista1 = b - a;
let arista2 = c - a;
let cruzado = cross(arista1, arista2);
let normal = normalize(cruzado);
let area = length(cruzado) * 0.5;

reflect, refract y faceForward

Las tres tienen definiciones exactas en la especificación y todas asumen que el segundo argumento está normalizado.

reflect(e1, e2) devuelve e1 - 2 * dot(e2, e1) * e2. El primer argumento es el vector incidente: el que va hacia la superficie, no el que sale de ella.

// v es el vector que va del punto A LA CAMARA (sale de la superficie).
// n es la normal de la superficie.
// El vector de reflexion para muestrear un cubemap de entorno:
let reflejado = reflect(-v, n);      // el signo menos convierte v en incidente

Ese menos es el error más frecuente de todos los shaders de reflexión. Sin él, el reflejo del entorno se mueve en sentido contrario al girar la cámara, que es un artefacto que confunde mucho porque a primera vista parece plausible.

refract(e1, e2, e3) calcula la dirección refractada con la ley de Snell, donde e1 es el incidente normalizado, e2 la normal normalizada y e3 la razón de índices de refracción. Su definición incluye el caso de la reflexión total interna:

k = 1 - e3² * (1 - dot(e2, e1)²)
si k < 0:  el resultado es el vector cero
si no:     e3 * e1 - (e3 * dot(e2, e1) + sqrt(k)) * e2

Ese vector cero cuando hay reflexión total es la parte que hay que manejar. Un shader de cristal que no lo comprueba produce puntos negros en los bordes, justo donde el ángulo rasante provoca la reflexión total:

let refractado = refract(-v, n, 1.0 / 1.5);
let hayRefraccion = dot(refractado, refractado) > 0.0;
let direccion = select(reflect(-v, n), refractado, hayRefraccion);

faceForward(e1, e2, e3) devuelve e1 si dot(e2, e3) es negativo, y -e1 en caso contrario. Sirve para orientar una normal hacia el lado desde el que se mira:

let nOrientada = faceForward(n, -v, n);

En la práctica, en WebGPU tienes @builtin(front_facing), que es información del rasterizador y es más fiable que deducirlo del producto escalar, sobre todo en superficies casi tangentes a la vista.

Determinante y traspuesta

Las dos únicas funciones de matrices:

let dt = determinant(m);    // solo matrices cuadradas
let tm = transpose(m);      // matCxR pasa a matRxC

determinant sirve para detectar si una transformada invierte la orientación —determinante negativo—, lo cual importa para decidir el sentido de giro de las caras y para corregir normales en objetos con escala negativa.

transpose aparece sobre todo en la construcción de la matriz normal y en el paso de espacio tangente, donde una base ortonormal se invierte simplemente trasponiéndola.

// Base tangente: la inversa de una matriz ortonormal es su traspuesta.
let tbn = mat3x3f(tangente, bitangente, normal);
let vistaEnTangente = transpose(tbn) * vistaEnMundo;
La normal interpolada nunca llega normalizada, y saltarse el normalize es la micro-optimización que más caro sale

Escribes normalize en el vertex shader, la normal viaja como variable interpolada, y en el fragment shader la usas directamente porque ya venía normalizada. Es un razonamiento que suena bien y es falso.

La interpolación lineal de dos vectores unitarios no produce un vector unitario. Entre dos normales separadas 60 grados, el punto medio interpolado tiene longitud 0.87, no 1. Y con corrección de perspectiva el efecto es mayor en las zonas donde la profundidad varía rápido. El resultado es una normal que se acorta hacia el centro de cada triángulo y se recupera en los vértices.

Lo que ese error produce no es un fallo evidente: es un patrón de facetas suaves siguiendo la topología de la malla. El término difuso, que es dot(n, l), se atenúa donde la normal se ha acortado, y el especular, que va elevado a un exponente alto, se atenúa muchísimo más. En una esfera suave se ve como si tuviera un relieve poligonal muy tenue, y quien lo ve suele culpar a la malla o al mapa de normales.

El coste de arreglarlo es prácticamente cero. normalize de un vec3 son un producto escalar, una raíz inversa y tres multiplicaciones, y la raíz inversa es una instrucción de hardware. En un fragment shader que además muestrea cuatro texturas y calcula un BRDF, ese coste no es medible.

La regla, sin excepciones que merezcan la pena: normaliza en el fragment shader toda dirección que haya viajado interpolada. La normal, la tangente, el vector a la luz si lo interpolaste, el vector a la cámara. Y si de verdad te sobra rendimiento para preocuparte por esas instrucciones, el ahorro de verdad está en no transportar esas direcciones y reconstruirlas desde la posición, no en saltarse la normalización.