wandres.dev
SHADERS IV · Ruido y patrones

Domain warping y patrones que se ven bien

Deformar el dominio antes de evaluar el ruido, y las recetas completas de madera, mármol y nubes con el material de Three.js montado.

⏱ 20 min

El FBM produce una textura que parece ruido. Lo que la convierte en madera, en mármol o en nubes son dos operaciones baratas: deformar el dominio antes de evaluarlo, y mapear el resultado a través de una función que introduzca estructura. Esta lección cierra el nivel con las recetas completas y con la única advertencia que importa: cuánto cuesta esto de verdad.

🎯 Al terminar esta lección sabrás
  • Implementar domain warping de uno y de dos niveles y explicar por qué funciona.
  • Escribir las recetas de madera, mármol y nubes con parámetros que se entienden.
  • Contar las evaluaciones de ruido por fragmento de un patrón completo.
  • Decidir cuándo hornear el patrón a una textura en vez de evaluarlo cada cuadro.

Deformar el dominio

La idea, de Inigo Quilez, es tan simple que cuesta creer el resultado: en vez de evaluar fbm( p ), evalúa fbm( p + q ) donde q es a su vez un campo de ruido vectorial. Es decir, usa ruido para decidir dónde muestrear el ruido.

// Un nivel
float warp1( vec2 p ) {
  vec2 q = vec2(
    fbm( p + vec2( 0.0, 0.0 ) ),
    fbm( p + vec2( 5.2, 1.3 ) )
  );
  return fbm( p + 4.0 * q );
}

Los desplazamientos 5.2, 1.3 son arbitrarios y solo sirven para que las dos componentes de q sean campos distintos e independientes. El factor 4.0 controla la intensidad de la deformación: con cero recuperas el FBM original, y con valores altos el resultado se retuerce hasta perder toda relación con la entrada.

Dos niveles producen el aspecto de flujo que reconocerás de cualquier fondo generativo:

float warp2( vec2 p, out vec2 q, out vec2 r ) {
  q = vec2(
    fbm( p + vec2( 0.0, 0.0 ) ),
    fbm( p + vec2( 5.2, 1.3 ) )
  );

  r = vec2(
    fbm( p + 4.0 * q + vec2( 1.7, 9.2 ) ),
    fbm( p + 4.0 * q + vec2( 8.3, 2.8 ) )
  );

  return fbm( p + 4.0 * r );
}

Los parámetros de salida q y r no son decorativos: sus magnitudes son las que dan color al resultado en el ejemplo clásico, porque señalan dónde la deformación ha sido más fuerte.

Por qué funciona: el FBM tiene una firma reconocible, un aspecto de nube homogénea con la misma estadística en todas partes. Deformar el dominio rompe esa homogeneidad porque la escala local del patrón pasa a depender del gradiente de la deformación: donde q cambia deprisa, el patrón se comprime; donde cambia despacio, se estira. Aparecen zonas de calma y zonas de turbulencia, que es exactamente lo que distingue una imagen de un ruido.

Tres recetas

Madera

Los anillos de crecimiento son curvas de nivel de la distancia al centro del tronco, perturbadas por el crecimiento irregular. La receta es tomar esa distancia, sumarle turbulencia y quedarse con la parte fraccionaria.

vec3 madera( vec2 p, float t ) {
  // La distancia al eje del tronco, con una ligera excentricidad
  float radio = length( p * vec2( 1.0, 0.35 ) );

  // Los anillos: 14 por unidad, deformados por ruido de baja frecuencia
  float anillos = radio * 14.0 + fbm( p * 1.6 ) * 2.5;

  // La parte fraccionaria da el perfil de cada anillo
  float g = fract( anillos );

  // Un anillo tiene un borde duro por dentro y una transicion larga por fuera
  float perfil = smoothstep( 0.0, 0.12, g ) * ( 1.0 - smoothstep( 0.2, 1.0, g ) );

  // La veta fina, de frecuencia mucho mas alta y muy anisotropa
  float veta = fbm( p * vec2( 60.0, 3.0 ) ) * 0.5 + 0.5;

  vec3 clara = vec3( 0.58, 0.39, 0.22 );
  vec3 oscura = vec3( 0.29, 0.17, 0.09 );

  vec3 c = mix( clara, oscura, perfil * 0.85 );
  c = mix( c, oscura, veta * 0.18 );
  return c;
}

Los dos detalles que hacen que se vea a madera y no a rayas: la escala anisótropa vec2( 60.0, 3.0 ) de la veta, que la estira a lo largo de la fibra, y el perfil asimétrico del anillo, porque la madera de primavera y la de verano no tienen el mismo grosor.

Mármol

Una veta de mármol es una superficie de nivel de una función suave a la que se ha aplicado turbulencia. La receta canónica es un seno sobre una coordenada perturbada.

vec3 marmol( vec2 p ) {
  float turb = turbulencia( p * 2.5 );

  // El seno crea las bandas; la turbulencia las retuerce
  float v = sin( ( p.x + turb * 3.2 ) * 6.283185307179586 );

  // Elevar a una potencia alta afila las vetas y ensancha el fondo
  float veta = pow( abs( v ), 0.35 );

  vec3 fondo = vec3( 0.88, 0.87, 0.85 );
  vec3 linea = vec3( 0.18, 0.20, 0.24 );

  return mix( linea, fondo, veta );
}

pow( abs( v ), 0.35 ) es la clave: con exponente uno tendrías bandas sinusoidales blandas; con un exponente menor que uno, el valor sube muy deprisa al alejarse del cero, así que la zona oscura se estrecha hasta parecer una línea dibujada y el resto queda claro. Es el mismo truco que convierte un ruido difuso en un patrón nítido, sin usar ningún umbral.

Nubes

Aquí el domain warping es lo que da el aspecto de volumen, y la evolución en el tiempo tiene que venir de dos velocidades distintas: una traslación global y una deformación interna.

float nubes( vec2 p, float t ) {
  vec2 desplazamiento = vec2( t * 0.02, 0.0 );

  vec2 q = vec2(
    fbm( p + desplazamiento ),
    fbm( p + desplazamiento + vec2( 5.2, 1.3 ) )
  );

  float n = fbm( p + 2.4 * q + desplazamiento * 0.5 );

  // De [-1, 1] a cobertura, con un umbral suave que controla cuanto cielo se ve
  float cobertura = 0.42;
  return smoothstep( cobertura, cobertura + 0.35, n * 0.5 + 0.5 );
}

cobertura es el único parámetro que un director de arte va a querer tocar, y va desde cielo despejado hasta cerrado. La anchura del smoothstep controla lo difusa que es la frontera de la nube.

El material completo

import * as THREE from 'three';

const material = new THREE.ShaderMaterial( {
  defines: { OCTAVAS: 5 },
  uniforms: {
    uTiempo: { value: 0 },
    uEscala: { value: 3.0 },
    uCobertura: { value: 0.42 },
  },
  vertexShader: /* glsl */`
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    }
  `,
  fragmentShader: /* glsl */`
    uniform float uTiempo;
    uniform float uEscala;
    uniform float uCobertura;
    varying vec2 vUv;

    // Aqui van snoise, fbm y turbulencia de las lecciones anteriores

    void main() {
      vec2 p = vUv * uEscala;
      float c = nubes( p, uTiempo );
      vec3 cielo = vec3( 0.29, 0.51, 0.85 );
      vec3 nube  = vec3( 0.97, 0.97, 0.99 );
      gl_FragColor = vec4( mix( cielo, nube, c ), 1.0 );
    }
  `,
} );

El comentario /* glsl */ antes de la plantilla no hace nada en tiempo de ejecución: es una convención que los editores y las extensiones de resaltado reconocen para colorear el GLSL dentro del JavaScript. Vale la pena adoptarla.

Contar el coste

Ésta es la parte que se salta todo el mundo. Cuenta las evaluaciones de ruido por fragmento del ejemplo de las nubes: q.x es un FBM de cinco octavas, q.y otro, y el FBM final otro más. Tres FBM por cinco octavas son quince evaluaciones de snoise por fragmento.

A 1920 por 1080 eso son treinta y un millones de evaluaciones de simplex por cuadro, y a sesenta cuadros por segundo, mil ochocientos millones por segundo. Cada snoise son unas cuarenta operaciones. Es una carga real, y en un móvil de gama media no cabe.

Las tres formas de reducirlo, en orden de eficacia:

Bajar octavas. De cinco a tres es un cuarenta por ciento menos, y en un patrón deformado apenas se nota porque el warp ya aporta detalle aparente.

Evaluar a menor resolución. Renderizar el patrón a un WebGLRenderTarget de la mitad de lado y muestrearlo con filtrado lineal cuesta la cuarta parte y, en algo tan difuso como una nube, es indistinguible.

Hornear. Si el patrón no cambia, calcúlalo una sola vez a un render target y luego usa esa textura como cualquier otra.

// Hornear un patron procedural a textura, una vez
function hornear( renderer, materialPatron, lado ) {
  const objetivo = new THREE.WebGLRenderTarget( lado, lado, {
    colorSpace: THREE.SRGBColorSpace,
    minFilter: THREE.LinearMipmapLinearFilter,
    magFilter: THREE.LinearFilter,
    generateMipmaps: true,
  } );

  const quad = new THREE.Mesh( new THREE.PlaneGeometry( 2, 2 ), materialPatron );
  const escena = new THREE.Scene().add( quad );
  const camara = new THREE.OrthographicCamera( -1, 1, 1, -1, 0.1, 10 );
  camara.position.z = 1;

  renderer.setRenderTarget( objetivo );
  renderer.render( escena, camara );
  renderer.setRenderTarget( null );

  quad.geometry.dispose();
  return objetivo.texture;
}
Un patron procedural pesa cero en la descarga y cuesta para siempre en la GPU

El argumento de venta de los patrones procedurales es que no ocupan nada: cero bytes de descarga, resolución infinita, y parámetros que se pueden animar. Todo cierto, y todo irrelevante frente al hecho que decide: una textura se paga una vez y un procedural se paga sesenta veces por segundo mientras el objeto esté en pantalla. Un mármol de dos mil por dos mil píxeles comprimido en KTX2 ocupa unos dos megas y cuesta un muestreo por fragmento. El mismo mármol calculado en el shader ocupa cero y cuesta quince evaluaciones de ruido por fragmento, indefinidamente. Con una conexión de diez megas por segundo, esos dos megas se descargan en dos décimas; para amortizar quince evaluaciones de ruido por fragmento harían falta unas cuantas horas de no descargarlas. El cálculo del punto de equilibrio no está ni cerca de ser ajustado. Y sin embargo los procedurales tienen tres nichos donde son insustituibles, y conviene reconocerlos para no descartarlos en bloque. El primero es cuando el patrón tiene que variar: mil rocas distintas con la misma textura se ven mal, y una semilla por instancia lo arregla sin mil texturas. El segundo es cuando no hay UV utilizables: un terreno de kilómetros o una superficie generada en tiempo real no se pueden texturizar sin repetición evidente, y el ruido en coordenadas de mundo sí. El tercero es cuando el patrón es la animación: nubes que evolucionan, agua que fluye, fuego. Fuera de esos tres, la pregunta correcta no es si el procedural queda bonito sino por qué no lo has horneado todavía.