wandres.dev
SHADERS IV · Ruido y patrones

Ruido de valor y ruido de gradiente

Las dos familias de ruido de retícula, implementadas completas, y por qué la función de interpolación decide si el patrón delata la rejilla.

⏱ 19 min

Un hash produce ruido blanco: cada punto independiente de sus vecinos, inútil para casi todo. Lo que hace falta es ruido con estructura a una escala controlada, y para conseguirlo hay que muestrear el hash en una retícula e interpolar entre los nodos. De cómo se hace esa interpolación —y de qué se interpola exactamente— salen las dos familias fundamentales y la razón de que Perlin cambiara su propia función en 2002.

🎯 Al terminar esta lección sabrás
  • Implementar ruido de valor y ruido de gradiente en dos dimensiones, completos.
  • Explicar por qué el ruido de gradiente no muestra rejilla y el de valor sí.
  • Comparar el coste de las dos familias y su crecimiento con la dimensión.
  • Elegir la función de interpolación según lo que vayas a hacer con el ruido.

Ruido de valor: interpolar valores

La idea más directa. Se coloca un valor aleatorio en cada nodo de una retícula entera y se interpola bilinealmente entre los cuatro que rodean al punto.

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 );
}

float ruidoValor( vec2 p ) {
  vec2 i = floor( p );
  vec2 f = fract( p );

  // Suavizado de la coordenada local: sin esto, la interpolacion es lineal
  // y se ven las aristas de la reticula.
  vec2 u = f * f * ( 3.0 - 2.0 * f );

  float a = hash12( i + vec2( 0.0, 0.0 ) );
  float b = hash12( i + vec2( 1.0, 0.0 ) );
  float c = hash12( i + vec2( 0.0, 1.0 ) );
  float d = hash12( i + vec2( 1.0, 1.0 ) );

  return mix( mix( a, b, u.x ), mix( c, d, u.x ), u.y );
}

El resultado está en el rango de cero a uno. Cuesta cuatro hashes y tres mix.

Y tiene un defecto estructural: los extremos del ruido caen siempre sobre los nodos de la retícula. Como el valor en cada nodo es exactamente el del hash y entre nodos solo hay interpolación, el máximo local está siempre en un nodo. Con una sola octava eso produce un patrón de manchas alineadas en cuadrícula que se ve a simple vista, y con muchas octavas sigue viéndose como una textura ligeramente ortogonal.

Ruido de gradiente: interpolar productos escalares

La solución de Ken Perlin en 1985 fue cambiar qué se guarda en el nodo. En vez de un valor, un vector de dirección aleatorio. Y el aporte de cada nodo al punto no es su valor sino el producto escalar entre su gradiente y el vector que va del nodo al punto.

vec2 gradiente( vec2 celda ) {
  float a = hash12( celda ) * 6.283185307179586;
  return vec2( cos( a ), sin( a ) );
}

float ruidoGradiente( vec2 p ) {
  vec2 i = floor( p );
  vec2 f = fract( p );

  // Quintica de Perlin: derivada Y segunda derivada nulas en los extremos
  vec2 u = f * f * f * ( f * ( f * 6.0 - 15.0 ) + 10.0 );

  float a = dot( gradiente( i + vec2( 0.0, 0.0 ) ), f - vec2( 0.0, 0.0 ) );
  float b = dot( gradiente( i + vec2( 1.0, 0.0 ) ), f - vec2( 1.0, 0.0 ) );
  float c = dot( gradiente( i + vec2( 0.0, 1.0 ) ), f - vec2( 0.0, 1.0 ) );
  float d = dot( gradiente( i + vec2( 1.0, 1.0 ) ), f - vec2( 1.0, 1.0 ) );

  return mix( mix( a, b, u.x ), mix( c, d, u.x ), u.y );
}

La diferencia clave está en el vector f - esquina: en el propio nodo ese vector es cero, así que el producto escalar es cero. El ruido de gradiente vale exactamente cero en todos los nodos de la retícula. Los extremos caen entre nodos, en posiciones que dependen de las direcciones de los gradientes vecinos, y por eso el patrón no delata la rejilla.

El rango es aproximadamente de menos raíz de dos partido por dos a más lo mismo, es decir unos menos 0.707 a 0.707 en dos dimensiones. Para llevarlo a cero-uno, n * 0.7071 * 0.5 + 0.5 o simplemente n * 0.5 + 0.5 aceptando que no llegará a los extremos.

La función de interpolación

Las dos implementaciones usan una curva distinta para suavizar la coordenada local, y esa elección no es cosmética.

La interpolación lineal, es decir usar f directamente sin suavizar, produce discontinuidades en la derivada al cruzar una línea de la retícula. Se ven como una cuadrícula de pliegues, y con ruido de gradiente el efecto es todavía peor porque los nodos valen cero y los pliegues pasan por ahí.

La cúbica, f * f * ( 3.0 - 2.0 * f ), que es exactamente lo que hace smoothstep, tiene derivada cero en los extremos. Elimina los pliegues visibles y es lo que Perlin usó en la versión original.

La quíntica, 6f⁵ - 15f⁴ + 10f³, tiene además la segunda derivada nula en los extremos. Perlin la introdujo en 2002, en el artículo donde revisó su propia función. La razón es concreta: si vas a usar el ruido como campo de alturas y calcular normales a partir de él, las normales dependen de la primera derivada, y un brillo especular sobre esas normales depende de la segunda. Con la cúbica, la segunda derivada salta al cruzar cada línea de la retícula, y ese salto reaparece en la iluminación como un enrejado tenue que solo se ve cuando la luz da rasante.

// Las tres, para comparar
vec2 lineal   = f;
vec2 cubica   = f * f * ( 3.0 - 2.0 * f );
vec2 quintica = f * f * f * ( f * ( f * 6.0 - 15.0 ) + 10.0 );

La quíntica cuesta unas cuatro operaciones más por eje. Si el ruido se va a ver solo como color, la cúbica basta. Si se va a derivar, la quíntica no es opcional.

Coste y dimensión

Valor 2D Gradiente 2D Valor 3D Gradiente 3D
Nodos a evaluar 4 4 8 8
Hashes 4 4 8 8
Productos escalares 0 4 0 8
Interpolaciones 3 3 7 7

El número de nodos es dos elevado a la dimensión. En dos dimensiones son cuatro y en tres son ocho; en cuatro dimensiones —que hacen falta para animar ruido 3D sin que se note el desplazamiento— son dieciséis. Ese crecimiento exponencial es la razón por la que existe el ruido simplex, que reduce los nodos a la dimensión más uno: tres en 2D, cuatro en 3D, cinco en 4D.

Para el caso muy frecuente de ruido 2D que se anima, hay un atajo que evita subir a 3D: desplazar las coordenadas.

// Barato: el patron se desplaza, no evoluciona
float n = ruidoGradiente( p + vec2( uTiempo * 0.2, 0.0 ) );

// Caro pero correcto: el patron cambia de forma
float n = ruidoGradiente3D( vec3( p, uTiempo * 0.2 ) );

El primero se ve como una textura que se mueve; el segundo como algo que hierve. Para nubes o humo, el segundo. Para agua que fluye en una dirección, el primero es lo que quieres de todas formas.

El ruido de gradiente vale cero en los nodos, y eso se ve si eliges mal la frecuencia

La propiedad que da su calidad al ruido de gradiente tiene un reverso que casi nadie anticipa: si evalúas el ruido exactamente en los nodos de la retícula, obtienes cero. Eso ocurre más a menudo de lo que parece. El caso clásico es aplicar ruido a la posición de los vértices de un PlaneGeometry con un factor de escala entero: si el plano mide diez unidades y tiene diez segmentos, cada vértice cae en una coordenada entera, y con ruidoGradiente( position.xy ) la deformación es exactamente cero en todos los vértices. La malla no se mueve, el shader parece roto, y el código es correcto. El mismo fallo aparece al muestrear ruido en una rejilla de instancias con separación entera, o al evaluar ruido sobre coordenadas UV multiplicadas por el número de segmentos. Detectarlo es trivial una vez que sabes que existe: cambia el factor de escala a un número no entero —0.97, 1.37, 2.13— y si el efecto aparece de golpe, ése era el problema. Y la costumbre que lo evita para siempre es introducir un desplazamiento irracional en el muestreo: ruidoGradiente( p * 1.37 + vec2( 13.7, 7.3 ) ). Con ruido de valor esto no pasa, porque los nodos tienen valor no nulo; con simplex tampoco, porque su retícula no es cuadrada. Es un defecto exclusivo del ruido de gradiente sobre retícula cuadrada, y por eso desconcierta tanto.