How to build UI Elements (Part 1 Rectangles)

 

Building a UI library is one of the hardest topics I have ever gone into. I mean, writing a function that shows a square and makes it clickable is not that hard. I am talking about something much bigger: rendering text, making buttons look genuinely good, supporting rounded corners, and much, much more.

What I will be talking about in this article is GPU rendering. Software rendering is a hell of a subject, and to be honest, I never actually finished my software rasterizer. Since we are mainly talking about a UI framework for games here, GPU rendering is the way to go.

Rectangles

Rectangles are the foundational building blocks of any UI, from buttons to containers (panels). On paper, drawing a rectangle and coloring it is dead simple: you just submit a quad and give it a color.

But in a real UI, that is almost never all you want. You need to support rotation and scaling. You need rounded corners (border radius). And on top of all that, you have to deal with the silent killer of clean UI: aliasing. If your rotated or rounded quads look like jagged, pixelated stairs, the illusion of a polished interface is immediately ruined.

We can obviously use MSAA for anti-aliasing, but that would be overkill. Imagine rendering the same rectangle, with all its logic like rounded corners, borders, etc., four times. (Assuming MSAA 4x)

Let's start with the simplest thing after rendering a quad: rounding. For rounding, I decided to use the famous Inigo Quilez SDF rounded box method.

Here is what the function looks like:

float sdRoundedBox(float2 p, float2 half, float r) {
    r = min(r, min(half.x, half.y));
    float2 q = abs(p) - half + r;
    return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}

At first glance, especially if you are not familiar with SDFs, you would say WTF! So let's walk through it slowly together.

The function accepts three parameters:

  • p: the point to test. In our case it will be the UV coordinate of the quad (we will talk about it a bit later).
  • half: half of the rectangle size.
  • r: the corner radius you want to use.

The first thing the code does is clamp the radius to prevent it from turning inside out. Meaning, if the user mistakenly uses a radius bigger than the actual size of the rectangle, we clamp it, which would eventually make it a circle.

float2 q = abs(p) - half + r;

1. Mirroring the Quadrants

By taking the absolute value of the test point (the UV in our case), we are basically folding all of 2D space into the top-right area. No matter where our pixel is, it will always fold to the top-right area. But why? Because that makes writing our math easier: we write it for one corner instead of special handling for every corner. So basically, q is a mapped point of p.

2. Shrinking the Box Core

How about subtracting half? We said earlier that half is simply the rectangle's half-size. We subtract it to move the rectangle's edge to zero. In other words, we have q, our new test point for the top-right corner, but instead of measuring it from the rectangle's center, we're now measuring it relative to the rectangle's edge.

This makes the math much easier. If q is negative, the point is still inside the rectangle. If it's zero, the point lies exactly on the edge. If it's positive, the point has moved outside the rectangle.

Now let's bring the corner radius back in:

Adding r after subtracting half is equivalent to subtracting a smaller half-size (half - r). In other words, we're temporarily pretending the rectangle is a little smaller on every side.

Why do this? Because calculating the distance to a sharp-cornered rectangle is simple. Once we have found that distance, we can expand the shape back outward by the same radius. That expansion naturally rounds the corners, producing the final rounded rectangle without needing separate math for straight edges and curved corners.

3. Stitching It All Together

Now for the line that scares everyone:

return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;

Relax. It looks like a mess, but it is really just two pieces glued into one formula: one piece for when the point is outside the box, and one piece for when it is inside. Then a final touch at the end to round everything.

Let's take the outside piece first: length(max(q, 0.0)). Remember that q is our point measured from the corner of the shrunken box. If the point is outside, at least one of q.x or q.y is positive. The max(q, 0.0) throws away anything negative and keeps only the positive part. So if you are outside along one edge, it kills the other axis and you get the straight perpendicular distance to that edge. If you are outside near the corner, both stay positive and length gives you the real diagonal distance to the corner. And if you are fully inside, both components are negative, max turns them into zero, and length becomes zero. So this whole piece is the distance when you are outside, and nothing when you are inside.

Now the inside piece: min(max(q.x, q.y), 0.0). When you are inside the box, both q.x and q.y are negative, and max(q.x, q.y) picks the one closest to an edge (the least negative one). That is exactly how far you are from the nearest edge, as a negative number. The min(..., 0.0) is just a switch: the moment you step outside, this piece becomes zero and gets out of the way so it does not fight with the outside piece.

Notice how the two pieces never fire at the same time. Outside, the first piece is zero and the second one does the work. Inside, the second piece is zero and the first one does the work. Add them together and you get one clean signed distance: negative inside, zero on the edge, positive outside. That is the whole point of an SDF.

And the last bit, - r? That is the promise we made back in the shrinking step. We measured the distance to the smaller, sharp-cornered box. Subtracting r pushes the whole surface outward by r in every direction. Pushing a sharp corner outward by a fixed amount is literally what draws a quarter circle, so the corners round themselves for free. No special case for edges, no special case for corners. One formula.

Once you have this signed distance, the rest is dead simple: negative means inside the shape so we shade the pixel, positive means outside so we drop it, and that thin band right around zero is exactly where we get to do smooth anti-aliasing. Play with the visualizer below and watch the readout. The outside row is the length(max(q, 0.0)) piece, the inside row is the min(max(q.x, q.y), 0.0) piece, and d is what you get after adding them and subtracting r.

q = abs(p) - half + r [ signed distance field ]
move over the field to test point p
visualize
p-
abs(p)-
q-
outside-
inside-
d (sdf)-
> hover the field

 

Anti-Aliasing

Now we have a rectangle with rounding. It looks good, but if you look closely you will notice the edges look jagged. It looks bad if you think about it, and it does not look like a production rectangle, like the ones Chrome uses for example. And as I mentioned earlier, we cannot just reach for MSAA, that would be a hell of a performance hit.

So we will go back to math to help us out. Let's first take a deep dive into a shader function called fwidth.

fwidth, by definition, returns the sum of the absolute values of the derivatives in x and y. But what is that supposed to mean? fwidth(dist), where dist in our case is the result of the SDF equation, is really asking the GPU a very smart question: if I move one pixel over on the screen, how much does the value of dist change?

I won't go into the details of how fwidth works, you can check Khronos for more information. What I care about here is how we can use it for anti-aliasing.

Let's take a step back first and look at what we do once we have dist. The naive approach: if dist < 0 make the pixel white (inside), otherwise make it black (outside). That is a harsh threshold if you think about it, because that hard jump straight from black to white is exactly what causes the jaggies.

Instead, we can take the result of fwidth, call it aa, and use it to smoothstep from negative aa to positive aa: smoothstep(-aa, aa, dist).

So what does that actually give us? smoothstep takes a lower edge, an upper edge, and a value, and it returns a smooth 0 to 1 ramp between those two edges. Feed it dist and it returns 0 when we are well inside (dist below -aa), 1 when we are well outside (dist above +aa), and a soft curve in the thin band between them. That band is our edge.

Right now it reads backwards for us: 0 inside, 1 outside. So we flip it:

float dist     = sdRoundedBox(input.local, gPush.halfSize, gPush.radius);
float aa       = max(fwidth(dist), 1e-4);
float outer    = 1.0 - smoothstep(-aa, aa, dist);   // 1 inside, fades to 0 outside

Now outer is 1 deep inside the shape, 0 outside it, and a smooth value in between. No more hard black to white jump, we get a gentle ramp across the edge instead, and that ramp is what kills the jaggies.

Here is the part that makes fwidth the right tool and not just any small number. The width of that ramp is aa, and aa is fwidth(dist), which is how much dist changes over a single pixel. So the transition is always about one pixel wide, no matter how big or small the rectangle is on screen. Zoom in until the rect fills the monitor, or shrink it down to a tiny icon, and the edge keeps the same one pixel of softness. If we had hardcoded a fixed width instead, the edge would look sharp at one size and blurry or jagged at another.

And the max(fwidth(dist), 1e-4) is the guard rail. If fwidth ever comes back as zero (a flat area where dist is not changing, or some degenerate fragment), the ramp would collapse back into a hard edge, or blow up into a NaN once it gets used further down the line. Clamping it to a tiny value keeps a sane, minimal ramp alive.

That is it. Two extra lines of math and the edges are smooth. No MSAA, no rendering the rectangle four times.

The output of this step is the outer and the fill color

Borders

After anti aliasing step we got outer where 1 is inside, 0 is outside, and edges are smoothed. We will need now to inner shape. This shape will be the normal shape + the border width. Something like this:

float inner= 1.0 - smoothstep(-aa, aa, dist+borderWidth);  

Remember here we add the border width because negative dist means inside the rectangle while positive dist means outside the shape

Outer and inner feel confusing a bit and you might think it's inverted. But surprisingly they correct. They are relative to the shape itself not for the quad. Meaning out

If you think everything above is magic. Let me walk you to the real magic where we link everything together. First, Let's create the border ring. It's quite easy

float  ring   = saturate(outer - inner);

Basically just pixels with the border width but smoothed to avoid aliasing.

From this step we have two outputs the border color and the ring

 

Render The Rectangle

We now use the ring, border color, and the fill color to give our rectangle a color. The equation is quite easy

float3 out = borderColor * ring + fillColor * outer * (1 - ring)

 

Bonus Step

While everything we said above is correct. It lacks very important detail which is alpha. Usually when we define a color we have an alpha channel and defaults to 255 If not present.

In the equation above, we assume two things. First, the border's strength on top of the fill is just the ring which is the coverage of the border. Second, It's an opaque rectangle we cannot make it transparent for instance. To fix this we will take ring and outer out of equation and introduce immediate steps.

First, Filling Opacity

float  fa   = fill.a * outer;

Second, Border Opacity

float ba   = border.a * ring;

Third, Out Opacity

float  outA   = ba + fa * (1.0 - ba);

1.0 - ba multiplied by filling alpha for a simple reason. We do borders on top of filling color. So, we are telling the alpha fill only places that border does note exist

 

So, Final code would look something like this:

float4 PSMain(VSOutput input) : SV_Target {
    float dist  = sdRoundedBox(input.local, gPush.halfSize, gPush.radius);
    float aa    = max(fwidth(dist), 1e-4);
    float outer = 1.0 - smoothstep(-aa, aa, dist); // coverage of the rounded shape

    float4 fill = input.col;
    float  fa   = fill.a * outer;

    float  ba   = 0.0;
    float3 brgb = float3(0.0, 0.0, 0.0);
    if (gPush.borderWidth > 0.0) {
        float  inner  = 1.0 - smoothstep(-aa, aa, dist + gPush.borderWidth);
        float  ring   = saturate(outer - inner);
        float4 border = UnpackRgba(gPush.borderColor);
        ba   = border.a * ring;
        brgb = border.rgb;
    }

    // Straight-alpha "border over fill" within the shape; the blend state does the over-composite
    // against the framebuffer.
    float  outA   = ba + fa * (1.0 - ba);
    float3 outRgb = outA > 1e-5 ? (brgb * ba + fill.rgb * fa * (1.0 - ba)) / outA : fill.rgb;
    return float4(outRgb, outA);
}

If you are familiar with SDFs you will see something wrong. SDFs by nature assume (0,0) is the middle but in default quad UV (0,0) exist in bottom left. For this, We have two options:

  • Center the UV in vertex shader.
  • Bake UV in quad so uv moves from (-hx,-hy) to (hx,hy) (The method I am using)

 


With everything above. We did a good fast implementation for a rectangle that support basic features like border, radius, opacity, and anti aliasing. Our rectangle still miss two important features:

  • Gradients (Will take it in another article)
  • Inside Clipping which called in web (overview: hidden)

Let's first start with How rectangles will be rendered relative to each other in screen for GPU optimizations? Let me first explain something. I built my own Flexbox Calculator so the output positions and sizes are absolute not relative ti their parents With this in mind. We have literally two cases:

  • Overview hidden containers (In my case Row, Column, Stack,...etc.) which clip anything
  • Scroll container

Let's focus first on overview-hidden containers. Say you build a container with 100 objects, but the solved size can hold only 20 of them. That means 80 items are never actually visible. If we blindly submit draw calls for every one of them anyway, we waste GPU time shading pixels nobody sees, or worse, waste CPU time recording draw calls that get thrown away regardless.

This is where clipping comes in, and it works on two levels: a coarse cull on the CPU, and a hard visual clip on the GPU.

Push and Pop: The Clip Stack

Every container that clips pushes a rectangle onto a stack before drawing its children, and pops it back off once it is done. That is really all PushClip and PopClip mean: maintain a stack of rectangles, and whatever we draw next only shows up inside whatever is currently on top.

The important detail is that a nested clip does not replace the outer one, it intersects with it. If a scrollable list (clip A) contains a card (clip B), and the card is only half inside the scroll viewport, the visible region for anything inside the card is the intersection of A and B, not just B. So pushing a clip really means:

Rect newClip = parentClip.isValid() ? parentClip.intersect(myRect) : myRect;
clipStack.push(newClip);

and popping just restores whatever was on top before.

The Cheap Win: CPU-Side Cull

Before we even get to the GPU, this stack gives us a free optimization. Since we already know the active clip rectangle while walking the tree, we can test each child's bounding box against it before recording anything. If a child's box does not intersect the clip window at all, we skip it, and its entire subtree, completely. Going back to our 100-object container holding 20 visible ones: as we scroll down that list, only the roughly 20 that intersect the viewport ever get visited. The other 80 are skipped with a single bounding-box check each, no draw call, no vertex shader, no fragment shader, nothing.

This matters more than it sounds. A long scrollable list is exactly the pattern where this pays off, the deeper and heavier a card's subtree is (text, images, nested containers), the more work a single bounding-box rejection saves you.

The GPU Side: Scissor Rect

Once we know what is roughly on screen, the GPU still needs to clip individual pixels, a card that is half inside the viewport should have its bottom half cut off, not culled entirely. That is the scissor test: fixed-function rasterizer hardware that rejects any pixel outside a given rectangle before the fragment shader even runs. It is essentially free, since it happens earlier in the pipeline than shading.

The catch is a scissor rect is always axis-aligned and always a hard edge. That is fine for a plain rectangular clip, but our containers can have rounded corners, and we already went through the trouble of anti-aliasing our rounded rectangles earlier in this article. Clipping to a hard rectangular scissor would throw all that softness away right at the boundary.

So the scissor alone only gets us the coarse rectangle. To bring back the smooth rounded edge, we compute a coverage value per pixel, which is the exact same idea we used for the rectangle fill earlier. We reuse our sdRoundedBox function, but with a little new machinery around it. Let's start with a simple question: how does a pixel know whether it sits near the edge of the clip, so it can soften itself there? The answer is easy. If a fragment shader requests SV_POSITION the GPU hands it the pixel's screen space position, which we will call fragPx.

We will also need clipCenter, the center of the clip box in that same screen space, computed on the CPU from the solved x, y, width, and height.

Why both? Because sdRoundedBox only knows how to measure a point against a box centered on the origin. fragPx is an absolute screen coordinate, and the clip box can sit anywhere on screen. So we subtract clipCenter from fragPx to re-express the pixel relative to the box's own center, which is the only frame the SDF understands. That one subtraction is what lets a single formula clip a box wherever it lands.

Now we know what coverage is, what we need to compute it, and where those inputs come from.

Let's link everything together:

float ClipCoverage(float2 fragPx){
        float2 p = fragPx - gPush.clipCenter;
        float  dist = sdRoundedBox(p, gPush.clipHalf, gPush.clipRadius);
        float  aa   = max(fwidth(dist), 1e-4);
        return 1.0 - smoothstep(-aa, aa, dist);
}

Then we multiply the result into the alpha channel of the fragment output:

return float4(outRgb, outA * ClipCoverage(input.pos.xy));

Play with the visualizer below. The container holds far more objects than it can show. Switch between drawing everything, clipping with a hard scissor rectangle, and clipping with the SDF coverage on top. Watch the corners: the plain scissor is a bounding box and cannot round, so content leaks into the corners no matter what radius you ask for. Coverage is what actually carves the rounded, anti-aliased edge. Drag inside the canvas to move the clip box around.

outA *= ClipCoverage(fragPx) [ scissor vs coverage ]
drag to move the clip, hover to read coverage
clip mode
fragPx-
clipCenter-
p = frag-center-
dist (sdf)-
coverage-
> hover the field

One thing worth mentioning: running this coverage on every pixel is not free. We can skip it entirely whenever the clip radius is 0, which covers the vast majority of containers, since a square clip is already pixel perfect under the plain scissor rectangle. In that case the scissor alone does the job and we never pay for the SDF.

That is the whole trick: cull subtrees on the CPU using rect intersection tests, hard-clip the rest with a cheap scissor rect, and only pay the SDF cost exactly where the rounded edge actually lives.

Overview-hidden containers is one of our two cases. The other is scroll containers, which need one more trick layered on top: everything above just clips in place, but a scroll container also has to move its content around before clipping it.

 

Now we built a great rectangle that can be used as a container and some cool stuff like labels and buttons or even better custom drawing using painter like web canvas and QT painter. In the next article we will discuss how to render text and how to coverage them for good looking text. Stay tuned!

This article was updated on July 17, 2026