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).
Specifically, this effect is composed of:
- A subdivided translucent water mesh with displaced vertices to make waves.
- Static water lines on the surface.
- Fake buoyancy on the boats.
- Dynamic foam lines around the edge of objects in the water.
- 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.
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.
- Drag both files into the asset window in your PlayCanvas project.
- Select the material that was automatically created, and set its diffuse map to the
.png
file.
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 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.
- Copy the contents of
mouse-input.js
andorbit-camera.js
from that tutorial project into the files of the same name in your own project. - Add a Script component to your camera.
- 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:
Water.prototype.GeneratePlaneMesh = function(options){ // 1 - Set default options if none are provided if(options === undefined) options = {subdivisions:100, width:10, height:10}; // 2 - Generate points, uv's and indices var positions = []; var uvs = []; var indices = []; var row, col; var normals; for (row = 0; row <= options.subdivisions; row++) { for (col = 0; col <= options.subdivisions; col++) { var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0)); positions.push(position.x, position.y, position.z); uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions); } } for (row = 0; row < options.subdivisions; row++) { for (col = 0; col < options.subdivisions; col++) { indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + (row + 1) * (options.subdivisions + 1)); } } // Compute the normals normals = pc.calculateNormals(positions, indices); // Make the actual model var node = new pc.GraphNode(); var material = new pc.StandardMaterial(); // Create the mesh var mesh = pc.createMesh(this.app.graphicsDevice, positions, { normals: normals, uvs: uvs, indices: indices }); var meshInstance = new pc.MeshInstance(node, mesh, material); // Add it to this entity var model = new pc.Model(); model.graph = node; model.meshInstances.push(meshInstance); this.entity.addComponent('model'); this.entity.model.model = model; this.entity.model.castShadows = false; // We don't want the water surface itself to cast a shadow };
Now you can call this in the initialize
function:
Water.prototype.initialize = function() { this.GeneratePlaneMesh({subdivisions:100, width:10, height:10}); };
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.
Waves
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:
Water.prototype.CreateWaterMaterial = function(){ // Create a new blank material var material = new pc.Material(); // A name just makes it easier to identify when debugging material.name = "DynamicWater_Material"; // Create the shader definition // dynamically set the precision depending on device. var gd = this.app.graphicsDevice; var fragmentShader = "precision " + gd.precision + " float;\n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; // A shader definition used to create a new shader. var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; // Create the shader from the definition this.shader = new pc.Shader(gd, shaderDefinition); // Apply shader to this material material.setShader(this.shader); return material; };
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):
Water.attributes.add('vs', { type: 'asset', assetType: 'shader', title: 'Vertex Shader' }); Water.attributes.add('fs', { type: 'asset', assetType: 'shader', title: 'Fragment Shader' });
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.
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:
void main(void) { vec4 color = vec4(0.0,0.0,1.0,0.5); gl_FragColor = color; }
And this in Water.vert:
attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); }
Finally, go back to Water.js and make it use our new custom material instead of the standard material. So instead of:
var material = new pc.StandardMaterial();
Do:
var material = this.CreateWaterMaterial();
Now, if you launch the game, the plane should now be 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:
// initialize code called once per entity Water.prototype.initialize = function() { this.GeneratePlaneMesh(); // Save the current shaders this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; };
And in the update, check if there have been any changes:
// update code called every frame Water.prototype.update = function(dt) { if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){ // Re-create the material so the shaders can be recompiled var newMaterial = this.CreateWaterMaterial(); // Apply it to the model var model = this.entity.model.model; model.meshInstances[0].material = newMaterial; // Save the new shaders this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; } };
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:
vec4 color = vec4(0.0,0.7,1.0,0.5);
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:
gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0);
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 uniformvariable 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:
var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader };
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:
attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); }
The plane should now look more rectangular. Nothing strange there. Now what happens if we instead try modifying gl_Position
?
attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; //pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); gl_Position.x *= 0.5; }
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:
uniform float uTime;
Then, to pass this to our shader, go back to Water.js and define a time variable in the initialize:
Water.prototype.initialize = function() { this.time = 0; ///// First define the time here this.GeneratePlaneMesh(); // Save the current shaders this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; };
Now, to pass this to our shader, we use material.setParameter
. First we set an initial value at the end of the CreateWaterMaterial
function:
// Create the shader from the definition this.shader = new pc.Shader(gd, shaderDefinition); ////////////// The new part material.setParameter('uTime',this.time); this.material = material; // Save a reference to this material //////////////// // Apply shader to this material material.setShader(this.shader); return material;
Now in the update
function we can increment time and access the material using the reference we created for it:
this.time += 0.1; this.material.setParameter('uTime',this.time);
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.
Water.prototype.swap = function(old) { this.time = old.time; };
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
:
pos.y += cos(uTime)
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.
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.
Translucency
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:
finalColor = source * 0.4 + destination * 0.6;
In PlayCanvas, the pc.BLEND_NORMAL option does exactly this.
To enable this, just set the property on the material inside CreateWaterMaterial
:
material.blendType = pc.BLEND_NORMAL;
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.
We can fix this by using alpha to coverage, which is a multi-sampling technique to achieve transparencyinstead of blending:
//material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true;
But this is only available in WebGL 2. For the remainder of this tutorial, I'll be using blending to keep it simple.
Summary
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.