Triangles, triangles everywhere

Taking the maths we developed in part 1 and turning it into geometry. The first in a series on GPU shaders.

About Shaders

Shaders are small programs or functions that run on the GPU. I’ll make some broad generalisations here, as this post is about the concepts more than the details, and I’m in no way an expert on GPU programming. But the concepts are important, and will help you understand the rest of the series and how computer graphics work in general.

If you already have a general understanding of graphics pipelines, feel free to skip this section. On the other hand, if you want to dive deeper, I’d suggest checking out The Book of Shaders or Learn OpenGL.

GPUs vs CPUs - two different beasts with very different strengths

Modern CPUs are marvels of engineering, capable of executing complex code and logic with ease. They excel at tasks that require branching, can handle a wide variety of workloads, and execute instructions at unfathomable speeds (hundreds of billions of instructions per second). By and large though, they execute instructions sequentially on a small number of cores, relying on their raw speed to crunch through sequential workloads.

GPUs, on the other hand, are built around the concept of parallelism. They’re vastly more specialised than CPUs, but each core is relatively simple and slow. However, they make up for this by having a lot of them. They also have specialised memory, an order of magnitude faster than normal RAM.

This combination of many cores and fast memory makes GPUs excel at tasks that can be broken down into many smaller, independent tasks that can be executed in parallel. They execute the same instruction across many data points simultaneously - perfect for processing vast numbers of triangles in a 3D scene, or millions of pixels on a screen.

To a GPU, the time it takes for the CPU to read data from system memory and copy it over the bus to GPU memory feels like an eternity (the main bus has a bandwidth of around 64GB/s, thirty times slower than the GPU’s memory). For this reason, GPUs are designed to be fed data in large batches, then left to process it without interruption and draw directly to the screen, without needing to go back to the CPU. Any time we need to pass data between the CPU and GPU, we introduce a bottleneck that forces the GPU to halt and wait.

That all leads to a broad tenet worth keeping in mind for the rest of this series: The CPU will be responsible for all gameplay, simulation, network code (netcode), and user interaction. We’ll call all of this together the gamestate. The GPU will be responsible for taking this gamestate, generating a visual representation, and rendering it to the screen. Think of the CPU as the brains of the game, tracking everything that’s going on and deciding what happens next, and the GPU as a window into that state, responsible for conveying it to the player in a visually compelling way, but never contributing to it.

Surface mesh and the motion of the ocean

In the previous post, we covered constructing a convincing ocean mathematically, using a sum of Gerstner waves. The result was a function that allows us to find the position of any point on the surface, referenced from a flat plane: the displacement.

In this post, we’ll take the function and use it to render that surface to the screen. To do that, we’ll write small programs for the GPU, called shaders.

Graphics pipeline - from CPU to GPU

You can think of the graphics pipeline as a series of steps that take data from the CPU and decide how it should be displayed on the screen.

The CPU knows there’s a light over here, our camera is over there, and our objects have a certain shape (mesh) and texture (colours) - all the relevant context about our 3D world. The GPU takes that information and uses it to decide what colour each pixel on the screen should be. Each step in the process is called a shader stage, and each stage has a specific role in the process.

A simplified graphics pipeline. The CPU pushes the gamestate; the GPU turns it into pixels. The two stages we program are the vertex shader (shapes the mesh) and the fragment shader (colours each pixel). The GPU then sends this directly to the screen - the CPU never sees the final pixels.

The full pipeline has many intermediate steps and is beyond the scope of this post (and indeed my expertise), but the two stages we particularly care about are called the vertex shader and the fragment shader. If you get interested in graphics programming, you’ll see these two come up a lot.

The Vertex Shader - shaping the world

A vertex shader is a program that runs on the GPU for every vertex in a mesh. The shader takes each vertex’s position and applies transformations to it, such as moving it around or rotating it.

In the context of this game, the CPU doesn’t actually need to know every detail about the ocean surface. It only needs to know the position of a few points, specifically anywhere we need to calculate buoyancy or physics - a few points for each floating object, which is perhaps only a few dozen points in total. Everything else doesn’t actually affect the gamestate and is visual only.

We express the ocean surface as a mesh of triangles, but we don’t need to calculate the position of every vertex on the CPU. Instead, we can hand over a flat mesh to the GPU, and let the vertex shader do the work.

The vertex shader’s job is to take the flat mesh and shape it, using the function we developed in the previous post. It takes each vertex of the mesh, applies the displacement function to it, and outputs its new position. Crucially, it does this in parallel for every vertex at the same time.

vertex shader — flat row → wave
The dotted line is a single row of the mesh, sliced sideways. Each arrow is a single vertex's displacement vector. The solid line is the final shape. Drag Displacement from 0 (the flat mesh the CPU hands over) up to 1 (the final shape).

The Fragment Shader - colouring the world

Now that we have our final mesh geometry, we need to paint it onto the screen. The GPU does this by breaking down each triangle into a series of fragments.

You can think of a fragment as something that has the potential to be a pixel on the screen. Some fragments will be discarded, due to being occluded by other geometry, being too small or distant, or a number of other ways the GPU culls fragments; but ultimately every pixel on the screen is coloured by a fragment shader. So if the vertex shader is responsible for shaping the world, the fragment shader is responsible for painting it.

The vertex shader operated on the corners of each triangle, but it’s the faces of the mesh that we actually see, so another step in the pipeline, called the rasteriser, is responsible for breaking the mesh into fragments and passing them to the fragment shader. The fragment shader then works out how to paint each fragment.

In the simplest case, we can just check the colour of the fragment’s material (texture), and use that:

// The simplest possible water fragment shader: colour straight from the triangle's material.
    return texture(material, uv);
Flat: Every fragment gets its colour directly, with no consideration for lighting. This might look like a screenshot, but it's actually a video - the mesh really is moving.

This looks …terrible. We haven’t accounted for light, or the fact that the ocean is translucent, or that it refracts light, or that it scatters light, or that it reflects the sky, etc.

We still won’t do most of those things, but we can add some basic lighting with only a few lines of code.

By comparing the direction the triangle is facing (its normal) to the direction of the sun, we can determine how much light is hitting it and shade the texture colour accordingly. This is called diffuse lighting, and it gives us a sense of depth and shape.

// Basic diffuse lighting: colour from the texture, adjusted for how squarely it faces the sun.
    vec3 LightDirection = normalize(sun_position - fragment_position);
    float IncidentLight = max(dot(Normal, LightDirection), 0.0); // how much light hits this triangle. 0: [facing away] to 1: [facing directly into the sun]
    return texture(material, uv) * IncidentLight; // scale the texture colour by how much light hits it, black for no light, full colour for direct light.
Diffuse shading: each triangle is shaded based on its angle to the sun.

An extremely common improvement to this is to use the Phong reflection model, which also takes the direction of the camera into account to add a specular highlight, giving it a shiny appearance. By controlling the relative strength of the diffuse and specular components, we can make the surface look more or less shiny.

// Phong reflection model: colour from the texture, adjusted for how directly it faces the sun, and how it reflects toward the camera.
    vec3 LightDirection = normalize(sun_position - fragment_position);
    float IncidentLight = max(dot(Normal, LightDirection), 0.0); // how much light hits this triangle. 0: [facing away] to 1: [facing directly into the sun]

    vec3 ViewDirection = normalize(camera_position - fragment_position);
    vec3 ReflectionDirection = reflect(-LightDirection, Normal);
    float SpecularFactor = pow(max(dot(ViewDirection, ReflectionDirection), 0.0), Shininess); // how much light is reflected directly toward the camera

    return texture(material, uv) * IncidentLight + (SpecularFactor * SpecularColour); //Same as before, but add the specular highlight on top of the diffuse colour.
Phong reflection: the specular highlight adds a shiny glint where the sun reflects toward the camera

That’s not normal - the illusion of a smooth surface

Right about now you might be thinking that still looks like a bunch of triangles, not a smooth ocean. To make it convincing we need more detail, and there are a few ways to get it.

An obvious one is just to literally have more detail, by adding triangles. Wavebreakers does do this selectively using a process called level of detail (LOD, a topic for another post), but it comes at a cost - more triangles means more vertices, which means more calculations in the vertex shader and more data to pass around.

But there’s also a way to fake it. We could give the illusion of more detail, if we could treat the triangles as curves, not flat facets. The trick is to give each fragment its own normal, by smoothly interpolating between each vertex normal, rather than just the normal of the triangle as a whole. This is called Phong Shading, and it allows us to make the surface look smooth without actually adding more triangles.

In fact, my examples so far have been slightly simplified, as the rasteriser already does this for us automatically, passing interpolated normals to the fragment shader.

vertex normals, interpolated
The vertex shader computes a vertex normal (the big arrows) at each vertex. The rasteriser then interpolates them across each face, giving every fragment its interpolated normal (small arrows).
The same mesh with interpolated normals: shading now varies smoothly across each triangle, hiding the facet edges and giving the illusion of a continuous, curved surface.

By this point we have a somewhat recognisable ocean, but the Phong reflection model makes it look like shiny plastic, not a translucent ocean. There are many more things we can add to make it more convincing, but they all build on this same basic approach.

In the next post we’ll explore adding depth, and use lookup tables (LUT) to apply our own stylistic choices and start to dial in the game’s visual style.