wandres.dev
SHADERS IV · Ruido y patrones

Random determinista y la fragilidad de fract sin

Por qué un shader necesita un hash y no un generador, qué falla exactamente en el fract(sin(dot())) de todos los tutoriales, y las dos alternativas que sí son portables.

⏱ 19 min

Un shader no puede tener un generador de números aleatorios porque no tiene estado. Lo que necesita es otra cosa: una función que convierta una coordenada en un número que parezca aleatorio y que devuelva siempre el mismo para la misma coordenada. Eso es un hash, no un generador, y la distinción importa porque el hash que todo el mundo copia está construido sobre una imprecisión del hardware que ni siquiera está especificada.

🎯 Al terminar esta lección sabrás
  • Explicar por qué un shader necesita una función hash y no un generador de secuencia.
  • Enumerar los cuatro modos en que falla el hash basado en fract( sin( ... ) ).
  • Implementar hashes por multiplicación y por operaciones de bits, y elegir entre ellos.
  • Comprobar visualmente la calidad de un hash antes de construir nada encima.

Hash, no generador

Un generador de números pseudoaleatorios mantiene un estado y lo hace avanzar en cada llamada. Un shader no tiene estado entre invocaciones, y además las invocaciones no tienen orden, así que la idea de secuencia no significa nada.

Lo que sí funciona es una función pura: un hash que toma una coordenada —la posición del fragmento, el índice de una celda, un vértice— y devuelve un valor bien repartido en el rango de cero a uno. Sus requisitos son cuatro:

Determinismo, para que la misma coordenada dé siempre lo mismo, en todos los cuadros y en todas las máquinas. Buena avalancha, para que cambiar mínimamente la entrada cambie por completo la salida. Distribución uniforme, para que ningún valor salga más que otro. Y baratura, porque se va a llamar millones de veces por cuadro.

El hash de todos los tutoriales

Este es el que aparece en el noventa por ciento del código que encontrarás:

float random( vec2 st ) {
  return fract( sin( dot( st.xy, vec2( 12.9898, 78.233 ) ) ) * 43758.5453123 );
}

Funciona por accidente. La idea es que sin de un número grande produce un valor cuya representación en coma flotante es esencialmente impredecible, y que multiplicarlo por una constante grande y quedarse con la parte fraccionaria destruye cualquier estructura restante. Tiene cuatro problemas, y el último es el que descalifica la técnica.

Uno: sin no está especificado con precisión. La especificación de GLSL da un margen de error para las funciones trigonométricas y no obliga a ningún algoritmo concreto de reducción de rango. Con argumentos grandes, distintas implementaciones dan resultados distintos en los bits bajos, que son precisamente los bits que este hash usa. Un patrón generado así no es el mismo en una GPU de Apple, en una de AMD y en una de Intel bajo ANGLE.

Dos: se rompe con coordenadas grandes. Si st viene de coordenadas de mundo en una escena de miles de unidades, el argumento de sin llega a cientos de miles. En ese punto el flotante de treinta y dos bits ya no puede representar la fase con precisión suficiente, la reducción de rango pierde los bits significativos y el resultado empieza a repetirse o a formar bandas visibles.

Tres: en media precisión no sobrevive. Con mediump, sin de un número grande deja del orden de tres bits de entropía. Sobre hardware móvil que ejecute a media precisión, el ruido se convierte en un patrón de rayas.

Cuatro: hay estructura. Aun en el mejor caso, la correlación entre celdas vecinas no es despreciable, y con ciertos valores de las constantes aparecen alineaciones diagonales que se ven en cuanto amplías el patrón.

Para verlo con tus propios ojos hacen falta cuatro líneas:

// Pinta el hash directamente. Deberia parecer television sin senal.
void main() {
  vec2 celda = floor( vUv * 400.0 );
  gl_FragColor = vec4( vec3( random( celda ) ), 1.0 );
}

Y para ver el fallo de las coordenadas grandes, cambia vUv por vUv * 4000.0 + 100000.0 y observa cómo aparecen bandas.

Las dos alternativas que sí valen

Hash por multiplicación

Sin transcendentales, solo multiplicaciones y fract. Son los hashes de Dave Hoskins, ampliamente probados y sin dependencia de ninguna función imprecisa:

// vec2 a float
float hash12( vec2 p ) {
  vec3 p3 = fract( vec3( p.xyx ) * 0.1031 );
  p3 += dot( p3, p3.yzx + 33.33 );
  return fract( ( p3.x + p3.y ) * p3.z );
}

// vec2 a vec2
vec2 hash22( vec2 p ) {
  vec3 p3 = fract( vec3( p.xyx ) * vec3( 0.1031, 0.1030, 0.0973 ) );
  p3 += dot( p3, p3.yzx + 33.33 );
  return fract( ( p3.xx + p3.yz ) * p3.zy );
}

// vec3 a vec3
vec3 hash33( vec3 p ) {
  p = fract( p * vec3( 0.1031, 0.1030, 0.0973 ) );
  p += dot( p, p.yxz + 33.33 );
  return fract( ( p.xxy + p.yxx ) * p.zyx );
}

Son deterministas hasta el bit en cualquier hardware que cumpla IEEE para multiplicación y suma, que es todo. Siguen degradándose con coordenadas enormes, porque fract de un número grande pierde precisión igual, pero el umbral está mucho más arriba y no dependen de sin.

Hash entero

Ésta es la buena, y está disponible en cualquier ShaderMaterial de Three.js r184 porque el shader se compila como GLSL ES 3.00, que tiene enteros sin signo y operadores de bits. Es el hash PCG, del artículo de Mark Jarzynski sobre funciones hash para renderizado:

uvec3 pcg3d( uvec3 v ) {
  v = v * 1664525u + 1013904223u;
  v.x += v.y * v.z;
  v.y += v.z * v.x;
  v.z += v.x * v.y;
  v ^= v >> 16u;
  v.x += v.y * v.z;
  v.y += v.z * v.x;
  v.z += v.x * v.y;
  return v;
}

// De una celda entera a tres valores en [0, 1)
vec3 hashCelda3( ivec3 celda ) {
  return vec3( pcg3d( uvec3( celda ) ) ) * ( 1.0 / 4294967296.0 );
}

// Version de dos dimensiones, usando solo dos componentes
vec2 hashCelda2( ivec2 celda ) {
  return hashCelda3( ivec3( celda, 0 ) ).xy;
}

Sus propiedades son cualitativamente mejores. Es exacto en aritmética entera, así que da el mismo bit en cualquier GPU sin depender de ninguna tolerancia. Su avalancha es completa: cambiar un bit de la entrada cambia la mitad de los bits de la salida. Y no se degrada con coordenadas grandes, porque los enteros no tienen mantisa que perder.

El coste es de una decena de multiplicaciones enteras, comparable al hash de multiplicaciones flotantes y bastante más barato que un sin.

La única precaución es la conversión de entrada. Si tu coordenada es continua, hay que discretizarla primero, y hay que hacerlo de forma que no colisione:

// Correcto: la celda entera
ivec2 celda = ivec2( floor( p ) );
vec2 r = hashCelda2( celda );

// Incorrecto: truncar hacia cero hace que -0.5 y 0.5 caigan en la misma celda
ivec2 mal = ivec2( p );

Comprobar antes de construir

Todo el ruido, todos los patrones y todas las distribuciones que vienen después se apoyan en el hash. Un hash con estructura produce ruido con estructura, y el artefacto aparece a tres capas de distancia donde nadie lo va a relacionar con su causa. Merece la pena dedicarle un minuto:

void main() {
  // Panel de cuatro cuadrantes para comparar
  vec2 uv = vUv * 2.0;
  vec2 celda = floor( uv * 200.0 );
  float v;
  if ( uv.x < 1.0 && uv.y < 1.0 )       v = random( celda );
  else if ( uv.x >= 1.0 && uv.y < 1.0 ) v = hash12( celda );
  else                                   v = hashCelda2( ivec2( celda ) ).x;
  gl_FragColor = vec4( vec3( v ), 1.0 );
}

Lo que buscas es ausencia de estructura: sin diagonales, sin rejilla, sin zonas más claras. Y la prueba de fuego: multiplica las coordenadas por mil y comprueba que sigue igual.

El hash decide si tu efecto se ve igual en la maquina de otro, y eso no se descubre hasta produccion

La consecuencia grave de usar fract( sin( ... ) ) no es la calidad estadística sino la portabilidad. Como la precisión de sin fuera de un rango pequeño es una decisión de la implementación, el mismo shader con las mismas entradas produce un campo de ruido distinto en una GPU de Apple, en una de NVIDIA y en Chrome sobre Windows, donde ANGLE traduce a Direct3D y la función pasa por otro compilador más. Si tu efecto es niebla o grano, da igual. Si es la distribución de vegetación de un terreno procedural, la posición de las grietas de una textura, o cualquier cosa cuyo aspecto concreto hayas ajustado a ojo hasta que quedó bien, entonces lo que has ajustado solo existe en tu máquina. Y el fallo es especialmente cruel porque no reproduce: el desarrollador ve una cosa, el diseñador ve otra, y ninguno de los dos tiene motivos para sospechar del generador de números. Hay un segundo escenario todavía peor: si el mismo campo de ruido se calcula en dos sitios que tienen que coincidir —el color en el fragment shader y el desplazamiento en el vertex shader, o la GPU y una comprobación equivalente en JavaScript— la discrepancia produce desalineaciones imposibles de depurar. La regla es simple y no cuesta nada: para cualquier cosa que no sea ruido puramente decorativo, usa el hash entero. Es más rápido, es exacto, y es el mismo en todas partes.

⚔️ Reto práctico

Implementa las tres funciones de esta lección en un mismo shader y píntalas en tres franjas verticales, con las coordenadas multiplicadas por un uniform que puedas subir de 1 a 100000. Encuentra el valor en el que cada una empieza a mostrar estructura. La distancia entre esos tres números es el argumento.