This blog post will show how to create a shader that animates waves like on the ocean. All with a low poly (or flat shaded) look! It is geared towards beginners with some knowledge of Elm. This is what the end result looks like:

example of the shader running on a plane

This weekend I tried playing around with shaders for the first time. I had a basic understanding of how Elm and WebGL work together to draw interesting things on the screen. What really helped me was reading the documentation in the Elm WebGL package. It will do way better explaining how it works than I can.

So what we need to do is create a plane mesh, consisting of triangles. This mesh will go through the vertex shader to change the positions for every vertex (every corner of a triangle is a vertex.) So the steps we will touch are:

1) Draw a single triangle

2) Draw a square

3) Draw multiple squares next to each other

4) Draw a plane

5) Animate the plane to show waves

6) Add a low poly look to the water

Drawing a single triangle

As a start I used the triangle example from the WebGL package. You can clone the repository where the example lives and run git checkout step1 . Run elm reactor and open http://localhost:8000/src/Main.elm. If everything went correct you should something similar:

It is a triangle rotating around the y axis. We will modify this example to draw a triangle without rotating it, and we will draw it on the X and Z axis so it will be the start of a horizontal plane. This means the following changes need to be done:

-- We will change the type of uniforms so the logic describing the --- camera and perspective are easier to understand

type alias Uniforms =

{ perspective : Mat4

, camera : Mat4

, t : Float

}

-- The camera and perspective are defined separately

perspective : Mat4

perspective =

Mat4.makePerspective 45 1 0.01 1000 -- The camera will look at the triangle from distance

camera : Mat4

camera =

Mat4.makeLookAt (vec3 0 25 25) (vec3 0 0 0) (vec3 0 1 0)

-- The vertex coordinates will change to show the triangle

-- horizontal

mesh : Mesh Vertex

mesh =

WebGL.triangles

[ ( Vertex (vec3 1 0 0) (vec3 1 0 0)

, Vertex (vec3 0 0 -1) (vec3 0 1 0)

, Vertex (vec3 0 0 1) (vec3 0 0 1)

)

]



Please check the diff for all related changes or run git checkout step2 to continue. There are some changes to the shader to use the separated perspective and camera.

Draw a square

A single square is drawn of two triangles.

-- A new type describing a triangle

type alias Triangle =

( Vertex, Vertex, Vertex )

-- A square is an array of Two Triangles

square : List Triangle

square x y =

[ ( Vertex (vec3 -1 0 -1) (vec3 1 0 0)

, Vertex (vec3 -1 0 1) (vec3 0 1 0)

, Vertex (vec3 1 0 1) (vec3 0 0 1)

)

, ( Vertex (vec3 1 0 1) (vec3 1 0 0)

, Vertex (vec3 1 0 -1) (vec3 0 1 0)

, Vertex (vec3 -1 0 -1) (vec3 0 0 1)

)

]

mesh : Mesh Vertex

mesh =

let

vs =

square 0 0

in

WebGL.triangles

vs

Changing this will render the following image (run git checkout step3 to get here):

Draw multiple squares next to each other

To draw multiple squares we can actually use the arguments to the square function. We add the Float x to the vertex coordinates on the X axis and add the Float z to the coordinates on the Z axis.

-- This will create vertices for a single square offset by the

-- two floats x, z

square : Float -> Float -> List Triangle

square x z =

[ ( Vertex (vec3 (-1 + x * 2) 0 (-1 + z * 2)) (vec3 1 0 0)

, Vertex (vec3 (-1 + x * 2) 0 (1 + z * 2)) (vec3 0 1 0)

, Vertex (vec3 (1 + x * 2) 0 (1 + z * 2)) (vec3 0 0 1)

)

, ( Vertex (vec3 (1 + x * 2) 0 (1 + z * 2)) (vec3 1 0 0)

, Vertex (vec3 (1 + x * 2) 0 (-1 + z * 2)) (vec3 0 1 0)

, Vertex (vec3 (-1 + x * 2) 0 (-1 + z * 2)) (vec3 0 0 1)

)

]

-- We use the new function to create squares and combine them in a

-- single list

mesh : Mesh Vertex

mesh =

let

vs =

square 0 0 vs2 =

square 0 1 vs3 =

square 1 0 vs4 =

square 1 1

in

WebGL.triangles

(vs ++ vs2 ++ vs3 ++ vs4)

(Don’t forget you can run git checkout step4 to get here)

Draw a plane

A single square could be considered a plane as well. But as we are trying to get the plane to animate waves, having more vertices allows for smoother animations. So instead of one square we will draw multiple rows of squares:

-- Draw a single row of squares with an offset on the Z axis

rowOfSquares : Int -> Float -> List Triangle

rowOfSquares size z =

let

list =

List.range 0 size fn x =

square (toFloat x) z

in

List.concatMap fn list

-- Create a list of rows and combine them to one Mesh

mesh : Mesh Vertex

mesh =

let

size =

16 list =

List.range 0 size fn x =

rowOfSquares size (toFloat x) rows =

List.concatMap fn list

in

WebGL.triangles

rows



This will get you to (a boring) plane:

Animate the plane to show waves

Now we get to do some shader work. As mentioned in the linked WebGL package (please make sure you have read it) a vertex shader loops over all vertices in the mesh and lets the computer know where on the screen the current vertex is located. This is currently already happening in the previous steps, we apply the perspective and position & direction of the camera to the position of the vertex. That is why the vertices don’t move yet, we don’t do anything with them yet.

To make the plane move like ocean waves we will use a sine wave. If we would use a single sine wave it would be a very boring wave, so we combine a few sine waves together to make for a more natural movement.

In the shader we have access to the X and Z coordinates of the vertex. We will calculate the Y coordinate; that will be the height of the surface. Our goal is to let the height of the surface change over time, creating a wave. This is the new main function for the vertexShader (calculateSurface will be defined next.)

void main () {

float y = calculateSurface(position.x, position.z);

vec3 newVertexCoord = vec3(position.x, y, position.z);



gl_Position = perspective *

camera *

vec4(newVertexCoord, 1.0);



vcolor = color;

}

The height of the surface will be calculated using multiple sine waves. One for the X coord and one for the Z coord, to make the waves animate in both directions, if we would only use one coord the waves would animate over that axis.

Remember the uniform in the beginning of the post?

type alias Uniforms =

{ perspective : Mat4

, camera : Mat4

, t : Float

}

Uniforms are arguments you can pass to the shader, and use the values in the calculations. The variable t is changed by an Elm subscription to RequestAnimationFrame. This will be an ever increasing number. We use this variable to animate the sine waves over time. That is why we add the value of t to the coordinates before we calculate sine. This will give the effect of moving the sine wave over time.

float calculateSurface(float x, float z) {

float scale = 10.0;

float y = 0.0;

y += (sin(x * 1.0 / scale + t * 1.0) + sin(x * 2.3 / scale + t * 1.5) + sin(x * 3.3 / scale + t * 0.4)) / 3.0;

y += (sin(z * 0.2 / scale + t * 1.8) + sin(z * 1.8 / scale + t * 1.8) + sin(z * 2.8 / scale + t * 0.8)) / 3.0;

return y;

}

This is wat the plane looks like after the shader changes. You can look at the diff or run git checkout step6 to get to the same result.

Add a low poly look to the water

To create a low poly look, also called flat shaded we need to have every triangle get it’s own unique color. the hard edges between the different triangles will get you that particular look. In some instances you can calculate the way the light source that hits every triangle and use that to let every triangle get it’s own intensity of that reflection and color. Because this is a simple scene and there is no concept of light, we will use another method.

You can check the master branch for the final state. I did some refactoring;

Every single Vertex now not only has the current position, but also the three vertices of the triangle he belongs to:

type alias Vertex =

{ position : Vec2

, vert1 : Vec2

, vert2 : Vec2

, vert3 : Vec2

}

square : Float -> Float -> List Triangle

square x z =

let

no =

(vec2 (1 + x * 2) (-1 + z * 2)) nw =

(vec2 (-1 + x * 2) (-1 + z * 2)) so =

(vec2 (-1 + x * 2) (1 + z * 2)) sw =

(vec2 (1 + x * 2) (1 + z * 2))

in

[ ( Vertex nw nw so sw

, Vertex so nw so sw

, Vertex sw nw so sw

)

, ( Vertex sw sw no nw

, Vertex no sw no nw

, Vertex nw sw no nw

)

]

This allows us to get at every vertex in the vertexShader the positions of all the corners of the current triangle. We use a bit of a confusing trick to calculate something that is called a surface normal. A surface normal is somewhat like the orientation of the triangle, or what direction the surface of the triangle is pointing to. This property is described by a vec3 which consists of three floats. As these floats will be the same for all the vertices of the same triangle, we could use these floats to set the color in the fragment shader. This guarantees that every triangle will get a single, solid colour.

Credit where credit is due, the sources/articles I used as inspiration and information. I can’t find all of them, but here are some in no particular order: