Intro to 2D signed distance functions and drawing touch controls with the GPU

Touch controls are a very important part of every mobile game. Due to the lack of haptic feedback, you want your touch controls to convey some kind of "feeling". This is accomplished in 2 ways:

  1. How your player responds when the touch controls are used - the "feel"
  2. How your touch controls look and appear to interact with the user

I in this article I am going to show you a unique way to present touch controls. To be completely honest most mobile game joysticks and touch controls look pretty terrible. I will first introduce signed distance functions, and then show you how to make a satisfying elastic joystick.

Why use signed distance functions?

I develop a game called Gate Escape which originally used software rendering to draw touch controls. My most recent project was to offload this work to the GPU. I was recently inspired by this video which uses ray marching to render signed distance functions as 3d shapes. Signed distance functions can also be rendered in 2d which has several advantages over raster graphics for simple shapes:

  • Pixel perfect at any resolution/scale.
  • Can apply union, intersection etc to shapes to combine/negate the functions to derive new shapes.
  • Can apply more complicated blending and mixing to create interesting effects than you can with raster graphics.
  • Runs 100% on the GPU (no CPU overhead)

In application, they are often called signed distance fields. Here is an example of the final result from Gate Escape:

The rest of the article will give code examples in GLSL and use https://www.shadertoy.com as a preview.

What is a signed distance function?

A signed distance function is a function that represents a shape that takes a point as an input and returns the shortest distance to that shape. The sign of the output value determines if the point exists inside the shape or not. If you want a better explanation, check out this article which breaks it down into a lot more detail.

The simplest SDF is a circle:

float circleDist(vec2 p, float radius) {
    return length(p) - radius;
}

Let's draw a circle with a GLSL fragment shader:

vec2 point = fragCoord.xy;
float circle = circleDist(point, 100.0);
// Choose red or black based on the distance to the circle
vec3 col = mix(vec3(1.0, 0.0, 0.0), vec3(0.0), clamp(circle, 0.0, 1.0));
fragColor = vec4(col, 1.0);

(if you don't see anything below your browser probably does not support WebGL).

Source Code

So what is your GPU actually doing? I am not going to go into great detail on how fragment shaders work, but essentially your GPU will perform this calculation once for each pixel and try to do as many as possible in parallel. The reason our circle is aligned at the bottom left is we are always measuring the distance of point (which is our fragment coordinate - or rather the pixel position in the viewport) to the origin (0, 0) and the origin is located at the bottom left of the screen. So for each pixel coordinate our code will test to see if it exists within the circle with a radius of 100. If the result of circleDist(point, 100.0) > 0 the point is outside the circle and our shader colors it black. If the distance to the point is < 0 it exists inside the circle and our shader colors it red.

Eventually our shader will evaluate this for every pixel and you get a nice picture on the screen. Let's add some fun colors and move our circle to the center of the screen. We can move the circle by subtracting the point by the coordinates for the center of the screen.

vec2 center = iResolution.xy / 2.0;
float circle = circleDist(point - center, circleRadius);

See the full source in the link below on how the colors are added.

Source Code

Combining signed distance functions

One of the cool properties of SDFs is that you can combine them to create composite shapes. Lets define some shapes and setup a scene:

// the sample point and the center of the screen
vec2 point = fragCoord.xy;
vec2 center = iResolution.xy / 2.0;

float circleRadius = 100.0;
    
vec2 circle1Point = vec2(center.x / 2.0, center.y);
vec2 circle2Point = vec2(center.x * 1.5, center.y);
// This circle moves and uses time as an input
vec2 circle3Point = vec2(center.x + sin(iTime) * 200.0, center.y);

Compute the distances of our circles:

float circle1 = circleDist(point - circle1Point, circleRadius);
float circle2 = circleDist(point - circle2Point, circleRadius);
float circle3 = circleDist(point - circle3Point, circleRadius / 2.0);

Now that we have distances to a few circles defined we can combine them in various ways:

Union of shapes

The union is found by taking the min of two distances:

float dist = min(circle1, circle2);
dist = min(dist, circle3);

This lets our 3 circles blend together and appear to pass through each other.

Source Code

Intersection of shapes

The intersection can be found by taking the max of two distances, or in this case the min of the inverted first distance. (taking the max in this case would invert the shape intersection - try it in shader toy!).

float dist = min(circle1, circle2);
dist = min(-dist, circle3);

Source Code

Smooth minimum

This all seems pretty uninteresting - but it gets better. Instead of finding a simple union or intersection, you can compute what is called a smooth minimum.

float smin(float a, float b, float k) {
    float h = max(k - abs(a - b), 0.0) / k;
    return min(a, b) - h * h * k * (1.0 / 4.0);
}

I will not go into detail on how it works, that is much better explained here. But essentially this allows you to take the union of two objects, and smooth out the intersection point. This allows shapes to blend together as they get near each other. The gravity of the "blending" can be adjusted by the variable k in the function above. The result is very satisfying:

Source Code

Drawing the virtual joystick

Finally, let's combine everything above to create a cool joystick for a mobile game with an elastic effect.  First we have to clip the mouse to a max radius:

 if (distance(mouse, center) > circleRadius) {
   mouse = mix(center, mouse, circleRadius / distance(mouse, center));
}

Then we can define our circles:

// center of the view
vec2 circle1Point = center; 
// a point thats 90% between the center and the mouse
vec2 circle2Point = mix(center, mouse, 0.9); 
// the mouse position
vec2 circle3Point = mouse;

Compute the distances to each circle:

float circle1 = circleDist(point - circle1Point, circleRadius);
float circle2 = circleDist(point - circle2Point, circleRadius / 3.0);
float circle3 = circleDist(point - circle3Point, circleRadius / 3.0);

Take the smooth min of circle1Point and circle2Point:

float dist = smin(circle1, circle2, 80.0);

And intersect the the shape with the stick circle:

dist = min(-dist, circle3);

Click with your mouse and drag around! (sorry mobile users - Shader Toy's touch handling is not great)

Source Code

Closing remarks

In order to make this work for your game, you will need to provide uniform inputs for the touch points on the screen. Shader toy handles this for you for simple inputs like the mouse (which are used in the examples).

Rendering 2d simple UI like touch controls is a cool and useful application of signed distance functions. Another huge source of inspiration was this super cool shader toy that really shows how far you can take this effect!

I hope you found this helpful or insightful - if you want to see it in action please check out Gate Escape on the App Store!