Simulation Stages en Niagara

Simulation Stages es una nueva característica, desde la versión 4.26, que permite iterar sobre partículas, texels en render targets ó celdas en un grid 2D.

Simulation Stage funciona como una "etapa" adicional. Del mismo modo que tienes las etapas Emitter Spawn, Emitter Update, Particle Spawn, Particle Update, etc., ahora tendrás etapas Simulation Stage.

Simulation Stage

La principal diferencia es que la etapa Simulation Stage funciona como un FOR (bucle). Por lo que, en cada frame, repetirás la étapa el número de iteraciones que indiques. Puedes iterar sobre partículas, píxeles de un render target, etc.,

Para poder añadir Simulation Stage debes activarlo desde Emitter Properties:

Enable Simulation Stages

¿Para qué podemos usar Simulation Stages?

  • Iterate Constraints
  • Solvers basados en grid
  • Leer y escribir atributos en un grid
  • Escribir render targets

Piensa en simulación de fluidos, autómatas, físicas, colisiones entre partículas, comportamientos de enjamble, etc.,

Escribir en un render target

Vamos a ilustrar con un ejemplo muy básico el uso de un Simulation Stage.

En este ejemplo, vamos a leer una textura y la escribiremos en un render target.

Primero, crea un material básico que tome una textura como parámetro. Este parámetro lo usaremos posteriormente para pasarle el render target desde el propio Niagara:

Material de ejemplo M_Test

Crea un nuevo sistema de partículas, lo he llamado CopyToRT, con un emisor vacío y elimina todos los módulos:

Emisor vacío

Activa Simulation Stages desde Emitter Properties deberá aparecer Add Simulation Stage.

Add Simulation Stage

Vamos a crear dos atributos en el emisor: una textura y un render target. Copiaremos la textura en el render target.

Texture y Render Target

Ahora vamos a crear una Simulation Stage. Pulse el simbolo + de Add Simulation Stage:

Simulation Stage vacío

Puedes cambiar el nombre en el módulo "Generic Simulation Stage Settings":

Cambiar el nombre

Fíjate como, por defecto, la simulation stage itera sobre cada partícula.

Nosotros queremos que itere sobre cada texel del render target y escribiremos en dicho texel el valor correspondiente de TextureSource. Es decir, copiaremos TextureSource a RenderTarget texel a texel.

Cambia el campo "Iteration Source" de Particles a Data Interface. En el campo Data Interface asigna nuestro Render Target.

Iteration Source a Data Interface

Añade un nuevo módulo. Este módulo leerá la textura y la escribirá en el render target.

Nuevo Scratch Module

Recuerda que este módulo se ejecutará por cada texel ya que así has configurado el Simulation Stage.

En el nuevo módulo crea dos entradas para el Render Target y la textura (Texture Sample), respectivamente:

RenderTarget y Texture Sample como inputs en el nuevo módulo

Fíjate como para el data interface de tipo RenderTarget tenemos a disposición los siguientes métodos:

  • GetRenderTargetSize/SetRenderTargetSize, qué como su propio nombre indica devuelve/setea el tamaño del render target.
  • SetRenderTargetValue, qué como su propio nombre indica actualiza un valor (color) en una coordenada del render target
  • LinearToIndex, es un método muy útil ya que convierte el número de iteración (se obtiene con el nodo Execution Index) en coordenadas 2d del render target.

Recuerda que, tal y como hemos configurado previamente el Simulation Stage, este módulo se ejecutará por cada texel del render target.

Si el render target es de tamaño 256x256 entonces se ejecutará 65.536 veces.  Podemos obtener el número de iteración actual con el nodo Executión Index. En nuestro ejemplo este nodo devolverá un valor desde 0 a 65.535

¿Cómo convertimos el número de iteración a coordenadas (x,y) del render target? Usando el nodo LinearToIndex.

Con todo esto, el módulo para copiar la textura al render target quedaría:

Módulo para copiar una textura al RenderTarget

Recuerda setear correctamente las entradas al módulo con los parámetros correctos:

RenderTarget y TextureSource como Inputs para el módulo

¡Y con esto ya lo tenemos listo!

Como prueba de concepto, vamos a hacer uso de nuestro render target en nuestro propio sistema de partículas. Añade un módulo Sprite Renderer, setea el material:

Material M_Test al módulo Sprite Renderer

No olvides añadir como parámetro del material el propio parámetro render target:

Material Parameter Binding

Añade algunas partículas (con el módulo Spawn Rate o el que prefieras) y verás como las partículas son renderizadas con el material que usa, a su vez, el render target que hemos calculado:

Material en acción

Grid2D Collection

Vamos a ilustrar con otro ejemplo el uso de Simulation Stages.

En este caso, vamos a usar un Grid2D Collection.

Un Grid2D Collection es un array de 2D buffers que puede almacenar atributos floats y vectors.

En el ejemplo, simularemos el juego de la vida de Conway.

El emisor tiene la siguiente pinta:

Game of Life

En este caso en vez de spawnear partículas, Niagara permite ahorrarse todo el coste de las etapas Particle Spawn y Particle Update si simplemente spawneamos una partícula.

Para este caso, en Sprite Renderer indica que vamos a usar los atributos del namespace Emitter en vez de Particle:

Set Source Mode to Emitter

En Emitter Spawn creamos dos nuevos atributos de tipo Grid2D Collection y Render Texture:

Grid2D Collection y Render Texture

Ahora vamos a crear tres Simulation Stages.

  1. Un primer Simulation Stage que inicialice el grid con valores aleatorios. Solo se ejecutará una vez al comienzo del ciclo de vida del emitter.
  2. Otro Simulation Stage que actualizará el contenido del Grid. En nuestro ejemplo simularemos el juego de la vida de Conway.
  3. Un último Simulation Stage que copiará el contenido del Grid a un render texture. Este render texture es el que usaremos para mostrar por pantalla la simulación.

El primer Simulation Stage, llamado Game of Life: Initial Stage,  está configurado tal que así:

Settings. Game of Life: Initial State.

Nota como está marcado Emitter Reset Only.

Esto es así ya que esta etapa solo la ejecutaremos una vez, al comienzo.

El módulo Write Texture to Grid (mal nombre por cierto) inicializa con valores aleatorios el grid 2d.

Módulo Write Texture to Grid que inicializa aleatoriamente el grid

Fíjate en la siguiente peculiaridad:

Stackcontext

Estamos escribiendo un atributo llamado Alive en el espacio de nombre Stackcontext. ¿Qué espacio de nombres es ese? ¿Acaso no deberías escribir el valor en una posición del grid?

StackContext realmente NO es un nuevo espacio de nombres. Es un espacio "dinámico" que, en función del contexto, de ahí el nombre, puede tomar distintos espacios de nombres.

En concreto, cuando StackContext es usado dentro de módulos que funcionan en etapas propias del emisor (Emitter Spawn, Emitter Update) este StackContext resuelve como Emitter. Por lo que realmente estás usando el espacio de nombres Emitter.

Cuando StackContext es usado dentro de módulos que funcionan en etapas propias de la partícula (Particle Spawn, Particle Update) y en Simulation Stages que iteran sobre partículas, entonces StackContext resuelve al espacio de nombres Particle.

¿Y qué sucede en nuestro ejemplo, que lo estamos usando en un módulo que funciona dentro de un Simulation Stage que itera sobre un Grid2D Collection? ¡Pues StackContext resuelve al propio Grid2D! Es decir, estamos seteando el valor del atributo que indicas en StackContext de la celda correspondiente a dicha iteración.

Dicho de otro modo, usar StackContext del modo anterior, sería equivalente a hacer esto:

Setear un valor de una posición del Grid

Pero con StackContext te ahorras todos estos nodos y queda mucho más elegante.

El módulo llamado Simulation Step de la etapa llamada Game of Life: Loop es el siguiente:

Cuyo código HLSL es:

float TL, TC, TR, BL, BC, BR, CL, CC, CR;

Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(-1, -1), TL);
Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(0, -1), TC);
Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(1, -1), TR);

Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(-1, 1), BL);
Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(0, 1), BC);
Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(1, 1), BR);

Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(-1, 0), CL);
Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(0, 0), CC);
Grid.SampleGridFloatValue<Attribute=Alive>(Unit + Offs * float2(1, 0), CR);

int K = ceil(TL + TC + TR + BL + BC + BR + CL + CR);
int E = ceil(CC);

Alive = ((K==2 && E==1) || K==3) ? 1.0 : 0.0;
Game of Life en HLSL

Fíjate que es una traducción directa del algoritmo del juego de la vida de Conway.

La última etapa llamada Write Render Texture copia el contenido del Grid a un Render Target:

Write Render Texture

Reaction-Diffusion

Puedes cambiar la simulación para que, en vez de simular el juego de la vida, realice el conocido efecto reaction-diffusion:

Más información de reaction-diffusion en el siguiente enlace:

Reaction-Diffusion Tutorial
Illustrated tutorial of the Gray-Scott Reaction-Diffusion model.

Para este caso necesitarás dos salidas, uno para la cantidad A y otro para la cantidad B.

La implementación quedaría:

float A_TL, A_TC, A_TR, A_BL, A_BC, A_BR, A_CL, A, A_CR;

Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(-1, -1), A_TL);
Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(0, -1), A_TC);
Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(1, -1), A_TR);

Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(-1, 1), A_BL);
Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(0, 1), A_BC);
Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(1, 1), A_BR);

Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(-1, 0), A_CL);
Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(0, 0), A);
Grid.SampleGridFloatValue<Attribute=A>(Unit + Offs * float2(1, 0), A_CR);

float B_TL, B_TC, B_TR, B_BL, B_BC, B_BR, B_CL, B, B_CR;

Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(-1, -1), B_TL);
Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(0, -1), B_TC);
Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(1, -1), B_TR);

Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(-1, 1), B_BL);
Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(0, 1), B_BC);
Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(1, 1), B_BR);

Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(-1, 0), B_CL);
Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(0, 0), B);
Grid.SampleGridFloatValue<Attribute=B>(Unit + Offs * float2(1, 0), B_CR);

float La = 0.05 * (A_TL + A_TR + A_BR + A_BL) + 0.2 * (A_TC + A_CL + A_BC + A_CR) - A;
float Lb = 0.05 * (B_TL + B_TR + B_BR + B_BL) + 0.2 * (B_TC + B_CL + B_BC + B_CR) - B;

OutA = A + (Da * La - A * B * B + f * (1.0 - A)) * dt;
OutB = B + (Db * Lb + A * B * B - (k + f) * B) * dt;
Reaction-Diffusion en HLSL

Jorge Moreno Aguilera

Jorge Moreno Aguilera