Algoritmo de representación de volumen rápido y fácil


Recientemente escribí un pequeño ShaderToy que hace una representación volumétrica simple, y luego decidí publicar una publicación explicando su trabajo. El ShaderToy interactivo se puede ver aquí . Si está leyendo desde un teléfono o computadora portátil, le recomiendo ver esta versión rápida. Incluí fragmentos de código en la publicación que lo ayudarán a comprender el rendimiento de ShaderToy a un alto nivel, pero no tienen todos los detalles. Si desea profundizar, le recomiendo consultar con el código ShaderToy.

Mi ShaderToy tenía tres tareas principales:

  1. Ejecución en tiempo real
  2. Sencillez
  3. Corrección física (... o algo así)

Comenzaré con esta escena de código en blanco. No entraré en detalles de la implementación, porque no es muy interesante, pero te diré brevemente por dónde comenzamos:

  1. Trazado de rayos de objetos opacos. Todos los objetos son primitivos con intersecciones simples con rayos (1 plano y 3 esferas)
  2. Para calcular la iluminación, se usa el sombreado de Phong, y en tres fuentes de luz esféricas, se usa un coeficiente de atenuación de luz personalizado. No se requieren rayos de sombras, porque iluminamos solo el plano.

Así es como se ve:

Captura de pantalla de ShaderToy

Representaremos el volumen como un pasaje separado que se mezcla con una escena opaca; Esto es similar a cómo todos los motores de renderizado en tiempo real procesan individualmente superficies opacas y translúcidas.

Parte 1: simular volumen


Pero primero, antes de que podamos comenzar la representación volumétrica, ¡necesitamos este mismo volumen! Para simular el volumen, decidí usar funciones de distancia con signo (SDF). ¿Por qué precisamente las funciones de los campos de distancia? Porque no soy un artista, pero te permiten crear formas muy orgánicas en solo unas pocas líneas de código. No hablaré en detalle sobre las funciones de distancias con un signo, porque Iñigo Kiles ya las ha explicado maravillosamente. Si tiene curiosidad, entonces hay una gran lista de diferentes funciones de letreros y modificadores. Y aquí hay otro artículo sobre estos SDF raymarching.

Comencemos con uno simple y agreguemos una esfera aquí:

Captura de pantalla de ShaderToy

Ahora agregaremos otra esfera y utilizaremos una conjugación suave para fusionar las funciones de distancia de las esferas. Este código lo tomé directamente de la página de Íñigo, pero para mayor claridad, lo insertaré aquí:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

El emparejamiento suave es una herramienta extremadamente poderosa, porque puedes obtener algo bastante interesante simplemente combinándolo con algunas formas simples. Así es como se ven mis muchas esferas suavemente conjugadas:

Captura de pantalla de ShaderToy

Entonces, tenemos algo en forma de lágrima, pero necesitamos algo más como una nube que una gota. Una gran característica de SDF es lo fácil que es distorsionar la superficie simplemente agregando un poco de ruido al SDF. Así que agreguemos algo de movimiento browniano fractal (fBM) encima del ruido, usando la posición para indexar la función de ruido. Iñigo Kiles también cubrió este tema en un excelente artículo sobre el ruido fBM. Así se verá la imagen con ruido fBM superpuesto:

Captura de pantalla de ShaderToy

¡Multa! Gracias al ruido fBM, el objeto de repente comenzó a parecer mucho más interesante.

Ahora necesitamos crear la ilusión de que el volumen interactúa con el plano de la tierra. Para hacer esto, agregué una distancia del plano firmado ligeramente por debajo del plano de tierra y reutilicé la combinación de emparejamiento suave con un valor de emparejamiento muy agresivo (parámetro k). Después de eso, tenemos esta imagen:

Captura de pantalla de ShaderToy

El toque final será el cambio en el índice xz del ruido fBM a lo largo del tiempo, de modo que el volumen se vea como una niebla en remolino. En movimiento, ¡se ve muy bien!

Captura de pantalla de ShaderToy

¡Genial, tenemos algo así como una nube! El código de cálculo SDF también es bastante compacto:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

Esto solo representa un objeto opaco. ¡Necesitamos una hermosa niebla magnífica!

¿Cómo lo representamos en forma de volumen y no como un objeto opaco? Primero hablemos de la física que simulamos. El volumen es una gran cantidad de partículas en un área determinada del espacio. Y cuando digo "enorme", me refiero a "ENORME". Tanto es así que modelar cada una de estas partículas hoy es una tarea imposible, incluso para el renderizado sin conexión. Buenos ejemplos de esto son el fuego, la niebla y las nubes. Estrictamente hablando, todo es volumen, pero en aras de la velocidad de los cálculos, es más fácil cerrar los ojos y fingir que no lo es. Representamos la acumulación de estas partículas como valores de densidad que generalmente se almacenan en algún tipo de cuadrícula 3D (o algo más complejo, por ejemplo, en OpenVDB).

Cuando la luz pasa a través de un volumen, pueden ocurrir un par de fenómenos cuando la luz colisiona con una partícula. Puede dispersarse e ir en la otra dirección, o parte de la luz puede ser absorbida por la partícula y disolverse. Para cumplir con el requisito de ejecución en tiempo real, realizaremos lo que se denomina dispersión única. Esto significa lo siguiente: asumiremos que la luz se dispersa solo una vez, cuando la luz colisiona con una partícula y vuela hacia la cámara. Es decir, no podremos simular los efectos de la dispersión múltiple, por ejemplo, niebla, en la que los objetos a distancia generalmente se ven más vagos. Pero para nuestro sistema esto es suficiente. Así es como se ve la dispersión única al raymarching:

Captura de pantalla de ShaderToy

El pseudocódigo para esto se ve más o menos así:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

Es decir, estamos tratando con cálculos con complejidad O (n * m * k). Entonces la GPU tendrá que trabajar duro.

Calculamos la absorcion


Primero, veamos la absorción de la luz en el volumen a lo largo del haz de la cámara (es decir, todavía no realicemos el marcado de rayos en la dirección de las fuentes de luz). Para hacer esto, necesitamos dos acciones:

  1. Realizar raymarching dentro del volumen
  2. Calcule la absorción / iluminación en cada paso

Para calcular cuánta luz se absorbe en cada punto, utilizamos la ley de Bouguer - Lambert - Beer , que describe la atenuación de la luz al pasar a través de un material. Los cálculos son sorprendentemente simples:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

El coeficiente de absorción es un parámetro material. Por ejemplo, en un volumen transparente, por ejemplo, en agua, este valor será bajo, y para algo más grueso, por ejemplo, leche, el coeficiente será más alto.

Para realizar el marcado de rayos por volumen, simplemente tomamos pasos de un tamaño fijo a lo largo del haz y obtenemos absorción en cada paso. Es posible que no entienda por qué tomar pasos fijos en lugar de algo más rápido, por ejemplo, trazar una esfera, pero si recuerda que la densidad dentro del volumen es heterogénea, entonces todo se vuelve claro. A continuación se muestra el código de absorción de raymarching y acumulación. Algunas variables están fuera del alcance de este fragmento de código, así que revisa la implementación completa en ShaderToy.

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

Y esto es lo que obtenemos con esto:

Captura de pantalla de ShaderToy

¡Parece algodón de azúcar! ¡Quizás para algunos efectos esto sea suficiente! Pero nos faltan las sombras. La luz alcanza todas las partes del volumen por igual. Pero esto no es físicamente correcto, dependiendo del tamaño del volumen entre el punto renderizado y la fuente de luz, recibiremos una cantidad diferente de luz entrante.

Auto sombreado


Ya hemos hecho lo más difícil. Necesitamos hacer lo mismo que hicimos para calcular la absorción a lo largo del haz de la cámara, pero solo a lo largo del haz de luz. El código para calcular la cantidad de luz que llega a cada punto será esencialmente una repetición del código, pero duplicarlo es más fácil que piratear HLSL para obtener la recurrencia que necesitamos. Así que así es como se verá:

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

Agregar auto-sombreado nos da lo siguiente:

Captura de pantalla de ShaderToy

Suavizar los bordes


Por el momento, ya me gusta bastante nuestro volumen. Se lo mostré al talentoso líder del departamento de efectos visuales de The Coalition, James Sharp. Inmediatamente notó que los bordes del volumen se veían demasiado afilados. Y esto es absolutamente cierto: los objetos como las nubes están constantemente dispersos en el espacio a su alrededor, por lo que sus bordes se mezclan con el espacio vacío alrededor del volumen, lo que debería conducir a la creación de bordes muy suaves. James me ofreció una gran idea: reducir la densidad según lo cerca que estemos del borde. Y como estamos trabajando con funciones de distancia con un signo, ¡es muy fácil de implementar! Entonces, agreguemos una función que se pueda usar para solicitar densidad en cualquier punto del volumen:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

Y luego simplemente lo colapsamos en el valor de absorción:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

Y así es como se ve:

Captura de pantalla de ShaderToy

Función de densidad


Ahora que tenemos la función de densidad, puede agregar fácilmente un poco de ruido al volumen para darle detalles adicionales y esplendor. En este caso, solo reutilizo la función fBM que utilizamos para ajustar la forma del volumen.

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

Y así obtuvimos lo siguiente:

Captura de pantalla de ShaderToy

Opaco auto-sombreado


¡El volumen ya se ve muy bonito! Pero aún se filtra una pequeña luz. Aquí vemos cómo se filtra el color verde donde el volumen definitivamente debería absorberlo:

Captura de pantalla de ShaderToy

Esto sucede porque los objetos opacos se procesan antes de que se procese el volumen, por lo que no tienen en cuenta el sombreado causado por el volumen. Esto es bastante fácil de solucionar: tenemos una función GetLightVisiblity que se puede usar para calcular el sombreado, por lo que solo debemos llamarla para iluminar un objeto opaco. Obtenemos lo siguiente:

Captura de pantalla de ShaderToy

Además de crear hermosas sombras multicolores, esto ayuda a mejorar las sombras y aumentar el volumen de la escena. Además, gracias a los bordes suaves del volumen, obtenemos sombras suaves, a pesar de que, estrictamente hablando, trabajamos con fuentes puntuales de iluminación. ¡Eso es todo! Aquí se puede hacer mucho más, pero me parece que he logrado la calidad visual que necesito, conservando la relativa simplicidad del ejemplo.

Optimizaciones


Al final, enumeraré brevemente algunas posibles optimizaciones:

  1. Antes de realizar el marcado de rayos en la dirección de la fuente de luz, es necesario verificar por el valor de la extinción de la luz si una cantidad significativa de esta luz realmente alcanza el punto en cuestión. En mi implementación, miro el brillo de la luz multiplicado por el albedo del material, y me aseguro de que el valor sea lo suficientemente grande como para realizar el marcado de rayos.
  2. , , raymarching
  3. raymarching . , . , raymarching , .


¡Eso es todo! Personalmente, me sorprendió que pueda crear algo físicamente correcto en una cantidad de código tan pequeña (aproximadamente 500 líneas). Gracias por leer, espero que haya sido interesante.

Y una nota más: aquí hay un cambio divertido: agregué emisión de luz en función de la distancia SDF para crear un efecto de explosión. Después de todo, las explosiones nunca son muchas.

Captura de pantalla de ShaderToy

All Articles