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.
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.
- 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
atanymod. - 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( 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.