Saltar al contenido

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.

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:

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

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

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

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

Texto
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:

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

GLSL
uniform vec3 uDireccionLuz;    // en espacio de mundouniform 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.

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