Sploosh! In this tutorial, I’ll show you how you can use simple math, physics, and particle effects to simulate great looking 2D water waves and droplets.
Note: Although this tutorial is written using C# and XNA, you should be able to use the same techniques and concepts in almost any game development environment.
Final Result Preview
If you have XNA, you can download the source files and compile the demo yourself. Otherwise, check out the demo video below:
There are two mostly independent parts to the water simulation. First, we’ll make the waves using a spring model. Second, we’ll use particle effects to add splashes.
Making the Waves
To make the waves, we’ll model the surface of the water as a series of vertical springs, as shown in this diagram:
This will allow the waves to bob up and down. We will then make water particles pull on their neighbouring particles to allow the waves to spread.
Springs and Hooke’s Law
One great thing about springs is that they’re easy to simulate. Springs have a certain natural length; if you stretch or compress a spring, it will try to return to that natural length.
The force provided by a spring is given by Hooke’s Law:
\[
F = -kx
\]
F
is the force produced by the spring, k
is the spring constant, and x
is the spring’s displacement from its natural length. The negative sign indicates the force is in the opposite direction to which the spring is displaced; if you push the spring down, it will push back up, and vice versa.
The spring constant, k
, determines the stiffness of the spring.
To simulate springs, we must figure out how to move particles around based on Hooke’s Law. To do this, we need a couple more formulas from physics. First, Newton’s Second Law of Motion:
\[
F = ma
\]
Here, F
is force, m
is mass and a
is acceleration. This means the stronger a force pushes on an object, and the lighter the object is, the more it accelerates.
Combining these two formulas and rearranging gives us:
\[
a = -\frac{k}{m} x
\]
This gives us the acceleration for our particles. We’ll assume that all our particles will have the same mass, so we can combine k/m
into a single constant.
To determine position from acceleration, we need to do numerical integration. We’re going to use the simplest form of numerical integration – each frame we simply do the following:
Position += Velocity; Velocity += Acceleration;
This is called the Euler method. It’s not the most accurate type of numerical integration, but it’s fast, simple and adequate for our purposes.
Putting it all together, our water surface particles will do the following each frame:
public float Position, Velocity; public void Update() { const float k = 0.025f; // adjust this value to your liking float x = Height - TargetHeight; float acceleration = -k * x; Position += Velocity; Velocity += acceleration; }
Here, TargetHeight
is the natural position of the top of the spring when it’s neither stretched nor compressed. You should set this value to where you want the surface of the water to be. For the demo, I set it to halfway down the screen, at 240 pixels.
Tension and Dampening
I mentioned earlier that the spring constant, k
, controls the stiffness of the spring. You can adjust this value to change the properties of the water. A low spring constant will make the springs loose. This means a force will cause large waves that oscillate slowly. Conversely, a high spring constant will increase the tension in the spring. Forces will create small waves that oscillate quickly. A high spring constant will make the water look more like jiggling Jello.
A word of warning: do not set the spring constant too high. Very stiff springs apply very strong forces that change greatly in a very small amount of time. This does not play well with numerical integration, which simulates the springs as a series of discrete jumps at regular time intervals. A very stiff spring can even have an oscillation period that’s shorter than your time step. Even worse, the Euler method of integration tends to gain energy as the simulation becomes less accurate, causing stiff springs to explode.
There is a problem with our spring model so far. Once a spring starts oscillating, it will never stop. To solve this we must apply some dampening. The idea is to apply a force in the opposite direction that our spring is moving in order to slow it down. This requires a small adjustment to our spring formula:
\[
a = -\frac{k}{m} x - dv
\]
Here, v
is velocity and d
is the dampening factor – another constant you can tweak to adjust the feel of the water. It should be fairly small if you want your waves to oscillate. The demo uses a dampening factor of 0.025. A high dampening factor will make the water look thick like molasses, while a low value will allow the waves to oscillate for a long time.
Making the Waves Propagate
Now that we can make a spring, let’s use them to model water. As shown in the first diagram, we’re modelling the water using a series of parallel, vertical springs. Of course, if the springs are all independent, the waves will never spread out like real waves do.
I’ll show the code first, and then go over it:
for (int i = 0; i < springs.Length; i++) springs[i].Update(Dampening, Tension); float[] leftDeltas = new float[springs.Length]; float[] rightDeltas = new float[springs.Length]; // do some passes where springs pull on their neighbours for (int j = 0; j < 8; j++) { for (int i = 0; i < springs.Length; i++) { if (i > 0) { leftDeltas[i] = Spread * (springs[i].Height - springs [i - 1].Height); springs[i - 1].Speed += leftDeltas[i]; } if (i < springs.Length - 1) { rightDeltas[i] = Spread * (springs[i].Height - springs [i + 1].Height); springs[i + 1].Speed += rightDeltas[i]; } } for (int i = 0; i < springs.Length; i++) { if (i > 0) springs[i - 1].Height += leftDeltas[i]; if (i < springs.Length - 1) springs[i + 1].Height += rightDeltas[i]; } }
This code would be called every frame from your Update()
method. Here, springs
is an array of springs, laid out from left to right. leftDeltas
is an array of floats that stores the difference in height between each spring and its left neighbour. rightDeltas
is the equivalent for the right neighbours. We store all these height differences in arrays because the last two if
statements modify the heights of the springs. We have to measure the height differences before any of the heights are modified.
The code starts by running Hooke’s Law on each spring as described earlier. It then looks at the height difference between each spring and its neighbours, and each spring pulls its neighbouring springs towards itself by altering the neighbours’ positions and velocities. The neighbour-pulling step is repeated eight times to allow the waves to propagate faster.
There’s one more tweakable value here called Spread
. It controls how fast the waves spread. It can take values between 0 and 0.5, with larger values making the waves spread out faster.
To start the waves moving, we’re going to add a simple method called Splash()
.
public void Splash(int index, float speed) { if (index >= 0 && index < springs.Length) springs[i].Speed = speed; }
Any time you want to make waves, call Splash()
. The index
parameter determines at which spring the splash should originate, and the speed
parameter determines how large the waves will be.
Rendering
We’ll be using the XNA PrimitiveBatch
class from the XNA PrimitivesSample. The PrimitiveBatch
class helps us draw lines and triangles directly with the GPU. You use it like so:
// in LoadContent() primitiveBatch = new PrimitiveBatch(GraphicsDevice); // in Draw() primitiveBatch.Begin(PrimitiveType.TriangleList); foreach (Triangle triangle in trianglesToDraw) { primitiveBatch.AddVertex(triangle.Point1, Color.Red); primitiveBatch.AddVertex(triangle.Point2, Color.Red); primitiveBatch.AddVertex(triangle.Point3, Color.Red); } primitiveBatch.End();
One thing to note is that, by default, you must specify the triangle vertices in a clockwise order. If you add them in a counter clockwise order the triangle will be culled and you won’t see it.
It’s not necessary to have a spring for each pixel of width. In the demo I used 201 springs spread across an 800 pixel wide window. That gives exactly 4 pixels between each spring, with the first spring at 0 and the last at 800 pixels. You could probably use even fewer springs and still have the water look smooth.
What we want to do is draw thin, tall trapezoids that extend from the bottom of the screen to the surface of the water and connect the springs, as shown in this diagram:
Since graphics cards don’t draw trapezoids directly, we have to draw each trapezoid as two triangles. To make it look a bit nicer, we’ll also make the water darker as it gets deeper by colouring the bottom vertices dark blue. The GPU will automatically interpolate colours between the vertices.
primitiveBatch.Begin(PrimitiveType.TriangleList); Color midnightBlue = new Color(0, 15, 40) * 0.9f; Color lightBlue = new Color(0.2f, 0.5f, 1f) * 0.8f; var viewport = GraphicsDevice.Viewport; float bottom = viewport.Height; // stretch the springs' x positions to take up the entire window float scale = viewport.Width / (springs.Length - 1f); // be sure to use float division for (int i = 1; i < springs.Length; i++) { // create the four corners of our triangle. Vector2 p1 = new Vector2((i - 1) * scale, springs[i - 1].Height); Vector2 p2 = new Vector2(i * scale, springs[i].Height); Vector2 p3 = new Vector2(p2.X, bottom); Vector2 p4 = new Vector2(p1.X, bottom); primitiveBatch.AddVertex(p1, lightBlue); primitiveBatch.AddVertex(p2, lightBlue); primitiveBatch.AddVertex(p3, midnightBlue); primitiveBatch.AddVertex(p1, lightBlue); primitiveBatch.AddVertex(p3, midnightBlue); primitiveBatch.AddVertex(p4, midnightBlue); } primitiveBatch.End();
Here is the result:
Making the Splashes
The waves look pretty good, but I’d like to see a splash when the rock hits the water. Particle effects are perfect for this.
Particle Effects
A particle effect uses a large number of small particles to produce some visual effect. They’re sometimes used for things like smoke or sparks. We’re going to use particles for the water droplets in the splashes.
The first thing we need is our particle class:
class Particle { public Vector2 Position; public Vector2 Velocity; public float Orientation; public Particle(Vector2 position, Vector2 velocity, float orientation) { Position = position; Velocity = velocity; Orientation = orientation; } }
This class just holds the properties a particle can have. Next, we create a list of particles.
List<Particle> particles = new List<Particle>();
Each frame, we must update and draw the particles.
void UpdateParticle(Particle particle) { const float Gravity = 0.3f; particle.Velocity.Y += Gravity; particle.Position += particle.Velocity; particle.Orientation = GetAngle(particle.Velocity); } private float GetAngle(Vector2 vector) { return (float)Math.Atan2(vector.Y, vector.X); } public void Update() { foreach (var particle in particles) UpdateParticle(particle); // delete particles that are off-screen or under water particles = particles.Where(x => x.Position.X >= 0 && x.Position.X <= 800 && x.Position.Y <= GetHeight(x.Position.X)).ToList(); }
We update the particles to fall under gravity and set the particle’s orientation to match the direction it’s going in. We then get rid of any particles that are off-screen or under water by copying all the particles we want to keep into a new list and assigning it to particles. Next we draw the particles.
void DrawParticle(Particle particle) { Vector2 origin = new Vector2(ParticleImage.Width, ParticleImage.Height) / 2f; spriteBatch.Draw(ParticleImage, particle.Position, null, Color.White, particle.Orientation, origin, 0.6f, 0, 0); } public void Draw() { foreach (var particle in particles) DrawParticle(particle); }
Below is the texture I used for the particles.
Now, whenever we create a splash, we make a bunch of particles.
private void CreateSplashParticles(float xPosition, float speed) { float y = GetHeight(xPosition); if (speed > 60) { for (int i = 0; i < speed / 8; i++) { Vector2 pos = new Vector2(xPosition, y) + GetRandomVector2(40); Vector2 vel = FromPolar(MathHelper.ToRadians(GetRandomFloat(-150, -30)), GetRandomFloat(0, 0.5f * (float)Math.Sqrt(speed))); particles.Add(new Particle(pos, velocity, 0)); } } }
You can call this method from the Splash()
method we use to make waves. The parameter speed is how fast the rock hits the water. We’ll make bigger splashes if the rock is moving faster.
GetRandomVector2(40)
returns a vector with a random direction and a random length between 0 and 40. We want to add a little randomness to the positions so the particles don’t all appear at a single point. FromPolar()
returns a Vector2
with a given direction and length.
Here is the result:
Using Metaballs as Particles
Our splashes look pretty decent, and some great games, like World of Goo, have particle effect splashes that look much like ours. However, I’m going to show you a technique to make the splashes look more liquid-like. The technique is using metaballs, organic-looking blobs which I’ve written a tutorial about before. If you’re interested in the details about metaballs and how they work, read that tutorial. If you just want to know how to apply them to our splashes, keep reading.
Metaballs look liquid-like in the way they fuse together, making them a good match for our liquid splashes. To make the metaballs, we will need to add new class variables:
RenderTarget2D metaballTarget; AlphaTestEffect alphaTest;
Which we initialize like so:
var view = GraphicsDevice.Viewport; metaballTarget = new RenderTarget2D(GraphicsDevice, view.Width, view.Height); alphaTest = new AlphaTestEffect(GraphicsDevice); alphaTest.ReferenceAlpha = 175; alphaTest.Projection = Matrix.CreateTranslation(-0.5f, -0.5f, 0) * Matrix.CreateOrthographicOffCenter(0, view.Width, view.Height, 0, 0, 1);
Then we draw the metaballs:
GraphicsDevice.SetRenderTarget(metaballTarget); GraphicsDevice.Clear(Color.Transparent); Color lightBlue = new Color(0.2f, 0.5f, 1f); spriteBatch.Begin(0, BlendState.Additive); foreach (var particle in particles) { Vector2 origin = new Vector2(ParticleImage.Width, ParticleImage.Height) / 2f; spriteBatch.Draw(ParticleImage, particle.Position, null, lightBlue, particle.Orientation, origin, 2f, 0, 0); } spriteBatch.End(); GraphicsDevice.SetRenderTarget(null); device.Clear(Color.CornflowerBlue); spriteBatch.Begin(0, null, null, null, null, alphaTest); spriteBatch.Draw(metaballTarget, Vector2.Zero, Color.White); spriteBatch.End(); // draw waves and other things
The metaball effect depends on having a particle texture that fades out as you get further from the center. Here’s what I used, set on a black background to make it visible:
Here’s what it looks like:
The water droplets now fuse together when they are close. However, they don’t fuse with the surface of the water. We can fix this by adding a gradient to the water’s surface that makes it gradually fade out, and rendering it to our metaball render target.
Add the following code to the above method before the line GraphicsDevice.SetRendertarget(null)
:
primitiveBatch.Begin(PrimitiveType.TriangleList); const float thickness = 20; float scale = GraphicsDevice.Viewport.Width / (springs.Length - 1f); for (int i = 1; i < springs.Length; i++) { Vector2 p1 = new Vector2((i - 1) * scale, springs[i - 1].Height); Vector2 p2 = new Vector2(i * scale, springs[i].Height); Vector2 p3 = new Vector2(p1.X, p1.Y - thickness); Vector2 p4 = new Vector2(p2.X, p2.Y - thickness); primitiveBatch.AddVertex(p2, lightBlue); primitiveBatch.AddVertex(p1, lightBlue); primitiveBatch.AddVertex(p3, Color.Transparent); primitiveBatch.AddVertex(p3, Color.Transparent); primitiveBatch.AddVertex(p4, Color.Transparent); primitiveBatch.AddVertex(p2, lightBlue); } primitiveBatch.End();
Now the particles will fuse with the water’s surface.
Adding the Beveling Effect
The water particles look a bit flat, and it would be nice to give them some shading. Ideally, you would do this in a shader. However, for the sake of keeping this tutorial simple, we’re going to use a quick and easy trick: we’re simply going to draw the particles three times with different tinting and offsets, as illustrated in the diagram below.
To do this, we want to capture the metaball particles in a new render target. We’ll then draw that render target once for each tint.
First, declare a new RenderTarget2D
just like we did for the metaballs:
particlesTarget = new RenderTarget2D(GraphicsDevice, view.Width, view.Height);
Then, instead of drawing metaballsTarget
directly to the backbuffer, we want to draw it onto particlesTarget
. To do this, go to the method where we draw the metaballs and simply change these lines:
GraphicsDevice.SetRenderTarget(null); device.Clear(Color.CornflowerBlue);
…to:
GraphicsDevice.SetRenderTarget(particlesTarget); device.Clear(Color.Transparent);
Then use the following code to draw the particles three times with different tints and offsets:
Color lightBlue = new Color(0.2f, 0.5f, 1f); GraphicsDevice.SetRenderTarget(null); device.Clear(Color.CornflowerBlue); spriteBatch.Begin(); spriteBatch.Draw(particlesTarget, -Vector2.One, new Color(0.8f, 0.8f, 1f)); spriteBatch.Draw(particlesTarget, Vector2.One, new Color(0f, 0f, 0.2f)); spriteBatch.Draw(particlesTarget, Vector2.Zero, lightBlue); spriteBatch.End(); // draw waves and other stuff
Conclusion
That’s it for basic 2D water. For the demo, I added a rock you can drop into the water. I draw the water with some transparency on top of the rock to make it look like it’s underwater, and make it slow down when it’s underwater due to water resistance.
To make the demo look a bit nicer, I went to opengameart.org and found an image for the rock and a sky background. You can find the rock and sky at http://opengameart.org/content/rocks and opengameart.org/content/sky-backdrop respectively.