Unlimited WordPress themes, graphics, videos & courses! Unlimited asset downloads! From $16.50/m
Advertisement
  1. Game Development
  2. Shaders
Gamedevelopment

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:

Haga clic para generar más humo. Puede bifurcar y editar esto en CodePen.

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:

Figure taken from Jos Stams paper cited above

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:

Smoke shader algorithm interactive demo
Vea la demostración interactiva en CodePen.

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:

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:

Grid of values to diffuse

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:

¡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):

¡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í:

Grid of diffused values

Miremos la difusión en la celda en el medio otra vez:

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

Puede bifurcar y editar esto en CodePen. Haga clic en los botones en la parte superior para ver el HTML, CSS y JS.

Nuestro shader es simplemente:

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

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

Shader process

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:

Repeatedly applying the shader to the texture

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:

  1. Pase A a través de su sombreador, renderice en B.
  2. Render B a la pantalla.
  3. Pase B a través del sombreador, renderice en A.
  4. Renderiza A en tu pantalla.
  5. Repite 1.

O bien, una forma más concisa de codificar esto sería:

  1. Pase A a través de su sombreador, renderice en B.
  2. Render B a la pantalla.
  3. Cambie A y B (de modo que la variable A ahora contiene la textura que estaba en B y viceversa).
  4. Repita 1.

Eso es todo lo que se necesita. Aquí hay una implementación de eso en ThreeJS:

Puede bifurcar y editar esto en CodePen. El nuevo código de sombreado está en la pestaña HTML.

Esta sigue siendo una pantalla en negro, que es con lo que comenzamos. Nuestro sombreador tampoco es muy diferente:

Excepto ahora si agrega esta línea (¡pruébelo!):

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.

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.

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.

Haga clic para agregar "humo". Puede bifurcar y editar esto en CodePen.

Así es como se ve nuestro sombreador ahora:

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:

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:

Haga clic para agregar humo. Puede bifurcar y editar esto en CodePen.

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:

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:

Haga clic para agregar humo. Puede bifurcar y editar esto en CodePen.

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:

Y esto es lo que parece:

Haga clic para agregar humo. Puede bifurcar y editar esto en CodePen.

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:

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.

Velocity field from Jon Stams paper

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!

Advertisement
Advertisement
Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.