Flat Shading in WebGL with PlayCanvas — A Quick Tip

Omar Shehata
6 min readDec 29, 2017

I recently tried to get flat shading working for a procedurally generated terrain in PlayCanvas for a game jam. It wasn’t as straightforward as I expected, and I ended up spending the whole time trying to figure it out instead of making a game. I thought I’d write this quick guide as a reference should you find yourself in the same situation.

The Desired Look

The goal is to create something just like in the PlayCanvas homepage.

Figure 1

This “low poly” look creates the retro feel I was going for. Normally you can do this just by exporting your models with flat shading. In Blender, you can just select your model and toggle flat/smooth shading in the tools panel.

Figure 2

The problem is when you’re trying to do this for procedurally generated models, like a terrain. In the image below, the left is what I got following the PlayCanvas tutorial on terrain generation and on the right is what I wanted.

Figure 3

The Problem With WebGL

The generated geometry itself is not smooth, and looks like the right half of the image above. The reason the left half looks smooth is because of a modern graphics trick where the normals between vertices are interpolated.

If we took a flat plane and bent it like this:

Figure 4

The true normals along the surface would look like this:

Figure 5

If you want this surface to look smoother you could create many more subdivisions, but that would be costly to render. Instead, you can just interpolate the normals at each pixel, so that when you apply your lighting, it will look smooth.

Figure 6

Figure 6 shows you what the normals look like all pointing towards the true normal vs if they gradually interpolate between the normals at the vertices. This interpolation is not a subtle effect. In Blender, this would be the difference between the two versions of this model:

Figure 7

Interpolating the normals is a handy trick the hardware handles for us. The GLSL docs mention a flat keyword that can turn this interpolation off, but this is not supported in WebGL.

The Solution

All we have to do is simply duplicate the vertices. It’s so “simple” in fact that I couldn’t find any explanation of what it meant to duplicate the vertices or why that fixed anything. It’s only straightforward if you know how the geometry is stored and sent to the GPU, but these details are usually hidden away in high level frameworks, so I’m going to spell it out here.

Let’s look at how a flat plane like this is rendered:

Figure 8

We know the building block of all of our objects is a triangle, so this plane needs to be decomposed into triangles somehow. To communicate information about what triangles to draw to the GPU, WebGL uses buffers. You can think of a buffer conceptually as just a simple flat array.

There are two buffers that we’re concerned with here: the vertex buffer and the index buffer.

The vertex buffer is a list of coordinates. For the plane above, it might look something like this:

vertexBuffer = [0,5,0, 5,5,0, 5,0,0, 0,0,0]

Where each triplet is the coordinate of one point. There are 12 numbers in this array, which correspond to the 4 points of our plane.

The order of the points in the vertex buffer does not matter, because the index buffer specifies which triplets make up a triangle. It might look something like this:

indexBuffer = [0,1,3, 1,2,3]

This says that the first triangle is made up of the 0th, 1st and 3rd points. Another triangle is made up of the 1st, 2nd and 3rd points.

Note that 1 in the indexBuffer does NOT refer to vertexBuffer[1]. It refers to the 1st triplet, which would be the three numbers 5,5,0 .

Notice how there are two shared vertices between these two triangles. The indices 1 and 3 appear twice.

Remember that the normals are interpolated between vertices. In this particular case of a flate plane, all the normals at the vertices are pointing straight towards the camera, so even after interpolation, they’re all pointing in the same direction. Let’s look at what happens when our surface is not flat.

Figure 9

It’s obvious where the normals at 0 and 2 should be pointing (same direction as the normal of the triangle they’re attached to), but what about vertices 1 and 3? They belong to two different triangles, whose normals point in different directions. The way shared vertices are handled is that their normal is an average of the normals of the triangles they are attached to. So in this case, the normals at 1 and 3 point straight towards the camera (just like the normals in the center in figure 6).

And herein lies the issue. Consider triangle 0,1,3. The normal at 0 points towards the left. The normals at 1 and 3 point straight up. Any pixel on the triangle will have an interpolated normal between those, which will give you smooth shading.

To avoid this, we have to restructure our data somehow such that none of the vertices are shared. That way each vertex’s normal will match the triangle’s normal it belongs to. The interpolation will still happen, but interpolating between normals of equal value will be the same as not interpolating at all.

If we don’t want any shared vertices, we need to create copies so that each triangle can have its own. Specifically, we want to have two of vertex 1 and two of vertex 3 so that each triangle can get one. Our new geometry would look like this:

Figure 10

Our two triangles are now 0,1,4 and 2,3,5. This gives us a total of 6 vertices, where each triangle gets 3 unique points. Our buffers would be reorganized as follows:

Before:

vertexBuffer = [0,5,0, 5,5,0, 5,0,0, 0,0,0]
indexBuffer = [0,1,3, 1,2,3]

After:

vertexBuffer = [0,5,0, 5,5,0, 5,5,0, 5,0,0, 0,0,0, 0,0,0]
indexBuffer = [0,1,4, 2,3,5]

So “duplicate vertices” means duplicate all shared vertices such that each triangle has 3 unique points. An invariant that should always be true is that you should have as many vertices (triplets in the vertexBuffer) as indices (actual elements in the indexBuffer). In other words:

indexBuffer.length == (vertexBuffer.length / 3)

You can see an actual code example of this in this project. In particular, line 120 in terrain.js defines a function to duplicate all necessary vertices.

If you found this tutorial useful, I’d love to hear what you were working on/how you stumbled on it! Sign up to my newsletter to follow my WebGL/WebGPU work & stay in touch.

--

--

Omar Shehata

Graphics programmer working on maps. I love telling stories and it's why I do what I do, from making games, to teaching & writing. https://omarshehata.me/