Device independent pixels, or points, are a convenient way to design for multiple devices. They allow a consistent layout without worrying about exact pixel densities. It’s quite convenient, but it can lead to blurriness if we aren’t careful. To remedy this I endeavoured to add some pixel snapping and stroke adjustment features to our product Fuse last week.
In this article I’ll use the iOS term “points” to refer to “device independent pixels”.
Why it’s blurry
The problem stems from abstract units that don’t map 1:1 with the physical device pixels. Our request to draw a line in these units ends up partially covering several physical pixels. The GPU interpolates the values and draws a somewhat fuzzy line. It’s not the correct color due to blending, usually ending up lighter than intended.
Consider an element, perhaps a button, positioned at
5,7 (our designer likes odd margin values). I wish to draw a
1 point border along the top: a line along
Y=7 (represented as the dark blue line in the picture). These point sizes are abstract and must be converted to actual device pixels. Android defines a point to be equivalent to a physical pixel at 160PPI. What happens if our device is a 240DPI device? The density is 150% of the reference, so our values are scaled up. Our line along
Y=7 points becomes a line along
Y=10.5 pixels and the width of
1 point becomes
My first step to getting a crisper display is lining up the UI elements. I want all the borders of our elements to fall exactly on the pixel edges of the device.
The button at
5,7 on a device with 150% density translates to
7.5,10.5 which is not on the edge of a pixel. I round the values and get
8,11 as the desired pixel location. For layout I need to ensure the logical position reflect its real position on the device. From the rounded pixel values I reverse the calculation to get the point location at
5.3333,7.3333. This aligns the top-left corner of the element with device pixels.
I must also consider the size of the element. A size of
40,45 points at 150% density results in a pixel size of
60,67.5. I round this to
60,68 and reverse to get a point size of
40,45.3333. Since the top-left of the element is pixel aligned, adding this pixel snapped size will result in the bottom-right corner also being pixel aligned.
What does that do to the border?
The edge of the element aligns to a pixel edge (the dark blue dashed line). The border however is still drawn partially in both of the surrounding pixels.
A stroke has two potential problems: it isn’t a integral pixel width; or it doesn’t align with the pixels. The first problem is easy to resolve by rounding the width of the stroke to the nearest pixel. This produces a noticeable change in the size of the stroke, but it achieves our goal of being sharp and the correct color.
In this case we got lucky since the pixel size of the stroke was rounded to
2. Centered on the edge of the element this fills the surrounding two pixels. What if the size of the stroke were rounded down to
1 pixel? Centering on the element edge would fail again since it’d partially cover the two surrounding pixels.
Any odd-pixel width will have this problem. I therefore offset these a half-pixel so that the stroke edges both align with a pixel boundary. The offset could either be inside or outside of the element. I’ve chosen to put it inside at the moment, so that a 1-pixel border doesn’t require any extra space in our elements. All of this is obviously adjustable at the API level.
With just those few simple adjustments all of our borders are sharp. They cover entire pixels so the GPU doesn’t have to interpolate partially. It does mean that the width of strokes, and sizes of elements, differs slightly from device to device.
While for many UIs this is okay, sometimes sub-pixel correctness is more important than sharpness. All of this will need to be configurable, plus I suspect I’ll be revisiting the code often.