wandres.dev
SHADERS III · El lenguaje GLSL

El catálogo de funciones que de verdad se usan

fract, mod, length, distance, dot, normalize, reflect, refract, pow y atan: definición exacta, la trampa de cada una, y los idiomas donde aparecen.

⏱ 20 min

La biblioteca estándar de GLSL tiene unas ochenta funciones y en la práctica se usan quince. Lo que separa a quien las conoce de quien las busca cada vez no es la lista sino los detalles: qué hace mod con negativos, cuándo pow devuelve NaN, qué significa que refract devuelva el vector cero. Esta lección es la referencia con esos detalles dentro.

🎯 Al terminar esta lección sabrás
  • Recordar la definición exacta de las diez funciones que aparecen en todo shader.
  • Anticipar el comportamiento de cada una en sus casos degenerados.
  • Escribir coordenadas polares y un caleidoscopio con atan y mod.
  • Blindar los cálculos que pueden producir NaN.

Repetición y envoltura

fract( x ) devuelve x - floor( x ). Con negativos eso da un resultado que sorprende y que es exactamente lo que quieres: fract( -0.25 ) vale 0.75. Es el operador de repetición: fract( uv * 8.0 ) divide el espacio en ocho por ocho celdas, cada una con coordenadas de cero a uno.

mod( x, y ) devuelve x - y * floor( x / y ). El signo del resultado sigue al de y, no al de x, que es lo contrario del operador % de C. mod( -1.0, 3.0 ) vale 2.0, no -1.0. Esa es la propiedad que hace que mod sirva para ángulos y para índices cíclicos sin casos especiales.

Matemáticamente mod( x, 1.0 ) y fract( x ) coinciden, pero fract suele compilar a una instrucción y mod a una división más un floor más una multiplicación. Usa fract cuando el módulo sea uno.

// Celdas repetidas, con coordenada local y con indice de celda
vec2 escalado = vUv * 8.0;
vec2 celda = floor( escalado );      // que celda soy
vec2 local = fract( escalado );      // donde estoy dentro de ella

Longitudes y ángulos

length( v ) es sqrt( dot( v, v ) ). distance( a, b ) es length( a - b ). Las dos llevan una raíz cuadrada, así que cuando solo necesitas comparar distancias, compara los cuadrados:

// Mas barato que distance( a, b ) < r
vec3 d = a - b;
bool cerca = dot( d, d ) < r * r;

dot( a, b ) es la suma de productos componente a componente. Con dos vectores unitarios es el coseno del ángulo entre ellos, y de ahí sale la mitad de la iluminación: max( dot( N, L ), 0.0 ) es el término difuso de Lambert, y 1.0 - dot( N, V ) es la base de cualquier fresnel.

normalize( v ) divide por la longitud. Es indefinido para el vector cero: produce una división por cero y por tanto NaN. Si hay alguna posibilidad de que el vector sea nulo, protégete:

vec3 normalizarSeguro( vec3 v ) {
  float l2 = dot( v, v );
  return l2 > 1e-12 ? v * inversesqrt( l2 ) : vec3( 0.0, 0.0, 1.0 );
}

inversesqrt( x ) calcula uno partido por la raíz, y en la mayoría del hardware es una única instrucción, más rápida que hacer la raíz y luego dividir.

atan( y, x ) es la versión de dos argumentos, equivalente a atan2: devuelve el ángulo del punto en el rango de menos pi a pi, con el cuadrante correcto. Es indefinida solo si los dos argumentos valen cero. La versión de un argumento, atan( y_entre_x ), devuelve en el rango de menos medio pi a medio pi y pierde el cuadrante.

// Coordenadas polares
float r = length( p );
float a = atan( p.y, p.x );          // de -PI a PI

// Y la vuelta
vec2 cartesiana = vec2( cos( a ), sin( a ) ) * r;

Con esas dos funciones se escribe un caleidoscopio completo:

vec2 caleidoscopio( vec2 p, float lados ) {
  float r = length( p );
  float a = atan( p.y, p.x );
  float sector = 6.283185307179586 / lados;
  a = mod( a, sector );                // plegar al primer sector
  a = abs( a - sector * 0.5 );         // y reflejar dentro de el
  return vec2( cos( a ), sin( a ) ) * r;
}

Ese mod funciona con ángulos negativos precisamente porque su resultado sigue el signo del divisor.

Potencias, reflexión y refracción

Potencias y exponentes

pow( x, y ) calcula x elevado a y, y su especificación es explícita: es indefinido si x es negativo, y también si x vale cero y y es menor o igual que cero. Ésa es la fuente número uno de NaN en código de iluminación, porque el argumento típico es un producto escalar que puede salir negativo.

// Mal: si dot(N, H) es negativo, NaN
float especular = pow( dot( N, H ), 48.0 );

// Bien
float especular = pow( max( dot( N, H ), 0.0 ), 48.0 );

exp, log, exp2, log2 y sqrt completan el grupo. sqrt de un negativo también es indefinido. log de cero o de un negativo, igual.

Reflexión y refracción

reflect( I, N ) calcula I - 2.0 * dot( N, I ) * N. Dos condiciones que la documentación da por sabidas: N tiene que estar normalizado, y I tiene que apuntar hacia la superficie, no hacia fuera. Si usas el vector que va del punto a la cámara, tienes que negarlo primero.

vec3 I = normalize( vPosicionMundo - cameraPosition );   // hacia la superficie
vec3 R = reflect( I, normalize( vNormal ) );
vec3 color = textureCube( uEntorno, R ).rgb;

refract( I, N, eta ) aplica la ley de Snell con eta igual a la razón de índices de refracción del medio de origen entre el de destino. Su definición incluye una rama:

// Lo que refract calcula por dentro
float ci = dot( N, I );
float k = 1.0 - eta * eta * ( 1.0 - ci * ci );
vec3 resultado = k < 0.0 ? vec3( 0.0 ) : eta * I - ( eta * ci + sqrt( k ) ) * N;

Cuando k es negativo hay reflexión total interna y la función devuelve el vector cero. Ese cero no es un valor útil: muestrear un cubemap con la dirección cero da un resultado arbitrario, y normalizarlo da NaN. Hay que detectarlo:

vec3 refractado = refract( I, N, 1.0 / 1.52 );     // aire a vidrio
float hayRefraccion = step( 1e-6, dot( refractado, refractado ) );
vec3 direccion = mix( reflect( I, N ), refractado, hayRefraccion );

Ese mix es correcto porque refract devuelve exactamente cero, no NaN, en el caso de reflexión total. Si la implementación devolviera NaN, el mix lo propagaría y habría que usar un if.

La tabla de referencia

Función Devuelve Caso degenerado
fract( x ) x - floor( x ) ninguno
mod( x, y ) x - y * floor( x / y ) y igual a cero
length( v ) raíz de dot( v, v ) ninguno
dot( a, b ) suma de productos ninguno
normalize( v ) v partido por su longitud vector nulo da NaN
reflect( I, N ) I - 2 dot( N, I ) N N sin normalizar da basura
refract( I, N, eta ) Snell devuelve cero en reflexión total
pow( x, y ) x elevado a y x negativo, o cero con y no positivo
atan( y, x ) ángulo con cuadrante los dos argumentos a cero
clamp( x, a, b ) min( max( x, a ), b ) NaN atraviesa
sign( x ) menos uno, cero o uno devuelve cero exactamente en cero
abs( x ) valor absoluto ninguno
mod con un divisor grande pierde el sentido, y hay una alternativa exacta

mod( x, y ) se implementa como x - y * floor( x / y ), y esa fórmula tiene un problema de cancelación que aparece cuando x es mucho mayor que y. Con x del orden de un millón y y igual a uno, la división da un millón, el floor da un millón, la multiplicación devuelve un millón, y la resta de dos números casi idénticos de siete cifras deja disponibles muy pocos bits significativos para el resultado. En flotante de treinta y dos bits, mod( 1000000.7, 1.0 ) puede devolver cualquier cosa entre 0.6 y 0.8 según el redondeo, y mod( 100000000.5, 1.0 ) devuelve cero porque el argumento ni siquiera puede representar la parte fraccionaria. Dónde ocurre esto en la práctica: patrones repetidos sobre coordenadas de mundo en escenas grandes, animaciones basadas en un tiempo que lleva horas acumulándose, y cualquier ruido evaluado sobre coordenadas sin normalizar. El síntoma es que el patrón se vuelve grueso y se detiene a medida que te alejas del origen, o que la animación se congela después de un rato largo. Hay dos arreglos y conviene tener claro cuál aplica. Si el problema es la magnitud del tiempo, hazle el módulo en JavaScript antes de pasarlo como uniform: en JavaScript los números son de sesenta y cuatro bits y el módulo es exacto hasta magnitudes absurdas. Y si el problema es la magnitud de las coordenadas, resta el origen de la celda antes de operar: trabaja siempre en coordenadas locales de un tramo y lleva la parte grande por separado, que es la misma técnica que usan los motores de mundos abiertos para evitar que la geometría tiemble a diez kilómetros del origen.