Saltar al contenido

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.

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.

GLSL
// Un nivelfloat 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:

GLSL
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.

GLSL
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.

GLSL
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.

GLSL
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

JS
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.

JS
// Hornear un patron procedural a textura, una vezfunction 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;}