Aleatoriedad, Noise y fBM

Existen varios algoritmos para generar ruido. Vamos a estudiar alguno de ellos junto con algunas de sus aplicaciones.

Aleatoriedad

Empezamos por el rey de los ruidos: la aleatoriedad. En concreto, para materiales, estamos interesados en la pseudo-aleatoriedad.

En este contexto decimos que una función $f(x)$ es pseudo-aleatoria si la función devuelve el mismo valor para dos entradas iguales. Es decir si $x_o = x_1$ entonces $f(x_0) = f(x_1)$.

Una función pseudo-aleatoria debe devolver valores sin relación aparente incluso para entradas muy parecidas.

¿Cómo conseguir aleatoriedad partiendo de funciones matemáticas bien definidas?

La idea es tomar la entrada (no acotada) y pasarla por una función que la acote. Una buena candidata es $sin$.

float random(float x) {
    float y = sin(x);
    // ...
}

Teniendo la salida acotada ahora podemos "ampliarla" multiplicando por algún factor relativamente grande:

float random(float x) {
    float y = 35137.5234 * sin(x);
    // ...
}

La salida $y$ aún no es aleatoria, aunque estamos muy cerca.

De hecho, podemos aplicarle otra función que la convierte en pseudo-aleatoria. ¿Cuál? La función $frac$, es decir, tomar sus decimales.

float random(float x) {
    float y = 35137.5234 * sin(x);
    return frac(y);
}
Random 1 valor

La función anterior cumple con los criterios que necesitamos:

  1. que para dos valores distintos de $x$ la salida no esté aparentemente relacionada.
  2. para valores de $x$ parecidos la salida sea suficientemente distinta.
  3. para valores de $x$ iguales la salida es la misma.

Si necesitamos que la entrada sea un vector puedes utilizar el producto escalar sobre otro vector:

float random(float2 uv) {
    float x = dot(uv, float2(43152.7612, -127.88));
    float y = 35137.5234 * sin(x);
    return fract(y);
}
Random 2 valores

Usando el nodo Custom Node para escribir código hlsl:

Custom Node con Random en el editor de materiales

Fíjate como se crea ruido blanco,¡pseudo-aleatoriedad!

O usando los nodos habituales sin Custom Node:

Random en el editor de materiales

Este ruido "puro" puede ser usado en multitud de efectos:

Ejemplo de uso de la función random

Hash function

Aunque nuestra función ruido funciona relativamente bien no podemos obviar que también es costosa computacionalmente.

Para aliviar el coste podríamos sustituirla por las bien conocidas funciones hash. Hay multitud de funciones hash: para checksum, CRC, para almacenar mapas claves-valor, criptográficas, etc.,

Algunas funciones hash suelen exhibir buenas propiedades como las ya comentadas para ser usadas como función pseudo-aleatoria.

Aquí algunas:

// Dave Hoskins hash
// https://www.shadertoy.com/view/4djSRW

float hash11(float p)
{
    p = fract(p * 0.1031);
    p *= p + 33.33;
    p *= p + p;
    return fract(p);
}

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

Sustituyendo la función anterior usando $sin$ por alguna $hash$ anterior deberíamos tener el mismo resultado.

Value Noise

En la naturaleza no encontramos un ruido tan puro. Si bien es cierto existe aleatoriedad, pero esta es "contínua".

Es posible conseguir aleatoriedad "contínua" a partir de aleatoriedad pura. Es tan directo como tomar dos valores aleatorios puros e interpolarlos.

En caso de más de una dimensión podemos dividir el espacio en un grid. Para cada esquina (vértice) del grid le asignamos un valor aleatorio puro. El resto de valores dentro del grid será una interpolación de los cuatro vértices (en caso de 2D) u ocho vértices (en caso 3D).

Veamos el caso 2D.

He creado un material llamado M_VNoise.

Divimos el espacio en un grid de NxN celdas:

Dividir el espacio en un grid de N celdas

La parte fraccional ofrece las coordenadas UV de cada celda:

Coordenadas UV dentro de cada celda

Mientras que con floor obtenemos la columna y fila de cada celda. La celda superior-izquierda tendrá las coordenadas (0,0) mientras que la inferior derecha (N-1, N-1).

Para cada celda tenemos que obtener sus cuatro vértices. Si estuvieramos en un espacio 3D sus ocho vértices. Para cada vértice computar un valor aleatorio puro:

Obtener los valores aleatorios para cada vértice de la celda

Y ahora toca interpolar cada valor. Primero interpolamos los dos pares de vértices alineados horizontales. Es decir, el vértice superior izquierdo – superior derecho e inferior izquierdo – inferior derecho:

Interpolar en u

Y ahora interpolamos en vertical.

Interpolar en v

La salida de la interpolación es precisamente el valor aleatorio contínuo. También llamado Value Noise.

El último nodo antes de conectar con emissive es para remapear el ruido (-1 a 1) al rango 01.

Value Noise

Podemos suavizar aún más el ruido. En vez de usar tal cuál los valores $u$ y $v$ como alpha para interpolar linearmente, utilizar una función de interpolación cúbica o includo quinta potencia:

vec2 f = fract(p);
f = f*f*f*(f*(f*6.0-15.0)+10.0);
Función de interpolación

Podemos encapsular esta función en un material function llamado M_SmoothAlpha y usarla así:

Usando la función de interpolación
Con smooth y sin smooth

En GLSL:

float vnoise(vec2 p) 
{
    vec2 id = floor(p);
    vec2 f = fract(p);
    
    f = f*f*f*(f*(f*6.0-15.0)+10.0);
    
    return 
        mix(
            mix(hash12(id + vec2(0., 0.)), 
                hash12(id + vec2(1., 0.)), 
                f.x),
            mix(hash12(id + vec2(0., 1.)), 
                hash12(id + vec2(1., 1.)), 
                f.x),
            f.y
        );
}
Value Noise en Shadertoy por jorgemoag 

Turbulence y fBM

Usando value noise como base podemos crear ruidos más complejos.

En efecto, si sumamos value-noise cambiando su frecuencia y amplitud por cada sumando tenemos el ruido fBM. Normalmente se va doblando la frecuencia y reduciendo a la mitad la amplitud.

Como decimos a esta forma de proceder se suele llamar Fractional Brownian Motion (fBM).

$$ fBM(p) = \frac{1}{2} n(p) + \frac{1}{4} n(2p) + \frac{1}{8} n(3p) + \frac{1}{16} n(4p) + ... $$

Siendo $n(x)$ la función ruido. Por ejemplo, value-noise.

Una implementación en GLSL:

float fbm(vec2 p, int octaves, float laccun, float gain)
{   
    float c = 0.;
    
    float g = gain, l = 1.;
    
    for (int iter = 0; iter < octaves; ++iter)
    {
        c += g * vnoise(p * l);
        g *= gain;
        l *= laccun;        
    }
    
    return c;
}
fBM

Quizás te sorprenda el uso de nombres pero tiene su sentido.

  • Octaves es el número de sumandos. Como normalmente se dobla la frecuencia para cada sumando, esto en música es una octava superior. De ahí el nombre.
  • Laccunarity es el multiplicador de frecuencia. Normalmente se dobla en cada sumando, así que normalmente $laccun = 2$. En geometría se suele usar el término laccunarity para describir el "hueco" entre patrones.
  • Gain es el multiplicador de la amplitud para cada sumando. Normalmente cada sumando "pesa" o contribuye al ruido total la mitad que el sumando anterior, así que normalmente este parámetro será igual a $\frac{1}{2}$.

El ruido fBM tiene una pinta tal que así:

Usando este ruido podemos conseguir efectos como éste:

fBM como ruido

Si en vez de sumar tal cuál, antes hacemos valor absoluto de cada sumando, obtenemos el algoritmo Turbulence:

float turbulence(vec2 p, int octaves, float laccun, float gain)
{
    float c = 0.;    
    float g = gain, l = 1.;    
    for (int iter = 0; iter < octaves; ++iter)
    {
        // la diferencia con respecto a fbm es el uso de abs
        c += g * abs(vnoise(p * l));        
        g *= gain;
        l *= laccun;        
    }    
    return c;
}
Turbulence
Turbulence para crear texturas proceduralmente

Perlin / Gradient Noise

A veces ocurre con value noise que los valores random obtenidos en cada vértice del grid se "parecen" mucho entre ellos para una misma fila o columna, provocando que al interpolar no parezca "tan" aleatorio.

Para ello se usa gradient noise. En vez de elegir aleatoriamente un valor para cada vértice elegimos un gradiente. Esto es, elegimos aleatoriamente un vector 2D (ó 3D si estamos en un espacio 3D) para cada vértice del grid.

El objetivo es terminar interpolando valores escalares. Es decir, hacer un value noise "normal".

¿Cómo convertimos un gradiente en un escalar? En gradient noise usamos el producto escalar contra el vector diferencia entre el punto a evaluar y el vértice.

En GLSL el algoritmo queda así:

float pnoise(vec2 p)
{
    vec2 id = floor(p);
    vec2 st = fract(p);
    
    vec2 f = st;
    f = f*f*f*(f*(f*6.0-15.0)+10.0);
    
    return 
        mix(
            mix(dot(hash22(id + vec2(0., 0.)), vec2(0., 0.) - st),
                dot(hash22(id + vec2(1., 0.)), vec2(1., 0.) - st), 
                f.x),
            mix(dot(hash22(id + vec2(0., 1.)), vec2(0., 1.) - st),
                dot(hash22(id + vec2(1., 1.)), vec2(1., 1.) - st), 
                f.x),
            f.y
        );
}
Perlin / Gradient Noise

Fíjate como el algoritmo es el mismo que value noise salvo que el valor a interpolar es el resultado del producto escalar entre un gradiente escogido aleatoriamente por vértice y el vector diferencia entre dicho vértice y el punto en concreto:

Value vs Gradient noise

Tanto fBM como Turbulence se pueden usar con gradient noise.

Composición de fBM

Podemos usar la salida de un fBM como entrada a otro fBM para crear efectos complejos.

Iñigo Quilez tiene en su web un estupendo artículo explicándolo.

Vamos a ilustrarlo usando ShaderToy.

Empezamos creando las funciones para la aleatoriedad, vector noise y fBM tal y como hemos visto en apartados anteriores:

float random(vec2 p)
{    
	float x = dot(p,vec2(4371.321,-9137.327));    
    return 2.0 * fract(sin(x)*17381.94472) - 1.0;
}

float noise( in vec2 p )
{
    vec2 id = floor( p );
    vec2 f = fract( p );
	
	vec2 u = f*f*(3.0-2.0*f);

    return mix(mix(random(id + vec2(0.0,0.0)), 
                   random(id + vec2(1.0,0.0)), u.x),
               mix(random(id + vec2(0.0,1.0)), 
                   random(id + vec2(1.0,1.0)), u.x), 
               u.y);
}

float fbm( vec2 p )
{
    float f = 0.0;
    float gat = 0.0;
    
    for (float octave = 0.; octave < 5.; ++octave)
    {
        float la = pow(2.0, octave);
        float ga = pow(0.5, octave + 1.);
        f += ga*noise( la * p ); 
        gat += ga;
    }
    
    f = f/gat;
    
    return f;
}
random, vector noise y fbm en GLSL

Ahora usamos la composición. Podemos componer fBM de infinidad de modos, puedes probar tu mismo. Aquí un ejemplo:

float noise_fbm(vec2 p)
{
    float h = fbm(0.09*iTime + p + fbm(0.065*iTime + 2.0 * p - 5.0 * fbm(4.0 * p)));  
    return h; 
}

Remapeando el ruido a distintos rangos y luego haciendo interpolación a distintos colores podemos conseguir el efecto final:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y;

    // remapeamos el ruido para usarlo como interpolador de colores
    float a2 = smoothstep(-0.5, 0.5, noise_fbm(p));
    
    // usamos un fbm normal para darle otro toque de color
    float a1 = smoothstep(-1.0, 1.0, fbm(p));
    
    // obtemos el color base, interpolar entre tres colores
    // usando el ruido anterior como interpolador
    vec3 cc = mix(mix(vec3(0.50,0.00,0.10), 
                     vec3(0.50,0.75,0.35), a1), 
                     vec3(0.00,0.00,0.02), a2);      
    
    // le damos algunos toques para mayor complejidad
    cc += 0.5 * vec3(0.1, 0.0, 0.2) * noise_fbm(p);
    cc += 0.25 * vec3(0.3, 0.4, 0.6) * noise_fbm(2.0 * p);
    
    // ¡¡prueba a comentar los bloques anteriores!!
    
    fragColor = vec4( vec3(cc), 1.0 );
}

Podemos usar una convolución sencilla para intentar obtener el ouline de la figura:

float outline(vec2 p, float eps)
{
    float f = noise_fbm(p - vec2(0.0, 0.0));
    
    float ft = noise_fbm(p - vec2(0.0, eps));
    float fl = noise_fbm(p - vec2(eps, 0.0));
    float fb = noise_fbm(p + vec2(0.0, eps));
    float fr = noise_fbm(p + vec2(eps, 0.0));
    
    float gg = clamp(abs(4. * f - ft - fr - fl - fb), 0., 1.);
    
    return gg;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // ...
    cc += vec3(0.0,0.2,1.0) * outline(p, 0.0005);
    cc += vec3(1.0,1.0,1.0) * outline(p, 0.0025);
    
    fragColor = vec4( vec3(cc), 1.0 );
}
Ouline barato

Y añadirlo para obtener el efecto final con outline:

fBM usando domain warp inspirado en el artículo de iquilez

Este efecto podemos traducirlo trivialmente a HLSL y, usando algunos Custom Node, portarlo a UE4:

fBM en UE4