Normales por gradiente y sombreado
Cómo obtener la normal de una superficie que no tiene vértices, el truco del tetraedro, y sombras suaves y oclusión ambiental que salen casi gratis de la propia SDF.
Una malla trae sus normales de fábrica, interpoladas entre vértices. Una SDF no tiene vértices, así que la normal hay que inventarla en cada punto. Resulta que la información ya está en la función: el gradiente de un campo de distancia apunta, por construcción, en la dirección de máximo crecimiento, que es exactamente hacia fuera de la superficie. Y una vez tienes esa herramienta, el mismo campo regala sombras suaves y oclusión ambiental que en mallas cuestan pases enteros.
- Calcular la normal de una SDF por diferencias finitas centradas.
- Implementar la variante del tetraedro y decir cuántas evaluaciones ahorra.
- Elegir el epsilon del gradiente en relación con el de la marcha.
- Añadir sombras suaves y oclusión ambiental derivadas de la propia SDF.
El gradiente es la normal
Si la SDF fuera exacta en todo el espacio, su gradiente tendría módulo uno y apuntaría en la dirección de máximo crecimiento de la distancia: alejándose de la superficie. Esa es la normal. Como no tenemos la derivada analítica, la aproximamos por diferencias finitas centradas:
vec3 normalIngenua( vec3 p ) {
const float e = 0.0005;
return normalize( vec3(
mapa( p + vec3( e, 0.0, 0.0 ) ) - mapa( p - vec3( e, 0.0, 0.0 ) ),
mapa( p + vec3( 0.0, e, 0.0 ) ) - mapa( p - vec3( 0.0, e, 0.0 ) ),
mapa( p + vec3( 0.0, 0.0, e ) ) - mapa( p - vec3( 0.0, 0.0, e ) )
) );
}
Correcto y caro: seis evaluaciones de mapa() por píxel sombreado. Recordando que mapa() es la escena entera, eso equivale a seis pasos extra de marcha en cada píxel que toca superficie.
Una versión de cuatro evaluaciones se obtiene tomando las diferencias respecto al valor en p, que ya conoces:
vec3 normalAdelante( vec3 p ) {
const float e = 0.0005;
float d = mapa( p );
return normalize( vec3(
mapa( p + vec3( e, 0.0, 0.0 ) ) - d,
mapa( p + vec3( 0.0, e, 0.0 ) ) - d,
mapa( p + vec3( 0.0, 0.0, e ) ) - d
) );
}
Tres evaluaciones nuevas más una que reaprovechas. Pero la diferencia hacia adelante es de primer orden y sesga la normal en la dirección de los ejes positivos, lo que se nota como un ligero desplazamiento del sombreado en superficies curvas.
El truco del tetraedro
La versión que se usa en producción muestrea en los cuatro vértices de un tetraedro regular en lugar de en los seis extremos de una cruz. Cuatro evaluaciones, precisión de segundo orden, sin sesgo direccional:
vec3 normal( vec3 p ) {
const float e = 0.0005;
const vec2 k = vec2( 1.0, -1.0 );
return normalize(
k.xyy * mapa( p + k.xyy * e ) +
k.yyx * mapa( p + k.yyx * e ) +
k.yxy * mapa( p + k.yxy * e ) +
k.xxx * mapa( p + k.xxx * e )
);
}
Los cuatro vectores k.xyy, k.yyx, k.yxy y k.xxx son los signos de los vértices de un tetraedro inscrito en el cubo unidad. Sumar cada evaluación multiplicada por su propia dirección es una forma compacta de proyectar el gradiente sobre esa base, y como los cuatro vectores suman cero, los términos constantes se cancelan y solo queda la parte que varía. Por eso funciona sin restar nada.
La ganancia sobre la versión de seis muestras es de un tercio del coste del sombreado, que en una escena densa se nota. Es la implementación por defecto y no hay razón para usar otra.
El epsilon del gradiente no es el de la marcha
Son dos tolerancias distintas y se ajustan por separado. El de la marcha decide cuándo has llegado; el del gradiente decide sobre qué escala mides la pendiente.
Demasiado pequeño y el gradiente se ahoga en el ruido de precisión de coma flotante: la normal tiembla, y en float de 32 bits eso pasa antes de lo que uno espera, sobre todo lejos del origen. Demasiado grande y el gradiente promedia sobre una región amplia: los detalles finos se suavizan y las aristas se redondean visualmente aunque el campo las tenga vivas.
Un valor entre 0.0005 y 0.002 funciona en escenas de tamaño unitario. Y por el mismo argumento que en la marcha, escalarlo con la distancia recorrida mejora las superficies lejanas:
vec3 n = normal( p, max( 0.0005, 0.0005 * t ) );
El ruido en la normal es el artefacto más frecuente y el más fácil de diagnosticar: se ve como un moteado de alta frecuencia en las zonas iluminadas, y empeora cuanto más lejos está la superficie del origen. Casi siempre es precisión de coma flotante en el gradiente, no un problema del campo ni de la marcha. Multiplicar el epsilon del gradiente por diez y volver a mirar es la primera comprobación.
Sombreado
Con la normal en la mano, el sombreado es el de siempre. Un Lambert con luz direccional y un término ambiental basta para juzgar la forma:
vec3 sombrear( vec3 p, vec3 n, vec3 rd ) {
vec3 luzDir = normalize( vec3( 0.6, 0.8, 0.4 ) );
float difuso = max( dot( n, luzDir ), 0.0 );
float cielo = 0.5 + 0.5 * n.y; // gradiente vertical
float borde = pow( 1.0 - max( dot( n, -rd ), 0.0 ), 3.0 ); // fresnel barato
vec3 col = vec3( 0.75, 0.72, 0.70 ) * difuso;
col += vec3( 0.10, 0.14, 0.22 ) * cielo;
col += vec3( 0.30 ) * borde;
return col;
}
Lo interesante empieza cuando aprovechas que la SDF sabe cosas del espacio que un rasterizador no sabe.
Sombras suaves. Marchando un rayo desde la superficie hacia la luz, el valor mínimo de la SDF a lo largo del recorrido te dice cuánto ha estado cerca el rayo de tocar algo. Eso es, hasta una constante, la penumbra:
// k controla la dureza: valores altos, sombra dura; bajos, muy suave.
float sombraSuave( vec3 ro, vec3 rd, float tMin, float tMax, float k ) {
float res = 1.0;
float t = tMin;
for ( int i = 0; i < 48; i++ ) {
float h = mapa( ro + rd * t );
res = min( res, k * h / t );
t += clamp( h, 0.01, 0.2 );
if ( res < 0.0001 || t > tMax ) break;
}
return clamp( res, 0.0, 1.0 );
}
En una malla, una sombra con penumbra realista requiere un shadow map con filtrado de área o un trazado de rayos con muchas muestras. Aquí sale de una única marcha, porque la SDF ya contiene la información de proximidad que un depth buffer no guarda. El / t es lo que hace crecer la penumbra con la distancia al oclusor, igual que en la realidad.
Oclusión ambiental. Muestreando la SDF a lo largo de la normal, la diferencia entre la distancia esperada y la real mide cuánta geometría hay cerca:
float oclusion( vec3 p, vec3 n ) {
float ocl = 0.0;
float sca = 1.0;
for ( int i = 0; i < 5; i++ ) {
float h = 0.01 + 0.12 * float( i ) / 4.0;
float d = mapa( p + n * h );
ocl += ( h - d ) * sca;
sca *= 0.95;
}
return clamp( 1.0 - 3.0 * ocl, 0.0, 1.0 );
}
Si a distancia h sobre la normal la SDF vale h, no hay nada alrededor y la contribución es cero. Si vale menos, algo está tapando y la diferencia lo cuantifica. Cinco evaluaciones para una oclusión que en el pipeline de rasterizado necesita un pase entero de SSAO con su ruido, su desenfoque y su render target.
Y ya se puede juntar todo:
void main() {
vec3 ro, rd; // de la camara, como en la leccion anterior
float t = marchar( ro, rd );
vec3 col = vec3( 0.05, 0.06, 0.09 ); // fondo
if ( t < MAX_DIST ) {
vec3 p = ro + rd * t;
vec3 n = normal( p );
vec3 luzDir = normalize( vec3( 0.6, 0.8, 0.4 ) );
float som = sombraSuave( p + n * 0.002, luzDir, 0.02, 12.0, 16.0 );
float ao = oclusion( p, n );
col = sombrear( p, n, rd ) * mix( 0.35, 1.0, som ) * ao;
}
gl_FragColor = vec4( col, 1.0 );
}
El + n * 0.002 antes de lanzar la sombra es el desplazamiento que evita que el rayo se autointersecte con la superficie de la que sale. Es el mismo problema del shadow acne de los shadow maps, con la misma solución y el mismo compromiso: demasiado poco y aparecen bandas, demasiado y las sombras se despegan del objeto.
Hay una asimetría de coste que casi nadie contabiliza y que decide la estructura de tu shader. Encontrar el impacto con la marcha cuesta, en la mayoría de los píxeles, cuatro o cinco evaluaciones de mapa(). Calcular la normal cuesta cuatro más, de golpe. La oclusión, cinco. La sombra suave, hasta cuarenta y ocho. Es decir: el sombreado de un píxel puede costar diez veces lo que costó encontrarlo. Eso invierte por completo la intuición que traes del rasterizado, donde el fragment shader es el sitio donde se gasta y el rasterizador es casi gratis. Y tiene tres consecuencias muy concretas. La primera: nunca calcules normal, oclusión ni sombra en un píxel que no ha impactado. Parece obvio y sin embargo mucho código de ejemplo lo hace, calculando todo y multiplicando al final por una máscara, lo cual en una GPU es exactamente igual de caro que hacerlo de verdad. La segunda: si el impacto está lejos, degrada el sombreado. A cuarenta unidades de distancia nadie ve la oclusión ambiental, y saltársela cuando t supera un umbral es rendimiento gratis sin coste visual. La tercera, la que más se paga por ignorarla: la sombra suave puede costar más que toda la marcha primaria de la escena junta, así que sus cuarenta y ocho iteraciones son el primer sitio donde mirar cuando el frame no cierra. Bájalas a dieciséis, sube el paso mínimo de 0.01 a 0.03, y compara: en la mayoría de las escenas la diferencia visual es indetectable y la ganancia de milisegundos es enorme. La lección general es que en raymarching el presupuesto se mide en evaluaciones de mapa() por píxel, no en primitivas ni en triángulos, y llevar esa cuenta a mano mientras escribes es lo que separa una escena que va a sesenta de una que va a quince.