Sometimes you need a mask of a certain shape and size. Image making software like Photoshop or Krita are great for this but you may want to be able to position, resize, and change its properties on the fly without creating an animated texture.
You can use shaders to create masks that have plenty of shapes using some trigonometry and math.
In this tutorial, we’ll create a donut shaped mask we can use in other shaders.
The mathematical term of donut is torus, so both terms will be used interchangeably.
A donut is like a full ellipse with its middle subtracted by a smaller ellipse. An image of an ellipse has the following properties:
Mathematically, that’s all you need, but we can include two more useful properties for our purposes to use it as a mask:
We start by adding the properties we listed with some sensible ranges and default values.
shader_type canvas_item; uniform float torus_thickness : hint_range(0.001, 1.0) = 0.5; uniform float torus_hardness = 1.0; uniform float torus_radius = 1.0; uniform float torus_invert : hint_range(-1.0, 1.0) = -1.0; uniform vec2 torus_center = vec2(0.5, 0.5); uniform vec2 torus_size = vec2(1.0, 1.0);
The thickness is not allowed to reach 0
because we’ll divide with it (and division by 0
is bad.)
The first step to drawing a torus is to figure out the distance from this particular pixel on screen to the center of the disc which is adjusted by the aspect ratio. With that information, we can determine how close to the center the pixel is (and thus how light or dark it should be.)
We also multiply this vector by the aspect ratio of the torus.
void fragment() { float torus_distance = length((UV - torus_center) * torus_size); }
We are drawing a torus, not a circle, so before we go any further we should get the information for the inner disc. We need the distance from the thickest part of the torus to its edge.
float radius_distance = torus_thickness / 2.0;
Which, subtracted by the radius of the entire torus, gives the radius of the inner circle.
float inner_radius = torus_radius - radius_distance;
We take the distance of the current pixel on screen to the center of the torus and subtract the inner radius. This gives us the distance of the current pixel to the edge of the thickness of the donut.
Once we divide this by the thickness, we get a percentage distance from the thickness. Anything that is outside of this thickness is clamped to 0
(black), whereas something that is in the middle is 1.0
(white.)
We can use abs()
to remove any negative signs because we don’t care about negative values. We just want the relative distance, whether it’s the outer edge to the middle or the middle to the inner edge.
float circle_value = clamp(abs(torus_distance - inner_radius) / torus_thickness, 0.0, 1.0);
To get the final alpha of the mask, we need to figure out how fuzzy or sharp it is with the hardness. We do this by raising the value to the power of the hardness. Raising the circle to the power of another makes small values even smaller, raising the amount of black and grey, and large values become even larger, raising the amount of white.
We also raise the hardness to the power of 2 to help keep the value we need for full hardness smaller, instead of needing to go all the way to 180 for fully hard.
float circle_alpha = pow(circle_value, pow(torus_hardness, 2.0));
Finally, we can change whether the mask should affect where the torus is, or where it is not, by checking if we should invert the mask. Because it’s a gradient from -1.0
to 1.0
, we can also control the intensity of the mask by approaching 0
.
If the inversion is below 0
, we want to get the inverse of the mask multiplied by the intensity of the inversion. We could use an if statement, something like:
float mask; if(torus_invert > 0.0) { mask = circle_alpha * torus_invert; } else { mask = (1.0 - circle_alpha) * abs(torus_invert); }
But GPUs work best when they don’t have to diverge and do one code instead of another. After all, shaders can run millions of times in chunks so it’s best practice to turn conditions into math when possible to keep them performing optimally.
float mask = abs(clamp(abs(sign(torus_invert)) - sign(torus_invert), 0.0, 1.0) - circle_alpha) * abs(torus_invert);
sign(torus_invert)
returns -1.0
if negative, 0.0
if 0
, and 1.0
if positive. Putting this inside of abs()
turns this into 1.0
, 0.0
or 1.0
.
- sign(torus_invert)
turns that into 2.0
, 0.0
, or 0.0
, so we clamp it to the range of 0.0
and 1.0
.
The rest of the logic is the same as the if statement version.
We can output this to a texture by applying this final mask value as the alpha of a flat white color.
COLOR = vec4(vec3(1.0), mask);
For a final shader:
shader_type canvas_item; uniform float torus_thickness : hint_range(0.001, 1.0) = 0.5; uniform float torus_hardness = 1.0; uniform float torus_radius = 1.0; uniform float torus_invert : hint_range(-1.0, 1.0) = -1.0; uniform vec2 torus_center = vec2(0.5, 0.5); uniform vec2 torus_size = vec2(1.0, 1.0); void fragment() { float torus_distance = length((UV - torus_center) * torus_size); float radius_distance = torus_thickness / 2.0; float inner_radius = torus_radius - radius_distance; float circle_value = clamp(abs(torus_distance - inner_radius) / torus_thickness, 0.0, 1.0); float circle_alpha = pow(circle_value, pow(torus_hardness, 2.0)); float mask = abs(clamp(abs(sign(torus_invert)) - sign(torus_invert), 0.0, 1.0) - circle_alpha) * abs(torus_invert); COLOR = vec4(vec3(1.0), mask); }
We can apply this to a ColorRect
, TextureRect
or a Sprite
and use it as a graphical element. This can be useful to preview what your mask will look like, and you can play around with the aspect ratio to get the fuzziness, size and positioning you’re after.
But a mask is only as useful as the effect it’s helping, which we’ll cover next by creating a shockwave effect.