Sobel shader

In this bonus lesson, we use a post-processing shader to trace the edges of our landmasses and biomes.

Here is the shader we got in the previous part:

Here is the result you will get at the end of this lesson, using the Sobel shader.

To achieve this effect, we need to do a bit of post-processing on the GPU with the help of shaders once again.

Open the WorldMap.tscn scene if you haven’t done so and add a new ColorRect node. Rename it to PostProcess. At this point you should have this scene tree:

WorldMap
├── Viewer
└── PostProcess

Make sure you have PostProcess selected in the Scene docker. From the 2D toolbar at the top, select Layout > Full Rect to expand the control node to cover the entire viewport. In the Inspector, add a new shader material under Material and add a new shader. You can find our implementation as sobel.shader under the folder you copied from our demos project.

Let’s go over the implementation. We start with a few uniform variables that allows us to control the edge thickness and edge color from the Inspector:

shader_type canvas_item;


uniform vec4 edge_color : hint_color;
uniform vec2 edge_width = vec2(0.1);

Next we have the sobel() function. This is a common operator in the image processing and shader world. You can learn more on it on its wikipedia page.

float sobel(sampler2D tex, vec2 uv, vec2 pixel_size) {
    vec2 edge_width_pixel = edge_width * pixel_size;

    vec4 h = vec4(0.0);
    vec4 v = vec4(0.0);

    h +=       texture(tex, uv + vec2(-1.0, -1.0) * edge_width_pixel);
    h -=       texture(tex, uv + vec2( 0.0, -1.0) * edge_width_pixel);
    h += 2.0 * texture(tex, uv + vec2(-1.0,  0.0) * edge_width_pixel);
    h -= 2.0 * texture(tex, uv + vec2( 1.0,  0.0) * edge_width_pixel);
    h +=       texture(tex, uv + vec2(-1.0,  1.0) * edge_width_pixel);
    h -=       texture(tex, uv + vec2( 1.0,  1.0) * edge_width_pixel);

    v +=       texture(tex, uv + vec2(-1.0, -1.0) * edge_width_pixel);
    v += 2.0 * texture(tex, uv + vec2( 0.0, -1.0) * edge_width_pixel);
    v +=       texture(tex, uv + vec2( 1.0, -1.0) * edge_width_pixel);
    v -=       texture(tex, uv + vec2(-1.0,  1.0) * edge_width_pixel);
    v -= 2.0 * texture(tex, uv + vec2( 0.0,  1.0) * edge_width_pixel);
    v -=       texture(tex, uv + vec2( 1.0,  1.0) * edge_width_pixel);

    return sqrt(dot(h, h) + dot(v, v));
}

The sobel operator works by sampling copies of the input texture in different directions horizontally (h above), then vertically (v above). Doing so is called applying a convolving a kernel. This means that for each pixel or fragment in our shader, we add values of specific adjacent pixels weighted by our convolution kernel, a 3x3 matrix in this case.

These kernels are not applied following regular matrix calculations. Instead, they represent offsets and weighted factors to sample and add or subtract the values of adjacent pixels.

In the case of the Sobel filter, the values in the kernel allow you to calculate the change in intensity between pixels in the image. In turn, as the biggest change in intensity is located around edges, we can use that to extract the edges of our image.

Here is the kernel for the horizontal axis. Note how all values in the second column are equal to zero, which is why we sample our texture six times.

1 0 -1
2 0 -2
1 0 -1

We apply it with the following code:

h +=       texture(tex, uv + vec2(-1.0, -1.0) * edge_width_pixel);
h -=       texture(tex, uv + vec2( 0.0, -1.0) * edge_width_pixel);
h += 2.0 * texture(tex, uv + vec2(-1.0,  0.0) * edge_width_pixel);
h -= 2.0 * texture(tex, uv + vec2( 1.0,  0.0) * edge_width_pixel);
h +=       texture(tex, uv + vec2(-1.0,  1.0) * edge_width_pixel);
h -=       texture(tex, uv + vec2( 1.0,  1.0) * edge_width_pixel);

And here is the kernel for the vertical axis. Note how all values in the second column are equal to zero, which is why we sample our texture six times.

1 2 1
0 0 0
-1 -2 -1

We apply it with this code:

v +=       texture(tex, uv + vec2(-1.0, -1.0) * edge_width_pixel);
v += 2.0 * texture(tex, uv + vec2( 0.0, -1.0) * edge_width_pixel);
v +=       texture(tex, uv + vec2( 1.0, -1.0) * edge_width_pixel);
v -=       texture(tex, uv + vec2(-1.0,  1.0) * edge_width_pixel);
v -= 2.0 * texture(tex, uv + vec2( 0.0,  1.0) * edge_width_pixel);
v -=       texture(tex, uv + vec2( 1.0,  1.0) * edge_width_pixel);

The last line, where we take the square root of the squares of h and v, calculates the magnitude of the change for each fragment.

return sqrt(dot(h, h) + dot(v, v));

This produces a mask where edges have a value close to 1.0 and the rest of the image is black.

Note how we use pixel_size which is the inverse of the game’s resolution, as we’ll see in fragment().

To apply the sobel effect, you can use the following fragment() function:

void fragment() {
    float s = sobel(SCREEN_TEXTURE, SCREEN_UV, SCREEN_PIXEL_SIZE);
    COLOR = texture(SCREEN_TEXTURE, SCREEN_UV);
    COLOR = mix(COLOR, edge_color, s);
}

Here we:

  1. Get the sobel() result which will either be close to 1 if an edge is detected or 0 otherwise.
  2. Get the screen-space texture color without any adjustments.
  3. mix() the screen-space texture color with edge_color based on s, the Sobel value.

The shader produces the following picture:

This concludes our lesson on world map generators. We hope you enjoyed it and that it will help you implement wonderful procedural world maps for your games!