How to draw styled rectangles using the GPU and Metal
This is a tutorial on drawing styled rectangles using Metal graphics shaders. Learn how to draw a rectangle, add borders, round the rectangle’s corners, and fill the rectangle with linear gradients.
For apps that require fast rendering, such as video processing apps or 3D game apps, using the GPU for rendering is often required. The GPU has more cores and so can perform data-parallel calculations like calculating pixel position and color very quickly.
The tradeoff of using the GPU is that we have to implement shaders. However, for a modern UI app, you'd only have to implement them for glyphs, icons, and rectangles. Warp's UI, for example, is entirely composed of those three primitives.
This tutorial will focus just on shaders for rectangles. The snack bar, centered at the top of the window, is just a rectangle with a border and rounded corners.
We will walk through drawing a rectangle, adding borders, rounding the rectangle’s corners, and filling the rectangle with a linear gradient. We will cover interesting graphics concepts like distance fields, vector projections, and antialiasing.
This tutorial is aimed at beginners who are new to GPU rendering. The code examples are in Metal, Apple’s official shader API.
Here is the table of contents: you can feel free to jump to any section.
- Why render on the GPU
- How to draw a basic rectangle using shaders
- How to draw borders on a rectangle using shaders
- How to draw rounded rectangles using shaders
- How to fill rectangles with gradients using shaders
- Putting it all together
We provide instructions to the GPU via a pair of functions: the vertex shader and the fragment shader. The vertex shader’s job is to produce the positions that need to be drawn. The fragment shader takes these positions and determines the color for every pixel within these position boundaries. In the case of a triangle, the vertex shader produces the three vertices, and the fragment shader fills the triangle pixel by pixel.
Let’s draw a rectangle with shaders.
For the vertex shader, we normalize the coordinates of the vertex to be independent of the viewport. Concretely, we do this by dividing the coordinates by half of the viewport (see the diagram above from these Apple Metal docs).
For the fragment shader, we can return the interpolated color of the pixel.
For brevity, we’ll focus only on writing the shaders for the rendering pipeline. We recommend:
- Apple’s ‘Using Metal to Draw a View’s Content’ to learn how to use MetalKit to create a view and send rendering commands to draw on it
- Apple’s ‘Using a Render Pipeline to Render Primitives’ explains how to use the shaders in the rendering commands to draw a shape on the view.
We used the code above, along with MetalKit and our custom layout engine, to draw the first iteration of tabs in Warp.
While drawing borders, the rectangle stays the same size and shape. Hence, we do not need to alter our vertex shader. Instead, we just have to edit the fragment shader.
The fragment shader processes a pixel at a time. For each pixel, we have to figure out whether it is inside or outside the border. First, we calculate the border boundary by subtracting the border widths corresponding to the pixel’s quadrant. If the pixel is above and to the right of the center of the rectangle, then we should subtract the rectangle corner by the border top and the border bottom.
And so on and so forth:
With the border corner obtained, we can then assign pixels outside the border corner with the border color, the ones inside with the background color.
And that’s how we form the bordered rectangle in Warp’s tab bar:
To round the corners of the rectangle, we need a framework that tells the the fragment shader whether a pixel falls inside or outside a rounded edge.
Distance fields are functions that help us define non-rectangular edges. Given a pixel, a distance field outputs its distance to the nearest edge of a shape. This is a useful API for fragment shaders, which only has access to one pixel at a time.
Using distance fields to express rounded corners
The following diagram draws out four distance fields of a rectangle (lines). Each line represents pixels that are the same distance away from the edge of a rectangle, similar to contour maps in geography. Notice that each distance field matches the outline of rounded rectangles.
The distance field of our rounded shape is simply the distance field of the shrunk rectangle minus the corner radius.
The formula for distance field of a rectangle is:
The code above will render a rectangle with rounded corners. However, the rounded edges will look jagged:
Now, we can render rounded bordered UIs like in Warp:
We can now produce gradient UI elements, like this header in Warp:
You can read through the complete code sample here.
Alongside glyphs and images, the rectangles we produce from these shaders form the UI surface of Warp. Using the GPU for rendering is what enables us to render large amounts of terminal text and UI at over 60fps on a 4K screen.
Our newer and more complicated UI components are compositions of these building blocks. This has enabled us to create a robust and maintainable UI framework. The code for all our primitives span only 300 lines.
If you want a fast performant terminal with modern UI, request early access here:
Experience the power of Warp
- Write with an IDE-style editor
- Easily navigate through output
- Save commands to reuse later
- Ask Warp AI to explain or debug
- Customize keybindings and launch configs
- Pick from preloaded themes or design your own