Matemáticas y trigonometría: el catálogo y lo que no garantiza
Las funciones numéricas integradas de WGSL, las cinco formas de redondear y en qué se diferencian, las que devuelven structs, y el margen de error que la especificación permite.
La librería estándar de WGSL es pequeña, está toda sobrecargada para escalares y vectores, y no tiene ni una sola función que no ejecute el hardware directamente o en unas pocas instrucciones. Eso la hace fácil de memorizar y esconde una trampa: la especificación no promete el mismo resultado bit a bit en dos GPUs distintas, y hay funciones cuyo error crece hasta hacerlas inservibles con argumentos grandes.
- Localizar la función integrada correcta para cada operación numérica.
- Distinguir
floor,ceil,round,truncyfracty elegir la que toca. - Usar las funciones que devuelven structs,
modfyfrexp. - Enunciar qué precisión garantiza la especificación y qué consecuencias tiene.
El catálogo
Todas estas funciones aceptan escalares y vectores del tipo correspondiente, y aplican componente a componente cuando reciben vectores.
| Familia | Funciones |
|---|---|
| Magnitud | abs, sign, min, max, clamp, saturate |
| Redondeo | floor, ceil, round, trunc, fract |
| Potencias | sqrt, inverseSqrt, pow, exp, exp2, log, log2 |
| Combinadas | fma, mix, step, smoothstep |
| Descomposición | modf, frexp, ldexp |
| Ángulos | degrees, radians |
| Trigonometría | sin, cos, tan, asin, acos, atan, atan2 |
| Hiperbólicas | sinh, cosh, tanh, asinh, acosh, atanh |
| Precisión | quantizeToF16 |
Tres ausencias que hay que tener presentes. No hay mod: el operador % calcula el resto con el signo del dividendo, que no es lo mismo que el mod de GLSL. No hay inverse para matrices, aunque sí hay determinant y transpose. Y no hay funciones de ruido: si quieres ruido, lo escribes tú.
saturate(x) es exactamente clamp(x, 0.0, 1.0) y existe porque muchas GPUs lo implementan como un modificador gratuito de la instrucción anterior, sin coste ninguno.
fma(a, b, c) calcula a * b + c con un solo redondeo al final, que es más preciso que hacer la multiplicación y la suma por separado. En la práctica el compilador ya fusiona multiplicaciones y sumas cuando le conviene; escribir fma explícitamente sirve cuando quieres garantizar el redondeo único.
Las cinco formas de redondear
Es donde más se confunde la gente, así que aquí están las cinco con los mismos valores de prueba:
| Función | 2.7 | -2.7 | 2.5 | 3.5 |
|---|---|---|---|---|
floor |
2 | -3 | 2 | 3 |
ceil |
3 | -2 | 3 | 4 |
trunc |
2 | -2 | 2 | 3 |
round |
3 | -3 | 2 | 4 |
fract |
0.7 | 0.3 | 0.5 | 0.5 |
Los dos detalles que se escapan:
round resuelve los empates hacia el par. round(2.5) es 2 y round(3.5) es 4. Es el redondeo estadísticamente insesgado de IEEE-754, y no es lo que hace Math.round de JavaScript, que redondea siempre hacia arriba. Si estás replicando un cálculo entre CPU y GPU, esa diferencia produce discrepancias en los valores terminados en medio.
fract está definido como x - floor(x), así que devuelve siempre un valor no negativo. fract(-2.7) es 0.3, no -0.7. Es la función correcta para envolver coordenadas de textura y para patrones repetidos, y es el sustituto natural del mod(x, 1.0) de GLSL.
Las que devuelven structs
Dos funciones descomponen un número en dos partes, y como WGSL no tiene parámetros de salida, devuelven una struct predeclarada con dos miembros:
// modf separa parte entera y parte fraccionaria, conservando el signo.
let m = modf(-2.75);
// m.whole = -2.0
// m.fract = -0.75
// frexp separa mantisa y exponente: x = fract * 2^exp, con fract en [0.5, 1).
let f = frexp(12.0);
// f.fract = 0.75
// f.exp = 4
// ldexp deshace lo anterior: fract * 2^exp
let x = ldexp(0.75, 4); // 12.0
frexp y ldexp son las herramientas para manipular el exponente sin pasar por logaritmos, y aparecen en código de tone mapping, en compresión de rango dinámico y en cualquier sitio donde haga falta escalar por potencias de dos exactas.
Fíjate en que modf conserva el signo en las dos partes, al contrario que fract.
Lo que la especificación no garantiza
Esta es la parte que hay que interiorizar. Para las operaciones básicas —suma, resta, multiplicación, división— WGSL sigue IEEE-754 y el resultado es el mismo en todas partes. Para las funciones trascendentes no: la especificación fija un error máximo permitido en unidades del último lugar, y cada implementación se ajusta a ese margen como puede.
En términos prácticos:
sin,cos,tan,exp,log,poweinverseSqrtpueden dar bits distintos en dos GPUs.- El margen permitido crece con el tamaño del argumento en las trigonométricas.
- El tratamiento de los subnormales puede variar: algunas implementaciones los vacían a cero.
- No se debe construir lógica sobre la igualdad exacta de resultados de estas funciones.
La consecuencia inmediata es que una simulación que dependa de reproducibilidad bit a bit entre máquinas no se puede escribir con trascendentes. Si necesitas determinismo entre clientes —una simulación en red, un test de regresión de imagen estricto— tienes que restringirte a las operaciones exactas o aceptar tolerancias.
Este es el fallo de precisión más común en shaders animados y casi nunca se diagnostica bien porque tarda horas en aparecer.
Pasas el tiempo transcurrido a un shader como un f32 y lo usas como argumento de una función periódica: sin(tiempo * 3.0) para una ondulación, fract(tiempo * 0.1) para un desplazamiento de textura. Todo perfecto durante la sesión de desarrollo. En una instalación que lleva encendida desde ayer, la ondulación va a saltos y el desplazamiento de textura avanza a tirones.
La aritmética es implacable. Un f32 tiene 24 bits de mantisa. A las dos horas, tiempo vale 7200, que necesita 13 bits para la parte entera, y quedan 11 para la fracción: la resolución temporal es de medio milisegundo. A las veinticuatro horas, tiempo vale 86400 y la resolución cae a unos cinco milisegundos, con lo que a 60 fotogramas por segundo hay fotogramas consecutivos que reciben exactamente el mismo valor de tiempo y otros que saltan el doble. La animación tiembla.
Y encima está la reducción de rango. Calcular sin(86400.0) obliga a la implementación a reducir el argumento módulo 2π, y esa reducción se hace con la precisión que hay: con 86400 la precisión absoluta es de milésimas, y el ángulo resultante tiene un error relativo enorme. El error permitido por la especificación en trigonométricas crece con el argumento precisamente por esto.
La solución es de una línea y va en la CPU, no en el shader: envolver el tiempo antes de subirlo. Para funciones periódicas, módulo 2π dividido por la frecuencia más baja que uses, o simplemente módulo un número redondo de segundos que sea múltiplo de todos tus periodos. Para desplazamientos, acumular la fase en JavaScript en un double y subir solo la parte envuelta.
const fase = (performance.now() * 0.001) % (Math.PI * 2);
La misma clase de problema aparece con las coordenadas del mundo en escenas grandes y con los contadores de fotogramas usados como semilla. La regla general: cualquier valor que crezca sin límite y viaje a la GPU en un f32 es una bomba de relojería, y la mecha dura exactamente lo que tarde en agotar la mantisa.