How to get a 1 pixel line with NSBezierPath?
Asked Answered
H

4

15

I'm developing a custom control. One of the requirements is to draw lines. Although this works, I noticed that my 1 pixel wide lines do not really look like 1 pixel wide lines - I know, they're not really pixels but you know what I mean. They look more like two or three pixels wide. This becomes very apparent when I draw a dashed line with a 1 pixel dash and a 2 pixel gap. The 1 pixel dashes actually look like tiny lines in stead of dots.

I've read the Cocoa Drawing documentation and although Apple mentions the setLineWidth method, changing the line width to values smaller than 1.0 will only make the line look more vague and not thinner.

So, I suspect there's something else influencing the way my lines look.

Any ideas?

Halie answered 4/11, 2011 at 22:49 Comment(0)
D
26

Bezier paths are drawn centered on their path, so if you draw a 1 pixel wide path along the X-coordinate, the line actually draws along Y-coordinates { -0.5, 0.5 } The solution is usually to offset the coordinate by 0.5 so that the line is not drawn in the sub pixel boundaries. You should be able to shift your bounding box by 0.5 to get sharper drawing behavior.

Dellora answered 4/11, 2011 at 22:57 Comment(3)
With a box, you usually want to inset it or outset it by one half the stroke width rather than shifting it.Upbraiding
It's actually amazing that such an important aspect is not documented by Apple.Halie
It is covered in the cocoa and quartz drawing guidesHenderson
M
20

Francis McGrew already gave the right answer, but since I did a presentation on this once, I thought I'd add some pictures.

The problem here is that coordinates in Quartz lie at the intersections between pixels. This is fine when filling a rectangle, because every pixel that lies inside the coordinates gets filled. But lines are technically (mathematically!) invisible. To draw them, Quartz has to actually draw a rectangle with the given line width. This rectangle is centered over the coordinates:

The rectangle you tell Quartz to draw when you specify integral coordinates

So when you ask Quartz to stroke a rectangle with integral coordinates, it has the problem that it can only draw whole pixels. But here you see that we have half pixels. So what it does is it averages the color. For a 50% black (the line color) and 50% white (the background) line, it simply draws each pixel in grey:

Half pixels averaged out when drawing between pixels

This is where your washed-out drawings come from. The fix is now obvious: Don't draw between pixels, and you achieve that by moving your points by half a pixel, so your coordinate is centered over the desired pixel:

Coordinates offset by 0.5 towards lower right

Now of course just offsetting may not be what you wanted. Because if you compare the filled variant to the stroked one, the stroke is one pixel larger towards the lower right. If you're e.g. clipping to the rectangle, this will cut off the lower right:

Comparison between offset stroked and non-offset filled rectangle

Since people usually expect the rectangle to stroke inside the specified rectangle, what you usually do is that you offset by 0.5 towards the center, so the lower right effectively moves up one pixel. Alternately, many drawing apps offset by 0.5 away from the center, to avoid overlap between the border and the fill (which can look odd when you're drawing with transparency).

Note that this only holds true for 1x screens. 2x Retina screens actually exhibit this problem differently, because each of the pixels below is actually drawn by 4 Retina pixels, which means they can actually draw the half-pixels. However, you still have the same problem if you want a sharp 0.5pt line. Also, since Apple may in the future introduce other Retina screens where e.g. every pixel is made up of 9 Retina pixels (3x), or whatever, you should really not rely on this. Instead, there are now API calls to convert rectangles to "backing aligned", which does this for you, no matter whether you're running 1x, 2x, or a fictitious 3x.

PS - Since I went to the hassle of writing this all up, I've put this up on my web site: http://orangejuiceliberationfront.com/are-your-rectangles-blurry-pale-and-have-rounded-corners/ where I'll update and revise this description and add more images.

Made answered 8/3, 2014 at 10:11 Comment(1)
Great Answer! Maybe the best solution to "adopt" pixels for 1x, the detection process in Apple docs: developer.apple.com/library/mac/documentation/Cocoa/Conceptual/…Indomitable
M
10

The answer is (buried) in the Apple Docs:

"To avoid antialiasing when you draw a one-point-wide horizontal or vertical line, if the line is an odd number of pixels in width, you must offset the position by 0.5 points to either side of a whole-numbered position"

Hidden in Drawing and Printing Guide for iOS: iOS Drawing Concepts, though nothing that specific to be found in the current, standard (OS X) Cocoa Drawing Guide..

As for the effects of invoking setDefaultLineWidth: the docs also state that:

"A width of 0 is interpreted as the thinnest line that can be rendered on a particular device. The actual rendered line width may vary from the specified width by as much as 2 device pixels, depending on the position of the line with respect to the pixel grid and the current anti-aliasing settings. The width of the line may also be affected by scaling factors specified in the current transformation matrix of the active graphics context."

Maugre answered 26/3, 2013 at 8:8 Comment(4)
As for the 0 width, I've tried that and just get no line rather than the thinnest line.Stere
@Stere Yay. Retina or plain old display..?Maugre
Non-Retina Display. The Retina displays display fine.Stere
edit: the retina display does not display a fuzzy line, however if you set the width to 0 it also displays no line rather than the thinnest line for the device (which is the same problem with non-retina)Stere
H
6

I found some info suggesting that this is caused by anti aliasing. Turning anti aliasing off temporarily is easy:

[[NSGraphicsContext currentContext] setShouldAntialias: NO];

This gives a crisp, 1 pixel line. After drawing just switch it on again.

I tried the solution suggested by Francis McGrew by offsetting the x coordinate with 0.5, however that did not make any difference to the appearance of my line.

EDIT: To be more specific, I changed x and y coordinates individually and together with an offset of 0.5.

EDIT 2: I must have done something wrong, as changing the coordinates with an offset of 0.5 actually does work. The end result is better than the one obtained by switching off the anti aliasing so I'll make Francis MsGrew's answer the accepted answer.

Halie answered 5/11, 2011 at 9:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.