Coordinates for lines vs points (and pixel grid alignment for points)

Questions about the LÖVE API, installing LÖVE and other support related questions go here.
Forum rules
Before you make a thread asking for help, read this.
stingund
Prole
Posts: 5
Joined: Tue Apr 13, 2021 5:18 am

Coordinates for lines vs points (and pixel grid alignment for points)

Post by stingund »

While doing some tests, I noticed that if I drew lines and points with the same coordinates, I couldn't get them aligned. For example, calling:

Code: Select all

	
love.graphics.points(10, 1)
love.graphics.line(10, 2, 15, 2)
will result in the point being drawn one pixel before the line, as if it had been issued at (9,1)

The image below illustrates that, the alternating pixels start at the same X coordinate as the blue/red lines, yet they are not aligned.
points_vs_lines.png
points_vs_lines.png (2.24 KiB) Viewed 9196 times
I've since figured out that it's due to the following property of points in the documentation for love.graphics.points() (https://love2d.org/wiki/love.graphics.points)
The pixel grid is actually offset to the center of each pixel. So to get clean pixels drawn use 0.5 + integer increments.
Points are not affected by love.graphics.scale - their size is always in pixels.
While I have it figured out and can account for it, what is the purpose of aligning the pixel grid this way? Given that we have routines for drawing lines, rectangles, etc., that don't have this offset alignment, it is counter intuitive to have it when drawing points.

I understand that graphic sampling may work this way, but given line(), rectangle(), etc., one really expects points() to behave as if you were placing pixels at an exact position on a surface.

Thoughts?
User avatar
pgimeno
Party member
Posts: 3541
Joined: Sun Oct 18, 2015 2:58 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by pgimeno »

It's just natural that coordinates work that way. The edges of the pixels are at integral positions, the centres are at a distance of 0.5 from either edge. It's the same for lines and rectangles; try drawing horizontal or vertical lines, or rectangles in line mode, at integer coordinates with love.graphics.setLineStyle("smooth") and love.graphics.setLineWidth(1) and without zoom. The line is drawn on the edge of the pixel, and extends 0.5 pixels to each side of the edge. If you add 0.5 to the coordinates, the line is drawn pretty sharp.

If it's a problem for you, you can start your love.draw() with love.graphics.translate(0.5, 0.5), so that integral coordinates are always at the centre of the pixels. See the introduction for love.graphics.
stingund
Prole
Posts: 5
Joined: Tue Apr 13, 2021 5:18 am

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by stingund »

Hello,

What I pointed out is that I'm not getting the same behaviour for all primitives. If you look at the picture I posted, passing the same x coordinate to points() and line() does not result them in starting at the same horizontal pixel.

In my example, I set the line drawing mode to "rough" (not "smooth" as you suggested) because I want the least amount of tampering possible when I'm comparing line and point drawing.

Leaving preferences aside, you should expect consistency, and right now I'm not seeing that between (for example) points and lines when drawing them as simply (smooth mode disabled, etc.) as possible.
The line is drawn on the edge of the pixel, and extends 0.5 pixels to each side of the edge
This is something different (I did notice it though). It seems to be doing that to smooth out the line, extending the line with a pixel of the same color but with some amount of transparency. This is different. Trying to compare points and "smooth" lines is not a good comparison.

Also, it's only the documentation for points that mentions the 0.5 aligned pixel grid. Where are you seeing that this applies to lines and rectangles?
If it's a problem for you, you can start your love.draw() with love.graphics.translate(0.5, 0.5)
I would still see the same relative inconsistency between lines and points, it wouldn't change anything as far as I understand. If I wanted to do this, I would substitute the points() function in the table with my own and would offset the coordinates before submitting them (since only points have this 0.5 offset).

Again, this isn't a problem anymore for me since I figured out why I wasn't getting the behaviour I was expecting. This is feedback about the API, and what seem to be an inconsistency for drawing certain types of primitives (points vs lines in my example).
User avatar
pgimeno
Party member
Posts: 3541
Joined: Sun Oct 18, 2015 2:58 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by pgimeno »

I see no inconsistency in behaviour. There seems to be some inconsistency in the rounding performed by your graphics card driver when drawing points, which I believe is an OpenGL native operation, while love.graphics.line is polygon-based, so the rounding for lines is performed on the Löve side.

The problem lies in the fact that you're drawing the points at the edge of the pixel, and your graphic driver is forced to choose which pixel it should draw at, because the place you're asking it to draw the point at is right in the middle of two pixels. Your graphic driver chooses to round half down and Löve chooses to round half up, and that's what appears to you as an inconsistency.

My graphic driver does that too, but it suffices to use 10.01 instead of 10 as the coordinate, for the point to be drawn at the pixel that you expect.

Lines are subject to the same rules. When you use the "rough" line style, using an integer coordinate, you're effectively asking Löve to draw at a place that lies between two pixels, forcing Löve in this case, instead of GL like in the case of points, to decide which direction to round. Löve tends to round up, meeting your expectations, but sometimes it fails to do so, as you can see in this case: https://love2d.org/forums/viewtopic.php ... 69#p238369
User avatar
pgimeno
Party member
Posts: 3541
Joined: Sun Oct 18, 2015 2:58 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by pgimeno »

Well, I was wrong, but not by much. As it turns out, the rounding depends on whether the line is horizontal or vertical, and there are good reasons for that.

First, let's talk about filled rectangles. Rectangles don't have the same problems as lines, because they have an edge. You place the top left edge in the chosen (integral) coordinates, and specify how many whole pixels it should extend; for example, love.graphics.rectangle("fill", 5, 13, 2, 1) places the left edge in the edge between pixels 4 and 5, the right edge between pixels 6 and 7, the top edge between pixels 12 and 13, and the bottom edge between pixels 13 and 14.

For points, however, you don't specify the position of the top left edge of the point; you specify the position of its centre, and extends 0.5 pixels in all 4 directions.

Now we come to horizontal lines. They are a mix: the X coordinate specifies the position of one edge, and the Y coordinate specifies the centre of the line, which extends love.graphics.getLineWidth()/2 to each side (that is, 0.5 pixels by default). So, in the vertical direction, you get the same behaviour as points, while in the horizontal direction, you get the same behaviour as rectangles.

For vertical lines, it's the same reasoning but with X and Y, vertical and horizontal swapped.

I haven't checked what happens with lines that aren't axis-aligned. In rough line mode, it may be the case that it depends on whether the line is longer horizontally than vertically for the behaviour to change.

For example, all of these should be drawn a well defined positions:

Code: Select all

love.graphics.setLineStyle("rough")
function love.draw()
  love.graphics.points(10.5, 1.5)
  love.graphics.line(10, 2.5, 15, 2.5)
  love.graphics.line(10.5, 3, 10.5, 8)
  love.graphics.rectangle(10, 9, 5, 5)
end
However, this code is much more unpredictable and may get you unexpected results depending on the graphic driver:

Code: Select all

love.graphics.setLineStyle("rough")
function love.draw()
  love.graphics.points(10, 1)
  love.graphics.line(10, 2, 15, 2)
  love.graphics.line(10, 3, 10, 8)
  love.graphics.rectangle(10.5, 9.5, 4, 4)
end
stingund
Prole
Posts: 5
Joined: Tue Apr 13, 2021 5:18 am

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by stingund »

In the end, it doesn't matter how the underlying hardware works. The only thing which matters is the contract that the API offers. And for a 2D platform such as Love2D, specified pixel locations should *absolutely* be accurate because they should directly map to an underlying surface, for example an 800x600 grid (discounting the scaling that the OS later does as it blits the surface, as I want to cover things that are in Love's realm of control).

Having established this, if you specify (2,2) for drawing a primitive of any type, be it a point, line or triangle, then pixel [2,2] in the surface should be unconditionally targetted. This should certainly hold whenever drawing axis aligned primitives. When drawing oblique lines, the rasterization process could change things slightly, but I would still expect the starting point of a line to be exactly at the pixel you've specified.

Again, Love2D is a 2D platform, like pico-8. If you specify you want a point or a line at (3,3), that's where it should be. This is the API contract that should be presented to the user. And from reading the description of the various functions in the API, this is what seems to be implied.

I am confused to see you justify how there are essentially different behaviours for the 3 primitives you mentioned, and further differentation depending on whether we are talking about horizontal or vertical lines. We shouldn't even get there.

I hope to some day to find some time to peek at the implementation to understand what is causing this behaviour. For now, I'm able to get consistent behaviour by taking this into account, but a user of the API should certainly not have to do that. Behaviour should be consistent and predictable.
User avatar
4vZEROv
Party member
Posts: 126
Joined: Wed Jan 02, 2019 8:44 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by 4vZEROv »

stingund wrote: Mon May 03, 2021 12:43 am In the end, it doesn't matter how the underlying hardware works.
Oh yes, yes it does x).

The best APIs are those that reflect how the hardware work, because they build knowledge of what exactly is going on physically.
For exemple, why do you think Vulkan is such a hit ?

Maybe this small point stuff seem pointless, but you will encounter a lot of much more bigger black boxes that you wish you could peek inside.

Also if a behavior doesn't fit what you like, just program it !

Love2d source code is avaible here if you want to know what's going on: https://github.com/love2d/love
grump
Party member
Posts: 947
Joined: Sat Jul 22, 2017 7:43 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by grump »

4vZEROv wrote: Mon May 03, 2021 7:42 am
stingund wrote: Mon May 03, 2021 12:43 am In the end, it doesn't matter how the underlying hardware works.
Oh yes, yes it does x).
Oh no, no it doesn't. LÖVE is an abstraction layer over an abstraction layer, over an abstraction layer,... Abstracting these details away in an intuitive way is literally the reason why it exists.
The best APIs are those that reflect how the hardware work, because they build knowledge of what exactly is going on physically.
And yet here we are, writing Lua code that compiles to bytecode that compiles to machine code, calling APIs that call APIs to call drivers that draw stuff. But how emulated primitives are grid-aligned, that's where we should draw the line (haha) with regards to abstraction. To "build knowledge" and frustrate the fuck out of your users. Yup, that makes sense.
User avatar
pgimeno
Party member
Posts: 3541
Joined: Sun Oct 18, 2015 2:58 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by pgimeno »

stingund wrote: Mon May 03, 2021 12:43 am In the end, it doesn't matter how the underlying hardware works. The only thing which matters is the contract that the API offers. And for a 2D platform such as Love2D, specified pixel locations should *absolutely* be accurate because they should directly map to an underlying surface, for example an 800x600 grid (discounting the scaling that the OS later does as it blits the surface, as I want to cover things that are in Love's realm of control).

Having established this, if you specify (2,2) for drawing a primitive of any type, be it a point, line or triangle, then pixel [2,2] in the surface should be unconditionally targetted. This should certainly hold whenever drawing axis aligned primitives. When drawing oblique lines, the rasterization process could change things slightly, but I would still expect the starting point of a line to be exactly at the pixel you've specified.

Again, Love2D is a 2D platform, like pico-8. If you specify you want a point or a line at (3,3), that's where it should be. This is the API contract that should be presented to the user. And from reading the description of the various functions in the API, this is what seems to be implied.

I am confused to see you justify how there are essentially different behaviours for the 3 primitives you mentioned, and further differentation depending on whether we are talking about horizontal or vertical lines. We shouldn't even get there.

I hope to some day to find some time to peek at the implementation to understand what is causing this behaviour. For now, I'm able to get consistent behaviour by taking this into account, but a user of the API should certainly not have to do that. Behaviour should be consistent and predictable.
Behaviour is consistent and predictable, to the extent that floating-point math allows it. If you pass unambiguous coordinates depending on the task at hand, you'll get predictable results.

The problem seems to be that you're used to an integer coordinate system where each pixel is assigned an X and Y coordinate number, and that assumption is wrong in the case of OpenGL and, by extension, in the case of Löve. Pixels have areas and lengths; the coordinates are floats and can be anywhere within a pixel. In this system, integer coordinates actually lie between pixels. You need to get rid of that prejudice of a perfect grid where each pixel has a unique integer coordinate; that's not how it works. It's how simpler systems like Pico8 or the programming languages of many old computers work; but it's not how more modern systems work. In Pico8, you can't smoothly move an image in 0.1 pixel increments, for example, while in Löve you can. In Android, by default the coordinates are not even pixels; instead they are some arbitrary units whose size is set by the vendor.

Purely speaking, you can't draw a line. A line is an infinitely thin mathematical object, and therefore it can't be visible. Bresenham's algorithm and similar ones work by drawing pixels that are closest to the centres of lines; that actually results in variable average thickness depending on angle - for example, a 45° line has an average thickness of sqrt(2)/2 ≅ 0.7071 pixels, while a horizontal or vertical line has a thickness of 1 pixel. That's what Pico8 can do. In contrast, Löve tries to draw lines with uniform thickness, the one given in love.graphics.setLineWidth. That's closer to what vector drawing programs like Inkscape, and the more sophisticated bitmap drawing programs like GIMP, actually do. You seem to expect the behaviour of classic pixel-drawing programs like Deluxe Paint or Autodesk Animator, and that's not how things work in today's hardware.

Rectangles in Löve work just as you expect. The border of the rectangle is aligned with the border of the pixel, and the borders of pixels lie in integer coordinates, therefore passing an integer coordinate to a rectangle causes no ambiguity.

Lines are different. They start and end at the given endpoints, and extend half the given line width to each side (i.e. they extend 0.5 pixels to each side by default), forming a rectangle (Löve does not support different line caps, as some drawing programs do; only different joints). Conceptually, a "rough" line and a "smooth" line are treated the same; the difference is that "rough" forces pixel alpha to be always 1 or 0. They are still drawn using OpenGL quads, not pixel by pixel, and not with Bresenham's algorithm. If you draw a horizontal line of width 1 using all integer coordinates, you're creating a rectangle that extends horizontally from the exact coordinate you've specified, to the other exact coordinate you've specified; and vertically, from half the top pixel to half the bottom pixel. This is ambiguous when using rough line style. However, if you offset the vertical coordinate by 0.5 pixels, the rectangle will exactly cover a row of pixels, and the ambiguity disappears. (The mistake I made before was believing that this also applied to the horizontal coordinate, but that's not the case because of the aforementioned fact that lines are not capped). For vertical lines, the situation is the inverse, because now the line extends 0.5 pixels to the left and right of the coordinate you specify, while the top and bottom are unambiguous when coordinates are integral.

Points are similar to lines. The conceptual object has no width or height, and OpenGL (and Löve) creates a rectangle around it of 0.5 pixels in every direction, in order to make it have a width and height of 1 pixel. If you draw it in an intersection of 4 pixels, as you would if you used integral coordinates, the point would overlap each surrounding pixel; I believe some graphics drivers actually do that. In order to get a single pixel drawn, you need to draw it in the centre of a pixel, that's why you need to add 0.5.

Finally, about hardware abstraction. Löve is a thin layer on top of OpenGL, by design, because that's how you do accelerated graphics and stay competitive. It's not a "let's do things the way the user expects at all costs, even if that means the inability to take advantage of hardware acceleration" kind of engine. It takes compromises. And if you feed it ambiguous data, it won't always make the same choice, because it leaves the resolution of the ambiguity in the hands of OpenGL, which is to say in the hands of the OpenGL driver writers. But with an understanding of how coordinates work, you are able to feed it unambiguous data which will work the same in all systems.
Last edited by pgimeno on Mon May 03, 2021 11:36 am, edited 1 time in total.
User avatar
4vZEROv
Party member
Posts: 126
Joined: Wed Jan 02, 2019 8:44 pm

Re: Coordinates for lines vs points (and pixel grid alignment for points)

Post by 4vZEROv »

grump wrote: Mon May 03, 2021 8:51 am but how emulated primitives are grid-aligned, that's where we should draw the line (haha) with regards to abstraction. To "build knowledge" and frustrate the fuck out of your users. Yup, that makes sense.
Nice strawman !
Post Reply

Who is online

Users browsing this forum: Ahrefs [Bot] and 16 guests