7 days of WordPress themes, graphics & videos - for free!* Unlimited asset downloads! Start 7-Day Free Trial
  1. Game Development
  2. Shaders

Creating Toon Water for the Web: Part 3

Scroll to top
Read Time: 12 mins
This post is part of a series called Creating Toon Water for the Web.
Creating Toon Water for the Web: Part 2

Welcome back to this three-part series on creating stylized toon water in PlayCanvas using vertex shaders. In Part 2 we covered buoyancy & foam lines. In this final part, we're going to apply the underwater distortion as a post-process effect.

Refraction & Post-Process Effects

Our goal is to visually communicate the refraction of light through water. We've already covered how to create this sort of distortion in a fragment shader in a previous tutorial for a 2D scene. The only difference here is that we'll need to figure out which area of the screen is underwater and only apply the distortion there. 


In general, a post-process effect is anything applied to the whole scene after it is rendered, such as a colored tint or an old CRT screen effect. Instead of rendering your scene directly to the screen, you first render it to a buffer or texture, and then render that to the screen, passing through a custom shader.

In PlayCanvas, you can set up a post-process effect by creating a new script. Call it Refraction.js, and copy this template to start with:

This is just like a normal script, but we define a RefractionPostEffect class that can be applied to the camera. This needs a vertex and a fragment shader to render. The attributes are already set up, so let's create Refraction.frag with this content:

And Refraction.vert with a basic vertex shader:

Now attach the Refraction.js script to the camera, and assign the shaders to the appropriate attributes. When you launch the game, you should see the scene exactly as it was before. This is a blank post effect that simply re-renders the scene. To verify that this is working, try giving the scene a red tint.

In Refraction.frag, instead of simply returning the color, try setting the red component to 1.0, which should look like the image below.

Scene rendered with a red tint Scene rendered with a red tint Scene rendered with a red tint

Distortion Shader

We need to add a time uniform for the animated distortion, so go ahead and create one in Refraction.js, inside this constructor for the post effect:

Now, inside this render function, we pass it to our shader and increment it:

Now we can use the same shader code from the water distortion tutorial, making our full fragment shader look like this:

If it all worked out, everything should now look like as if it's underwater, as below.

Underwater distortion applied to the whole scene Underwater distortion applied to the whole scene Underwater distortion applied to the whole scene
Challenge #1: Make the distortion only apply to the bottom half of the screen.

Camera Masks

We're almost there. All we need to do now is to apply this distortion effect just on the underwater part of the screen. The most straightforward way I've come up with to do this is to re-render the scene with the water surface rendered as a solid white, as shown below.

Water surface rendered as a solid white to act as a maskWater surface rendered as a solid white to act as a maskWater surface rendered as a solid white to act as a mask

This would be rendered to a texture that would act as a mask. We would then pass this texture to our refraction shader, which would only distort a pixel in the final image if the corresponding pixel in the mask is white.

Let's add a boolean attribute on the water surface to know if it's being used as a mask. Add this to Water.js:

We can then pass it to the shader with material.setParameter('isMask',this.isMask); as usual. Then declare it in Water.frag and set the color to white if it's true.

Confirm that this works by toggling the "Is Mask?" property in the editor and relaunching the game. It should look white, as in the earlier image.

Now, to re-render the scene, we need a second camera. Create a new camera in the editor and call it CameraMask. Duplicate the Water entity in the editor as well, and call it WaterMask. Make sure the "Is Mask?" is false for the Water entity but true for the WaterMask.

To tell the new camera to render to a texture instead of the screen, create a new script called CameraMask.js and attach it to the new camera. We create a RenderTarget to capture this camera's output like this:

Now, if you launch, you'll see this camera is no longer rendering to the screen. We can grab the output of its render target in Refraction.js like this:

Notice that I pass this mask texture as an argument to the post effect constructor. We need to create a reference to it in our constructor, so it looks like:

Finally, in the render function, pass the buffer to our shader with:

Now to verify that this is all working, I'll leave that as a challenge.

Challenge #2: Render the uMaskBuffer to the screen to confirm it is the output of the second camera.

One thing to be aware of is that the render target is set up in the initialize of CameraMask.js, and that needs to be ready by the time Refraction.js is called. If the scripts run the other way around, you'll get an error. To make sure they run in the right order, drag the CameraMask to the top of the entity list in the editor, as shown below.

PlayCanvas editor with CameraMask at top of entity listPlayCanvas editor with CameraMask at top of entity listPlayCanvas editor with CameraMask at top of entity list

The second camera should always be looking at the same view as the original one, so let's make it always follow its position and rotation in the update of CameraMask.js:

And define CameraToFollow in the initialize:

Culling Masks

Both cameras are currently rendering the same thing. We want the mask camera to render everything except the real water, and we want the real camera to render everything except the mask water.

To do this, we can use the camera's culling bit mask. This works similarly to collision masks if you've ever used those. An object will be culled (not rendered) if the result of a bitwise AND between its mask and the camera mask is 1.

Let's say the Water will have bit 2 set, and WaterMask will have bit 3. Then the real camera needs to have all bits set except for 3, and the mask camera needs to have all bits set except for 2. An easy way to say "all bits except N" is to do:

You can read more about bitwise operators here.

To set up the camera culling masks, we can put this inside CameraMask.js's initialize at the bottom:

Now, in Water.js, set the Water mesh's mask on bit 2, and the mask version of it on bit 3:

Now, one view will have the normal water, and the other will have the solid white water. The left half of the image below is the view from the original camera, and the right half is from the mask camera.

Split view of mask camera and original cameraSplit view of mask camera and original cameraSplit view of mask camera and original camera

Applying the Mask

One final step now! We know the areas underwater are marked with white pixels. We just need to check if we're not at a white pixel, and if so, turn off the distortion in Refraction.frag:

And that should do it!

One thing to note is that since the texture for the mask is initialized on launch, if you resize the window at runtime, it will no longer match the size of the screen.


As an optional clean-up step, you might have noticed that edges in the scene now look a little sharp. This is because when we applied our post effect, we lost anti-aliasing. 

We can apply an additional anti-alias on top of our effect as another post effect. Luckily, there's one available in the PlayCanvas store we can just use. Go to the script asset page, click the big green download button, and choose your project from the list that appears. The script will appear in the root of your asset window as posteffect-fxaa.js. Just attach this to the Camera entity, and your scene should look a little nicer! 

Final Thoughts

If you've made it this far, give yourself a pat on the back! We covered a lot of techniques in this series. You should now be comfortable with vertex shaders, rendering to textures, applying post-processing effects, selectively culling objects, using the depth buffer, and working with blending and transparency. Even though we were implementing this in PlayCanvas, these are all general graphics concepts you'll find in some form on whatever platform you end up in.

All these techniques are also applicable to a variety of other effects. One particularly interesting application I've found of vertex shaders is in this talk on the art of Abzu, where they explain how they used vertex shaders to efficiently animate tens of thousands of fish on screen.

You should now also have a nice water effect you can apply to your games! You could easily customize it now that you've put together every detail yourself. There's still a lot more you can do with water (I haven't even mentioned any sort of reflection at all). Below are a couple of ideas.

Noise-Based Waves

Instead of simply animating the waves with a combination of sine and cosines, you can sample a noise texture to make the waves look a bit more natural and unpredictable.

Dynamic Foam Trails

Instead of completely static water lines on the surface, you could draw onto that texture when objects move, to create a dynamic foam trail. There are a lot of ways to go about doing this, so this could be its own project.

Source Code

You can find the finished hosted PlayCanvas project here. A Three.js port is also available in this repository.

Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.