wandres.dev
SHADERS V · Vertex shaders creativos

Recalcular normales I: la vía analítica

Derivar la función de desplazamiento para obtener la normal exacta, con el caso del campo de alturas resuelto y el de las ondas de Gerstner completo.

⏱ 20 min

En cuanto mueves un vértice, el atributo normal que traía la geometría describe una superficie que ya no existe. La iluminación se calcula con esa normal obsoleta, y el resultado es un objeto con silueta ondulada y sombreado plano: el error visual más delator de todo el bloque de shaders. Cuando la deformación es una función que sabes derivar, la solución es exacta, barata y elegante.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la normal del atributo deja de ser válida tras desplazar.
  • Derivar la normal de un campo de alturas a partir de sus derivadas parciales.
  • Implementar normales analíticas para una suma de ondas de Gerstner.
  • Reconocer los límites del método y cuándo deja de compensar.

Por qué falla la normal original

La normal es la dirección perpendicular a la superficie. Si la superficie cambia, la perpendicular cambia. El atributo normal que llega al vertex shader se calculó cuando se creó la geometría, y describe la malla sin deformar.

El síntoma no es sutil: un plano ondulado iluminado con las normales originales tiene todas las normales apuntando en la misma dirección, así que se ilumina exactamente igual que un plano liso. Ves las olas por su silueta y por su sombra, pero la superficie no tiene volumen. Con un material especular el efecto es todavía peor, porque el brillo aparece como una mancha uniforme sobre toda la superficie en lugar de recorrer las crestas.

El campo de alturas

El caso más común y el más fácil. Una superficie es un campo de alturas cuando se puede escribir como una función de dos parámetros que devuelve un desplazamiento en un solo eje. Con el PlaneGeometry en su orientación natural —plano XY, normal en Z positivo— eso es:

P( x, y ) = ( x, y, h( x, y ) )

Los dos vectores tangentes a esa superficie son las derivadas parciales:

dP/dx = ( 1, 0, dh/dx )
dP/dy = ( 0, 1, dh/dy )

Y la normal es su producto vectorial. Haciendo la cuenta:

N = dP/dx  x  dP/dy = ( -dh/dx, -dh/dy, 1 )

Ese resultado es todo lo que hace falta. La normal de un campo de alturas es el gradiente cambiado de signo, con un uno en el eje del desplazamiento, normalizado.

Para una onda sinusoidal h = A sin( k x + w t ), la derivada respecto de x es A k cos( k x + w t ) y la derivada respecto de y es cero:

uniform float uTiempo;
uniform float uAmplitud;
uniform float uLongitud;

varying vec3 vNormal;
varying vec3 vPosicionVista;

void main() {
  float k = 6.283185307179586 / uLongitud;
  float fase = k * position.x - uTiempo * 2.0;

  vec3 p = position;
  p.z += uAmplitud * sin( fase );

  // Derivadas parciales de h, calculadas a mano
  float dhdx = uAmplitud * k * cos( fase );
  float dhdy = 0.0;

  vec3 normalObjeto = normalize( vec3( -dhdx, -dhdy, 1.0 ) );

  vNormal = normalMatrix * normalObjeto;
  vec4 posicionVista = modelViewMatrix * vec4( p, 1.0 );
  vPosicionVista = posicionVista.xyz;
  gl_Position = projectionMatrix * posicionVista;
}

Y el fragment shader que lo ilumina, con viewMatrix, que es uno de los tres uniforms que Three.js sí declara en esa etapa:

uniform vec3 uDireccionLuz;    // en espacio de mundo
uniform vec3 uColor;

varying vec3 vNormal;
varying vec3 vPosicionVista;

void main() {
  vec3 N = normalize( vNormal );
  vec3 L = normalize( ( viewMatrix * vec4( uDireccionLuz, 0.0 ) ).xyz );
  vec3 V = normalize( -vPosicionVista );
  vec3 H = normalize( L + V );

  float difusa = max( dot( N, L ), 0.0 );
  float especular = pow( max( dot( N, H ), 0.0 ), 64.0 );

  vec3 color = uColor * ( 0.12 + difusa * 0.9 ) + vec3( especular ) * 0.5;
  gl_FragColor = vec4( color, 1.0 );
}

El 0.0 en la componente w de la dirección de luz anula la traslación de la matriz, que es lo correcto para un vector de dirección.

Sumar ondas y transformar la normal

La linealidad es lo que hace escalar el método

La propiedad que convierte esto en una técnica y no en un truco puntual: la derivada de una suma es la suma de las derivadas. Si tu desplazamiento es la suma de ocho ondas, la derivada es la suma de las ocho derivadas, y cada una cuesta un coseno.

Para las ondas de Gerstner hay una complicación adicional, porque el desplazamiento no es solo vertical: los puntos también se mueven en horizontal, así que la superficie ya no es un campo de alturas. Pero las tangentes se siguen derivando igual, sumando las derivadas de las tres componentes.

// Acumula la posicion desplazada y las dos tangentes a la vez.
// tangenteX empieza en (1,0,0) y tangenteY en (0,1,0), que son las
// tangentes del plano sin deformar.
void gerstnerAcumulado(
  vec2 p, vec2 direccion, float longitud, float pendiente, float t,
  inout vec3 desplazamiento, inout vec3 tangenteX, inout vec3 tangenteY
) {
  float k = 6.283185307179586 / longitud;
  float c = sqrt( 9.8 / k );
  vec2 d = normalize( direccion );
  float a = pendiente / k;
  float f = k * ( dot( d, p ) - c * t );

  float sf = sin( f );
  float cf = cos( f );

  desplazamiento += vec3( d.x * a * cf, d.y * a * cf, a * sf );

  // Derivadas respecto de p.x
  tangenteX += vec3(
    -d.x * d.x * a * k * sf,
    -d.y * d.x * a * k * sf,
     d.x * a * k * cf
  );

  // Derivadas respecto de p.y
  tangenteY += vec3(
    -d.x * d.y * a * k * sf,
    -d.y * d.y * a * k * sf,
     d.y * a * k * cf
  );
}

void main() {
  vec3 desplazamiento = vec3( 0.0 );
  vec3 tx = vec3( 1.0, 0.0, 0.0 );
  vec3 ty = vec3( 0.0, 1.0, 0.0 );

  gerstnerAcumulado( position.xy, vec2(  1.0,  0.0 ), 8.0, 0.30, uTiempo,
                     desplazamiento, tx, ty );
  gerstnerAcumulado( position.xy, vec2(  0.6,  0.8 ), 4.7, 0.22, uTiempo,
                     desplazamiento, tx, ty );
  gerstnerAcumulado( position.xy, vec2( -0.7,  0.5 ), 2.3, 0.16, uTiempo,
                     desplazamiento, tx, ty );

  vec3 p = position + desplazamiento;
  vec3 normalObjeto = normalize( cross( tx, ty ) );

  vNormal = normalMatrix * normalObjeto;
  vec4 posicionVista = modelViewMatrix * vec4( p, 1.0 );
  vPosicionVista = posicionVista.xyz;
  gl_Position = projectionMatrix * posicionVista;
}

Tres ondas cuestan tres senos, tres cosenos y unas treinta multiplicaciones, y devuelven la normal exacta de la superficie matemática. Compáralo con la alternativa numérica de la lección siguiente, que para lo mismo necesitaría evaluar las tres ondas tres veces.

normalMatrix y no modelViewMatrix

La normal no se transforma como un punto. Bajo una escala no uniforme, aplicar la matriz de modelo-vista a una normal la inclina en la dirección equivocada: al aplastar un objeto en un eje, las normales deberían inclinarse hacia el eje contrario, no hacia el mismo.

La matriz correcta es la traspuesta de la inversa de la parte lineal, y Three.js la calcula por objeto y la expone como normalMatrix, un mat3. Úsala siempre.

Y una consecuencia de que sea la traspuesta de la inversa: no conserva la longitud. Aunque metas un vector unitario, sale con otra longitud, así que hay que normalizar después, en el fragment shader, donde de todas formas hay que hacerlo por la interpolación.

Dónde deja de compensar

El método analítico es exacto y barato, y tiene un techo claro: hay que saber derivar la función.

Con senos y sumas de senos, trivial. Con un mapa de alturas leído de una textura, imposible: no hay expresión que derivar. Con un FBM de seis octavas de simplex, técnicamente posible —la derivada analítica de simplex existe y se puede calcular a la vez que el valor— pero hay que implementar una versión de snoise que devuelva también su gradiente, y encadenarlo por la regla de la cadena a través de todas las octavas y de cualquier domain warping. Es un ejercicio real y algunos lo hacen; la mayoría no.

Y con una deformación que no sea un campo de alturas ni una suma de desplazamientos derivables —un twist, una deformación por matriz variable, un morphing entre dos mallas— la derivación se complica lo bastante como para que la vía numérica gane.

La normal analitica describe la superficie ideal, no la malla que dibujas

Hay una situación en la que la normal analítica, siendo la exacta, se ve peor que una aproximación, y entenderla evita perseguir un fantasma. La normal analítica es la de la superficie matemática continua. Lo que dibujas no es esa superficie: es una malla poligonal que la aproxima con un número finito de triángulos. Si la teselación es gruesa, las dos cosas se separan, y el resultado es una incoherencia entre el sombreado y la silueta: la iluminación dice que ahí hay una curva suave con una cresta bien definida, y el contorno del objeto dice que ahí hay tres segmentos rectos. A ángulos rasantes eso se lee como si la superficie estuviera pintada sobre una figura de papel plegado, y es especialmente visible en el borde de una ola donde el especular es intenso. La aproximación numérica de la lección siguiente, evaluada con un épsilon del tamaño del espaciado entre vértices, mide la malla que de verdad existe en vez de la superficie ideal, y por eso en teselaciones gruesas puede verse más coherente pese a ser matemáticamente menos exacta. La regla que se deduce es útil: el épsilon del método numérico es un parámetro con significado, no una constante que haya que hacer lo más pequeña posible. Con un épsilon minúsculo obtienes la normal analítica; con un épsilon igual al espaciado de vértices obtienes la normal de la malla; y en medio tienes un control continuo sobre cuánto detalle de la función quieres que aparezca en el sombreado. En un terreno con FBM, ese parámetro es una de las palancas artísticas más útiles que existen, porque separa la escala de la geometría de la escala del relieve aparente.