Cómo escribir un sombreador de humo
Spanish (Español) translation by Luis Chiabrera (you can also view the original English article)
Siempre ha habido un cierto aire de misterio alrededor del humo. Es estéticamente agradable de ver y difícil de modelar. Al igual que muchos fenómenos físicos, es un sistema caótico, lo que hace que sea muy difícil de predecir. El estado de la simulación depende en gran medida de las interacciones entre sus partículas individuales.
Esto es exactamente lo que hace que sea un gran problema abordar con la GPU: se puede dividir en el comportamiento de una sola partícula, que se repite simultáneamente millones de veces en diferentes lugares.
En este
tutorial, te guiaré para escribir un sombreador de humo desde cero y te
enseñaré algunas técnicas útiles de sombreado para que puedas expandir
tu arsenal y desarrollar tus propios efectos.
Lo que aprenderás
Este es el resultado final para el que trabajaremos:
Implementaremos el algoritmo presentado en el trabajo de Jon Stam sobre dinámica de fluidos en tiempo real en juegos. También aprenderá cómo renderizar una textura, también conocida como el uso de búferes de cuadros, que es una técnica muy útil en la programación del sombreador para lograr muchos efectos.
Antes de comenzar
Los ejemplos y detalles de implementación específicos en este tutorial usan JavaScript y ThreeJS, pero debe poder seguir en cualquier plataforma que admita sombreadores. (Si no está familiarizado con los principios básicos de la programación del sombreador, asegúrese de realizar al menos los primeros dos tutoriales de esta serie).
Todos los ejemplos de código están alojados en CodePen, pero también puede encontrarlos en el repositorio de GitHub asociado con este artículo (que podría ser más legible).
Teoría y antecedentes
El algoritmo en
el documento de Jos Stam favorece la velocidad y la calidad visual sobre
la precisión física, que es exactamente lo que queremos en una
configuración de juego.
Este documento puede parecer mucho más complicado de lo que realmente es, especialmente si no está bien versado en ecuaciones diferenciales. Sin embargo, la esencia de esta técnica se resume en esta figura:



Esto es todo lo que necesitamos implementar para obtener un efecto de humo de aspecto realista: el valor en cada celda se disipa a todas las celdas vecinas en cada iteración. Si no está claro de inmediato cómo funciona esto, o si solo quieres ver cómo se vería esto, puedes jugar con esta demostración interactiva:



Al hacer clic en cualquier celda establece su valor a 100
. Puede ver cómo cada célula pierde lentamente su valor para sus vecinos con el tiempo. Puede ser más fácil ver haciendo clic en Siguiente para ver los marcos individuales. Cambie el Modo de visualización para ver cómo se vería si hiciéramos que un valor de color correspondiera a estos números.
La demostración anterior se ejecuta en la CPU con un ciclo que pasa por cada celda. Así es como se ve ese lazo:
1 |
//W = number of columns in grid
|
2 |
//H = number of rows in grid
|
3 |
//f = the spread/diffuse factor
|
4 |
//We copy the grid to newGrid first to avoid editing the grid as we read from it
|
5 |
for(var r=1; r<W-1; r++){ |
6 |
for(var c=1; c<H-1; c++){ |
7 |
newGrid[r][c] += |
8 |
f * |
9 |
(
|
10 |
gridData[r-1][c] + |
11 |
gridData[r+1][c] + |
12 |
gridData[r][c-1] + |
13 |
gridData[r][c+1] - |
14 |
4 * gridData[r][c] |
15 |
);
|
16 |
}
|
17 |
}
|
Este fragmento es realmente el núcleo del algoritmo. Cada célula gana un poco de sus cuatro celdas vecinas, menos su propio valor, donde f
es un factor menor que 1. Multiplicamos el valor actual de la celda por 4 para asegurarnos de que se difunda desde el valor más alto al valor inferior.
Para aclarar este punto, considere este escenario:

Tome la celda en el medio (en la posición [1,1]
en la cuadrícula) y aplique la ecuación de difusión anterior. Supongamos que f
es 0.1
:
1 |
0.1 * (100+100+100+100-4*100) = 0.1 * (400-400) = 0 |
¡No se produce difusión porque todas las celdas tienen valores iguales!
Si consideramos
la celda en la parte superior izquierda en su lugar (supongamos que las
celdas fuera de la cuadrícula ilustrada son todas 0
):
1 |
0.1 * (100+100+0+0-4*0) = 0.1 * (200) = 20 |
¡Así que obtenemos un aumento neto de 20! Consideremos un caso final. Luego de un paso de tiempo (aplicando esta fórmula a todas las celdas), nuestra grilla se verá así:

Miremos la difusión en la celda en el medio otra vez:
1 |
0.1 * (70+70+70+70-4*100) = 0.1 * (280 - 400) = -12 |
¡Obtenemos una disminución neta de 12! Por lo tanto, siempre fluye de los valores más altos a los más bajos.
Ahora, si quisiéramos que esto se viera más realista, podríamos disminuir el tamaño de las celdas (lo que puedes hacer en la demostración), pero en algún punto, las cosas se volverán realmente lentas, ya que estamos obligados a ejecutarlas secuencialmente a través de cada célula. Nuestro objetivo es poder escribir esto en un sombreado, donde podemos usar el poder de la GPU para procesar todas las celdas (como píxeles) simultáneamente en paralelo.
Entonces, para resumir, nuestra técnica general es hacer que cada píxel ceda algo de su valor de color, cada fotograma, a sus píxeles vecinos. Suena bastante simple, ¿no? ¡Implementemos eso y veamos lo que obtenemos!
Implementación
Comenzaremos con un sombreador básico que dibuja sobre toda la pantalla. Para asegurarse de que esté funcionando, intente configurar la pantalla en un negro sólido (o en cualquier color arbitrario). Así es como la configuración que estoy usando se ve en Javascript.
Nuestro shader es simplemente:
1 |
uniform vec2 res; |
2 |
void main() { |
3 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
4 |
gl_FragColor = vec4(0.0,0.0,0.0,1.0); |
5 |
}
|
res
y pixel
están ahí para darnos la coordenada del píxel actual. Estamos pasando las dimensiones de la pantalla en res
como una variable uniforme. (No los estamos usando ahora, pero lo haremos pronto).
Paso 1: mover valores en píxeles
Esto es lo que queremos implementar de nuevo:
Nuestra técnica general es hacer que cada píxel ceda algo de su valor de color en cada cuadro a sus píxeles vecinos.
Dicho en su forma actual, esto es imposible de hacer con un sombreador. ¿Puedes ver por qué? Recuerde que todo lo que un sombreador puede hacer es devolver un valor de color para el píxel actual que está procesando, por lo que debemos repetirlo de una manera que solo afecte al píxel actual. Podemos decir:
Cada píxel debería ganar algo de color de sus vecinos, mientras pierde algo propio.
Ahora esto es algo que podemos implementar. Si realmente intentas hacer esto, sin embargo, podrías encontrarte con un problema fundamental ...
Considere un caso mucho más simple. Digamos que solo quiere hacer un sombreado que convierte una imagen en roja lentamente con el tiempo. Puede escribir un sombreador como este:
1 |
uniform vec2 res; |
2 |
uniform sampler2D texture; |
3 |
void main() { |
4 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
5 |
|
6 |
gl_FragColor = texture2D( tex, pixel );//This is the color of the current pixel |
7 |
gl_FragColor.r += 0.01;//Increment the red component |
8 |
}
|
Y esperamos que, en cada fotograma, el componente rojo de cada píxel aumente en 0.01
. En cambio, todo lo que obtendrás es una imagen estática donde todos los píxeles son solo un poco más rojos de lo que comenzaron. El componente rojo de cada píxel solo aumentará una vez, a pesar de que el sombreador ejecuta cada fotograma.
¿Puedes ver por qué?
El problema
El problema es que cualquier operación que hacemos en nuestro sombreador se envía a la pantalla y luego se pierde para siempre. Nuestro proceso en este momento se ve así:



Pasamos nuestras variables y texturas uniformes al sombreador, hace que los píxeles sean ligeramente más rojos, los dibuja a la pantalla y luego vuelve a empezar desde el principio. Todo lo que dibujemos dentro del sombreador quedará despejado la próxima vez que dibujemos.
Lo que queremos es algo como esto:



En lugar de dibujar directamente en la pantalla, podemos dibujar algo de textura y luego dibujar esa textura en la pantalla. Obtienes la misma imagen en la pantalla que tendrías de lo contrario, excepto que ahora puedes volver a pasar tu salida como entrada. De modo que puede tener sombreadores que crean o propagan algo, en lugar de eliminarlos todo el tiempo. Eso es lo que llamo el "truco del búfer de cuadros".
El truco del búfer de cuadros
La técnica general es la misma en cualquier plataforma. La búsqueda de "renderizar a textura" en cualquier idioma o herramienta que esté utilizando debe mostrar los detalles de implementación necesarios. También puede buscar cómo utilizar objetos de búfer de cuadro, que es solo otro nombre para poder renderizar en un búfer en lugar de representarlo en la pantalla.
En ThreeJS, el equivalente a esto es el WebGLRenderTarget. Esto es lo que usaremos como nuestra textura intermedia para procesar. Hay una pequeña advertencia a la izquierda: no se puede leer y renderizar con la misma textura simultáneamente. La forma más fácil de evitarlo es simplemente usar dos texturas.
Deje que A y B sean dos texturas que ha creado. Su método sería entonces:
- Pase A a través de su sombreador, renderice en B.
- Render B a la pantalla.
- Pase B a través del sombreador, renderice en A.
- Renderiza A en tu pantalla.
- Repite 1.
O bien, una forma más concisa de codificar esto sería:
- Pase A a través de su sombreador, renderice en B.
- Render B a la pantalla.
- Cambie A y B (de modo que la variable A ahora contiene la textura que estaba en B y viceversa).
- Repita 1.
Eso es todo lo que se necesita. Aquí hay una implementación de eso en ThreeJS:
Esta sigue siendo una pantalla en negro, que es con lo que comenzamos. Nuestro sombreador tampoco es muy diferente:
1 |
uniform vec2 res; //The width and height of our screen |
2 |
uniform sampler2D bufferTexture; //Our input texture |
3 |
void main() { |
4 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
5 |
gl_FragColor = texture2D( bufferTexture, pixel ); |
6 |
}
|
Excepto ahora si agrega esta línea (¡pruébelo!):
1 |
gl_FragColor.r += 0.01; |
Verá que la pantalla se vuelve roja lentamente, en lugar de solo aumentar 0.01
una vez. Este es un paso
bastante significativo, por lo que debe tomarse un momento para jugar y
compararlo con el funcionamiento de nuestra configuración inicial.
Desafío: ¿Qué sucede si pones gl_FragColor.r + = pixel.x;
cuando se utiliza un ejemplo de búfer de cuadros, en comparación con cuando se usa el ejemplo de configuración? Tómese un momento para pensar por qué los resultados son diferentes y por qué tienen sentido.
Paso 2: obteniendo una fuente de humo
Antes de que podamos hacer que algo se mueva, necesitamos una forma de crear humo en primer lugar. La forma más fácil es establecer manualmente un área arbitraria en blanco en su sombreador.
1 |
//Get the distance of this pixel from the center of the screen
|
2 |
float dist = distance(gl_FragCoord.xy, res.xy/2.0); |
3 |
if(dist < 15.0){ //Create a circle with a radius of 15 pixels |
4 |
gl_FragColor.rgb = vec3(1.0); |
5 |
}
|
Si queremos probar si nuestro frame buffer está funcionando correctamente, podemos intentar agregarle valor al color en lugar de solo configurarlo. Debería ver el círculo cada vez más blanco y más blanco.
1 |
//Get the distance of this pixel from the center of the screen
|
2 |
float dist = distance(gl_FragCoord.xy, res.xy/2.0); |
3 |
if(dist < 15.0){ //Create a circle with a radius of 15 pixels |
4 |
gl_FragColor.rgb += 0.01; |
5 |
}
|
Otra forma es reemplazar ese punto fijo con la posición del mouse. Puede pasar un tercer valor para presionar o no el mouse, de esa manera puede hacer clic para crear humo. Aquí hay una implementación para eso.
Así es como se ve nuestro sombreador ahora:
1 |
//The width and height of our screen
|
2 |
uniform vec2 res; |
3 |
//Our input texture
|
4 |
uniform sampler2D bufferTexture; |
5 |
//The x,y are the posiiton. The z is the power/density
|
6 |
uniform vec3 smokeSource; |
7 |
|
8 |
void main() { |
9 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
10 |
gl_FragColor = texture2D( bufferTexture, pixel ); |
11 |
|
12 |
//Get the distance of the current pixel from the smoke source
|
13 |
float dist = distance(smokeSource.xy,gl_FragCoord.xy); |
14 |
//Generate smoke when mouse is pressed
|
15 |
if(smokeSource.z > 0.0 && dist < 15.0){ |
16 |
gl_FragColor.rgb += smokeSource.z; |
17 |
}
|
18 |
}
|
Desafío: Recuerde que las ramificaciones (condicionales) suelen ser costosas en los sombreadores. ¿Puedes reescribir esto sin usar una instrucción if? (La solución está en el CodePen).
Si esto no tiene sentido, hay una explicación más detallada del uso del mouse en un sombreador en el tutorial de iluminación anterior.
Paso 3: Difunda el humo
¡Ahora esta es la parte fácil y la más gratificante! Ahora tenemos todas las piezas, solo necesitamos decirle al sombreador: cada píxel debería ganar algo de color por parte de sus vecinos, mientras pierde algo propio.
Which looks something like this:
1 |
//Smoke diffuse
|
2 |
float xPixel = 1.0/res.x; //The size of a single pixel |
3 |
float yPixel = 1.0/res.y; |
4 |
|
5 |
vec4 rightColor = texture2D(bufferTexture,vec2(pixel.x+xPixel,pixel.y)); |
6 |
vec4 leftColor = texture2D(bufferTexture,vec2(pixel.x-xPixel,pixel.y)); |
7 |
vec4 upColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y+yPixel)); |
8 |
vec4 downColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y-yPixel)); |
9 |
|
10 |
//Diffuse equation
|
11 |
gl_FragColor.rgb += |
12 |
14.0 * 0.016 * |
13 |
(
|
14 |
leftColor.rgb + |
15 |
rightColor.rgb + |
16 |
downColor.rgb + |
17 |
upColor.rgb - |
18 |
4.0 * gl_FragColor.rgb |
19 |
);
|
Tenemos nuestro factor f
como antes. En este caso
tenemos el paso del tiempo (0.016
es 1/60, porque estamos corriendo a 60
fps) y seguí probando números hasta que llegué a los 14
, lo que parece
verse bien. Este es el resultado:
Oh, ¡está atascado!
Esta es la misma ecuación difusa que utilizamos en la demostración de la CPU, ¡y sin embargo, nuestra simulación se atasca! ¿Lo que da?
Resulta que las texturas (como todos los números en una computadora) tienen una precisión limitada. En algún momento, el factor por el que estamos restando es tan pequeño que se redondea a 0, por lo que los simuladores se quedan pegados. Para solucionar esto, debemos verificar que no caiga por debajo de un valor mínimo:
1 |
float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); |
2 |
//We have to account for the low precision of texels
|
3 |
float minimum = 0.003; |
4 |
if (factor >= -minimum && factor < 0.0) factor = -minimum; |
5 |
|
6 |
gl_FragColor.rgb += factor; |
Estoy usando el
componente r
en lugar del rgb
para obtener el factor, porque es más
fácil trabajar con números individuales, y porque todos los componentes
tienen el mismo número de todos modos (ya que nuestro humo es blanco).
Por prueba y error, encontré que 0.003
es un buen umbral en el que no se bloquea. Solo me preocupa el factor cuando es negativo, para asegurar que siempre disminuya. Una vez que aplicamos esta solución, esto es lo que obtenemos:
Paso 4: Difunde el humo hacia arriba
Sin embargo, esto no se parece mucho al humo. Si queremos que fluya hacia arriba en lugar de en todas direcciones, debemos agregar algunos pesos. Si los píxeles inferiores siempre tienen una influencia mayor que las otras direcciones, entonces nuestros píxeles parecerán moverse hacia arriba.
Al jugar con los coeficientes, podemos llegar a algo que se ve bastante decente con esta ecuación:
1 |
//Diffuse equation
|
2 |
float factor = 8.0 * 0.016 * |
3 |
(
|
4 |
leftColor.r + |
5 |
rightColor.r + |
6 |
downColor.r * 3.0 + |
7 |
upColor.r - |
8 |
6.0 * gl_FragColor.r |
9 |
);
|
Y esto es lo que parece:
Una nota sobre la ecuación difusa
Básicamente, jugueteé con los coeficientes allí para que se vea bien fluyendo hacia arriba. También puedes hacer que fluya en cualquier otra dirección.
Es importante tener en cuenta que es muy fácil hacer que esta simulación "explote". (Intente cambiar el 6.0
allí a 5.0
y vea qué sucede). Esto es obviamente porque las células están ganando más de lo que están perdiendo.
Esta ecuación es en realidad lo que el documento que cité se refiere como el modelo "malo difuso". Presentan una ecuación alternativa que es más estable, pero no es muy conveniente para nosotros, principalmente porque necesita escribir en la grilla de la que está leyendo. En otras palabras, necesitaríamos poder leer y escribir con la misma textura al mismo tiempo.
Lo que tenemos
es suficiente para nuestros propósitos, pero puede echar un vistazo a la
explicación en el documento si tiene curiosidad. También
encontrará la ecuación alternativa implementada en la demostración
interactiva de la CPU en la función diffuse_advanced ()
.
Una solución rápida
Una cosa que puedes notar, si juegas con tu humo, es que se queda atascado en la parte inferior de la pantalla si generas algo allí. Esto se debe a que los píxeles en esa fila inferior están tratando de obtener los valores de los píxeles a continuación ellos, que no existen.
Para solucionar esto, simplemente nos aseguramos de que los píxeles en la fila inferior encuentren 0
debajo de ellos:
1 |
//Handle the bottom boundary
|
2 |
//This needs to run before the diffuse function
|
3 |
if(pixel.y <= yPixel){ |
4 |
downColor.rgb = vec3(0.0); |
5 |
}
|
En la demostración de la CPU, traté con eso simplemente no haciendo que las celdas en el límite se difundan. Alternativamente,
puede configurar manualmente cualquier celda fuera de límites para que
tenga un valor de 0
. (La cuadrícula en la demostración de la CPU se
extiende por una fila y columna de celdas en cada dirección, por lo que
nunca verá los límites)
Una cuadrícula de velocidad
¡Felicitaciones! ¡Ahora tiene un sombreador de humo que funciona! Lo último que quería discutir brevemente es el campo de velocidad que menciona el documento.



Su humo no tiene que difundirse uniformemente hacia arriba o en cualquier dirección específica; podría seguir un patrón general como el que se muestra en la imagen. Puedes hacer esto enviando otra textura donde los valores de color representen la dirección en que debe fluir el humo en esa ubicación, de la misma manera que utilizamos un mapa normal para especificar una dirección en cada píxel en nuestro tutorial de iluminación.
¡De hecho, tu textura de velocidad tampoco tiene que ser estática! Podría usar el truco de búfer de cuadros para que también las velocidades cambien en tiempo real. No lo cubriré en este tutorial, pero hay mucho potencial por explorar.
Conclusión
Si hay algo que
sacar de este tutorial, es que poder renderizar una textura en lugar de
solo a la pantalla es una técnica muy útil.
¿Para qué sirven los búferes de cuadro?
Un uso común para esto es el postprocesamiento en juegos. Si desea aplicar algún tipo de filtro de color, en lugar de aplicarlo a cada objeto, puede representar todos sus objetos en una textura del tamaño de la pantalla, luego aplicar el sombreador a esa textura final y dibujarla en la pantalla.
Otro ejemplo es cuando implementa sombreadores que requieren múltiples pases, como desenfoque. Por lo general, ejecuta su imagen a través del sombreador, difumina en la dirección x y luego la ejecuta de nuevo para difuminar en la dirección y.
Un último ejemplo es el renderizado diferido, como se discutió en el tutorial de iluminación anterior, que es una manera fácil de agregar de manera eficiente muchas fuentes de luz a su escena. Lo bueno de esto es que el cálculo de la iluminación ya no depende de la cantidad de fuentes de luz que tenga.
No tengas miedo de los artículos técnicos
Definitivamente hay más detalles cubiertos en el documento que he citado, y supone que tiene cierta familiaridad con el álgebra lineal, pero no permita que esto le impida diseccionarlo e intentar implementarlo. La esencia de esto terminó siendo bastante simple de implementar (después de algunos retoques con los coeficientes).
Esperamos que hayas aprendido un poco más acerca de los sombreadores aquí, y si tienes alguna pregunta, sugerencia o mejora, ¡por favor compártelos a continuación!