Saltar al contenido

Las normales después de deformar: las tres maneras de recalcularlas

Por qué desplazar posiciones invalida las normales, la fórmula analítica, el método de diferencias finitas en el vertex shader, y cuándo basta con las derivadas en pantalla.

Por qué la normal deja de valer

La normal de una superficie es el producto vectorial de dos de sus vectores tangentes. Los tangentes describen cómo cambia la posición al moverte por la superficie. Si desplazas las posiciones, cambias los tangentes, y por tanto cambias la normal. Esto no es una particularidad de Three.js: es la definición.

Un ejemplo concreto lo hace evidente. Un plano horizontal tiene normal (0, 1, 0) en todos sus vértices. Le aplicas una ola en el eje vertical. La superficie deja de ser horizontal en todas partes menos en las crestas y los valles, pero el atributo normal sigue lleno de (0, 1, 0). Con una luz rasante, la superficie ondulada se ilumina exactamente igual de brillante en toda su extensión, porque el producto escalar entre la normal y la dirección de la luz no ha cambiado en ningún punto.

El síntoma es inconfundible una vez que lo has visto: relieve visible en la silueta y ninguna variación en el sombreado. La geometría se mueve, la luz no reacciona.

El caso de la deformación en CPU tiene una salida perezosa, computeVertexNormals(), con su coste de tres a cuatro veces el de la deformación. En el vertex shader no hay equivalente, porque el shader solo conoce el vértice que está procesando y no tiene forma de mirar a sus vecinos ni de acumular nada. Hay que calcular la normal en el mismo shader, para ese vértice, a partir de la función de desplazamiento.

La normal analítica

Si tu desplazamiento es vertical y viene de una función conocida y = f(x, z), la superficie deformada tiene dos tangentes evidentes: (1, ∂f/∂x, 0) y (0, ∂f/∂z, 1). Su producto vectorial, normalizado, da:

Texto
normal = normalizar( −∂f/∂x, 1, −∂f/∂z )

Es exacta, cuesta lo mismo que evaluar dos derivadas, y no tiene ningún parámetro que ajustar. Para la ola de las lecciones anteriores, con f(x,z) = A · sin(0.6x + t) · cos(0.4z − 0.7t), las derivadas se escriben solas:

GLSL
uniform float uTiempo;uniform float uAmplitud;float altura( vec2 p ) {  return sin( p.x * 0.6 + uTiempo ) * cos( p.y * 0.4 - uTiempo * 0.7 ) * uAmplitud;}vec3 normalAnalitica( vec2 p ) {  float dfdx =   0.6 * cos( p.x * 0.6 + uTiempo ) * cos( p.y * 0.4 - uTiempo * 0.7 ) * uAmplitud;  float dfdz = - 0.4 * sin( p.x * 0.6 + uTiempo ) * sin( p.y * 0.4 - uTiempo * 0.7 ) * uAmplitud;  return normalize( vec3( - dfdx, 1.0, - dfdz ) );}

Es la mejor opción siempre que sea posible, y hay más casos de los que parece: una suma de senos, una función polinómica, un ruido con derivada analítica —el ruido simplex la tiene—, cualquier deformación construida a partir de operaciones derivables.

Deja de ser posible en dos situaciones. Cuando el desplazamiento viene de una textura, porque no hay derivada cerrada. Y cuando la función tiene tantos términos que derivarla a mano es un ejercicio de paciencia con alto riesgo de error, que es lo que ocurre en cuanto encadenas varias octavas de ruido con rotaciones entre ellas.

Diferencias finitas

El método general. En lugar de derivar, evalúas la función de desplazamiento en el vértice y en dos puntos cercanos, y calculas la normal del triángulo que forman los tres resultados:

GLSL
vec3 desplazado( vec3 p ) {  p.y += altura( p.xz );  return p;}vec3 normalPorDiferencias( vec3 p, float e ) {  vec3 a = desplazado( p );  vec3 b = desplazado( p + vec3( e, 0.0, 0.0 ) );  vec3 c = desplazado( p + vec3( 0.0, 0.0, e ) );  return normalize( cross( c - a, b - a ) );}

Funciona con cualquier función, incluida una que muestree una textura, y cuesta tres evaluaciones del desplazamiento en lugar de una. Ese factor de tres es el precio de no conocer la derivada, y en la GPU suele ser perfectamente asumible.

El orden del producto vectorial no es indiferente: cross( c − a, b − a ) con b en la dirección de X y c en la de Z da una normal que apunta hacia arriba, coherente con el sentido de recorrido de un suelo. Invertir los argumentos la voltea.

Inyectado en un material PBR completo:

JS
import * as THREE from 'three';const geometria = new THREE.PlaneGeometry( 10, 10, 200, 200 );geometria.rotateX( - Math.PI / 2 );const uniforms = {  uTiempo: { value: 0 },  uAmplitud: { value: 0.6 }};const material = new THREE.MeshStandardMaterial( { color: 0x89b4fa, roughness: 0.3 } );material.onBeforeCompile = ( shader ) => {  shader.uniforms.uTiempo = uniforms.uTiempo;  shader.uniforms.uAmplitud = uniforms.uAmplitud;  shader.vertexShader = shader.vertexShader    .replace( '#include <common>', `      #include <common>      uniform float uTiempo;      uniform float uAmplitud;      float altura( vec2 p ) {        return sin( p.x * 0.6 + uTiempo ) * cos( p.y * 0.4 - uTiempo * 0.7 ) * uAmplitud;      }      vec3 desplazado( vec3 p ) {        p.y += altura( p.xz );        return p;      }    ` )    .replace( '#include <beginnormal_vertex>', `      #include <beginnormal_vertex>      {        float e = 0.05;        vec3 a = desplazado( position );        vec3 b = desplazado( position + vec3( e, 0.0, 0.0 ) );        vec3 c = desplazado( position + vec3( 0.0, 0.0, e ) );        objectNormal = normalize( cross( c - a, b - a ) );      }    ` )    .replace( '#include <begin_vertex>', `      #include <begin_vertex>      transformed = desplazado( transformed );    ` );};material.customProgramCacheKey = () => 'ola-con-normales-v1';const malla = new THREE.Mesh( geometria, material );malla.frustumCulled = false;scene.add( malla );const reloj = new THREE.Timer();reloj.connect( document );renderer.setAnimationLoop( ( tiempo ) => {  reloj.update( tiempo );  uniforms.uTiempo.value = reloj.getElapsed();  renderer.render( scene, camera );} );

Dos detalles del parche merecen explicación.

Se inyecta en beginnormal_vertex, no en begin_vertex. El orden de los fragmentos en el vertex shader estándar coloca todo el tratamiento de normales antes que el de posiciones: primero beginnormal_vertex declara objectNormal, luego lo transforman los fragmentos de morph, esqueleto y matriz normal, y solo después begin_vertex declara transformed. Si escribieras la normal después de desplazar, llegarías tarde: los fragmentos que la transforman ya se habrían ejecutado. Por eso la función de desplazamiento se declara en common, que va antes que todo, y se usa desde los dos sitios.

Y por eso mismo, el primer paso al depurar un parche de este tipo es siempre mirar el fuente real:

JS
material.onBeforeCompile = ( shader ) => {  // ...los replace...  console.log( shader.vertexShader );   // el orden de los chunks, en tu versión concreta};

El valor de e importa y hay que elegirlo. Demasiado pequeño y la resta de dos números casi iguales pierde cifras significativas en coma flotante de precisión media, produciendo normales ruidosas. Demasiado grande y estás midiendo la pendiente media de una región amplia, lo que suaviza el detalle fino. El punto de partida razonable es el orden de la separación entre vértices: para un plano de diez unidades con doscientos segmentos, eso son cinco centésimas.

La salida barata: derivadas en pantalla

Hay una tercera vía que no cuesta nada y que a veces es exactamente lo que quieres. flatShading calcula la normal en el fragment shader a partir de las derivadas en pantalla de la posición en espacio de vista:

GLSL
vec3 fdx = dFdx( vViewPosition );vec3 fdy = dFdy( vViewPosition );vec3 normal = normalize( cross( fdx, fdy ) );

Y lo que hace especialmente útil este mecanismo aquí es que opera sobre la posición ya desplazada. El vertex shader mueve el vértice, el rasterizador interpola, y las derivadas miden la superficie real que se está dibujando. Así que activar flatShading sobre un material con desplazamiento en el vertex shader da normales correctas sin escribir una sola línea:

JS
const material = new THREE.MeshStandardMaterial( { color: 0x89b4fa, flatShading: true } );material.onBeforeCompile = /* solo el parche de begin_vertex, sin tocar las normales */;

Lo que obtienes son normales de cara, así que la superficie se ve facetada. Para una estética de bajo poligonaje es perfecto y es la solución más limpia posible. Para una superficie que quieres suave, no sirve.

El resumen de las tres opciones:

Método Coste Precisión Cuándo
Analítica 1 evaluación más las derivadas Exacta Conoces la derivada
Diferencias finitas 3 evaluaciones Muy buena, depende de e Función arbitraria o textura
Derivadas en pantalla Gratis Normal de cara Estética facetada

Un caso que conviene mencionar porque aparece constantemente: si el desplazamiento no es vertical sino a lo largo de la normal —que es lo que hace displacementMap y lo que se usa para inflar una esfera con ruido—, las diferencias finitas necesitan dos direcciones tangentes a la superficie, no los ejes X y Z. Para una esfera se pueden construir a partir de la posición; para una malla arbitraria hace falta el atributo tangent, con computeTangents(), y usar la tangente y la bitangente como direcciones de muestreo. Es el mismo método con más contabilidad.