Skip to content

Pixel Perfect Rendering

Mikko Mononen edited this page Jun 5, 2026 · 2 revisions

Pixel Perfect Anti-aliased Rendering

How Anti-aliased Rendering Work?

AddRectFilled({1.5, 1}, {3, 3})

Anti-aliased vector rendering works by calculating how much a shape covers a pixel. The coverage is used to blend the drawn color at the specific pixel.

For example, if a rectangle covers half a pixel, the pixel will be painted 50% transparency. If the rectangle covers the whole pixel, it is fully filled.

Pixel Perfect Drawing

AddRectFilled({1, 1}, {3, 3})

If we want to render pixel perfect rectangles, then we should always snap the coordinates to whole numbers. This ensures that whole pixels are filled, and things look crisp.

When rendering circles, thought, we should think about the bounding rectangle, rather than center and radius. If we snap the center and radius, we always end up with even numbered diameter circles. Odd diameter circles are needed for example when using them as line end decorators for odd thickness lines.

To generalize, we should think about the boundary of the shape, and how to fill the inside.

Lines

In vector graphics, a line is specified by an infinitely thin segment between two end points, and stroke thickness.

When rendering, the stroke is expanded each side of the line. You can also specify a square cap for the line, in which case the stroke is also expanded beyond start and end of the line.

Pixel Perfect Lines

// Thickness 1
AddLine({1, 2}, {4, 2}, 1)       // A
AddLine({1, 2.5}, {4, 2.5}, 1)   // B

// Thickness 2
AddLine({1, 2}, {4, 2}, 2)       // C
AddLine({1, 2.5}, {4, 2.5}, 2)   // D

Pixel perfect lines can be frustrating at first, since we need to think about the how the line covers the pixels, rather than the line coordinates.

For example if we render 1px line snapped at whole number coordinates, we will get blurry line because the actual geometry of the line will cover half a pixel.

On the other hand, 2px line will look nice and crisp, because we expanded the line a whole pixel. This dance keeps going for every even and odd thickness.

Stroke Position

// Thickness 1
AddLineH(1,4, 2, 1, StrokeInside)          // A

// Thickness 2
AddLineH(1,4, 2, 2, StrokeInside)          // B

// Rounding 0, Thickness 1
AddRect({1,1}, {5,4}, 0, 1, StrokeInside)  // C

Trying to position the line coordinatess to cover the right pixels can be a lot of work, especially if the code needs to support multiple line widths.

One way to solve this is to make the thickness to grow only on one side of the line, instead both sides. That way, as long as the thickness is a whole number, and coordinates are snapped, we will get crips rendering regardless of the thickness.

The stroke position, especially when placed inside the shape, can help to deal with UI layout, and stacking fills and strokes.

Keeping the visuals inside makes sure that they will not spill over to other UI elements, and simplifies things like culling and hit testing.

Going back to our generalization, think about boundary, and fill or stroke inside.

Stroke Position Visualized

The side of the shape is defined by the path direction. Closed filled shapes are wound clockwise. To generalize to open shapes, the right side of a path segment is inside, and left is out.

The CenterBiased stroke position allows to render crips centered lines for odd thickness too. It offsets the stroke so that half the thickeness, rounded down, is placed outside the shape, and rest towards inside. For integer thickness, this means that odd thickness lines have more of the thickness inside the shape.

Lines vs Paths

// A
AddLine({1.5,1.5}, {4.5,1.5})
AddLine({4.5,1.5}, {4.5,3.5})
AddLine({4.5,3.5}, {1.5,3.5})
AddLine({1.5,3.5}, {1.5,1.5})

// B
PathLineTo({1,1})
PathLineTo({5,1})
PathLineTo({5,4})
PathLineTo({1,4})
PathStroke(Closed | StrokeInside)

It might be tempting to use multiple separate lines to draw shapes. This can lead to corners missing coverage, since the line did not quite cover the whole pixel. Or the overdraw can be visible when the line color is transparent.

A better option is to use paths. When a path is stroked, the renderer builds the geometry so that corners are neatly joined and no gaps or overdraw is left (for nice geometry, that is).

Combining paths and stroke position can make coordinates simpler easier to compute.

Conclusion

All in all, the general gist for pixel perfect rendering is to think about the boundary of the shapes, rather than focusing on the coordinates, and fill or stroke inside.

Clone this wiki locally