Despite their notoriety, creating water levels is a time-honored tradition in the history of video games, whether that's to shake up the game mechanics or just because water is so beautiful to look at. There are various ways to produce an underwater feeling, from simple visuals (like tinting the screen blue) to mechanics (like slow movement and weak gravity).
We're going to look at distortion as a way to visually communicate the presence of water (imagine that you're standing at the edge of a pool and peering at things inside—that's the kind of effect we want to recreate). You can check out a demo of the final look here on CodePen.
It might look a little complicated, but the effect itself is only a couple of lines of code! It's nothing more than different displacement effects compounded together. We'll start from scratch and see exactly what that means.
Rendering a Basic Image
Head over to Shadertoy and create a new shader. Before we can apply any distortion, we need to render an image. We know from previous tutorials that we just need to select an image in one of the bottom channels on the page, and map it to the screen with texture2D:
vec2 uv = fragCoord.xy / iResolution.xy; // Get the current pixel's normalized position fragColor = texture2D(iChannel0,uv); //Get the current pixel's color in the texture, and set it to the color on screen
Here's what I picked:
Our First Displacement
Now what happens if instead of just rendering the pixel at position
uv, we render the pixel at
It's always easiest to think in terms of what happens on one single pixel when working with shaders. Given any position on the screen, instead of drawing the original color in the texture, it's going to draw the color of a pixel to its right. That means, visually, everything gets shifted left. Try it!
By default, Shadertoy sets the wrap mode on all textures to repeat. So if you try to sample a pixel on the right of the rightmost pixel, it will simply wrap around. Here, I changed it to clamp (which you can do from the gear icon on the box where you selected the texture).
Challenge: Can you make the whole image move slowly to the right? How about move back and forth? What about in a circle?
Hint: Shadertoy gives you a running time variable called iGlobalTime.
Moving a whole image isn't very exciting, and doesn't require the highly parallel power of the GPU. What if instead of displacing each position by a fixed amount (such as 0.1), we displaced different pixels by different amounts?
We need a variable that's somehow unique for every pixel. Any variable you declare or uniform you pass in will not vary between pixels. Luckily, we already have something that varies like this: the pixel's own x and y. Try this:
vec2 uv = fragCoord.xy / iResolution.xy; uv.y += uv.x; //Move the y by the current pixel's x fragColor = texture2D(iChannel0,uv);
We're vertically offsetting each pixel by its x value. The leftmost pixels will get the least offset (0) while the rightmost will get the maximum offset (1).
Now we've got a value that varies across the image from 0 to 1. We're using this to push the pixels down, so we get this slant. Now for your next challenge!
Challenge: Can you use this to create a wave? (As pictured below)
Hint: Your offset variable goes from 0 to 1. You want it to periodically go from -1 to 1 instead. The cosine/sine function is a perfect choice for that.
If you figured out the wave effect, try making it wiggle back and forth by multiplying by our time variable! Here's my attempt at that so far:
vec2 uv = fragCoord.xy / iResolution.xy; uv.y += cos(uv.x*25.)*0.06*cos(iGlobalTime); fragColor = texture2D(iChannel0,uv);
I multiply uv.x by some big number (25) to control the frequency of the wave. I then scale it down by multiplying by 0.06, so that's the maximum amplitude. Finally, I multiply by the cosine of the time, to have it periodically flip back and forth.
Note: If you really want to confirm that our distortion is following a sine wave, change that 0.06 to a 1.0 and watch it at its maximum!
Challenge: Can you figure out how to make it wiggle faster?
Hint: It's the same concept we used to increase the frequency of the wave spatially.
While you're at it, another thing you can try is applying the same thing for uv.x as well, so it's distorting on both the x and y (and maybe switch out the cos's for sin's).
Now this is wiggling in a wave motion, but something's off. That's not quite how water behaves...
A Different Way to Add Time
Water needs to look as if it's flowing. What we have right now is just going back and forth. Let's examine our equation again:
Our frequency isn't changing, which is good for now, but we don't want our amplitude to change either. We want the wave to stay the same shape, but to move across the screen.
To see where in our equation we want to offset, think about what determines where the wave starts and ends. uv.x is the dependent variable in that sense. Wherever uv.x is pi/2, there will be no displacement (since cos(pi/2) = 0), and where uv.x is around pi/2, that will be maximum displacement.
Let's tweak our equation a little bit:
Now both our amplitude and frequency are fixed, and the only thing that varies will be the position of the wave itself. With that bit of theory out of the way, time for a challenge!
Challenge: Implement this new equation and tweak the coefficients to get a nice wavy motion.
Putting It All Together
Here's my code for what we've got so far:
vec2 uv = fragCoord.xy / iResolution.xy; uv.y += cos(uv.x*25.+iGlobalTime)*0.01; uv.x += cos(uv.y*25.+iGlobalTime)*0.01; fragColor = texture2D(iChannel0,uv);
Now this is essentially the heart of the effect. However, we can keep tweaking things to make it look even better. For example, there's no reason you have to vary the wave by just the x or y coordinate. You can change both, so it varies diagonally! Here's an example:
float X = uv.x*25.+iGlobalTime; float Y = uv.y*25.+iGlobalTime; uv.y += cos(X+Y)*0.01; uv.x += sin(X-Y)*0.01;
It looked a bit repetitive so I switched the second cos for a sin to fix that. While we're at it, we can also try to vary the amplitude a bit:
float X = uv.x*25.+iGlobalTime; float Y = uv.y*25.+iGlobalTime; uv.y += cos(X+Y)*0.01*cos(Y); uv.x += sin(X-Y)*0.01*sin(Y);
And that's about as far as I've gotten, but you can always compound and combine more functions to get different results!
Applying It to a Section of the Screen
The last thing I want to mention in the shader is that in most cases, you're probably going to need to apply the effect to just a part of the screen instead of the whole thing. An easy way to do that is to pass in a mask. This would be an image that maps which areas of the screen should be affected. The ones that are transparent (or white) can be unaffected, and the opaque (or black) pixels can have the full effect.
In Shadertoy, you can't upload arbitrary images, but you can render to a separate buffer and pass that in as a texture. Here is a Shadertoy link where I apply the effect above to just the bottom half of the screen.
The mask you pass in doesn't need to be a static image. It can be a completely dynamic thing; as long as you can render it in real time and pass it to the shader, your water can move or flow throughout the screen seamlessly.
You can see how I pass in the images manually as uniforms, and I have to also update the time variable myself.
Phaser lets you apply shaders to individual objects, but you can also apply it to the world object, which is a lot more efficient. Similarly, it might be a good idea on another platform to render all your objects onto some buffer, and pass that through the water shader, instead of applying it to every individual object. That way it functions as a post-processing effect.
I hope going through composing this shader from scratch gave you some good insight into how a lot of complex effects are built by layering all these different little displacements!
As a final challenge, here's a sort of water ripple shader that relies on the same sort of displacement ideas we saw. You could try to take it apart, unfold the layers, and figure out what each piece does!