Saltar al contenido

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.

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:

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

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

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

GLSL
vec3 n = normal( p, max( 0.0005, 0.0005 * t ) );

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:

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

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

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

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