Progressing in Circles
Exploring shader based circular progress bars for Unity games
Progress bars are a common element in UIs, and for various reasons it’s not uncommon for then to be circles or arcs. I’m going to go over the common ways most people will end up implementing this. And discuss why I did something else.
The Usual Suspects
This may be the most common solution. It requires no programming, and is built into Unity’s UI systems, so it’s not surprising it’s the first recommendation you’re likely to find online.
This is an Image component with the Image Type set to Fill, with a Radial 360 Fill Method over another Image component for the background. The way it works is the UI system generates a mesh with the slice cut out of it. I would expect this to be how it’s implemented by most UI systems as it’s very efficient and fast to draw.
So what’s wrong with it? A few things. One is the inherent problems with layering sprites in any UI system. There’s a faint outline on the filled in bar where the background image still shows.
This isn’t a big problem if your UI style isn’t as precise as this, but if it is, it’s basically impossible to get this perfect. You could have both the background and bar fill be progress bars that are counter animating. Or you can adjust the artwork so the progress bar and background aren’t the same width. But it is another annoyance with this setup. There’s also problems with the end of the bar being aliased. You would need to use MSAA or some other post process AA on the UI. This is fine for in world UI, but rare for any traditional UI.
The last issue is a lot of people just don’t want to have to deal with Unity UI systems. They can be a performance problem with all the extra game objects, and can otherwise just be a nuisance to deal with.
So what are the other options?
Another common technique is to use a ring shaped mesh with a set of UVs for the bar, so that the color and fill can be done in a single pass shader.
Note: If you’re viewing the above animation and seeing the same outer edge darkening as the Canvas Image example, some devices do not display these animated gifs correctly! There is no dark edge in for this shader, or in the original gif!
This uses this extra set of UVs, which are along the shape of the mesh, to curve the progress bar. One axis of the UV is used as a gradient that the shader can compare the progress bar percentage against to calculate the progress bar edge. It’s even easy to do some anti-aliasing in the shader so the edge doesn’t require any post processing or MSAA. VFX & tech artists out there might be familiar with “soul coasters”, which this is a version of. If you have other shader based straight progress bars in your game, they may already be using a shader that can be applied directly to this mesh and “just work”. The above example is using the primary UV for the ring alpha texture, and a second UV for the progress bar.
The disadvantage is to get a curved bar as clean as above requires a lot of triangles to avoid the progress bar edge wiggling. Even the above example isn’t quite high poly enough to avoid it.
Having a mesh this high poly can be surprisingly bad for performance in some setups. Not because of the vertex count itself, but because of how long and thin the triangles are. It gets worse if you plan on using this in the world where it’s going to be rendered with the triangles thinner than a single pixel. Micro triangles are bad. And beyond just surprisingly bad performance, it may have aliasing problems when scaled down.
Here’s a mesh with a more realistic number of polys that will have fewer problems with micro triangles.
The wiggling edge is far more obvious here. The triangle structure of the mesh and its affect on UVs is immediately obvious.
And even with the high poly mesh, the anti-aliasing isn’t perfect. In the above image you might notice some artifacts when the bar is at 0%. But it happens elsewhere too. Plus the progress bar’s starting edge is aliased if the whole thing is rotated since it’s a geometry edge!
Since it is a geometry edge, MSAA can solve the starting edge aliasing. As can a slightly more complicated shader. But the other artifacts are unsolvable.
This technique does have the advantage of being able to do any shape you might want. You aren’t limited to simple circles, just as long as you’re mindful of needing to use enough tessellation to avoid that progress bar edge weirdness. It’s also a super cheap shader.
The compiled Unity shader for Direct3D has a vertex shader with 8 math (most of that for the vertex position), a fragment shader of either 2 or 13 math and 1 texture sample, depending of if you’re doing in shader anti-aliasing or not.
This is often considered the “perfect” implementation. It’s a shader using
atan2() to calculate the position’s angle, and using that angle to calculate the progress bar edge. Like the UV based approach, it’s a single pass shader and can be easily anti-aliased.
Note, the thin line that remains when it’s “empty” is a personal preference. The technique doesn’t have to do that if you don’t want it to.
Unlike the mesh UV approach it has no wiggling issues, and doesn’t require any custom mesh setup.
But I didn’t use this either. Why? Because
atan2() is expensive. Compared to modern PBR shaders, not really that expensive. But the shader is still several times more expensive compared to every other example on this page. It also does still have some alias problems! If the starting edge of the progress bar will alias if it is rotated at all, just like the mesh UV method. This is solvable, but tricky or expensive to get right. And unlike the mesh UV method, MSAA won’t help here.
The compiled Unity shader for Direct3D has a vertex shader with 8 math (most of that for the vertex position), a fragment shader of either 26 or 37 math and 1 texture sample, depending of if you’re doing anti-aliasing or not. Not super bad consider Unity’s Standard shader is somwhere between 60 and something like 320 math depending on what features you’re using and what platform (directional light maps with shadow mask get expensive!). But compared to 2 or 13, pretty slow. I wanted to do better!
This is an example you’ll find pretty often as a way to optimize the arc tangent based approach. Just use a texture gradient! That’s simple enough. Just open up your favorite 2D art program and make an angle gradient and you’re done!
You might notice the progress bar edge is aliased again. It also moves in larger steps. That’s not running at a lower frame rate than the other animations on this page, that’s just what it looks like. That’s because the gradient map is an 8 bit per channel texture, and we’re at the precision limits of what an 8 bit texture can do. 8 bit means 255 steps, so the bar can only represent 256 visual positions. There also is no way to get an anti-aliased edge with this method.
This technique gets used pretty often though as it is very cheap and doesn’t require any special geometry like the mesh UV based approach. The shader itself is very slightly more expensive than the mesh UV approach. The fragment shader is the same 2 math as the non anti-aliased mesh UV but 2 texture samples instead of 1. But not needing a custom mesh and avoiding the expensive arc tangent is a clear win. From what I’ve seen this is where a lot people end up who have gone down the same path of trying to do shader based circular bars end up. It’s certainly where I’ve ended up in the past. For many use cases it’s more than good enough.
Like the mesh UV based approach used for the ring mesh example, it also does have the benefit of easily working with other shapes. Whatever shape you can make a gradient texture of, you can make into a progress bar.
But, I didn’t use this because I wanted anti-aliased edges.
If you’re using an engine that can import in 16 bit single channel textures, then this is potentially a great option. Though an image file for a high resolution 16 floating point texture can be rather large. Unity does support importing 16 bit images via EXR or HDR files, but there are issues with that. Unity likes to modify floating point image data it imports in situations where you really don’t want it to.
Honestly, if I’d managed to get that to work back when I first tried this 5 years ago, I’d probably have stopped there. Luckily I didn’t.
Cheaper & Better?
My original use case for this was for use in VR on in world meters on things. This meant I needed something that had good anti-aliasing, and worked with a variety of camera angles and distances. Technically I could have leaned on MSAA more, but I don’t like to rely on it for all of my anti-aliasing needs.
Unity UI is out for compositing problems and generally wanting to avoid the hassle it and its systems bring. A ring mesh is out because of UV distortion issues. The gradient texture is out due to always aliasing. Arc tangent was the best option, but is relatively expensive and still aliases in some situations.
So do I just dig in and try to make the arc tangent faster by attempting some close enough alternative approximation? Do I try to fix the remaining aliasing problem? The answer is yes to both, but that went no where satisfactory and I had to abandon that option.
So now what?
So now we get to what I did. This is a technique that gets the same level of accuracy as the arc tangent method, but is way faster. It’s roughly the same shader cost as the anti-aliased mesh UV based approach, but without needing a custom high poly mesh. Or a baked out texture. Or really any setup at all. And both ends of the progress bar are perfectly anti-aliased. It also has some additional advantages we’ll get into.
To understand what I’m doing, lets reduce this down to a simple thought experiment example. Imaging you have the above circle drawn on a white piece of paper. How might you make that look like a progress bar? You could cover it with two more pieces of white paper and rotate one to expose the ring underneath.
Now this works pretty well for showing half the circle, but what about the other half? Well, you could cut out the paper, but lets try to do it without cutting anything. What if one piece of paper had the circle drawn on it too?
Well, now you have a circular progress bar made out of paper that you can animate without needing to cut anything. For the first 180 degrees you just rotate one piece of paper. Once you get to the second 180 degrees you flip over the piece with the circle on it over and rotate the other one.
This isn’t a novel ideal. If you look for tutorials for doing progress bars like this for Flash or other vector based art programs you’ll find some similar ideas.
In essence that’s what what my shader does. I realized all I needed was two sets of the base UVs, with one rotated by the needed angle. In the fragment shader I can easily “flip” one of them at the half way. More over, I can do the rotation in the vertex shader and pass it to the fragment shader very cheaply. Really, I don’t even need all 4 UV components, I only need the horizontal axis of the rotated UV!
The above image shows the subsequent masks the fragment shader generates from the passed in UVs. This is for a bar at 20% fill. I pass in 3 channels for reasons I’ll explain later, but lets just focus on the main two. This is basically a recreation of the first paper example. The red channel is the vertical mask to hide the left side of the bar.
And the blue channel is the rotated diagonal mask.
And the two masks together produce a pie slice for the first 20% of the progress bar.
And there we have it. Since the vertical and diagonal are separate gradients, they can be individually anti-aliased. Thus they both produce an extremely nice looking anti-aliased edge. Like mentioned above, once the bar is past the 50% mark I can “flip” the vertical mask. In the actually shader code I switch between doing a
max() and a
min() between the two masks when the progress bar is at the half way.
However I did find that there was one, small issue. With the anti-aliasing, when the progress bar is at 0.0, a little line would show up at the top and bottom of the bar.
In this case the anti-aliasing of the two masks don’t fully cover the entire bar. I mentioned above my personal preference is for a progress bar at “0%” to show a small sliver of the fill color at the start to denote it’s empty. I could certainly get rid of this by adjusting edges over a little. But that just means this artifact appears at “100%” instead.
I tried a few different options here, but in the end the cheapest solution I found was just to hide the bottom half of the progress bar at some arbitrary cutoff below “25%”. So for that I pass in a third mask.
So, after all that, how expensive is the shader? The compiled Unity shader for Direct3D comes out to…
Vertex shader: 12 math.
Fragment shader: 13 math, 1 texture.
So that’s 4 additional math instructions per vertex, and no change per fragment over the anti-aliased mesh UV method. Essentially identical performance. Actually faster when you consider the significant reduction in vertex count.
Now I am cheating slightly in that the mesh UV shader is using a
smoothstep() for the anti-aliased edge, and the rotated mask shader is just using a linear contrast. Without
smoothstep() the mesh UV shader’s fragment shader uses 8 math instructions. With
smoothstep() the rotated mask shader’s fragment shader uses 19 math instructions. So the fair comparison is really 8 vs 13, or 13 vs 18. But the main take away here is a minimal increase in shader complexity compared to the “cheaper” alternatives while achieving a overall higher quality than even arc tangent.
If you pass in one more rotated mask you can even do arbitrary arcs instead of full circles, with the fragment shader being the same cost as the original arc tangent method alone.
Now, I also don’t actually use an alpha texture for the real version of this shader. Instead I calculate the circle masks in the shader, which means the width of the line is controllable by the material settings. I also have the option to do nice anti-aliased outlines. This was super important for clarity and readability on the VR projects I first started using this technique on.
Obviously, at this point the shader isn’t cheaper than the basic arc tangent method presented above. We’re close to 50 math in the fragment shader with all these features. And if you wanted the bar to represent multiple separately colored segments it’ll probably start being cheaper to use the arc tangent for all of this. But the mask technique has the nice benefit of having the outline caps be straight lines that match the bar ends which would be more expensive to do with an arc tangent. Since the masks are based on linear UV gradients, it’s simple enough to just add an offset to the sharpened mask prior to clamping it. And everything is nicely anti-aliased, which again the arc tangent method couldn’t do as easily in all cases. So while this is an expensive shader, the arc tangent based version of it would have been more expensive, so it’s still a win.
Signed Distance Fields
This comment came up frequently after I posted this article. “What about using an SDF?” The basic rotated mask approach is an SDF! The first example is using a texture for the ring alpha, but the progress bar masks themselves are SDFs. The final shader presented above is 100% SDF based. I didn’t call out my shaders as being SDF based originally because I feel like when most people see the term “SDF” they see that as an acronym for “some magic happens”. I wanted to talk about how it worked without people having that on their mind.
For the final shader the ring and each edge mask is individually their own SDF rather than calculating an SDF for the entire shape. I did this mainly because I wanted sharp corners, and to ensure more correct derivatives for each SDF.
Using screen space partial derivative functions like
ddy() are great for anti-aliasing SDF shapes. But they have limitations. In the above image you’ll notice the center of the circle looks a little odd. That’s an example of the limitations of
ddx() not quite being able to calculate the correct derivatives near the center of the circle. Many examples you’ll find online of SDF rendering are being done in screen space, so they can get away with using manually defined derivatives. You can’t get away with using those for anything not done in screen space. Alternatively you can use Shader Model 5.0’s
ddy_fine() derivative functions, but those aren’t perfect either.
I also use
fwidth() derivative approximation for the straight edges rather than more correctly using
length(float2(ddx(x), ddy(x))). The quality difference isn’t significant for straight lines. But I do notice the artifacts on circles, so I use the correct derivative calculation there. It does mean on diagonal edges the anti-aliasing is slightly softer on the straight lines than it should be. Similarly this is also why I use
smoothstep() on the ring SDF, but not the masks.
There are of course other ways of doing circular or arced progress bars. This is by no means an exhaustive list. The initial list was just the common ones I’ve seen others (and myself) use. However all the other methods I know of all suffer from similar kinds of problems that the methods I chose not to use have. Some also have very strict requirements on camera orientation or perspective. Some examples would be to recreate the “paper” mockups with actual overlapping sprites (overlapping problems), maybe using stencils to do the masking (aliasing problems). A ring mesh where the vertices are collapsed by the shader (overlapping, aliasing and warping all in one!). There are also some techniques that can be used more easily if this wasn’t using Unity, but again, all the ones I know of suffer from the same artifacts I was looking to avoid.
As it is the new thing everyone is trying to use in Unreal, I figured I should mention it here. It’s totally possible to use this technique in Shader Graph. However since there’s no way to explicitly do operations in the vertex shader (yet?) all of the UV rotations will be done in the fragment shader. This still ends up being slightly faster than using an arc tangent, but not as significantly. It does however of course still solve the aliasing problem over using an arc tangent. The DDXY (equivalent of
fwidth()), Ellipse and Rotate nodes should all make implementing much of it easier.
Math vs Instruction Count vs Cycles
The “math” count isn’t a perfect comparison for performance. Not all math instructions are equal. Not all instructions in a compiled shader are math. And not all GPUs run compiled instructions the same way. I’m using that number as an easy to replicate comparison point. A more accurate number comparison would be using the number of cycles the shader uses. To get those numbers requires a GPU profiling tool. Even that ignores other aspects like the vertex count, screen coverage, and overdraw. Performance comparisons in a vacuum are hard. In the very rough general case, less math means faster, same math is about the same, more math is slower.