Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve efficiency and precision of Geometry2D.is_point_in_polygon. #101736

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

L4Vo5
Copy link
Contributor

@L4Vo5 L4Vo5 commented Jan 18, 2025

The previos version called segment_intersects_segment, which is less precise as it checks against an epsilon. As far as I can tell, the only inputs for which the result differs is when that imprecision comes into play.
So, this version also fixes #81042 and #82305 (at least, the provided reproductions now work)
It's also about 6x faster, from my (very limited) tests.

This version turned out to be the same concept as was suggested here. It still checks for an odd number of intersections, but instead of using a preliminary loop to finding a point outside the polygon in order to construct a line, it uses an infinite ray from p_point towards (-inf, p_point.y).
In the intersection calculation, between the ray and every line in the polygon, there are some edge cases that return true for points exactly on the polygon boundary. Otherwise, an intersection can only exist if p_point.y is between v1.y and v2.y. With that knowledge, we can calculate p = inverse_lerp(v1.y, v2.y, p_point.y), a value between 0 and 1. The x of the intersection point between the polygon's line, and the infinite line described by y = p_point.y, is x = lerp(v1.x, v2.x, p). If x <= p_point.x then there is an intersection. Since we only care about that inequality and don't actually want the exact x of the intersection point, the math can be simplified, and it turns out to basically be a cross product between p_point - v1 and v2 - v1. Which is easier to compute as it avoids a division caused by inverse_lerp.
I also replaced a remainder (%) with a ternary operation, which also seemed to boost performance slightly.

@L4Vo5 L4Vo5 requested a review from a team as a code owner January 18, 2025 03:45
@L4Vo5 L4Vo5 force-pushed the improve_is_point_in_polygon branch 2 times, most recently from 65ed53c to 511c990 Compare January 18, 2025 04:17
Comment on lines 393 to 399
float cross = (p_point.y - v1.y) * (v2.x - v1.x) - (p_point.x - v1.x) * (v2.y - v1.y);
if ((cross < 0) == (v1.y < v2.y)) {
intersections++;
}
if (cross == 0) {
return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cross == 0 condition should be first as it's an early return regardless of the previous condition's work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the code is a bit clearer like this, but it's simple enough that the compiler should reorder it as it sees fit

In fact it's possible that it's better to dispatch the longer chain of comparison instructions earlier, since they also involve more data dependency, instead of the early return that won't trigger in 99% of cases. but who knows.

if (v1.y < p_point.y != v2.y < p_point.y) {
float cross = (p_point.y - v1.y) * (v2.x - v1.x) - (p_point.x - v1.x) * (v2.y - v1.y);
if ((cross < 0) == (v1.y < v2.y)) {
intersections++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You start with intersections = 0 and then increment it every time, and at the end check if it's even or uneven. So it's more optimal to make it a bool that gets flipped here.

@kiroxas
Copy link
Contributor

kiroxas commented Jan 18, 2025

I have improved something similar in the navigation module not so long ago (in 3D). You can see it here. We could do the same thing here. The trick behind this, is that the winding order for the edges and the point all have to be the same sign for it to be inside. It means that you can easily early out with this algorithm, with no branch (other than the return) in the calculations.

@L4Vo5
Copy link
Contributor Author

L4Vo5 commented Jan 18, 2025

I have improved something similar in the navigation module not so long ago (in 3D). You can see it here. We could do the same thing here. The trick behind this, is that the winding order for the edges and the point all have to be the same sign for it to be inside. It means that you can easily early out with this algorithm, with no branch (other than the return) in the calculations.

Doesn't that only work for convex polygons?
Consider this concave polygon and the given point. If you trace the polygon clockwise and test the winding order of (start, end, point), most are clockwise, but the spike at the top is counterclockwise
image

@kiroxas
Copy link
Contributor

kiroxas commented Jan 18, 2025

Doesn't that only work for convex polygons?

Yes, it does only work for convex. So if the polygons can be any shape for this function, this will not work.

@L4Vo5 L4Vo5 force-pushed the improve_is_point_in_polygon branch from 511c990 to dbe066b Compare January 18, 2025 19:05
By checking against an infinite horizontal ray, we can avoid the
preliminary loop, and we can also simplify the math to improve performance,
and also avoid calling segment_intersects_segment, which currently is less
precise as it checks against an epsilon.
@L4Vo5 L4Vo5 force-pushed the improve_is_point_in_polygon branch from dbe066b to 7d04073 Compare January 18, 2025 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Geometry2D.is_point_in_polygon() give inconsistent result on exactly boundary points
4 participants