【转】如何使用Unity创造动态的2D水体效果
作者:Alex Rose
在本篇教程中,我们将使用简单的物理机制模拟一个动态的2D水体。我们将使用一个线性渲染器、网格渲染器,触发器以及粒子的混合体来创造这一水体效 果,最终得到可运用于你下款游戏的水纹和水花。这里包含了Unity样本源,但你应该能够使用任何游戏引擎以相同的原理执行类似的操作。
设置水体管理器
我们将使用Unity的一个线性渲染器来渲染我们的水体表面,并使用这些节点来展现持续的波纹。
我们将追踪每个节点的位置、速度和加速情况。为此,我们将会使用到阵列。所以在我们的类顶端将添加如下变量:
float[] xpositions;
float[] ypositions;
float[] velocities;
float[] accelerations;
LineRenderer Body;
LineRenderer将存储我们所有的节点,并概述我们的水体。我们仍需要水体本身,将使用Meshes来创造。我们将需要对象来托管这些网格。
GameObject[] meshobjects;
Mesh[] meshes;
我们还需要碰撞器以便事物可同水体互动:
GameObject[] colliders;
我们也存储了所有的常量:
const float springconstant = 0.02f;
const float damping = 0.04f;
const float spread = 0.05f;
const float z = -1f;
这些常量中的z是我们为水体设置的Z位移。我们将使用-1标注它,这样它就会呈现于我们的对象之前(游戏邦注:你可能想根据自己的需求将其调整为在对象之前或之后,那你就必须使用Z坐标来确定与之相关的精灵所在的位置)。
下一步,我们将保持一些值:
float baseheight;
float left;
float bottom;
这些就是水的维度。
我们将需要一些可以在编辑器中设置的公开变量。首先,我们将为水花使用粒子系统:
public GameObject splash:
接下来就是我们将用于线性渲染器的材料:
public Material mat:
此外,我们将为主要水体使用的网格类型如下:
public GameObject watermesh:
我们想要能够托管所有这些数据的游戏对象,令其作为管理器,产出我们游戏中的水体。为此,我们将编写SpawnWater()函数。
这个函数将采用水体左边、跑马度、顶点以及底部的输入:
public void SpawnWater(float Left, float Width, float Top, float Bottom)
{
(虽然这看似有所矛盾,但却有利于从左往右快速进行关卡设计)
创造节点
现在我们将找出自己需要多少节点:
int edgecount = Mathf.RoundToInt(Width) * 5;
int nodecount = edgecount + 1;
我们将针对每个单位宽度使用5个节点,以便呈现流畅的移动(你可以改变这一点以便平衡效率与流畅性)。我们由此可得到所有线段,然后需要在末端的节点 + 1。
我们要做的首件事就是以LineRenderer组件渲染水体:
Body = gameObject.AddComponent<LineRenderer>();
Body.material = mat;
Body.material.renderQueue = 1000;
Body.SetVertexCount(nodecount);
Body.SetWidth(0.1f, 0.1f);
我们在此还要做的是选择材料,并通过选择渲染队列中的位置而令其在水面之上渲染。我们设置正确的节点数据,将线段宽度设为0.1。
你可以根据自己所需的线段粗细来改变这一宽度。你可能注意到了SetWidth()需要两个参数,这是线段开始及末尾的宽度。我们希望该宽度恒定不变。
现在我们制作了节点,将初始化我们所有的顶级变量:
xpositions = new float[nodecount];
ypositions = new float[nodecount];
velocities = new float[nodecount];
accelerations = new float[nodecount];meshobjects = new GameObject[edgecount];
meshes = new Mesh[edgecount];
colliders = new GameObject[edgecount];baseheight = Top;
bottom = Bottom;
left = Left;
我们已经有了所有阵列,将控制我们的数据。
现在要设置我们阵列的值。我们将从节点开始:
for (int i = 0; i < nodecount; i++)
{
ypositions[i] = Top;
xpositions[i] = Left + Width * i / edgecount;
accelerations[i] = 0;
velocities[i] = 0;
Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z));
}
在此,我们将所有Y位置设于水体之上,之后一起渐进增加所有节点。因为水面平静,我们的速度和加速值最初为0。
我们将把LineRenderer (Body)中的每个节点设为其正确的位置,以此完成这个循环。
创造网格
这正是它棘手的地方。
我们有自己的线段,但我们并没有水体本身。我们要使用网格来制作,如下所示:
for (int i = 0; i < edgecount; i++)
{
meshes[i] = new Mesh();
现在,网格存储了一系列变量。首个变量相当简单:它包含了所有顶点(或转角)。
该图表显示了我们所需的网格片段的样子。第一个片段中的顶点被标注出来了。我们总共需要4个顶点。
Vector3[] Vertices = new Vector3[4];
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z);
Vertices[1] = new Vector3(xpositions[i + 1], ypositions[i + 1], z);
Vertices[2] = new Vector3(xpositions[i], bottom, z);
Vertices[3] = new Vector3(xpositions[i+1], bottom, z);
现在如你所见,顶点0处于左上角,1处于右上角,2是左下角,3是右下角。我们之后要记住。
网格所需的第二个性能就是UV。网格拥有纹理,UV会选择我们想撷取的那部分纹理。在这种情况下,我们只想要左上角,右上角,右下角和右下角的纹理。
Vector2[] UVs = new Vector2[4];
UVs[0] = new Vector2(0, 1);
UVs[1] = new Vector2(1, 1);
UVs[2] = new Vector2(0, 0);
UVs[3] = new Vector2(1, 0);
现在我们又需要这些数据了。网格是由三角形组成的,我们知道任何四边形都是由两个三角形组成的,所以现在我们需要告诉网格它如何绘制这些三角形。
看看含有节点顺序标注的转角。三角形A连接节点0,1,以及3,三角形B连接节点3,2,1。因此我们想制作一个包含6个整数的阵列:
int[] tris = new int[6] { 0, 1, 3, 3, 2, 0 };
这就创造了我们的四边形。现在我们要设置网格的值。
meshes[i].vertices = Vertices;
meshes[i].uv = UVs;
meshes[i].triangles = tris;
现在我们已经有了自己的网格,但我们没有在场景是渲染它们的游戏对象。所以我们将从包括一个网格渲染器和筛网过滤器的watermesh预制件来创造它们。
meshobjects[i] = Instantiate(watermesh,Vector3.zero,Quaternion.identity) as GameObject;
meshobjects[i].GetComponent<MeshFilter>().mesh = meshes[i];
meshobjects[i].transform.parent = transform;
我们设置了网格,令其成为水体管理器的子项。
创造碰撞效果
现在我们还需要自己的碰撞器:
colliders[i] = new GameObject();
colliders[i].name = “Trigger”;
colliders[i].AddComponent<BoxCollider2D>();
colliders[i].transform.parent = transform;
colliders[i].transform.position = new Vector3(Left + Width * (i + 0.5f) / edgecount, Top – 0.5f, 0);
colliders[i].transform.localScale = new Vector3(Width / edgecount, 1, 1);
colliders[i].GetComponent<BoxCollider2D>().isTrigger = true;
colliders[i].AddComponent<WaterDetector>();
至此,我们制作了方形碰撞器,给它们一个名称,以便它们会在场景中显得更整洁一点,并且再次制作水体管理器的每个子项。我们将它们的位置设置于两个节点之点,设置好大小,并为其添加了WaterDetector类。
现在我们拥有自己的网格,我们需要一个函数随着水体移动进行更新:
void UpdateMeshes()
{
for (int i = 0; i < meshes.Length; i++)
{Vector3[] Vertices = new Vector3[4];
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z);
Vertices[1] = new Vector3(xpositions[i+1], ypositions[i+1], z);
Vertices[2] = new Vector3(xpositions[i], bottom, z);
Vertices[3] = new Vector3(xpositions[i+1], bottom, z);meshes[i].vertices = Vertices;
}
}
你可能注意到了这个函数只使用了我们之前编写的代码。唯一的区别在于这次我们并不需要设置三角形的UV,因为这些仍然保持不变。
我们的下一步任务是让水体本身运行。我们将使用FixedUpdate()递增地来调整它们。
void FixedUpdate()
{
执行物理机制
首先,我们将把Hooke定律写Euler方法结合在一起找到新坐标、加速和速度。
Hooke定律是F=kx,这里的F是指由水流产生的力(记住,我们将把水体表面模拟为水流),k是指水流的常量,x则是位移。我们的位移将成为每个节点的y坐标减去节点的基本高度。
下一步,我们将添加一个与力的速度成比例的阻尼因素来削弱力。
for (int i = 0; i < xpositions.Length ; i++)
{
float force = springconstant * (ypositions[i] – baseheight) + velocities[i]*damping ;
accelerations[i] = -force;
ypositions[i] += velocities[i];
velocities[i] += accelerations[i];
Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z));
}
Euler方法很简单,我们只要向速度添加加速,向每帧坐标增加速度。
注:我只是假设每个节点的质量为1,但你可能会想用:
accelerations[i] = -force/mass;
现在我们将创造波传播。以下节点是根据Michael Hoffman的教程调整而来的:
float[] leftDeltas = new float[xpositions.Length];
float[] rightDeltas = new float[xpositions.Length];
在此,我们要创造两个阵列。针对每个节点,我们将检查之前节点的高度,以及当前节点的高度,并将二者差别放入leftDeltas。
之后,我们将检查后续节点的高度与当前检查节点的高度,并将二者的差别放入rightDeltas(我们将乘以一个传播常量来增加所有值)。
for (int j = 0; j < 8; j++)
{
for (int i = 0; i < xpositions.Length; i++)
{
if (i > 0)
{
leftDeltas[i] = spread * (ypositions[i] – ypositions[i-1]);
velocities[i - 1] += leftDeltas[i];
}
if (i < xpositions.Length – 1)
{
rightDeltas[i] = spread * (ypositions[i] – ypositions[i + 1]);
velocities[i + 1] += rightDeltas[i];
}
}
}
当我们集齐所有的高度数据时,我们最后就可以派上用场了。我们无法查看到最右端的节点右侧,或者最大左端的节点左侧,因此基条件就是i > 0以及i < xpositions.Length – 1。
因此,要注意我们在一个循环中包含整片代码,并运行它8次。这是因为我们想以少量而多次的时间运行这一过程,而不是进行一次大型运算,因为这会削弱流动性。
添加水花
现在我们已经有了流动的水体,下一步就需要让它溅起水花!
为此,我们要增加一个称为Splash()的函数,它会检查水花的X坐标,以及它所击中的任何物体的速度。将其设置为公开状态,这样我们可以在之后的碰撞器中调用它。
public void Splash(float xpos, float velocity)
{
首先,我们应该确保特定的坐标位于我们水体的范围之内:
if (xpos >= xpositions[0] && xpos <= xpositions[xpositions.Length-1])
{
然后我们将调整xpos,让它出现在相对于水体起点的位置上:
xpos -= xpositions[0];
下一步,我们将找到它所接触的节点。我们可以这样计算:
int index = Mathf.RoundToInt((xpositions.Length-1)*(xpos / (xpositions[xpositions.Length-1] – xpositions[0])));
这就是它的运行方式:
1.我们选取相对于水体左侧边缘位置的水花位置(xpos)。
2.我们将相对于水体左侧边缘的的右侧位置进行划分。
3.这让我们知道了水花所在的位置。例如,位于水体四分之三处的水花的值就是0.75。
4.我们将把这一数字乘以边缘的数量,这就可以得到我们水花最接近的节点。
velocities[index] = velocity;
现在我们要设置击中水面的物体的速度,令其与节点速度一致,以样节点就会被该物体拖入深处。
注:你可以根据自己的需求改变这条线段。例如,你可以将其速度添加到当前速度,或者使用动量而非速度,并除以你节点的质量。
现在,我们想制作一个将产生水花的粒子系统。我们早点定义,将其称为“splash”。要确保不要让它与Splash()相混淆。
首先,我们要设置水花的参,以便调整物体的速度:
float lifetime = 0.93f + Mathf.Abs(velocity)*0.07f;
splash.GetComponent<ParticleSystem>().startSpeed = 8+2*Mathf.Pow(Mathf.Abs(velocity),0.5f);
splash.GetComponent<ParticleSystem>().startSpeed = 9 + 2 * Mathf.Pow(Mathf.Abs(velocity), 0.5f);
splash.GetComponent<ParticleSystem>().startLifetime = lifetime;
在此,我们要选取粒子,设置它们的生命周期,以免他们击中水面就快速消失,并且根据它们速度的直角设置速度(为小小的水花增加一个常量)。
你可能会看着代码心想,“为什么要两次设置startSpeed?”你这样想没有错,问题在于,我们使用一个起始速度设置为“两个常量间的随机数” 这种粒子系统(Shuriken)。不幸的是,我们并没有太多以脚本访问Shuriken的途径 ,所以为了获得这一行为,我们必须两次设置这个值。
现在,我将添加一个你可能想或者不想从脚本中忽略的线段:
Vector3 position = new Vector3(xpositions[index],ypositions[index]-0.35f,5);
Quaternion rotation = Quaternion.LookRotation(new Vector3(xpositions[Mathf.FloorToInt(xpositions.Length / 2)], baseheight + 8, 5) – position);
Shuriken粒子击中你的物体时不会被破坏,所以如果你想确保它们不会在你的物体面前着陆,你可以采用两种对策:
1.令其固定在背景(你可以通过将Z坐标设为5来实现)
2.令粒子系统倾斜,令其总是指向你水体的中心——这样,粒子就不会飞溅到水面。
第二行代码位居坐标的中间点,向上移一点点,并指向粒子发射器。如果你要使用真正宽阔的水体,你可能就不需要这种行为。如果你的水体只是房间中的一个小水池,你可能就会想使用它。所以,你可以根据自己的需要抛弃关于旋转的代码。
GameObject splish = Instantiate(splash,position,rotation) as GameObject;
Destroy(splish, lifetime+0.3f);
}
}
现在,我们得制作水花,并让它在粒子应该消失之后的片刻再消失。为什么要在之后片刻呢?因为我们的粒子系统会发送出一些连续的粒子阵,所以即使首批粒子只会持续到Time.time + lifetime,我们最终的粒子阵也仍然会存留一小会儿。
没错,我们终于完工了,不是吗?
碰撞检测
错了!我们必须检测我们的对象,否则一切都是徒劳的!
记得我们之前向所有碰撞器添加脚本的情况吗?还记得WaterDetector吗?
我们现在就要把它制作出来!我们在其中只需要一个函数:
void OnTriggerEnter2D(Collider2D Hit)
{
使用OnTriggerEnter2D()我们可以规定2D刚体进入水体时所发生的情况。如果我们通过了Collider2D的一个参数,就可以找到更多关于该物体的信息:
if (Hit.rigidbody2D != null)
{
我们只需要包含rigidbody2D的物体:
transform.parent.GetComponent<Water>().Splash(transform.position.x, Hit.rigidbody2D.velocity.y*Hit.rigidbody2D.mass / 40f);
}
}
现在,我们所有的碰撞器都是水体管理器的子项。所以我们只需要从它们的母体撷取Water组件并从碰撞器的位置调用Splash()。
记住,我说过如果你想让它更具物理准确性,就可以传递速度或动量。这里就需要你传递一者。如果你将对象的Y速度与其质量相乘,就可以得到它的动量。如果你只想使用它的速度,就要从该行代码中去除质量。
最后,你将从某处调用SpawnWater(),如下所示:
void Start()
{
SpawnWater(-10,20,0,-10);
}
现在我们完成了!现在任何含有一个碰撞器并击中水面的rigidbody2D都会创造一个水花,并且波纹还能正确移动。
额外操作
作为一个额外操作,我还在SpawnWater()之上添加了几行代码。
gameObject.AddComponent<BoxCollider2D>();
gameObject.GetComponent<BoxCollider2D>().center = new Vector2(Left + Width / 2, (Top + Bottom) / 2);
gameObject.GetComponent<BoxCollider2D>().size = new Vector2(Width, Top – Bottom);
gameObject.GetComponent<BoxCollider2D>().isTrigger = true;
这几行代码会向水面本身添加一个方体碰撞器。你可以运用自己的知识,以此让物体漂浮在水面。
你将会制作一个称为OnTriggerStay2D()的函数,它有一个Collider2D Hit参数。之后,你可以使用我们之前使用的一个检查物体质量的弹性法则的调整版本,并为你的rigidbody2D添加一个力或速度以便令其漂浮在水面。
总结
在本篇教程中,我们以一些简单的物理代码和一个线性渲染器、网格渲染器、触发器和粒子执行了用于2D游戏的简单模拟水体。也许你会添加波浪起伏的水 体来作为自己下款平台游戏的障碍,准确让你的角色跳入水中或小心地穿过漂浮着的跳板,或者你可能想将它用于航海或冲浪游戏,甚至是一款只是需要玩家跳过水 面的岩石的游戏。总之,祝你好运!(本文为游戏邦/gamerboom.com编译,拒绝任何不保留版权的转载,如需转载请联系:游戏邦)
Creating Dynamic 2D Water Effects in Unity
by Alex Rose
In this tutorial, we’re going to simulate a dynamic 2D body of water using simple physics. We will use a mixture of a line renderer, mesh renderers, triggers and particles to create our effect. The final result comes complete with waves and splashes, ready to add to your next game. A Unity (Unity3D) demo source is included, but you should be able to implement something similar using the same principles in any game engine.
Related Posts
Make a Splash With Dynamic 2D Water Effects
How to Create a Custom 2D Physics Engine: The Basics and Impulse Resolution
Adding Turbulence to a Particle System
End Result
Here’s what we’re going to end up with. You’ll need the Unity browser plugin to try it out.
Click to create a new object to drop into the water.
Setting Up Our Water Manager
In his tutorial, Michael Hoffman demonstrated how we can model the surface of water with a row of springs.
We’re going to render the top of our water using one of Unity’s line
renderers, and use so many nodes that it appears as a continuous wave.
Create 2D Dynamic Water Effects in Unity (Unity3D)
We’ll have to keep track of the positions, velocities and accelerations of every node, though. To do that, we’re going to use arrays. So at the top of our class we’ll add these variables:
float[] xpositions;
float[] ypositions;
float[] velocities;
float[] accelerations;
LineRenderer Body;
The LineRenderer will store all our nodes and outline our body of water. We still need the water itself, though; we’ll create this with Meshes. We’re going to need objects to hold these meshes too.
GameObject[] meshobjects;
Mesh[] meshes;
We’re also going to need colliders so that things can interact with our water:
GameObject[] colliders;
And we’ll store all our constants as well:
const float springconstant = 0.02f;
const float damping = 0.04f;
const float spread = 0.05f;
const float z = -1f;
These constants are the same kind as Michael discussed, with the exception of z—this is our z-offset for our water. We’re going to use -1 for this so that it gets displayed in front of our objects. (You might want to change this depending on what you want to appear in front and behind of it; you’re going to have to use the z-coordinate to determine where sprites sit relative to it.)
Next, we’re going to hold onto some values:
float baseheight;
float left;
float bottom;
These are just the dimensions of the water.
We’re going to need some public variables we can set in the editor, too. First, the particle system we’re going to use for our splashes:
public GameObject splash:
Next, the material we’ll use for our line renderer (in case you want to reuse the script for acid, lava, chemicals, or anything else):
public Material mat:
Plus, the kind of mesh we’re going to use for the main body of water:
public GameObject watermesh:
These are all going to be based on prefabs, which are all included in the source files.
We want a game object that can hold all of this data, act as a manager, and spawn our body of water ingame to specification. To do that, we’ll write a function called SpawnWater().
This function will take inputs of the left side, the width, the top, and the bottom of the body of water.
public void SpawnWater(float Left, float Width, float Top, float Bottom)
{
(Though this seems inconsistent, it acts in the interest of quick level design when building from left to right).
Creating the Nodes
Now we’re going to find out how many nodes we need:
int edgecount = Mathf.RoundToInt(Width) * 5;
int nodecount = edgecount + 1;
We’re going to use five per unit width, to give us smooth motion that isn’t too demanding. (You can vary this to balance efficiency against smoothness.) This gives us all our lines, then we need the + 1 for the extra node on the end.
The first thing we’re going to do is render our body of water with the LineRenderer component:
Body = gameObject.AddComponent<LineRenderer>();
Body.material = mat;
Body.material.renderQueue = 1000;
Body.SetVertexCount(nodecount);
Body.SetWidth(0.1f, 0.1f);
What we’ve also done here is select our material, and set it to render above the water by choosing its position in the render queue. We’ve set the correct number of nodes, and set the width of the line to 0.1.
You can vary this depending on how thick you want your line. You may have noticed that SetWidth() takes two parameters; these are the width at the start and the end of the line. We want that width to be constant.
Now that we’ve made our nodes, we’ll initialise all our top variables:
xpositions = new float[nodecount];
ypositions = new float[nodecount];
velocities = new float[nodecount];
accelerations = new float[nodecount];
meshobjects = new GameObject[edgecount];
meshes = new Mesh[edgecount];
colliders = new GameObject[edgecount];
baseheight = Top;
bottom = Bottom;
left = Left;
So now we have all our arrays, and we’re holding on to our data.
Now to actually set the values of our arrays. We’ll start with the nodes:
for (int i = 0; i < nodecount; i++)
{
ypositions[i] = Top;
xpositions[i] = Left + Width * i / edgecount;
accelerations[i] = 0;
velocities[i] = 0;
Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z));
}
Here, we set all the y-positions to be at the top of the water, and then incrementally add all the nodes side by side. Our velocities and accelerations are zero initially, as the water is still.
We finish the loop by setting each node in our LineRenderer (Body) to their correct position.
Creating the Meshes
Here’s where it gets tricky.
We have our line, but we don’t have the water itself. And the way we can make this is using Meshes. We’ll start off by creating these:
for (int i = 0; i < edgecount; i++)
{
meshes[i] = new Mesh();
Now, Meshes store a bunch of variables. The first variable is pretty simple: it contains all the vertices (or corners).
Create 2D Dynamic Water Effects in Unity (Unity3D)
The diagram shows what we want our mesh segments to look like. For the first segment, the vertices are highlighted. We want four in total.
Vector3[] Vertices = new Vector3[4];
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z);
Vertices[1] = new Vector3(xpositions[i + 1], ypositions[i + 1], z);
Vertices[2] = new Vector3(xpositions[i], bottom, z);
Vertices[3] = new Vector3(xpositions[i+1], bottom, z);
Now, as you can see here, vertex 0 is the top-left, 1 is the top-right, 2 is the bottom-left, and 3 is the top-right. We’ll need to remember that for later.
The second property that meshes need is UVs. Meshes have textures, and the UVs choose which part of the textures we want to grab. In this case, we just want the top-left, top-right, bottom-left, and bottom-right corners of our texture.
Vector2[] UVs = new Vector2[4];
UVs[0] = new Vector2(0, 1);
UVs[1] = new Vector2(1, 1);
UVs[2] = new Vector2(0, 0);
UVs[3] = new Vector2(1, 0);
Now we need those numbers from before again. Meshes are made up of triangles, and we know that any quadrilateral can be made of two triangles, so now we need to tell the mesh how it should draw those triangles.
Create 2D Dynamic Water Effects in Unity (Unity3D)
Look at the corners with the node order labelled. Triangle A connects nodes 0, 1 and 3; Triangle B connects nodes 3, 2 and 0. Therefore, we want to make an array that contains six integers, reflecting exactly that:
int[] tris = new int[6] { 0, 1, 3, 3, 2, 0 };
This creates our quadrilateral. Now we set the mesh values.
meshes[i].vertices = Vertices;
meshes[i].uv = UVs;
meshes[i].triangles = tris;
Now, we have our meshes, but we don’t have Game Objects to render them in the scene. So we’re going to create them from our watermesh prefab which contains a Mesh Renderer and Mesh Filter.
meshobjects[i] = Instantiate(watermesh,Vector3.zero,Quaternion.identity) as GameObject;
meshobjects[i].GetComponent<MeshFilter>().mesh = meshes[i];
meshobjects[i].transform.parent = transform;
We set the mesh, and we set it to be the child of the water manager, to tidy things up.
Creating Our Collisions
Now we want our collider too:
colliders[i] = new GameObject();
colliders[i].name = “Trigger”;
colliders[i].AddComponent<BoxCollider2D>();
colliders[i].transform.parent = transform;
colliders[i].transform.position = new Vector3(Left + Width * (i + 0.5f) / edgecount, Top – 0.5f, 0);
colliders[i].transform.localScale = new Vector3(Width / edgecount, 1, 1);
colliders[i].GetComponent<BoxCollider2D>().isTrigger = true;
colliders[i].AddComponent<WaterDetector>();
Here, we’re making box colliders, giving them a name so they’re a bit tidier in the scene, and making them each children of the water manager again. We set their position to be halfway between the nodes, set their size, and add a WaterDetector class to them.
Now that we have our mesh, we need a function to update it as the water moves:
void UpdateMeshes()
{
for (int i = 0; i < meshes.Length; i++)
{
Vector3[] Vertices = new Vector3[4];
Vertices[0] = new Vector3(xpositions[i], ypositions[i], z);
Vertices[1] = new Vector3(xpositions[i+1], ypositions[i+1], z);
Vertices[2] = new Vector3(xpositions[i], bottom, z);
Vertices[3] = new Vector3(xpositions[i+1], bottom, z);
meshes[i].vertices = Vertices;
}
}
You might notice that this function just uses the code we wrote before. The only difference is that this time we don’t have to set the tris and UVs, because these stay the same.
Our next task is to make the water itself work. We’ll use FixedUpdate() to modify them all incrementally.
void FixedUpdate()
{
Implementing the Physics
First, we’re going to combine Hooke’s Law with the Euler method to find the new positions, accelerations and velocities.
So, Hooke’s Law is F=kx, where F is the force produced by a spring (remember, we’re modelling the surface of the water as a row of springs), k is the spring constant, and x is the displacement. Our displacement is simply going to be the y-position of each node minus the base height of the nodes.
Next, we add a damping factor proportional to the velocity of the force to dampen the force.
for (int i = 0; i < xpositions.Length ; i++)
{
float force = springconstant * (ypositions[i] – baseheight) + velocities[i]*damping ;
accelerations[i] = -force;
ypositions[i] += velocities[i];
velocities[i] += accelerations[i];
Body.SetPosition(i, new Vector3(xpositions[i], ypositions[i], z));
}
The Euler method is simple; we just add the acceleration to the velocity and the velocity to the position, every frame.
Note: I just assumed the mass of each node was 1 here, but you’ll want to use:
accelerations[i] = -force/mass;
if you want a different mass for your nodes.
Tip: For precise physics, we would use Verlet integration, but because we’re adding damping, we can only use the Euler method, which is a lot quicker to calculate. Generally, though, the Euler method will exponentially introduce kinetic energy from nowhere into your physics system, so don’t use it for anything precise.
Now we’re going to create wave propagation. The following code is adapted from Michael Hoffman’s tutorial.
float[] leftDeltas = new float[xpositions.Length];
float[] rightDeltas = new float[xpositions.Length];
Here, we create two arrays. For each node, we’re going to check the height of the previous node against the height of the current node and put the difference into leftDeltas.
Then, we’ll check the height of the subsequent node against the height of the node we’re checking, and put that difference into rightDeltas. (We’ll also multiply all values by a spread constant).
for (int j = 0; j < 8; j++)
{
for (int i = 0; i < xpositions.Length; i++)
{
if (i > 0)
{
leftDeltas[i] = spread * (ypositions[i] – ypositions[i-1]);
velocities[i - 1] += leftDeltas[i];
}
if (i < xpositions.Length – 1)
{
rightDeltas[i] = spread * (ypositions[i] – ypositions[i + 1]);
velocities[i + 1] += rightDeltas[i];
}
}
}
We can change the velocities based on the height difference immediately, but we should only store the differences in positions at this point. If we changed the position of the first node straight off the bat, by the time we looked at the second node, the first node will have already moved, so that’ll ruin all our calculations.
for (int i = 0; i < xpositions.Length; i++)
{
if (i > 0)
{
ypositions[i-1] += leftDeltas[i];
}
if (i < xpositions.Length – 1)
{
ypositions[i + 1] += rightDeltas[i];
}
}
So once we’ve collected all our height data, we can apply it at the end. We can’t look to the right of the node at the far right, or to the left of the node at the far left, hence the conditions i
> 0 and i < xpositions.Length – 1.
Also, note that we contained this whole code in a loop, and ran it eight times. This is because we want to run this process in small doses multiple times, rather than one large calculation, which would be a lot less fluid.
Adding Splashes
Now we have water that flows, and it shows. Next, we need to be able to disturb the water!
For this, let’s add a function called Splash(), which will check the x-position of the splash, and the velocity of whatever is hitting it. It should be public so that we can call it from our colliders later.
public void Splash(float xpos, float velocity)
{
First, we need to make sure that the specified position is actually within the bounds of our water:
if (xpos >= xpositions[0] && xpos <= xpositions[xpositions.Length-1])
{
And then we’ll change xpos so it gives us the position relative to the start of the body of water:
xpos -= xpositions[0];
Next, we’re going to find out which node it’s touching. We can calculate that like this:
int index = Mathf.RoundToInt((xpositions.Length-1)*(xpos / (xpositions[xpositions.Length-1] – xpositions[0])));
So, here’s what going on here:
We take the position of the splash relative to the position of the left edge of the water (xpos).
We divide this by the position of the right edge relative to the position of the left edge of the water.
This gives us a fraction that tells us where the splash is. For instance, a splash three-quarters of the way along the body of water would give a value of 0.75.
We multiply this by the number of edges and round this number, which gives us the node our splash was closest to.
velocities[index] = velocity;
Now we set the velocity of the object that hit our water to that node’s velocity, so that it gets dragged down by the object.
Note: You could change this line to whatever suits you. For instance, you could add the velocity to its current velocity, or you could use momentum instead of velocity and divide by your node’s mass.
Create 2D Dynamic Water Effects in Unity (Unity3D)
Now we want to make a particle system that’ll produce the splash. We defined that earlier; it’s called “splash” (creatively enough). Be sure not to confuse it with Splash(). The one I’ll be using is included in the source files.
First, we want to set the parameters of the splash to change with the velocity of the object.
float lifetime = 0.93f + Mathf.Abs(velocity)*0.07f;
splash.GetComponent<ParticleSystem>().startSpeed = 8+2*Mathf.Pow(Mathf.Abs(velocity),0.5f);
splash.GetComponent<ParticleSystem>().startSpeed = 9 + 2 * Mathf.Pow(Mathf.Abs(velocity), 0.5f);
splash.GetComponent<ParticleSystem>().startLifetime = lifetime;
Here, we’ve taken our particles, set their lifetime so they won’t die shortly after they hit the surface of the water, and set their speed to be based on the square of their velocity (plus a constant, for small splashes).
You may be looking at that code and thinking, “Why has he set the startSpeed twice?”, and you’d be right to wonder that. The problem is, we’re using a particle system (Shuriken, provided with the project) that has its start speed set to “random between two constants”. Unfortunately, we don’t have much access over Shuriken by scripts, so to get that behaviour to work we have to set the value twice.
Now I’m going to add a line that you may or may not want to omit from your script:
Vector3 position = new Vector3(xpositions[index],ypositions[index]-0.35f,5);
Quaternion rotation = Quaternion.LookRotation(new
Vector3(xpositions[Mathf.FloorToInt(xpositions.Length / 2)], baseheight +
8, 5) – position);
Shuriken particles won’t be destroyed when they hit your objects, so if you want to make sure they aren’t going to land in front of your objects, you can take two measures:
Stick them in the background. (You can tell this by the z-position being 5).
Tilt the particle system to always point towards the center of your body of water—this way, the particles won’t splash onto the land.
The second line of code takes the midpoint of the positions, moves upwards a bit, and points the particle emitter towards it. I’ve included this behaviour in the demo. If you’re using a really wide body of water, you probably don’t want this behaviour. If your water is in a small pool inside a room, you may well want to use it. So, feel free to scrap that line about rotation.
GameObject splish = Instantiate(splash,position,rotation) as GameObject;
Destroy(splish, lifetime+0.3f);
}
}
Now, we make our splash, and tell it to die a little after the particles are due to die. Why a little afterwards? Because our particle system sends out a few sequential bursts of particles, so even though the first batch only last till Time.time + lifetime, our final bursts will still be around a little after that.
Yes! We’re finally done, right?
Collision Detection
Wrong! We need to detect our objects, or this was all for nothing!
Remember we added that script to all our colliders before? The one called WaterDetector?
Well we’re going to make it now! We only want one function in it:
void OnTriggerEnter2D(Collider2D Hit)
{
Using OnTriggerEnter2D(), we can specify what happens whenever a 2D Rigid Body enters our body of water. If we pass a parameter of Collider2D we can find more information about that object.
if (Hit.rigidbody2D != null)
{
We only want objects that contain a rigidbody2D.
transform.parent.GetComponent<Water>().Splash(transform.position.x,
Hit.rigidbody2D.velocity.y*Hit.rigidbody2D.mass / 40f);
}
}
Now, all of our colliders are children of the water manager. So we just grab the Water component from their parent and call Splash(), from the position of the collider.
Remember again, I said you could either pass velocity or momentum, if you wanted it to be more physically accurate? Well here’s where you have to pass the right one. If you multiply the object’s y-velocity by its mass, you’ll have its momentum. If you just want to use its velocity, get rid of the mass from that line.
Finally, you’ll want to call SpawnWater() from somewhere. Let’s do it at launch:
void Start()
{
SpawnWater(-10,20,0,-10);
}
And now we’re done! Now any rigidbody2D with a collider that hits the
water will create a splash, and the waves will move correctly.
Create 2D Dynamic Water Effects in Unity (Unity3D)
Bonus Exercise
As an extra bonus, I’ve added a few lines of code to the top of SpawnWater().
gameObject.AddComponent<BoxCollider2D>();
gameObject.GetComponent<BoxCollider2D>().center = new Vector2(Left + Width / 2, (Top + Bottom) / 2);
gameObject.GetComponent<BoxCollider2D>().size = new Vector2(Width, Top – Bottom);
gameObject.GetComponent<BoxCollider2D>().isTrigger = true;
These lines of code will add a box collider to the water itself. You can use this to make things float in your water, using what you’ve learnt.
You’ll want to make a function called OnTriggerStay2D() which takes a parameter of Collider2D Hit. Then, you can use a modified version of the spring formula we used before that checks the mass of the object, and add a force or velocity to your rigidbody2D to make it float in the water.
Make a Splash
In this tutorial, we implemented a simple water simulation for use in 2D games with simple physics code and a line renderer, mesh renderers, triggers and particles. Perhaps you will add wavy bodies of fluid water as an obstacle to your next platformer, ready for your characters to dive into or carefully cross with floating stepping stones, or maybe you could use this in a sailing or windsurfing game, or even a game where you simply skip rocks across the water from a sunny beach. Good luck!(source:gamedevelopment)