Combining shapes. Conceptualizing commands.
Making a versatile UI drawing library
This is part 3 of a series of hands-on tutorials. To get the most value, follow along and do your own experiments on shadertoy. I will not be sharing copy-pasteable snippets; only screenshots from shadertoy.
In the previous installment, we expanded the utility of the SDF by deriving it for rectangles, got rounded corners for free, and touched on rotations, masking, and combining the masks from multiple shapes.
Here’s the answer to the previous tutorial’s homework:
Last time I said next time we’ll start writing code to start the project, but before that there are a few more concepts I want to cover.
One thing to note for the code samples in today’s episode: in preparing to start the code, I did a small modification. I inserted the following at the top of the shadertoy code editor:
#define float2 vec2
#define float4 vec4
#define float2x2 mat2
These are the typenames that are used in the Metal shader language. This should make it easier to have a portable section of the shader code.
Scaling
Just like rotation, we can do scaling around a “pivot” point by subtracting from the point and then performing the scaling.
float2 scale_around(float2 origin, float2 scale, float2 point) {
return ((point - origin) * scale) + origin;
}
Combining rotation and scaling we can achieve an effect like in the following video:
Instead of just a screenshot, I thought a little movie is better at demonstrating the effect. Notice how the order of operations matters.
Combining Shapes
In the last tutorial I talked about applying the mask from multiple shapes to achieve inner shadow / inner highlight effect.
However, combining masks is not the same as combining shapes.
The shape gives us a “signed distance” value. We can use this value to derive other shapes such as the “growth” derivation and the “border” derivation.
The “mask” value cannot be used to further process the shape. Combining two masks does not allow us to derive a “border” that follows the mask’s border.
This point was bothering me from the last tutorial but I did not mention it then because I didn’t have an answer for it.
However, after some research, it turns out the answer is simpler than I thought: we can take the min
of two distance values, and this gives us a new distance value that sort of describes the new shape. We can do the other shape operations on it like grow and border and inverse.
Let’s take an example, a video again, because it helps drive the point home better than just a lot of text and pictures.
We can combine in different ways by placing minus sign in three places:
[-]min([-]d1, [-]d2)
The video demonstrates the effect of placing the minus sign in various locations.
We can also use “max” instead of “min” and get the same effect with different placement of the minus sign, so it appears to be redundant.
Now, let’s try to create a speech bubble! We’ll scale and rotate a small rectangle and place it on top of a regular rounded rectangle, combine their shape, and derive a border:
We do get a border shape, but something about it is off .. the borders on the arrow portion of the bubble appear thinner than the rest of the rectangle.
This is because there’s “scaling” applied to the arrow, but not to the rest of the bubble. The signed distance values we get from the scaled rectangle shape are no longer correct; they are not pixel values. They are scaled, but only across one dimension, not both.
Let’s try again without any scaling; just rotation:
Now the borders look much better, don’t they?
This brings to doubt the usefulness of scaling. Scaling can also be used to draw oval shapes, but I don’t know if it’s a useful shape for constructing user interfaces.
Now, here’s an important point to notice: shape combinations are more powerful than mask combinations. You can combine shapes and produce a mask from their combinations, but the converse is not possible; we can’t produce an sdf value from a mask.
Redundancies
I don’t know if you have noticed, but can create a circle from a rectangle: make the rectangle’s half_size zero, and then “grow” it by the radius.
So we don’t need a special sdf function for circles.
Another redundancy is in how we’ve been defining the border with a center point and a half size. But this is redundant with “growth”. Defining a border as starting 5 pixels away and having a 5 pixel half-width is the same as growing the shape by 5 pixels first and then drowing a border 0 pixels away with 5 pixel half-width.
For shape combinations, we need only consider two cases: either shapes combine additively, or one shape is subtracted by the other shape. There’s no need to consider the placement of 3 minus signs on the `min(d1, d2)
` formula.
Why am I bothering with considering these redundancies? Because I want to come up with a description for a shape that can be expressed with the minimum amount of information.
Drawing Commands
When using a library to draw UI elements, we won’t be writing shader code. Instead, we’ll be issuing drawing commands. These commands are descriptions of shapes and how they combine. They get passed to the shader code to be drawn to the screen.
I want the following things out of a command system:
Simple shapes can be drawn in a single command
Complex shapes can be drawn with a series of commands that combine together to produce the final result
A simple shape would be something like a rounded rectangle with a fill color.
Simple shapes can be layered on top of each other to achieve move complex shapes without any combination of the mask or the distance fields. Simply by layering.
More complex shapes would require combining the distance fields and masks from multiple shapes to achieve.
A good example would be the inner highlight for a button: as we saw from last time, we need to combine the the blurry circle with the mask from the inner rectagle.
Another example is a speech bubble. We saw a simple speech bubble with a rotated rectangle, but we can do something more fancy: get a crescent shape by subtracting one circle from another, then add to it a rounded rect.
Note that for the speech bubble we’re not merely laying the rect on top of the crescent: we combine the sdf together. This is what allows us to for example draw a border on it and achieve a drop shadow effect as well.
Since we're eschewing "scaling", we need a different way to produce "pointy" heads that are not right angles.
One way to do this is with rectangles placed and rotated carefull to produce the desired shape. The following picture shows how to make an arrow head. The light rectangles all subtract from the dark rectangle to produce the end result.
This might be a bit too fancy for our needs. It’s not clear that this kind of shape is useful in building UI. If we can manage to get this kind of result cheaply, we might decide to add it (just to show off our capability). Otherwise we might just shelve it for later, or do away with it all together.
Remember that our system as we are concieving of it is not a generic draw any 2D art kind of system. Instead we want a versatile system for drawing 2D shapes that are useful for UI.
For example, rendering 2D charts is not a viable application of this SDF-based shape drawing system. If we want to render a chart, we would need an entirely different kind of building blocks: related to lines, splines, dotting, dashing, etc.
So our approach is to have some kind of ambitious north start, but start building up the features bit by bit until we reach a level with acceptable tradeoffs in terms of complexity, capabilities and limitations.
Homework
For today’s homework, try to reproduce the speech bubble above with the crescent produced from subtracting once circle from another, and draw its inner border.
You already saw all that is needed to get this kind of result from the small video demos in this tutorial. I feel confident that if you’ve been following along, this should be easy to produce.
I’ll post the solution as usual at the start of the next installment.
Next Steps
In the next episode, the plan is to start writing actual code.
I’ll be using the Odin programming language. It is in my view the ideal systems language in the current decade. It fixes all of C’s warts without deviating from the spirit of being a simple systems language.
Since I’m using a Macbook, I’d like to use Metal as the graphics API.
However, to help people following along get started, I’ll do the initial version in both Metal and OpenGL. If you’d like to use the native API on your machine, that would be great too!
I’ll be using SDL as the platform abstraction layer. It does a good job of abstracting away things likes creating windows and polling events. Unlike similar libraries in this space, it actually supports input events related to IMEs (input method editors), which are required to edit Japanese/Chinese text. This is important for a cross-platform UI library.
Odin bindings for SDL2 are shipped along with the compiler in the `vendor` collection, so it’s easy to get started. There are simple demos at the odin examples repo that use SDL along with Metal/OpenGL/Direct3D to put something interesting up on the screen.
Our goal will be getting a skeleton application to display a bunch of rectangles on the screen, (with both Metal and OpenGL), where the data for the rectangles (placement & color) are sent from the CPU to the GPU.
See you next time!