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.
Desplazar un vértice mueve la superficie pero deja su normal apuntando a donde apuntaba. El resultado es una geometría que se ondula y se ilumina como si fuera plana, y es el síntoma que delata a la mitad de los efectos de desplazamiento que hay en la web. No hay ninguna propiedad que lo arregle: la normal es una consecuencia de la superficie y si cambias la superficie tienes que recalcularla. Hay exactamente tres formas de hacerlo, con costes y precisiones distintos, y elegir bien depende de si conoces la derivada de tu función.
- Explicar por qué desplazar posiciones invalida las normales almacenadas.
- Derivar la normal analítica de un desplazamiento vertical y escribirla en GLSL.
- Implementar el método de diferencias finitas dentro de un material PBR.
- Decidir cuándo el sombreado plano por derivadas de pantalla es suficiente.
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:
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:
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:
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:
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:
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:
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:
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.
La razón profunda de que las normales den tantos problemas en todas partes —al deformar, al simplificar, al comprimir, al interpolar entre niveles de detalle— es que una normal es una derivada de la posición, y las derivadas amplifican el error. Un error del uno por mil en una posición es invisible; el mismo error en dos posiciones vecinas separadas por una distancia pequeña puede producir un error de varios grados en la pendiente que las une, y unos pocos grados de error en una normal son perfectamente visibles como una banda de sombreado. Ese factor de amplificación es exactamente el inverso de la distancia entre las muestras, y de ahí sale todo lo demás. Explica por qué el epsilon de las diferencias finitas tiene una ventana estrecha entre ruido y suavizado excesivo. Explica por qué comprimir normales a enteros de ocho bits produce bandas visibles mientras que comprimir posiciones a dieciséis no. Explica por qué un mapa de normales sin comprimir pesa lo que pesa y por qué los formatos de compresión de texturas tienen modos específicos para normales. Y explica la asimetría más útil de todas: casi siempre sale más barato transportar una normal calculada con precisión en otro sitio que recalcularla donde la necesitas. Es la lógica de hornear las normales de un modelo de alta resolución en una textura, es la lógica de guardar el canal de normales de un morph target en lugar de recalcularlo, y es la lógica de escribir la derivada analítica cuando la conoces en lugar de aproximarla. En cuanto empiezas a tratar las normales como datos caros y frágiles en lugar de como algo que se recalcula cuando hace falta, la mitad de los artefactos de sombreado dejan de aparecer.
- Aplica un desplazamiento en el vertex shader sin tocar las normales y describe cómo se ilumina.
- Escribe la normal analítica de la ola y comprueba que la luz reacciona.
- Implementa las diferencias finitas y verifica que da el mismo resultado que la analítica.
- Baja el epsilon a una milésima y súbelo a uno, y describe los dos artefactos.
- Activa
flatShadingsobre el material con desplazamiento y comprueba que las normales salen correctas y facetadas.