Last week, I released an app that I've been making for the past month. Functionally, it's a very simple tool, and all of the "backend" of it has been completed within the first few days.
However, it still took a whole month to complete. Why? I decided to make it ambitiously good-looking, smooth, and just fun to interact with. And to achieve that goal I also decided to create all of the UI using shaders. Now, the UI is still made out of standard nodes in Godot, all of the input processing and data connections are of course done with scripts. Scripts are also used for driving tweening/transitions.
But the visuals of these widgets are made entirely by your GPU at the runtime. No pre-drawn textures, pure math and visual effects. And in this article I would like to talk about my process creating such UI widgets.
No surprise where everything starts, right? The style that I picked for my app was skeuomorphism, more or less. I wanted all the knobs, buttons, and toggles to resemble some analog controls that you might find on a real DJ deck or audio equipment.
So naturally, there are a lot of sources for inspiration here. I kind of had a vision for some of the elements in my mind already. But I wouldn't recommend keeping it that way, solely in your head. Especially, not at first. And, to be fair, I'm not that experienced with shaders. So having a visual aid for what comes next was essential for me.
So, okay, we've looked at a bunch of reference photos to get inspired and now have a widget in mind. For the sake of being less vague, let's consider this switch, which you can often find on electric guitars. I think it's called a pickup switch there specifically, but I'm mainly interested in its flicking behavior. So I'm going to call it a flicker henceforth.
What I would do next is open a designer app and draw the element with a rough approximation of its stylized looks. I envision the flicker to be made of 3 main parts: a sphere on top, a stem, and a ring around its base. I envision it looking rather metallic-y. And I envision us looking at it from the top, with it going either all the way up or all the way down. So that's what I would then draw.
Since it's just a drawing, I make no effort to ensure that what I draw is implementable in the way I draw it. Basically, it's a newspaper cutout ransom note, not a carefully written letter. But it gives us the general shape and a better idea of the moving parts. Because, yeah, it will be moving!
One thing that you must realize about shaders is that it's just math. It can be a bit confusing at times, but you are not drawing on screen, you are doing calculating floating point values for each pixel. And you do so in parallel, meaning you cannot reference information about other pixels that you are currently calculating. This is important, because that's what makes shaders fast on the GPU. And this is important, because it defines how you should view the effect that you're trying to achieve.
Take a moment to recall your trigonometry classes in school. Don't worry about the specifics of what each trigonometry function does. Just picture a graph. What your teacher would explain to you about the graph is that you have a continuous, infinite scale going from some infinitely large negative number to some infinitely large positive number, and we're just hanging around the 0 for our convenience.
That scale is the X axis, and when we plot a graph we calculate and place a dot corresponding to each X value according to some relation. And that relation is typically represented as f(x). A function of X. That function, that relationship is what we want to figure out for our shader-based effect.
You see, if you make a shader without any underlying texture or noise, your main tool becomes the UV. And UV is kind of like our scale, it just goes in two directions instead of one. When we are given UV and some canvas to draw on, that UV tells us how far along the horizontal and the vertical edge of the canvas the pixel that we try to calculate is — from 0 all the way at the start to 1 all the way at the end.
So it's like coordinates, but it always goes from 0 to 1, no matter the size of your canvas. Coincidentally, colors are also expected to be in this range. Or rather their color components are. And you can use that fact to create a simple gradient by turning, say, the X value of UV into the color output. See where this might be headed? You can do whatever you want with that X value and then put the result of your experiments into the color output instead. You just need to figure out the mathematical relation between the UV that you have and the shape that you need to achieve.
That's my starting point. I look at the mockup that I've made before and try to imagine a way to turn UV into a shape. You don't have to find some magical formula that draws your entire thing. Instead, you can use composition just like you normally would. Remember, our flicker consists of a sphere, a stem, and a ring. These are all very trivial to achieve with shaders.
The title says "black and white", because the UV gives us only one source of data, so all color components — red, green, and blue — will be the same. Which means the output that we can create is grayscale, which is what I tend to envision in my mind too. This gives us the shape. But don't worry, we can colorize everything later.
So now that I know what I need to draw, I can go ahead and start writing the shader.
First of all, a fair warning, I'm not that experienced with shaders. In fact, the shaders that I've written for this project are likely sub-optimal and redundant in places. Thus perhaps don't take the code that I share too seriously. Still, it worked for me, so it'll work for you if you want it.
Second, I'm writing shaders with code, but you can achieve all the same with visual shaders. In fact, nothing of what I'm explaining here is even Godot-specific (except for Y-down coordinates in 2D, I guess).
With that out of the way, how do I approach coding shaders? Throughout this project I established a certain style for that. I always start with defining a vec3 for the color, a float for the alpha, and a vec2 for the UV. These values are usually taken from the predefined variables, such as COLOR and UV, but can also be hardcoded to some neutral value too (not the UV though).
I then implement each element of the widget separately. I start with the shape, a mask that is going to be filled with the contents. This mask serves a lot of purposes: it defines the shape and can be used as a basis for other calculations, it is added to the overall alpha output for the widget, and it can be used to subtract elements from each other, when they must overlap.
Composition of effects and calculations is the key here. With simple additions and multiplications I can do complex shapes or limit shapes in a specific way (such as cut them off at a certain point). Each of these effects can also have its own adjusted UV values, they don't have to all share. In fact, it's inevitable to have multiple UV-based vec2 values, because once they are converted into a float, a lot of base information is lost. So only by modifying values derived from the UV many effects can be achieved.
When the shape is done, I'm adding it to the alpha output, and I'm using it to mix some pre-configured element color with the output color. I want to make sure I only draw within the lines of the shape mask, as this helps with the composition.
void fragment() {
float ar = control_size.x / control_size.y;
vec2 uv = (UV - 0.5) * 2.0;
uv.y /= ar;
vec3 output_color = COLOR.rgb;
float output_alpha = 0.0;
float position_distance = position * (1.0 - deadzone);
float position_side = step(0.5, 1.0 + sign(position) * uv.y - 0.5);
// Create the knob shape.
vec2 knob_uv = uv;
knob_uv.y -= position_distance;
float knob_mask = length(knob_uv);
knob_mask = 0.0 - knob_mask + knob_size;
knob_mask = smoothstep(0.0, knob_smoothness, knob_mask);
// Create a shadow blob for the knob.
float knob_shadow_offset = -0.4 + 0.2 * abs(position - 0.3);
float knob_shadow_blob = length(knob_uv + vec2(0.0, knob_shadow_offset));
knob_shadow_blob = 0.0 - knob_shadow_blob + knob_size - 0.04;
knob_shadow_blob = smoothstep(0.0, knob_smoothness + 0.07, knob_shadow_blob);
// Add the shadow blob to the output.
float knob_shadow_blob_factor = knob_blob_intensity + knob_blob_intensity_range * abs(position);
output_color = overlay(output_color, knob_blob_color, knob_shadow_blob_factor, knob_shadow_blob);
output_alpha += knob_shadow_blob * knob_shadow_blob_factor;
// Create shading for the knob.
vec2 knob_shade_uv = knob_uv;
knob_shade_uv.y += knob_shade_offset * (1.5 + 0.2 * (position));
float knob_shade = length(knob_shade_uv);
knob_shade = knob_shade - knob_shade_size;
knob_shade = smoothstep(0.0, knob_shade_smoothness, knob_shade);
// Mix the knob color with shading.
vec3 knob_output = overlay(knob_color, knob_shade_color, knob_shade_intensity, knob_shade);
// Add the knob to the output.
output_color = overlay(output_color, knob_output, knob_mask, knob_mask);
output_alpha += knob_mask;
// Create the stem shape.
vec2 stem_uv = uv;
stem_uv.y = abs(stem_uv.y - position_distance / 2.0) - abs(position_distance / 2.0);
stem_uv.y = clamp(stem_uv.y, 0.0, 1.0);
//
vec2 stem_mask_uv = abs(stem_uv);
stem_mask_uv.x += stem_smoothness - stem_size;
stem_mask_uv.y -= (1.0 - length(stem_uv)) - 2.0 * stem_size;
stem_mask_uv = smoothstep(0.0, stem_smoothness, stem_mask_uv);
stem_mask_uv = 1.0 - stem_mask_uv;
float stem_mask = stem_mask_uv.x * stem_mask_uv.y;
stem_mask -= knob_mask;
stem_mask = clamp(stem_mask, 0.0, 1.0);
// Create shading for the stem.
vec2 stem_shade_uv = stem_uv;
stem_shade_uv.y += 0.1 * abs(position);
stem_shade_uv.x *= 2.0 + 0.3 * abs(position);
float stem_shade = length(stem_shade_uv);
stem_shade = stem_shade - stem_shade_size;
stem_shade = smoothstep(0.0, stem_shade_smoothness, stem_shade);
// Mix the stem color with shading.
vec3 stem_output = overlay(stem_color, stem_shade_color, stem_shade_intensity, stem_shade);
// Add the shadow blob shade to the stem.
stem_output = overlay(stem_output, stem_shade_color, stem_shade_intensity, knob_shadow_blob);
// Add the stem to the output.
output_color = overlay(output_color, stem_output, stem_mask, stem_mask);
output_alpha += stem_mask;
// Create the rim shape.
vec2 rim_uv = uv;
float rim_outer_mask = length(rim_uv);
rim_outer_mask = 0.0 - rim_outer_mask + rim_size;
rim_outer_mask = smoothstep(0.0, rim_smoothness, rim_outer_mask);
float rim_inner_mask = length(rim_uv);
rim_inner_mask = 0.0 - rim_inner_mask + rim_size - rim_thickness;
rim_inner_mask = smoothstep(0.0, rim_smoothness, rim_inner_mask);
//
float rim_mask = rim_outer_mask - rim_inner_mask;
rim_mask -= knob_mask + stem_mask * position_side; // Only in half.
rim_mask = clamp(rim_mask, 0.0, 1.0);
// Create shading for the rim.
vec2 rim_shade_uv = rim_uv;
rim_shade_uv.y += rim_shade_offset;
float rim_inner_shade = length(rim_shade_uv);
rim_inner_shade = rim_inner_shade - rim_shade_size;
rim_inner_shade = smoothstep(0.0, rim_shade_smoothness, rim_inner_shade);
float rim_outer_shade = length(rim_shade_uv);
rim_outer_shade = rim_outer_shade - rim_shade_size - rim_shade_thickness;
rim_outer_shade = smoothstep(0.0, rim_shade_smoothness, rim_outer_shade);
float rim_shade = 1.0 - clamp(rim_inner_shade - rim_outer_shade, 0.0, 1.0);
// Mix the rim color with shading.
vec3 rim_output = overlay(rim_color, rim_shade_color, rim_shade_intensity, rim_shade);
// Add the shadow blob to the rim.
rim_output = overlay(rim_output, rim_shade_color, rim_knob_shade_intensity, knob_shadow_blob);
// Add an extra glow on the stem from the rim.
float stem_rim_glow = length(rim_shade_uv - sign(position) * vec2(0, 0.13));
stem_rim_glow = stem_rim_glow - rim_shade_size;
stem_rim_glow = smoothstep(0.0, rim_shade_smoothness, stem_rim_glow);
stem_rim_glow *= 1.0 - position_side; // Only in half.
stem_rim_glow = stem_mask * stem_rim_glow;
stem_rim_glow = clamp(stem_rim_glow, 0.0, 1.0);
output_color = overlay(output_color, rim_color, rim_glow_intensity, stem_rim_glow);
// Add an extra shade on the stem from the rim.
float stem_rim_shade = length(rim_shade_uv);
stem_rim_shade = stem_rim_shade - rim_shade_size + 0.05;
stem_rim_shade = smoothstep(0.0, rim_shade_smoothness, stem_rim_shade);
stem_rim_shade *= 1.0 - position_side; // Only in half.
stem_rim_shade = stem_mask * stem_rim_shade;
stem_rim_shade = clamp(stem_rim_shade, 0.0, 1.0);
output_color = overlay(output_color, stem_shade_color, stem_shade_intensity, stem_rim_shade);
// Add the rim to the output.
output_color = overlay(output_color, rim_output, rim_mask, rim_mask);
output_alpha += rim_mask;
// Output.
COLOR = vec4(output_color, output_alpha);
}
A few useful tricks here:
Drawing something at the center is easy. Just subtract 0.5 from the UV, and now you have a scale that goes from -0.5 to 0.5. You can scale it back to -1.0 to 1.0, and you can also use the abs function to "remove" the negative sign from the left end. If you output the X value of UV now, you'll see a symmetrical gradient going both ways from center.
With smoothstep you can give your shapes a smooth and gentle edge. And if given more extreme values, it serves as a great source for shading. Smoothstep takes a value and a range — if the value is outside of this range, it returns 0 or 1 (depending if it's to the left or to the right of it), while values inside of range are remapped to the 0 to 1 range, giving you a gradient.
Don't forget to clamp! When you start mixing a lot of values which can go outside of the 0 to 1 range, your effects might break for the sole reason that you don't account for these values. When you put them on screen, they become invisible, because a color can only be within that 0 to 1 range (let's ignore HDR for now). So you may assume that the black area is just 0, but in reality it would go somewhere beyond that and when mixed with another value, you'd get unexpected results.
Split the code into multiple standalone line and introduce more intermediate variables. This gives you better tools for debugging, as you can quickly comment out individual operations and access some temporary result from anywhere in the code without disturbing the rest of it. I would often take my equations to the end of the fragment function in the shader and override all of the output with these computations that I'm trying to debug. Kind of like connecting some node to the output node in a visual shader graph.
Similarly, put many things into uniforms, so you can play around with them and adjust them using the editor inspector. Even if you don't need all of these parameters in the end, having access to them saves a lot of time as you tune the looks or try to compare the behavior.
There are two main kinds of transitions that I've used in this project: color change and movement. And there are two places where these transitions may be applied: node properties and the shader material. I'll give you some examples.
One of the elements in the project is a glowing button. It's supposed to look like a semi-transparent plastic button that has a light source behind it. So it shines when turned on and looks dark with only a hint of color when turned off. These colors are complementary, but not always related in the same way, depending on the main color. So we cannot just draw the button in grayscale and them colorize it with the modulate or self modulate property. So instead, we send them to the shader.
The same button also has a label with a glowing effect added to it. This effect is actually reused in a few places, in other widgets. The whole effect is driven by the alpha channel, not to mention that the UV and COLOR variables are not what you might expect when dealing with text rendering in Godot. So it makes more sense to change the modulation here instead, letting the shader redefine the shape a bit, while the color is entirely driven by the node's property.
Our friend from earlier, a flicker, moves alongside its vertical axis. It's a very complex movement design, where not only the sphere on top changes position, but also the stem changes shape, there is a dynamic shadow, and the shading on the sphere is moving as well. And the ring glows. All these elements benefit from knowing the exact position value of course, and the entire widget is drawn by the shader in one go. So it makes sense to animate the shader property here.
Then we have the roller knob, which has two moving parts: the roller itself, and the value display with text. The roller is completely drawn with the shader, so once again, it is animated via its shader parameters. The value display, however, is drawn with regular drawing. There are some rectangles in the back of it, and there are a bunch of precalculated text buffers placed on screen. With content clipping enabled, it looks like there is a roll with printed numbers inside of the value screen. In reality, everything is drawn in one continuous pass and is offset according to the scroll distance. This is done entirely in script.
@tool
class_name ElectronicLabel extends PanelContainer
const TRANSITION_DURATION := 0.12
@export var text: String = "":
set = set_text
var _label_tweener: Tween = null
@onready var _label: Label = %Label
func _ready() -> void:
_update_theme()
_update_label()
func _notification(what: int) -> void:
if what == NOTIFICATION_THEME_CHANGED:
_update_theme()
elif what == NOTIFICATION_EDITOR_PRE_SAVE:
_clear_theme()
elif what == NOTIFICATION_EDITOR_POST_SAVE:
_update_theme()
func _update_theme() -> void:
if not is_node_ready():
return
_label.add_theme_color_override("font_color", get_theme_color("font_color"))
_label.add_theme_font_override("font", get_theme_font("font"))
_label.add_theme_font_size_override("font_size", get_theme_font_size("font_size"))
(_label.material as ShaderMaterial).set_shader_parameter("intensity", get_theme_constant("full_intensity") / 100.0)
func _clear_theme() -> void:
if not is_node_ready():
return
_label.remove_theme_color_override("font_color")
_label.remove_theme_font_override("font")
_label.remove_theme_font_size_override("font_size")
(_label.material as ShaderMaterial).set_shader_parameter("intensity", 0.0)
# Text property.
func set_text(value: String) -> void:
if text == value:
return
text = value
_animate_text_changes()
func _update_label() -> void:
if not is_node_ready():
return
_label.text = text
func _animate_text_changes() -> void:
if not is_node_ready():
return
if is_instance_valid(_label_tweener):
_label_tweener.kill()
_label_tweener = create_tween()
_label_tweener.tween_method(_tween_text_visibility, _label.self_modulate.a, 0.0, TRANSITION_DURATION)
_label_tweener.tween_callback(_update_label)
_label_tweener.tween_method(_tween_text_visibility, 0.0, 1.0, TRANSITION_DURATION)
func _tween_text_visibility(value: float) -> void:
_label.self_modulate.a = value
var full_intensity := get_theme_constant("full_intensity") / 100.0
(_label.material as ShaderMaterial).set_shader_parameter("intensity", value * full_intensity)
No matter how exactly the transition is handled, it's implemented using tweens. Unlike animations, tweens allow me to animate from the current value, which ensures that all states can be transitioned smoothly even if interrupted in the middle of another transition. Press the glowing button as quickly as you can, and it should still be responsive and animate smoothly with no abrupt jumps.
With all that done, I create a new scene, add some colorful background to it, place my new widget in the center, scaled by some amount to be big and juicy, and record a short clip to tease my work on social :P
Then I go back to work, and place it into the actual layout and hook it up to the data. I always make sure that changing the value with user interactions and changing it by assigning to the script's property both result in smooth transitions. This is why hitting the randomize button in Glasan FX looks especially good, as all widgets update to their new state.
I also create signals and write any other external logic needed. But all this takes mere minutes in practice, because there is nothing particularly special about that part. A well-designed component would be indistinguishable from a native one, so the integration is often just a drag'n'drop replacement, with maybe a couple of extra properties configured.
Do you have any questions? Let me know if there is more you'd like to know. As a little treat, I extracted all of the widgets into a separate repository and made them a little bit easier to use and poke, if you want to!
Thanks again for your support and see you in the next one.
Cheers!
Loading comments and feedback...