Recalcular normales II: la vía numérica
Evaluar el desplazamiento en vecinos cercanos y reconstruir la normal con un producto vectorial: cómo elegir el épsilon, qué cuesta, y la alternativa con derivadas de pantalla.
Cuando la deformación no se puede derivar a mano —un mapa de alturas, un FBM de seis octavas, un domain warping— queda el método que funciona siempre: mover el vértice, mover también dos vecinos imaginarios, y reconstruir la normal a partir de los tres puntos resultantes. Cuesta tres evaluaciones en vez de una, funciona con cualquier función, y tiene un único parámetro que hay que elegir con criterio en vez de a ojo.
- Implementar normales por diferencias finitas hacia delante en el vertex shader.
- Elegir el épsilon con el criterio numérico correcto y saber qué falla en cada extremo.
- Calcular el coste real del método sobre una función cara.
- Usar las derivadas de pantalla como tercera vía y reconocer su limitación.
Diferencias finitas hacia delante
La idea completa cabe en seis líneas. Si desplazar( p ) es la función que mueve un punto, evalúala en el punto y en dos vecinos separados un épsilon en cada eje del plano tangente original. Los dos vectores que van del punto desplazado a los vecinos desplazados son las tangentes de la superficie deformada, y su producto vectorial es la normal.
uniform float uTiempo;
uniform float uAmplitud;
uniform float uEpsilon;
varying vec3 vNormal;
varying vec3 vPosicionVista;
// Aqui va snoise y fbm de las lecciones del nivel anterior
float altura( vec2 xy ) {
return fbm( xy * 0.35 + vec2( 0.0, uTiempo * 0.05 ) ) * uAmplitud;
}
void main() {
float e = uEpsilon;
vec3 p = position;
vec3 px = position + vec3( e, 0.0, 0.0 );
vec3 py = position + vec3( 0.0, e, 0.0 );
p.z += altura( p.xy );
px.z += altura( px.xy );
py.z += altura( py.xy );
vec3 tangenteX = px - p;
vec3 tangenteY = py - p;
vec3 normalObjeto = normalize( cross( tangenteX, tangenteY ) );
vNormal = normalMatrix * normalObjeto;
vec4 posicionVista = modelViewMatrix * vec4( p, 1.0 );
vPosicionVista = posicionVista.xyz;
gl_Position = projectionMatrix * posicionVista;
}
Comprueba la orientación mentalmente con la superficie sin deformar: tangenteX vale ( e, 0, 0 ), tangenteY vale ( 0, e, 0 ), y su producto vectorial es ( 0, 0, e² ), que normalizado da ( 0, 0, 1 ). Es la normal correcta del PlaneGeometry sin tocar. Si en tu caso sale invertida, cambia el orden de los factores del cross.
Fíjate también en que las dos evaluaciones adicionales usan position original, no p ya desplazado. Evaluar la función sobre coordenadas ya movidas mezclaría dos espacios y daría una normal que no corresponde a nada.
Elegir el épsilon
Éste es el único parámetro y tiene un valor óptimo que se puede calcular, no adivinar.
Con un épsilon demasiado grande, las tangentes atraviesan detalle de la función y la normal resultante es un promedio suavizado. El terreno se ilumina como si fuera más liso de lo que es. No es un error catastrófico y a veces es lo que quieres.
Con un épsilon demasiado pequeño, la resta px - p opera sobre dos números casi idénticos y la cancelación catastrófica se lleva casi todos los bits significativos. Con un flotante de treinta y dos bits, restar dos valores que coinciden en seis cifras decimales deja dos cifras útiles, y la normal se convierte en ruido. El síntoma es un moteado de píxeles brillantes que aparece y desaparece.
El óptimo de una diferencia finita hacia delante está en la raíz cuadrada del épsilon de máquina multiplicada por la escala del problema. Para flotante de treinta y dos bits el épsilon de máquina vale unos 1.19 por diez elevado a menos siete, y su raíz cuadrada es aproximadamente 3.4 por diez elevado a menos cuatro. Multiplicado por la escala del terreno:
// Para un plano de 20 unidades de lado
const escala = 20;
material.uniforms.uEpsilon.value = 3.4e-4 * escala; // unos 0.007
En la práctica, cualquier valor entre una milésima y una centésima de la extensión del objeto funciona. Lo que hay que evitar son los extremos: por debajo de 1e-4 en unidades absolutas hay ruido, y por encima de la separación entre vértices hay suavizado.
Y hay un uso deliberado del épsilon grande que conviene conocer: fijarlo exactamente en el espaciado entre vértices hace que la normal describa la malla real en lugar de la función continua, y por tanto que el sombreado y la silueta sean coherentes. Con PlaneGeometry( 20, 20, 256, 256 ) ese espaciado es 20 / 256, unos 0.078.
El coste
Tres evaluaciones en vez de una. Con una función barata da igual; con un FBM de seis octavas de simplex son dieciocho evaluaciones de snoise por vértice.
En una malla de 256 por 256 son 66049 vértices, así que 1.2 millones de evaluaciones de snoise por cuadro. Y si la malla proyecta sombra sobre dos luces, tres veces eso.
Frente a los cientos de millones de invocaciones de fragmento de las que hablábamos en el nivel 30, sigue siendo poco. La cuenta que hay que hacer es ésta: mientras el número de vértices sea mucho menor que el de píxeles cubiertos, el vertex shader puede permitirse ser caro. El punto en el que eso deja de ser cierto es cuando los triángulos bajan del tamaño de unos pocos píxeles, y ahí el problema ya no es el coste del método sino que la teselación está mal dimensionada.
Dos optimizaciones reales cuando el método pesa:
// A) Menos octavas para la normal que para la altura.
// El detalle fino apenas cambia la orientacion y si multiplica el coste.
float alturaDetalle( vec2 xy ) { return fbmSeisOctavas( xy ) * uAmplitud; }
float alturaGruesa( vec2 xy ) { return fbmTresOctavas( xy ) * uAmplitud; }
// B) Reutilizar el valor central. Con diferencias hacia delante ya lo haces:
// tres evaluaciones dan dos derivadas. Con diferencias centradas serian cuatro
// y solo ganarias precision de segundo orden, que aqui no compensa.
La tercera vía y la comparativa
Derivadas de pantalla
Hay una forma de obtener normales sin evaluar la función ni una sola vez de más: calcularlas en el fragment shader a partir de las derivadas de la posición interpolada.
// Fragment shader
varying vec3 vPosicionVista;
void main() {
vec3 N = normalize( cross( dFdx( vPosicionVista ), dFdy( vPosicionVista ) ) );
...
}
Coste: dos instrucciones. Y produce la normal geométrica exacta del triángulo, porque dentro de un triángulo la posición interpolada es lineal y sus derivadas son constantes.
Ahí está a la vez su virtud y su límite. Es exacta para la malla, así que la iluminación y la silueta coinciden siempre. Pero es plana por triángulo: el resultado es sombreado facetado, con cada polígono visiblemente distinto de su vecino. Con una teselación muy densa eso puede pasar por suave; con una normal es inaceptable.
Tres usos donde es la respuesta correcta. Cuando quieres facetado a propósito, que es un recurso estético frecuente. Cuando la malla es tan densa que el facetado no se distingue. Y como respaldo de depuración: comparar el sombreado con derivadas de pantalla contra el sombreado con tu normal calculada es la forma más rápida de saber si tu normal está mal, porque la primera nunca miente sobre la geometría.
Y recuerda las dos condiciones de las derivadas: solo existen en el fragment shader, y exigen control de flujo uniforme. Calcularlas dentro de una rama divergente da resultados indefinidos.
Las tres vías en una tabla
| Analítica | Numérica | Derivadas de pantalla | |
|---|---|---|---|
| Requiere derivar a mano | sí | no | no |
| Evaluaciones de la función | 1 | 3 | 1 |
| Etapa | vértices | vértices | fragmentos |
| Describe | la superficie ideal | la superficie o la malla, según el épsilon | la malla, siempre |
| Resultado | suave y exacto | suave, con un parámetro de control | facetado |
| Funciona con textura | no | sí | sí |
El fallo que más cuesta encontrar con este método no está en la fórmula sino en las unidades. El épsilon se suma a position, es decir a coordenadas de espacio del objeto, y la función de altura las consume en ese mismo espacio. Mientras eso se mantenga, todo cuadra. Pero en cuanto alguien escala la malla la relación se rompe de dos maneras distintas según cómo lo haga. Si escalas con mesh.scale.set( 5, 5, 5 ), position no cambia, la función sigue recibiendo las mismas coordenadas y el épsilon sigue siendo válido; lo único que cambia es que el relieve se estira junto con todo lo demás, y normalMatrix se encarga de corregir la normal. Si en cambio horneas la escala en la geometría con geometry.scale( 5, 5, 5 ), las posiciones sí cambian, la función recibe coordenadas cinco veces mayores —con lo que el relieve se vuelve cinco veces más fino— y el épsilon, que era una centésima de la extensión, pasa a ser una quincuagésima parte de lo que debería. La normal empieza a suavizarse sin motivo aparente. Y hay un tercer caso todavía más traicionero: una geometría cargada de glTF cuyo exportador aplicó una escala de 0.01 para pasar de centímetros a metros. Ahí el épsilon que funcionaba en el ejemplo se queda cien veces por debajo de lo razonable y la normal se convierte en ruido puro. La defensa es no escribir el épsilon como constante nunca: derívalo del propio objeto con geometry.computeBoundingSphere() y epsilon = radio * 0.002, calculado en JavaScript una sola vez al crear el material. Cuesta dos líneas y el efecto deja de depender de las unidades en las que alguien decidió modelar.
Monta el shader de esta lección con un deslizador para uEpsilon que vaya de 1e-5 a 0.5 en escala logarítmica. Recorre el rango entero mirando el especular: verás primero ruido, después el punto dulce, después el suavizado progresivo, y al final una superficie iluminada como si fuera plana. Anota los tres umbrales; te servirán de referencia para cualquier otro terreno.