Animación de miles de objetos con MultiMeshInstance

Este tutorial explora una técnica usada en el juego ABZU para renderizar y animar miles de peces usando animación de vértices e instanciación estática de malla.

En Godot, esto se puede lograr con un personalizado Shader y una MultiMeshInstance. Usando la siguiente técnica puedes renderizar miles de objetos animados, incluso en hardware de gama baja.

Empezaremos animando a un pez. Luego, veremos cómo extender esa animación a miles de peces.

Animando un objeto

Empezaremos con un solo pez. Carga tu modelo de pez en un MeshInstance y añade un nuevo ShaderMaterial.

Aquí está el pez que usaremos para las imágenes de ejemplo, puedes usar cualquier modelo de pez que quieras.

../../../_images/fish.png

Nota

El modelo de pez en este tutorial está hecho por QuaterniusDev y se comparte con una licencia creative commons. CC0 1.0 Universal (CC0 1.0) Public Domain Dedication https://creativecommons.org/publicdomain/zero/1.0/

Típicamente, usarías huesos y un Skeleton para animar objetos. Sin embargo, los huesos se animan en la CPU y así terminas teniendo que calcular miles de operaciones en cada fotograma y se hace imposible tener miles de objetos. Usando la vertex animation en un vertex sharder, evitas usar huesos y puedes en cambio calcular la animación completa en unas pocas líneas de código y completamente en la GPU.

La animación se hará con cuatro claves de movimiento:

  1. Un movimiento de lado a lado

  2. Un movimiento pivotante alrededor del centro del pez

  3. Un movimiento de onda de panorámico

  4. Un movimiento de giro de panorámica

Todo el código de la animación estará en el vertex shader uniforms que controlan la cantidad de movimiento. Usamos uniforms para controlar la fuerza del movimiento para que puedas ajustar la animación en el editor y ver los resultados en tiempo real, sin que el shader tenga que recompilar.

Todos los movimientos se harán usando ondas coseno aplicadas a VERTEX en el espacio modelo. Queremos que los vértices estén en el espacio modelo para que el movimiento sea siempre relativo a la orientación de los peces. Por ejemplo, de lado a lado siempre moverá al pez de izquierda a derecha, en lugar de en el eje x en la orientación del mundo.

Para controlar la velocidad de la animación, empezaremos definiendo nuestra propia variable de tiempo usando TIME.

//time_scale is a uniform float
float time = TIME * time_scale;

El primer movimiento que implementaremos es el movimiento de lado a lado. Puede hacerse compensando VERTEX.x por cos de TIME. Cada vez que la malla se renderice, todos los vértices se moverán de lado por la cantidad de cos(time).

//side_to_side is a uniform float
VERTEX.x += cos(time) * side_to_side;

La animación resultante debería ser algo así:

../../../_images/sidetoside.gif

A continuación, añadimos el pivote. Debido a que el pez está centrado en (0, 0), todo lo que tenemos que hacer es multiplicar VERTEX por una matriz de rotación para que gire alrededor del centro del pez.

Construimos una matriz de rotación así:

//angle is scaled by 0.1 so that the fish only pivots and doesn't rotate all the way around
//pivot is a uniform float
float pivot_angle = cos(time) * 0.1 * pivot;
mat2 rotation_matrix = mat2(vec2(cos(pivot_angle), -sin(pivot_angle)), vec2(sin(pivot_angle), cos(pivot_angle)));

Y luego lo aplicamos en los ejes x y z multiplicándolo por VERTEX.xz.

VERTEX.xz = rotation_matrix * VERTEX.xz;

Con sólo el pivote aplicado, deberías ver algo como esto:

../../../_images/pivot.gif

Los próximos dos movimientos necesitan bajar por la espina dorsal del pez. Para eso, necesitamos una nueva variable, body. body es un real que está 0 en la cola del pez y 1 en su cabeza."cuerpo" es un flotador que está 0 en la cola del pez y 1 en su cabeza.

float body = (VERTEX.z + 1.0) / 2.0; //for a fish centered at (0, 0) with a length of 2

El siguiente movimiento es una onda coseno que se mueve a lo largo del pez. Para hacer que se mueva a lo largo de la espina dorsal del pez, compensamos la entrada a cos por la posición a lo largo de la espina dorsal, que es la variable que definimos arriba, body.

//wave is a uniform float
VERTEX.x += cos(time + body) * wave;

Esto se ve muy similar al movimiento de lado a lado que definimos arriba, pero en este, al usar body para desplazar cos cada vertex a lo largo de la espina tienes una posición diferente en la onda haciendo que parezca que una onda se está moviendo a lo largo del pez.

../../../_images/wave.gif

El último movimiento es el giro, que es un rollo de desplazamiento a lo largo de la columna vertebral. De manera similar al pivote, primero construimos una matriz de rotación.

//twist is a uniform float
float twist_angle = cos(time + body) * 0.3 * twist;
mat2 twist_matrix = mat2(vec2(cos(twist_angle), -sin(twist_angle)), vec2(sin(twist_angle), cos(twist_angle)));

Aplicamos la rotación en los ejes xy para que el pez parezca girar alrededor de su espina. Para que esto funcione, la columna vertebral del pez necesita estar centrada en el eje z.

VERTEX.xy = twist_matrix * VERTEX.xy;

Aquí está el objeto con el giro aplicado:

../../../_images/twist.gif

Si aplicamos todos estos movimientos uno tras otro, obtenemos un movimiento fluido y como gelatina.

../../../_images/all_motions.gif

Los peces normales nadan principalmente con la mitad trasera de su cuerpo. Por consiguiente, necesitamos limitar los movimientos de desplazamiento a la mitad trasera del pez. Para hacer esto, creamos una nueva variable, mask.

mask es un valor decimal que va desde 0 en la parte delantera del pez hasta 1 en la parte trasera, utilizando la función smoothstep para controlar el punto en el cual ocurre la transición de 0 a 1.

//mask_black and mask_white are uniforms
float mask = smoothstep(mask_black, mask_white, 1.0 - body);

Abajo hay una imagen del pez con mask usada como COLOR:

../../../_images/mask.png

Para la ola, multiplicamos el movimiento por la mask que la limitará a la mitad trasera.

//wave motion with mask
VERTEX.x += cos(time + body) * mask * wave;

Para aplicar la máscara al giro, usamos mix. mix nos permite mezclar la vertex position entre un vértice completamente girado y uno que no está girado. Necesitamos usar mix en lugar de multiplicar la mask por el VERTEX girado porque no estamos añadiendo el movimiento al VERTEX, estamos reemplazando el VERTEX con la versión girada. Si lo multiplicamos por la mask, reduciremos el tamaño de los peces.

//twist motion with mask
VERTEX.xy = mix(VERTEX.xy, twist_matrix * VERTEX.xy, mask);

La unión de los cuatro movimientos nos da la animación final.

../../../_images/all_motions_mask.gif

Adelante, juega con los uniforms para alterar el ciclo de natación de los peces. Verás que puedes crear una gran variedad de estilos de natación usando estos cuatro movimientos.

Hacer un banco de peces

Godot facilita la renderización de miles de un mismo objeto utilizando un nodo MultiMeshInstance.

Se crea un nodo MultiMeshInstance y se utiliza de la misma manera que se haría un nodo MeshInstance. Para este tutorial, nombraremos al nodo MultiMeshInstance School, porque contendrá un banco de peces.

Una vez que tengas una MultiMeshInstance agrega un MultiMesh, y a esa MultiMesh agrega tu Mesh con el shader superior.

MultiMeshes dibuja tu malla con tres propiedades adicionales por sustancia: Transformación (rotación, traslación, escala), Color y Custom. Custom se usa para pasar 4 variables multiusos usando un Color.

"instance_count especifica cuántas instancias de la malla quieres dibujar. Por ahora, deja instance_count en 0 porque no puedes cambiar ninguno de los otros parámetros mientras instance_count sea mayor que 0. Más tarde pondremos instance_count en GDScript.

transform_format especifica si las transformaciones utilizadas son 3D o 2D. Para este tutorial, selecciona 3D.

Tanto para el color_format como para el custom_data_format puedes elegir entre None, Byte y Float. None significa que no pasarás esos datos (ya sea una variable de COLOR o INSTANCE_CUSTOM) al shader. Byte significa que cada número que compone el color que pasas se almacenará con 8 bits mientras que Float significa que cada número se almacenará en un número de real (32 bits). Float es más lento pero más preciso, Byte tomará menos memoria y será más rápido, pero puedes ver algunos artefactos visuales.

Ahora, pon instance_count al número de peces que quieres tener.

Lo siguiente que necesitamos es establecer las transformaciones por instancia.

Hay dos formas de establecer transformaciones por instancia para MultiMeshes. La primera está completamente en el editor y es descrita en el MultiMeshInstance tutorial.

La segunda es hacer un bucle sobre todas las instancias y establecer sus transformaciones en código. A continuación, usamos GDScript para hacer un bucle sobre todas las instancias y establecer su transformación en una posición aleatoria.

for i in range($School.multimesh.instance_count):
  var position = Transform()
  position = position.translated(Vector3(randf() * 100 - 50, randf() * 50 - 25, randf() * 50 - 25))
  $School.multimesh.set_instance_transform(i, position)

Ejecutar este guión colocará a los peces en posiciones aleatorias en una caja alrededor de la posición de la MultiMeshInstance.

Nota

Si la actuación es un problema para ti, intenta hacer la escena con GLES2 o con menos peces.

¿Nota que todos los peces están en la misma posición en su ciclo de natación? Los hace parecer muy robóticos. El siguiente paso es darle a cada pez una posición diferente en el ciclo de natación para que todo el banco parezca más orgánico.

Animar un banco de peces

Uno de los beneficios de animar a los peces usando funciones de cos es que se animan con un parámetro, time. Para darle a cada pez una posición única en el ciclo de natación, sólo necesitamos compensar el time.

Lo hacemos añadiendo el valor personalizado INSTANCE_CUSTOM a time.

float time = (TIME * time_scale) + (6.28318 * INSTANCE_CUSTOM.x);

A continuación, tenemos que pasar un valor a INSTANCE_CUSTOM. Lo hacemos añadiendo una línea en el bucle for superior. En el bucle for asignamos a cada instancia un conjunto de cuatro reales aleatorios para usar.

$School.multimesh.set_instance_custom_data(i, Color(randf(), randf(), randf(), randf()))

Ahora todos los peces tienen posiciones únicas en el ciclo de natación. Puedes darles un poco más de individualidad usando INSTANCE_CUSTOM para hacerlos nadar más rápido o más lento multiplicando por el TIME.

//set speed from 50% - 150% of regular speed
float time = (TIME * (0.5 + INSTANCE_CUSTOM.y) * time_scale) + (6.28318 * INSTANCE_CUSTOM.x);

Incluso puedes experimentar con el cambio de color por instancia de la misma manera que cambiaste el valor personalizado por instancia.

Un problema con el que te encontrarás en este punto es que los peces están animados, pero no se mueven. Puedes moverlos actualizando la transformación por instancia de cada pez en cada fotograma. Aunque hacerlo será más rápido que mover miles de MeshInstances por fotograma, es probable que siga siendo lento.

En el próximo tutorial cubriremos cómo usar Particles para aprovechar la GPU y mover cada pez individualmente mientras se reciben los beneficios del instanciamiento.