How to best to do a radial progress circle?

I’m using UI Toolkit but would like to implement a radial progress circle, such as for an rpg skill icon on cooldown.

In UGUI I could use a sprite with fill method radial 360, but this does not exist in UI Toolkit. What is the best way to go about this?

Should I create a UGUI Canvas and do a VisualElement->ScreenSpace conversion and draw on the canvas exactly where I want the circle? Or is there a better way to go about this?

You can use generateVisualContent although right now it’s very very aliased and doesn’t look great. If you can wait 6 months or a year or something, there’s supposed to be a vector API coming out that will fix it.

For anyone else looking for how to do this, I found Graphs and Charts and used that code with a small refactor.

I made an empty VisualElement with the correct x/y size called “circle-holder”, and then did VisualElement.Add(new CooldownCircle()) into it. This has been working great.

public class CooldownCircle : VisualElement {
  private float _fraction;
  public CooldownCircle() {
    generateVisualContent += OnGenerateVisualContent;
  }
  public void SetCooldownRemaining(float fraction) {
    _fraction = fraction;
  }
  void OnGenerateVisualContent(MeshGenerationContext mgc) {
    Rect rect = contentRect;
    // TODO: It's not clear why height/y is always 0, even after waiting for resolvedStyle, but width/x seems to be correct.
    Vector2 center = new(rect.center.x, rect.center.x);
    // Fill 100% of the square not just a circle.
    float radius = rect.size.x * 1.5f / 2;
    // Vertical up is -PI/2. Ending at 3*PI/2 is the full circle.
    float startAngle = -Mathf.PI / 2.0f + (1 - _fraction) * 2 * Mathf.PI;
    CircleCreator.Circle(center, radius, startAngle, 3*Mathf.PI/2.0f, GameColors.CooldownCircle, mgc);
  }
}

public static class CircleCreator {
  public static readonly int NumSegments = 250;
  public static void Circle(Vector2 pos, float radius, float startAngle, float endAngle, Color color, MeshGenerationContext context) {
    var segments = NumSegments;
    var mesh = context.Allocate(segments + 1, (segments - 1) * 3);
    mesh.SetNextVertex(new Vertex() { position = new Vector2(pos.x, pos.y), tint = color });
    var angle = startAngle;
    var range = endAngle - startAngle;
    var step = range / (segments - 1);
    for (int i = 0; i < segments; i++) {
      Vector2 offset = new(radius * Mathf.Cos(angle), radius * Mathf.Sin(angle));
      mesh.SetNextVertex(new Vertex() { position = new Vector2(pos.x, pos.y) + offset, tint = color });
      angle += step;
    }
    for (ushort i = 1; i < segments; i++) {
      mesh.SetNextIndex(0);
      mesh.SetNextIndex(i);
      mesh.SetNextIndex((ushort)(i + 1));
    }
  }
}
6 Likes

In the meantime, here’s some ideas to get a smooth edge:

  • Provide UVs and use a white-to-transparent texture-based gradient with your geometry (vertex color will multiply the texture color)

  • Add a small 3-4 pixel wide border around your circle and use vertex color interpolation to fade the alpha from 1 to 0

  • Use an intermediate render texture with MSAA enabled

2 Likes

Thank you @Baldrekr , very useful code. I have an idea to get a smooth edge but I’m not really sure how to implement it: could MeshGenerationContext be used to create a “mask” mesh over an image? I’m thinking about a smooth circle png cut in a radial rectangular shape
7610287--945358--upload_2021-10-28_15-15-30.png

The edge on the radius would not be smooth I’m afraid but yeah, it could work fine in my case

In this case you don’t actually need masking. You can simply create a textured mesh from the MeshGenerationContext. The third parameter of Allocate allows to specify a texture. Set the proper UVs on the vertices of your shape and set the vertex color to white.

1 Like

One cheesy thing you can do to get a circular mask is to set the border property on the UI element with width=0 but Radius=256px (or whatever), and that will prevent what is inside from getting drawn outside the circle.

Thank you for the suggestion. I tried and it indeed works. But I also found out that it is not that easy to generate that shape, there’s a lot of trigonometry to do :frowning:

The coundown widget I’m trying to recreate is actually a bit more complicated, as you can see from my graphic reference (created by our design team with photoshop):
7613173--946021--upload_2021-10-29_13-13-47.png

But my result is not 100% accurate.

This is the UXML:

<ui:VisualElement name="Timer">
    <ui:VisualElement name="TimerFillableCircleBackground" />
    <app:FillableCircle name="TimerFillableCircle" num-segments="50" fill-amount="0.7" fill-clockwise="true" />
    <ui:VisualElement name="TimerFillableCircleOverlay" />
    <ui:Label name="TimerLabel" text="14" />
</ui:VisualElement>

where FillableCircle is a class based on @Baldrekr code.

And here’s the result:
7613173--946024--upload_2021-10-29_13-18-10.png

It works but it has no smooth edges and it has a lot of triangles

We’re about to land a Vector API to 22.1 that would be the best way to achieve this. In the meantime, here’s some ideas to achieve a smooth edge:
a) Texture-less (vertex alpha): Split the geometry in 3 regions: the inner edge, the middle part, and the outer edge. For the inner and outer edges, have the alpha interpolate from to 0 on the outside to 1 towards the middle part.
b) Texture-based (gradient): Use a texture to define a linear gradient and map your UVs to this gradient.

unfortunately our company also faced this problem. We use distortion of the element’s background. The spinner consists of 2 parts, where each part is filled with a background (with a drawn circle, with a transparent center). All this is clipped by elements via overflow = hidden.

It is not possible to make a standard implementation as in html + css, since the clipping box is not rotated in unity3d.

These are the crutches you have to do, it would be great if the unit team would support such a decision.

@AlexandreT-unity Can you please explain how I can map the vertex uv’s to a gradient texture to achieve this smoothing effect? I was following along with the mesh generation code above and I understand I need to apply a texture to the Allocate method and then set the uv of each vertex.

@bugbeeb It’s basically the same as the texture-less method, but instead of encoding the fade with opacity, you use a gradient texture to do so. Assuming a horizontal stripe gradient, at the center of your circle (or at the inner border), your UVs are (0,0), and at the outer part of the circle, your UVs are (1,0). Then you put whatever you want in your gradient texture.

Is there any information on how to do this with the Vector API?

You can look into this post for a sample using the Vector API. I think you will want to look into the Arc or ArcTo method.
The geometry created using the painter2d will have “soft” anti-aliasing, and you can set the line caps if you want a rounded corner on the arc.

As it seems like this is meant to be a “dynamic” mesh, I think you will have to call MarkDirtyRepaint when the value change to let UI Toolkit know a refresh of the element is needed.

1 Like

Thank you so much, @SimonDufour ! I haven’t tried to do anything dynamically but I got an arc drawn on screen within a few minutes. The API is very reminiscent of the Canvas API in browser which is fantastic for me as a webdev.

That was our goal :slight_smile:

2 Likes

@SimonDufour If I may ask a follow-up question about using a dynamic mesh? I have setup a custom element following the docs. The attribute appears in the inspector in UI Builder and works great. However, I’d like to update the attribute at runtime. For some reason the value of progress is not updated when I set its value from my script. Is there a trick to this?

public class ProgressIndicator : VisualElement
{
    public float progress { get; set; }

    public new class UxmlFactory : UxmlFactory<ProgressIndicator, UxmlTraits> { }

    public new class UxmlTraits : VisualElement.UxmlTraits
    {
        UxmlFloatAttributeDescription m_progress = new()
        {
            name = "progress",
            defaultValue = 0,
        };

        public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
        {
            base.Init(ve, bag, cc);
            var ate = ve as ProgressIndicator;
            ate.progress = m_progress.GetValueFromBag(bag, cc);
        }
    }

    public ProgressIndicator()
    {
        generateVisualContent += Generate;
    }

    private void Generate(MeshGenerationContext context)
    {
        // Draw code with progress value
    }
}

Try adding MarkDirtyRepaint to your setter.

Sorry, I forgot to mention that I had tried calling MarkDirtyRepaint with no luck.