Unlimited WordPress themes, graphics, videos & courses! Unlimited asset downloads! From $16.50/m
  1. Game Development
  2. Shaders

Creating Toon Water for the Web: Part 1

This post is part of a series called Creating Toon Water for the Web.
Creating Toon Water for the Web: Part 2

In my Beginner's Guide to Shaders I focused exclusively on fragment shaders, which is enough for any 2D effect and every ShaderToy example. But there's a whole category of techniques that require vertex shaders. This tutorial will walk you through creating stylized toon water while introducing vertex shaders. I will also introduce the depth buffer and how to use that to get more information about your scene and create foam lines.

Here's what the final effect should look like. You can try a live demo here (left mouse to orbit, right mouse to pan, scroll wheel to zoom).

Kayak and lighthouse in water

Specifically, this effect is composed of:

  1. A subdivided translucent water mesh with displaced vertices to make waves.
  2. Static water lines on the surface.
  3. Fake buoyancy on the boats.
  4. Dynamic foam lines around the edge of objects in the water.
  5. A post-process distortion of everything underwater.

What I like about this effect is that it touches on a lot of different concepts in computer graphics, so it will allow us to draw on ideas from past tutorials, as well as developing techniques we can use for a variety of future effects.

I'll be using PlayCanvas for this just because it has a convenient free web-based IDE, but everything should be applicable to any environment running WebGL. You can find a Three.js version of the source code at the end. I'll be assuming you're comfortable using fragment shaders and navigating the PlayCanvas interface. You can brush up on shaders here and skim an intro to PlayCanvas here.

Environment Setup

The goal of this section is to set up our PlayCanvas project and place some environment objects to test the water against. 

If you don't already have an account with PlayCanvas, sign up for one and create a new blank project. By default, you should have a couple of objects, a camera and a light in your scene.

A blank PlayCanvas project showing the objects the scene contains

Inserting Models

Google's Poly project is a really great resource for 3D models for the web. Here is the boat model I used. Once you download and unzip that, you should find a .obj and a .png file.

  1. Drag both files into the asset window in your PlayCanvas project.
  2. Select the material that was automatically created, and set its diffuse map to the .png file.
Click on the diffuse tab and select the boat image

Now you can drag the Tugboat.json into your scene and delete the Box and Plane objects. You can scale the boat up if it looks too small (I set mine to 50).

You can scale the model up using the properties panel on the right once its selected

You can add any other models to your scene in the same way.

Orbit Camera

To set up an orbit camera, we'll copy a script from this PlayCanvas example. Go to that link, and click on Editor to enter the project.

  1. Copy the contents of mouse-input.js and orbit-camera.js from that tutorial project into the files of the same name in your own project.
  2. Add a Script component to your camera.
  3. Attach the two scripts to the camera.

Tip: You can create folders in the asset window to keep things organized. I put these two camera scripts under Scripts/Camera/, my model under Models/, and my material under Materials/.

Now, when you launch the game (play button on the top right of the scene view), you should be able to see your boat and orbit around it with the mouse. 

Subdivided Water Surface

The goal of this section is to generate a subdivided mesh to use as our water surface.

To generate the water surface, we're going to adapt some code from this terrain generation tutorial. Create a new script file called Water.js. Edit this script and create a new function called GeneratePlaneMesh that looks like this:

Now you can call this in the initialize function:

You should see just a flat plane when you launch the game now. But this is not just a flat plane. It's a mesh composed of a thousand vertices. As a challenge, try to verify this (it's a good excuse to read through the code you just copied). 

Challenge #1: Displace the Y coordinate of each vertex by a random amount to get the plane to look something like the image below.
A subdivided plane with displaced vertices


The goal of this section is to give the water surface a custom material and create animated waves.

To get the effects we want, we need to set up a custom material. Most 3D engines will have some pre-defined shaders for rendering objects and a way to override them. Here's a good reference for doing this in PlayCanvas.

Attaching a Shader

Let's create a new function called CreateWaterMaterial that defines a new material with a custom shader and returns it:

This function grabs the vertex and fragment shader code from the script attributes. So let's define those at the top of the file (after the pc.createScript line):

Now we can create these shader files and attach them to our script. Go back to the editor, and create two new shader files: Water.frag and Water.vert. Attach these shaders to your script as shown below.

Watervert and Waterfrag are attached to WaterInit

If the new attributes don't show up in the editor, click the Parse button to refresh the script.

Now put this basic shader in Water.frag:

And this in Water.vert:

Finally, go back to Water.js and make it use our new custom material instead of the standard material. So instead of:


Now, if you launch the game, the plane should now be blue.

The shader we wrote renders the plane as blue

Hot Reloading

So far, we've just set up some dummy shaders on our new material. Before we get to writing the real effects, one last thing I want to set up is automatic code reloading.

Uncommenting the swap function in any script file (such as Water.js) enables hot-reloading. We'll see how to use this later to maintain the state even as we update the code in real time. But for now we just want to re-apply the shaders once we've detected a change. Shaders get compiled before they are run in WebGL, so we'll need to recreate the custom material to trigger this.

We're going to check if the contents of our shader code have been updated and, if so, recreate the material. First, save the current shaders in the initialize:

And in the update, check if there have been any changes:

Now, to confirm this works, launch the game and change the color of the plane in Water.frag to a more tasteful blue. Once you save the file, it should update without having to refresh or relaunch! This was the color I chose:

Vertex Shaders

To create waves, we need to move every vertex in our mesh every frame. This sounds as if it's going to be very inefficient, but every vertex of every model already gets transformed on each frame we render. This is what the vertex shader does. 

If you think of a fragment shader as a function that runs on every pixel, takes a position, and returns a color, then a vertex shader is a function that runs on every vertex, takes a position, and returns a position.

The default vertex shader will take the world position of a given model, and return the screen position. Our 3D scene is defined in terms of x, y, and z, but your monitor is a flat two-dimensional plane, so we project our 3D world onto our 2D screen. This projection is what the view, projection, and model matrices take care of and is outside of the scope of this tutorial, but if you want to learn exactly what happens at this step, here's a very nice guide.

So this line:

Takes aPosition as the 3D world position of a particular vertex and transforms it into gl_Position, which is the final 2D screen position. The 'a' prefix on aPosition is to signify that this value is an attribute. Remember that a uniform variable is a value we can define on the CPU to pass to a shader that retains the same value across all pixels/vertices. An attribute's value, on the other hand, comes from an array defined on the CPU. The vertex shader is called once for each value in that attribute array.

You can see these attributes are set up in the shader definition we set up in Water.js:

PlayCanvas takes care of setting up and passing an array of vertex positions for aPosition when we pass this enum, but in general you could pass any array of data to the vertex shader.

Moving the Vertices

Let's say you want to squish the plane by multiplying all x values by half. Should you change aPosition or gl_Position?

Let's try aPosition first. We can't modify an attribute directly, but we can make a copy:

The plane should now look more rectangular. Nothing strange there. Now what happens if we instead try modifying gl_Position?

It might look the same until you start to rotate the camera. We're modifying screen space coordinates, which means it's going to look different depending on how you're looking at it.

So that's how you can move the vertices, and it's important to make this distinction between whether you're in world or screen space.

Challenge #2: Can you move the whole plane surface a few units up (along the Y axis) in the vertex shader without distorting its shape?
Challenge #3: I said gl_Position is 2D, but gl_Position.z does exist. Can you run some tests to determine if this value affects anything, and if so, what it's used for?

Adding Time

One last thing we need before we can create moving waves is a uniform variable to use as time. Declare a uniform in your vertex shader:

Then, to pass this to our shader, go back to Water.js and define a time variable in the initialize:

Now, to pass this to our shader, we use material.setParameter. First we set an initial value at the end of the CreateWaterMaterial function:

Now in the update function we can increment time and access the material using the reference we created for it:

As a final step, in the swap function, copy over the old value of time, so that even if you change the code it'll continue incrementing without resetting to 0.

Now everything is ready. Launch the game to make sure there are no errors. Now let's move our plane by a function of time in Water.vert:

And your plane should be moving up and down now! Because we have a swap function now, you can also update Water.js without having to relaunch. Try making time increment faster or slower to confirm this works.

Moving the plane up and down with a vertex shader
Challenge #4: Can you move the vertices so it looks like the wave below? 

As a hint, I talked in depth about different ways to create waves here. That was in 2D, but the same math applies here. If you'd rather just peek at the solution, here's the gist.


The goal of this section is to make the water surface translucent.

You might have noticed that the color we're returning in Water.frag does have an alpha value of 0.5, but the surface is still completely opaque. Transparency in many ways is still an open problem in computer graphics. One cheap way to achieve it is to use blending.

Normally, when a pixel is about to be drawn, it checks the value in the depth buffer against its own depth value (its position along the Z axis) to determine whether to overwrite the current pixel on the screen or discard itself. This is what allows you to render a scene correctly without having to sort objects back to front. 

With blending, instead of simply discarding or overwriting, we can combine the color of the pixel that's already drawn (the destination) with the pixel that's about to be drawn (the source). You can see all the available blending functions in WebGL here.

To make the alpha work the way we expect it, we want the combined color of the result to be the source multiplied by the alpha plus the destination multiplied by one minus the alpha. In other words, if the alpha is 0.4, the final color should be:

In PlayCanvas, the pc.BLEND_NORMAL option does exactly this.

To enable this, just set the property on the material inside CreateWaterMaterial:

If you launch the game now, the water will be translucent! This isn't perfect, though. A problem arises if the translucent surface overlaps with itself, as shown below.

Artifacts arise when a translucent surface overlaps with itself

We can fix this by using alpha to coverage, which is a multi-sampling technique to achieve transparency instead of blending:

But this is only available in WebGL 2. For the remainder of this tutorial, I'll be using blending to keep it simple.


So far we've set up our environment and created our translucent water surface with animated waves from our vertex shader. The second part will cover applying buoyancy on objects, adding water lines to the surface, and creating the foam lines around the edges of objects that intersect the surface. 

The final part will cover applying the underwater post-process distortion effect and some ideas for where to go next.

Source Code

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

Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.