Creando efectos dinámicos de agua en 2D en Unity
() translation by (you can also view the original English article)
En este tutorial, vamos a simular un cuerpo dinámico de agua en 2D utilizando la física simple. Usaremos una mezcla de un renderizador de línea, renderizadores de malla, disparadores y partículas para crear nuestro efecto. El resultado final viene completo con olas y salpicaduras, listo para agregarlo a tu próximo juego. Se incluye una fuente de demostración de Unity (Unity3D), pero deberías poder implementar algo similar utilizando los mismos principios en cualquier motor de juego.
Resultado final
Esto es lo que vamos a obtener. Necesitarás el complemento del navegador Unity para probarlo.
Configuración de nuestro administrador de agua
En su tutorial, Michael Hoffman demostró cómo podemos modelar la superficie del agua con una fila de springs.
Vamos a renderizar la parte superior de nuestra agua usando uno de los renderizadores de líneas de Unity, y usaremos tantos nodos que aparecerá como una onda continua.



Sin embargo, tendremos que hacer un seguimiento de las posiciones, velocidades y aceleraciones de cada nodo. Para hacer eso, vamos a utilizar matrices. Así que en la parte superior de nuestra clase agregaremos estas variables:
1 |
float[] xpositions; |
2 |
float[] ypositions; |
3 |
float[] velocities; |
4 |
float[] accelerations; |
5 |
LineRenderer Body; |
El LineRenderer
almacenará todos nuestros nodos y delineará nuestro cuerpo de agua. Sin embargo, todavía necesitamos el agua misma; vamos a crear esto con meshes
. Vamos a necesitar objetos para mantener estas mallas también.
1 |
GameObject[] meshobjects; |
2 |
Mesh[] meshes; |
También vamos a necesitar colisionadores para que las cosas puedan interactuar con nuestra agua:
1 |
GameObject[] colliders; |
Y también almacenaremos todas nuestras constantes:
1 |
const float springconstant = 0.02f; |
2 |
const float damping = 0.04f; |
3 |
const float spread = 0.05f; |
4 |
const float z = -1f; |
Estas constantes son del mismo tipo que Michael analizó, con la excepción dez
: esta es nuestra compensación z para nuestra agua. Vamos a utilizar -1
para eso, para que se muestre delante de nuestros objetos. (Es posible que desees cambiar esto dependiendo de lo que quieras que aparezca delante y detrás de ellos; vas a tener que usar la coordenada z para determinar dónde se encuentran los sprites en relación con los objetos).
A continuación, vamos a mantener algunos valores:
1 |
float baseheight; |
2 |
float left; |
3 |
float bottom; |
Estas son solo las dimensiones del agua.
Vamos a necesitar algunas variables públicas que también podemos establecer en el editor. Primero, el sistema de partículas que vamos a usar para nuestras salpicaduras:
1 |
public GameObject splash: |
A continuación, el material que usaremos para nuestro renderizador de líneas (en caso de que desees reutilizar el script para ácido, lava, productos químicos o cualquier otra cosa):
1 |
public Material mat: |
Además, el tipo de malla que usaremos para la masa de agua principal:
1 |
public GameObject watermesh: |
Todos estos se basarán en prefabs, que se incluyen en los archivos de origen.
Queremos un objeto de juego que pueda contener todos estos datos, actuar como un administrador y generar nuestro cuerpo de agua dentro del juego según las especificaciones. Para hacer eso, escribiremos una función llamada SpawnWater ()
.
Esta función tomará entradas del lado izquierdo, el ancho, la parte superior y la parte inferior del cuerpo de agua.
1 |
public void SpawnWater(float Left, float Width, float Top, float Bottom) |
2 |
{
|
(Aunque esto parece inconsistente, actúa en interés del diseño de niveles rápidos cuando se construye de izquierda a derecha).
Creando los nodos
Ahora vamos a averiguar cuántos nodos necesitamos:
1 |
int edgecount = Mathf.RoundToInt(Width) * 5; |
2 |
int nodecount = edgecount + 1; |
Vamos a utilizar cinco por unidad de ancho, para darnos un movimiento suave que no sea demasiado exigente. (Puedes variar esto para equilibrar la eficiencia contra la suavidad). Esto nos da todas nuestras líneas, entonces necesitamos el + 1
para el nodo extra en el extremo.
Lo primero que vamos a hacer es renderizar nuestro cuerpo de agua con el componente LineRenderer
:
1 |
Body = gameObject.AddComponent<LineRenderer>(); |
2 |
Body.material = mat; |
3 |
Body.material.renderQueue = 1000; |
4 |
Body.SetVertexCount(nodecount); |
5 |
Body.SetWidth(0.1f, 0.1f); |
Lo que también hemos hecho aquí es seleccionar nuestro material y configurarlo para que se renderice por encima del agua eligiendo su posición en la cola de procesamiento. Hemos establecido el número correcto de nodos y el ancho de la línea a 0.1
.
Puedes variar esto dependiendo del grosor que desees para tu línea. Puede que hayas notado que SetWidth()
toma dos parámetros; estos son el ancho al principio y al final de la línea. Queremos que el ancho sea constante.
Ahora que hemos creado nuestros nodos, inicializaremos todas nuestras variables principales:
1 |
xpositions = new float[nodecount]; |
2 |
ypositions = new float[nodecount]; |
3 |
velocities = new float[nodecount]; |
4 |
accelerations = new float[nodecount]; |
5 |
|
6 |
meshobjects = new GameObject[edgecount]; |
7 |
meshes = new Mesh[edgecount]; |
8 |
colliders = new GameObject[edgecount]; |
9 |
|
10 |
baseheight = Top; |
11 |
bottom = Bottom; |
12 |
left = Left; |
Así que ahora tenemos todas nuestras matrices, y nos aferramos a nuestros datos.
Ahora a establecer realmente los valores de nuestras matrices. Comenzaremos con los nodos:
1 |
for (int i = 0; i < nodecount; i++) |
2 |
{
|
3 |
ypositions[i] = Top; |
4 |
xpositions[i] = Left + Width * i / edgecount; |
5 |
accelerations[i] = 0; |
6 |
velocities[i] = 0; |
7 |
Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z)); |
8 |
}
|
Aquí, establecemos que todas las posiciones estén en la parte superior del agua, y luego agregamos incrementalmente todos los nodos uno al lado del otro. Nuestras velocidades y aceleraciones son cero inicialmente, ya que el agua está en calma.
Terminamos el ciclo estableciendo cada nodo en nuestro LineRenderer
(Body
) en su posición correcta.
Creando las Mallas
Aquí es donde se pone difícil.
Tenemos nuestra línea, pero no tenemos el agua en sí. Y la forma en que podemos hacer esto es usando mallas. Comenzaremos creando estas:
1 |
for (int i = 0; i < edgecount; i++) |
2 |
{
|
3 |
meshes[i] = new Mesh(); |
Ahora, las mallas almacenan un montón de variables. La primera variable es bastante simple: contiene todos los vértices (o esquinas).



El diagrama muestra cómo queremos que se vean nuestros segmentos de malla. Para el primer segmento, se resaltan los vértices. Queremos cuatro en total.
1 |
Vector3[] Vertices = new Vector3[4]; |
2 |
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z); |
3 |
Vertices[1] = new Vector3(xpositions[i + 1], ypositions[i + 1], z); |
4 |
Vertices[2] = new Vector3(xpositions[i], bottom, z); |
5 |
Vertices[3] = new Vector3(xpositions[i+1], bottom, z); |
Ahora, como puedes ver aquí, el vértice 0
es la parte superior izquierda, 1
es la parte superior derecha, 2
es la parte inferior izquierda y 3
es la parte superior derecha. Tendremos que recordar eso para más tarde.
La segunda propiedad que las mallas necesitan es UVs. Las mallas tienen texturas, y los rayos UV eligen qué parte de las texturas queremos agarrar. En este caso, solo queremos las esquinas superior izquierda, superior derecha, inferior izquierda e inferior derecha de nuestra textura.
1 |
Vector2[] UVs = new Vector2[4]; |
2 |
UVs[0] = new Vector2(0, 1); |
3 |
UVs[1] = new Vector2(1, 1); |
4 |
UVs[2] = new Vector2(0, 0); |
5 |
UVs[3] = new Vector2(1, 0); |
Ahora necesitamos esos números de antes otra vez. Las mallas están formadas por triángulos, y sabemos que cualquier cuadrilátero puede estar formado por dos triángulos, por lo que ahora necesitamos decirle a la malla cómo debe dibujar esos triángulos.



Mira las esquinas con el orden del nodo etiquetado. El triángulo A conecta los nodos 0
, 1
y 3
; El triángulo B conecta los nodos 3
, 2
y 0
. Por lo tanto, queremos hacer una matriz que contenga seis enteros, reflejando exactamente eso:
1 |
int[] tris = new int[6] { 0, 1, 3, 3, 2, 0 }; |
Esto crea nuestro cuadrilátero. Ahora establecemos los valores de la malla.
1 |
meshes[i].vertices = Vertices; |
2 |
meshes[i].uv = UVs; |
3 |
meshes[i].triangles = tris; |
Ahora, tenemos nuestras mallas, pero no tenemos Objetos de Juego para renderizarlos en la escena. Así que vamos a crearlos a partir de nuestra watermesh
prefabricada que contiene un procesador de malla y un filtro de malla.
1 |
meshobjects[i] = Instantiate(watermesh,Vector3.zero,Quaternion.identity) as GameObject; |
2 |
meshobjects[i].GetComponent<MeshFilter>().mesh = meshes[i]; |
3 |
meshobjects[i].transform.parent = transform; |
Establecemos la malla, y la configuramos para que sea la hija del administrador de agua, para ordenar las cosas.
Creando Nuestras Colisiones
Ahora queremos nuestro colisionador también:
1 |
colliders[i] = new GameObject(); |
2 |
colliders[i].name = "Trigger"; |
3 |
colliders[i].AddComponent<BoxCollider2D>(); |
4 |
colliders[i].transform.parent = transform; |
5 |
colliders[i].transform.position = new Vector3(Left + Width * (i + 0.5f) / edgecount, Top - 0.5f, 0); |
6 |
colliders[i].transform.localScale = new Vector3(Width / edgecount, 1, 1); |
7 |
colliders[i].GetComponent<BoxCollider2D>().isTrigger = true; |
8 |
colliders[i].AddComponent<WaterDetector>(); |
Aquí, estamos haciendo colisionadores de caja, dándoles un nombre para que estén un poco más ordenadas en la escena y convirtiéndo cada una en las hijas del administrador de agua nuevamente. Establecemos su posición a medio camino entre los nodos, establecemos su tamaño y les agregamos la clase WaterDetector
.
Ahora que tenemos nuestra malla, necesitamos una función para actualizarla a medida que el agua se mueve:
1 |
void UpdateMeshes() |
2 |
{
|
3 |
for (int i = 0; i < meshes.Length; i++) |
4 |
{
|
5 |
|
6 |
Vector3[] Vertices = new Vector3[4]; |
7 |
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z); |
8 |
Vertices[1] = new Vector3(xpositions[i+1], ypositions[i+1], z); |
9 |
Vertices[2] = new Vector3(xpositions[i], bottom, z); |
10 |
Vertices[3] = new Vector3(xpositions[i+1], bottom, z); |
11 |
|
12 |
meshes[i].vertices = Vertices; |
13 |
}
|
14 |
}
|
Puedes notar que esta función solo usa el código que escribimos antes. La única diferencia es que esta vez no tenemos que configurar los tris y los UV, porque estos siguen siendo los mismos.
Nuestra siguiente tarea es hacer que el agua en sí funcione. Usaremos FixedUpdate()
para modificarlos todos de manera incremental.
1 |
void FixedUpdate() |
2 |
{
|
Implementando la Física
Primero, vamos a combinar la Ley de Hooke con el método de Euler para encontrar las nuevas posiciones, aceleraciones y velocidades.
Entonces, la Ley de Hooke es \ (F = kx \), donde \ (F \) es la fuerza producida por un spring (recuerda, estamos modelando la superficie del agua como una fila de springs), \ (k \) es la constante de resorte, y \ (x \) es el desplazamiento. Nuestro desplazamiento simplemente será la posición de cada nodo menos la altura base de los nodos.
A continuación, agregamos un factor de amortiguamiento proporcional a la velocidad de la fuerza para amortiguar la fuerza.
1 |
for (int i = 0; i < xpositions.Length ; i++) |
2 |
{
|
3 |
float force = springconstant * (ypositions[i] - baseheight) + velocities[i]*damping ; |
4 |
accelerations[i] = -force; |
5 |
ypositions[i] += velocities[i]; |
6 |
velocities[i] += accelerations[i]; |
7 |
Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z)); |
8 |
}
|
El método de Euler es simple; simplemente sumamos la aceleración a la velocidad y la velocidad a la posición, cada fotograma.
Nota: asumí que la masa de cada nodo era 1
aquí, pero querrás usar:
1 |
accelerations[i] = -force/mass; |
Si quieres una masa diferente para tus nodos.
Consejo: para una física precisa, usaríamos la integración de Verlet, pero como estamos agregando amortiguamiento, solo podemos usar el método de Euler, que es mucho más rápido de calcular. En general, sin embargo, el método de Euler introducirá exponencialmente energía cinética de ninguna parte en tu sistema de física, así que no la uses para nada preciso.
Ahora vamos a crear la propagación de la onda. El siguiente código está adaptado del tutorial de Michael Hoffman.
1 |
float[] leftDeltas = new float[xpositions.Length]; |
2 |
float[] rightDeltas = new float[xpositions.Length]; |
Aquí, creamos dos matrices. Para cada nodo, vamos a comparar la altura del nodo anterior con la altura del nodo actual y poner la diferencia en leftDeltas
.
Luego, verificaremos la altura del nodo posterior con la altura del nodo que estamos verificando, y pondremos esa diferencia en rightDeltas
. (También multiplicaremos todos los valores por una constante de propagación).
1 |
for (int j = 0; j < 8; j++) |
2 |
{
|
3 |
for (int i = 0; i < xpositions.Length; i++) |
4 |
{
|
5 |
if (i > 0) |
6 |
{
|
7 |
leftDeltas[i] = spread * (ypositions[i] - ypositions[i-1]); |
8 |
velocities[i - 1] += leftDeltas[i]; |
9 |
}
|
10 |
if (i < xpositions.Length - 1) |
11 |
{
|
12 |
rightDeltas[i] = spread * (ypositions[i] - ypositions[i + 1]); |
13 |
velocities[i + 1] += rightDeltas[i]; |
14 |
}
|
15 |
}
|
16 |
}
|
Podemos cambiar las velocidades de acuerdo con la diferencia de altura de inmediato, pero solo debemos almacenar las diferencias de posición en este punto. Si cambiamos la posición del primer nodo directamente, para cuando veamos el segundo nodo, el primer nodo ya se habrá movido, lo que arruinará todos nuestros cálculos.
1 |
for (int i = 0; i < xpositions.Length; i++) |
2 |
{
|
3 |
if (i > 0) |
4 |
{
|
5 |
ypositions[i-1] += leftDeltas[i]; |
6 |
}
|
7 |
if (i < xpositions.Length - 1) |
8 |
{
|
9 |
ypositions[i + 1] += rightDeltas[i]; |
10 |
}
|
11 |
}
|
Entonces, una vez que hayamos recopilado todos nuestros datos de altura, podemos aplicarlos al final. No podemos mirar a la derecha del nodo en el extremo derecho, o a la izquierda del nodo en el extremo izquierdo, por lo tanto, las condiciones i > 0
y i < xpositions.Length - 1.
Además, ten en cuenta que conteníamos todo este código en un bucle y lo ejecutamos ocho veces. Esto se debe a que queremos ejecutar este proceso en pequeñas dosis varias veces, en lugar de un cálculo grande, que sería mucho menos fluido.
Añadiendo salpicaduras
Ahora tenemos agua que fluye, y se nota. A continuación, ¡tenemos que ser capaces de batir el agua!
Para esto, agreguemos una función llamada Splash()
, que verificará la posición x de la salpicadura y la velocidad de lo que sea que golpee. Debe ser público para que podamos llamarlo desde nuestros colisionadores más tarde.
1 |
public void Splash(float xpos, float velocity) |
2 |
{
|
Primero, debemos asegurarnos de que la posición especificada se encuentre realmente dentro de los límites de nuestra agua:
1 |
if (xpos >= xpositions[0] && xpos <= xpositions[xpositions.Length-1]) |
2 |
{
|
Y luego cambiaremos xpos
para que nos dé la posición relativa al inicio del cuerpo de agua:
1 |
xpos -= xpositions[0]; |
A continuación, vamos a averiguar qué nodo está tocando. Podemos calcular eso así:
1 |
int index = Mathf.RoundToInt((xpositions.Length-1)*(xpos / (xpositions[xpositions.Length-1] - xpositions[0]))); |
Entonces, esto es lo que está pasando aquí:
- Tomamos la posición de la salpicadura en relación con la posición del borde izquierdo del agua (
xpos
). - Lo dividimos por la posición del borde derecho en relación con la posición del borde izquierdo del agua.
- Esto nos da una fracción que nos dice dónde está la salpicadura. Por ejemplo, una salpicadura de tres cuartos del camino a lo largo del cuerpo de agua daría un valor de
0.75
. - Multiplicamos esto por el número de aristas y redondeamos este número, lo que nos da el nodo al que más se ha acercado nuestra salpicadura.
1 |
velocities[index] = velocity; |
Ahora establecemos la velocidad del objeto que golpea nuestra agua a la velocidad de ese nodo, para que el objeto la arrastre hacia abajo.
Nota: Puedes cambiar esta línea según lo que te convenga. Por ejemplo, podrías sumar la velocidad a su velocidad actual, o podrías usar el impulso en lugar de la velocidad y dividir entre la masa del nodo.



Ahora queremos hacer un sistema de partículas que produzca la salpicadura. Lo definimos antes; Se llama "splash" (lo suficientemente creativo). Asegúrate de no confundirlo con Splash()
. El que usaré está incluido en los archivos de origen.
Primero, queremos configurar los parámetros de la salpicadura para que cambien con la velocidad del objeto.
1 |
float lifetime = 0.93f + Mathf.Abs(velocity)*0.07f; |
2 |
splash.GetComponent<ParticleSystem>().startSpeed = 8+2*Mathf.Pow(Mathf.Abs(velocity),0.5f); |
3 |
splash.GetComponent<ParticleSystem>().startSpeed = 9 + 2 * Mathf.Pow(Mathf.Abs(velocity), 0.5f); |
4 |
splash.GetComponent<ParticleSystem>().startLifetime = lifetime; |
Aquí, tomamos nuestras partículas, fijamos su vida útil para que no mueran poco después de que toquen la superficie del agua, y establezcan su velocidad en función del cuadrado de su velocidad (más una constante, para pequeñas salpicaduras) .
Es posible que estés mirando ese código y pienses: "¿Por qué ha establecido el startSpeed
dos veces?", Y tendrías razón al preguntártelo. El problema es que estamos usando un sistema de partículas (Shuriken, provisto con el proyecto) que tiene su velocidad de inicio establecida en "aleatorio entre dos constantes". Lamentablemente, no tenemos mucho acceso a Shuriken mediante scripts, por lo que para que funcione ese comportamiento debemos establecer el valor dos veces.
Ahora voy a agregar una línea que puedes o no desear omitir de tu script:
1 |
Vector3 position = new Vector3(xpositions[index],ypositions[index]-0.35f,5); |
2 |
Quaternion rotation = Quaternion.LookRotation(new Vector3(xpositions[Mathf.FloorToInt(xpositions.Length / 2)], baseheight + 8, 5) - position); |
Las partículas de Shuriken no se destruirán cuando golpeen tus objetos, por lo que si quieres asegurarte de que no vayan a aterrizar frente a ellos, puedes tomar dos medidas:
- Pégalos en el fondo. (Puedes decir esto por la posición de z siendo
5
). - Inclina el sistema de partículas para que siempre apunten hacia el centro del cuerpo de agua; de esta manera, las partículas no salpican hacia el suelo.
La segunda línea de código toma el punto medio de las posiciones, se mueve un poco hacia arriba y apunta el emisor de partículas hacia él. He incluido este comportamiento en el demo. Si estás usando un cuerpo de agua muy ancho, probablemente no quieras este comportamiento. Si tu agua está en una piscina pequeña dentro de una habitación, es posible que desees utilizarlo. Entonces, siéntete libre de desechar esa línea acerca de la rotación.
1 |
GameObject splish = Instantiate(splash,position,rotation) as GameObject; |
2 |
Destroy(splish, lifetime+0.3f); |
3 |
}
|
4 |
}
|
Ahora, hacemos nuestra salpicadura, y le decimos que muera un poco después de que las partículas mueran. ¿Por qué un poco después? Debido a que nuestro sistema de partículas envía unas pocas ráfagas secuenciales de partículas, por lo que aunque el primer lote solo dure hasta Time.time + lifetime
, nuestras ráfagas finales seguirán existiendo un poco después de eso.
¡Sí! Finalmente hemos terminado, ¿verdad?
Detección de colisiones
¡Incorrecto! Necesitamos detectar nuestros objetos, ¡o esto fue todo por nada!
¿Recuerdas que agregamos ese script a todos nuestros colisionadores antes? El que se llama WaterDetector
?
Bueno, vamos a hacerlo ahora! Solo queremos una función en el:
1 |
void OnTriggerEnter2D(Collider2D Hit) |
2 |
{
|
Usando OnTriggerEnter2D ()
, podemos especificar qué sucede cada vez que un Cuerpo Rígido 2D ingresa a nuestro cuerpo de agua. Si pasamos un parámetro de Collider2D
podemos encontrar más información sobre ese objeto.
1 |
if (Hit.rigidbody2D != null) |
2 |
{
|
Solo queremos objetos que contengan un rigidbody2D
.
1 |
transform.parent.GetComponent<Water>().Splash(transform.position.x, Hit.rigidbody2D.velocity.y*Hit.rigidbody2D.mass / 40f); |
2 |
}
|
3 |
}
|
Ahora, todos nuestros colisionadores son "hijos" del administrador del agua. Así que solo tomamos el componente Water
de su padre y llamamos a Splash ()
, desde la posición del colisionador.
Recuerda de nuevo, dije que podrías pasar la velocidad o el impulso, si quisieras que fuera físicamente más preciso. Bueno aquí es donde tienes que pasar lo correcto. Si multiplicas la velocidad del objeto por su masa, tendrás su impulso. Si solo quieres usar su velocidad, deshazte de la masa de esa línea.
Finalmente, querrás llamar a SpawnWater ()
desde algún lugar. Hagámoslo en el lanzamiento:
1 |
void Start() |
2 |
{
|
3 |
SpawnWater(-10,20,0,-10); |
4 |
}
|
¡Y ya hemos terminado! Ahora cualquier rigidbody2D
con un colisionador que golpee el agua creará una salpicadura y las olas se moverán correctamente.



Ejercicio de bonificación
Como bono adicional, he agregado algunas líneas de código a la parte superior de SpawnWater ()
.
1 |
gameObject.AddComponent<BoxCollider2D>(); |
2 |
gameObject.GetComponent<BoxCollider2D>().center = new Vector2(Left + Width / 2, (Top + Bottom) / 2); |
3 |
gameObject.GetComponent<BoxCollider2D>().size = new Vector2(Width, Top - Bottom); |
4 |
gameObject.GetComponent<BoxCollider2D>().isTrigger = true; |
Estas líneas de código agregarán un colisionador de caja al agua en sí. Puedes usar esto para hacer que las cosas floten en tu agua, usando lo que has aprendido.
Querrás hacer una función llamadaOnTriggerStay2D()
que toma un parámetro de Collider2D Hit
. Luego, puedes usar una versión modificada de la fórmula de resorte que usamos antes para verificar la masa del objeto y agregar una fuerza o velocidad a tu rigidbody2D
para hacer que flote en el agua.
Haz una salpicadura
En este tutorial, implementamos una simulación de agua simple para usar en juegos 2D con código de física simple y un renderizador de líneas, renderizadores de malla, disparadores y partículas. Tal vez agregues cuerpos ondulados de agua fluida como un obstáculo para tu próximo juego de plataformas, listo para que tus personajes se zambullan o se crucen cuidadosamente con escalones flotantes, o tal vez podrías usar esto en un juego de navegación o windsurf, o incluso en un juego donde tu simplemente saltas rocas a través del agua desde una playa soleada. ¡Buena suerte!