diff --git a/CHANGELOG.md b/CHANGELOG.md index ed72e048f..14463cc09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,14 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Fixed `check_for_collision` (and the list-based collision functions) missing collisions when a sprite was flipped with a negative scale. The negative width/height cancelled out in the broad-phase distance check, so flipped sprites could pass through each other. - Fixed `SpatialHash` queries (`get_sprites_near_sprite`, `get_sprites_near_point`, `get_sprites_near_rect`, and the collision functions that use them) adding an empty bucket for every grid cell they looked at. Memory use grew as sprites moved around large maps. Queries and adding sprites to a spatial hash are also faster, since the hit box points are now scanned once instead of four times. - Fixed `are_polygons_intersecting` (and sprite collision checks) always returning `False` when a polygon had a repeated point, such as a closed polygon whose first point is repeated at the end. +- Rotated hit boxes at right angles (90, 180, 270 degrees, etc.) now have exact point coordinates. Before, values like `sin(radians(180))` being about 1.2e-16 instead of 0 left tiny errors that could make exactly touching sprites count as colliding, or not, differently from unrotated ones. ### New Features - Added `HitBox.get_adjusted_bounds()`, which returns the cached `(left, right, bottom, top)` bounds of the adjusted hit box points. ### Misc Changes - Sped up sprite collision checks. Sprites that pass the quick distance check are now compared by cached hit box bounds before the polygon test, and the polygon test skips horizontal and vertical edges, which the bounds check already covers. Checks that reach the polygon test are about 2-4x faster, e.g. 8.0 to 2.3 µs for two box hit boxes and 21.6 to 10.2 µs for two default octagon hit boxes. `are_polygons_intersecting` is also faster (7.3 to 1.6 µs for two rectangles). +- Sped up collision checks further by caching each hit box's distinct edge directions. Parallel edges (such as opposite sides of the default octagon hit boxes, or matching edges on two sprites with the same angle) are only tested once, and the cache is kept when a sprite moves. Two unrotated octagon hit boxes go from 12.0 to 4.6 µs, and two rotated 30° from 20.8 to 6.9 µs. - Updated the optional `pymunk` extra to 7.3.0 (from 7.2.0). Packaging-only release (free-threaded CPython and pyodide wheels, improved type hints) with no breaking changes or deprecations. ## 4.0.0.dev7 diff --git a/arcade/geometry.py b/arcade/geometry.py index d2686ce3e..e126f527c 100644 --- a/arcade/geometry.py +++ b/arcade/geometry.py @@ -6,6 +6,7 @@ Point in polygon function from https://www.geeksforgeeks.org/how-to-check-if-a-given-point-lies-inside-a-polygon/ """ +from collections.abc import Iterable from sys import maxsize as sys_int_maxsize from arcade.types import Point2, Point2List @@ -73,6 +74,33 @@ def _are_polygons_intersecting_sat(poly_a: Point2List, poly_b: Point2List) -> bo return True +def _are_polygons_overlapping_on_axes( + poly_a: Point2List, poly_b: Point2List, axes: Iterable[Point2] +) -> bool: + """ + Separating axis test for two polygons using a given set of axes. + + Like :py:func:`_are_polygons_intersecting_sat`, but the caller provides + the axes to test, so they can be cached and duplicates removed. + + Args: + poly_a: List of points that define the first polygon. + poly_b: List of points that define the second polygon. + axes: The axes to project onto. They don't need to be unit vectors. + + Returns: + ``True`` if the polygons overlap on every axis, ``False`` otherwise + """ + for normal_x, normal_y in axes: + projected_a = [normal_x * px + normal_y * py for px, py in poly_a] + projected_b = [normal_x * px + normal_y * py for px, py in poly_b] + + if max(projected_a) <= min(projected_b) or max(projected_b) <= min(projected_a): + return False + + return True + + def is_point_in_box(p: Point2, q: Point2, r: Point2) -> bool: """ Checks if point ``q`` is inside the box defined by ``p`` and ``r``. diff --git a/arcade/hitbox/base.py b/arcade/hitbox/base.py index f27bd189c..12ae8b4a6 100644 --- a/arcade/hitbox/base.py +++ b/arcade/hitbox/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from math import cos, radians, sin +from math import cos, hypot, radians, sin from typing import Any from PIL.Image import Image @@ -10,6 +10,86 @@ __all__ = ["HitBoxAlgorithm", "HitBox", "RotatableHitBox"] +#: Maps a rounded unit normal to an edge normal with that direction. See _add_axis(). +Axes = dict[tuple[float, float], tuple[float, float]] + +# Unit vectors are rounded to this many decimal places when checking +# whether two edges are parallel. +_AXIS_DECIMALS = 9 + +# Cosine and sine for 0, 90, 180 and 270 degrees clockwise +_RIGHT_ANGLE_ROTATIONS = ((1.0, 0.0), (0.0, -1.0), (-1.0, 0.0), (0.0, 1.0)) + + +def _get_rotation(angle: float) -> tuple[float, float]: + """ + Get the cosine and sine used to rotate points clockwise by ``angle`` degrees. + + Right angles give exact values. Otherwise ``sin(radians(180))`` is + about 1.2e-16 instead of 0, and rotated hit boxes pick up tiny errors + that can decide whether exactly touching sprites collide. + """ + quarter_turns, remainder = divmod(angle, 90) + if remainder == 0: + return _RIGHT_ANGLE_ROTATIONS[int(quarter_turns) % 4] + rad = radians(-angle) + return cos(rad), sin(rad) + + +def _add_axis(axes: Axes, normal_x: float, normal_y: float) -> None: + """ + Add an edge normal to a set of separating axes. + + Normals are keyed by their rounded unit vector, with the sign chosen so + parallel edges facing either way share a key. The stored normal is not + normalized: projecting onto it keeps exact arithmetic where possible, + so exactly touching edges stay exactly touching. Zero-length edges and + axis-aligned normals are skipped: the bounding box check done before + the separating axis test already covers the x and y axes. + """ + # Only skip exactly axis-aligned normals. Nearly axis-aligned ones + # still matter for exactly touching edges. + if normal_x == 0 or normal_y == 0: + return + length = hypot(normal_x, normal_y) + key_x = round(normal_x / length, _AXIS_DECIMALS) + key_y = round(normal_y / length, _AXIS_DECIMALS) + if key_x < 0 or (key_x == 0 and key_y < 0): + key_x, key_y = -key_x, -key_y + axes[(key_x, key_y)] = (normal_x, normal_y) + + +def _axes_from_points(points: Point2List) -> Axes: + """Get the distinct, non axis-aligned edge normals of a polygon.""" + axes: Axes = {} + if not points: + return axes + prev_x, prev_y = points[-1] + for x, y in points: + _add_axis(axes, y - prev_y, prev_x - x) + prev_x, prev_y = x, y + return axes + + +def _edge_directions(points: Point2List) -> list[Point2]: + """Get one edge vector for each distinct edge direction of a polygon.""" + directions: dict[tuple[float, float], Point2] = {} + if not points: + return [] + prev_x, prev_y = points[-1] + for x, y in points: + edge_x, edge_y = x - prev_x, y - prev_y + prev_x, prev_y = x, y + length = hypot(edge_x, edge_y) + if length == 0: + continue + key_x = round(edge_x / length, _AXIS_DECIMALS) + key_y = round(edge_y / length, _AXIS_DECIMALS) + if key_x < 0 or (key_x == 0 and key_y < 0): + key_x, key_y = -key_x, -key_y + directions.setdefault((key_x, key_y), (edge_x, edge_y)) + return list(directions.values()) + class HitBoxAlgorithm: """ @@ -127,6 +207,12 @@ def __init__( self._adjusted_bounds: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) self._adjusted_bounds_points: Point2List | None = None + # Separating axes, see _get_axes() + self._edge_directions: list[Point2] = [] + self._edge_directions_points: Point2List | None = None + self._axes: Axes = {} + self._axes_key: tuple[Any, ...] | None = None + @property def points(self) -> Point2List: """ @@ -214,6 +300,68 @@ def get_adjusted_bounds(self) -> tuple[float, float, float, float]: self._adjusted_bounds_points = points return self._adjusted_bounds + def _get_axes(self) -> Axes: + """ + Get the separating axes to test this hit box against another. + + These are the distinct edge normals of the adjusted points, without + the x and y axes. They are cached, and since the directions don't + depend on position, moving the hit box doesn't recalculate them. + """ + if type(self).get_adjusted_points is not HitBox.get_adjusted_points: + return self._get_axes_from_adjusted_points() + return self._get_transformed_axes(0.0) + + def _get_transformed_axes(self, angle: float) -> Axes: + """ + Get the axes by scaling and rotating the raw edge directions. + + This must apply the same transform as get_adjusted_points(), + without the offset. + """ + points = self._points + scale = self._scale + key = self._axes_key + if key is not None and key[0] is points and key[1] == scale and key[2] == angle: + return self._axes + + # Scaling and rotation keep parallel edges parallel, so duplicate + # directions only need removing once for the raw points. + if self._edge_directions_points is not points: + self._edge_directions = _edge_directions(points) + self._edge_directions_points = points + + scale_x, scale_y = scale + axes: Axes = {} + if angle: + rad_cos, rad_sin = _get_rotation(angle) + for edge_x, edge_y in self._edge_directions: + x = edge_x * scale_x + y = edge_y * scale_y + _add_axis(axes, x * rad_sin + y * rad_cos, y * rad_sin - x * rad_cos) + else: + for edge_x, edge_y in self._edge_directions: + _add_axis(axes, edge_y * scale_y, -edge_x * scale_x) + + self._axes = axes + self._axes_key = (points, scale, angle) + return axes + + def _get_axes_from_adjusted_points(self) -> Axes: + """ + Get the axes from the adjusted points. + + Used for subclasses that override get_adjusted_points(), since they + may transform the points differently. + """ + points = self.get_adjusted_points() + key = self._axes_key + if key is not None and key[0] is points and key[1] is None: + return self._axes + self._axes = _axes_from_points(points) + self._axes_key = (points, None, None) + return self._axes + @property def scale(self) -> tuple[float, float]: """ @@ -316,6 +464,11 @@ def angle(self, angle: float): self._angle = angle self._adjusted_cache_dirty = True + def _get_axes(self) -> Axes: + if type(self).get_adjusted_points is not RotatableHitBox.get_adjusted_points: + return self._get_axes_from_adjusted_points() + return self._get_transformed_axes(self._angle) + def get_adjusted_points(self) -> Point2List: """ Return the offset, scaled, & rotated points of this hitbox. @@ -326,11 +479,10 @@ def get_adjusted_points(self) -> Point2List: if not self._adjusted_cache_dirty: return self._adjusted_points - rad = radians(-self._angle) + angle = self._angle scale_x, scale_y = self._scale position_x, position_y = self._position - rad_cos = cos(rad) - rad_sin = sin(rad) + rad_cos, rad_sin = _get_rotation(angle) if angle else (1.0, 0.0) def _adjust_point(point) -> Point2: x, y = point @@ -338,7 +490,7 @@ def _adjust_point(point) -> Point2: x *= scale_x y *= scale_y - if rad: + if angle: rot_x = x * rad_cos - y * rad_sin rot_y = x * rad_sin + y * rad_cos x = rot_x diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 865b1c182..215ffcdc5 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from arcade.geometry import ( - _are_polygons_intersecting_sat, + _are_polygons_overlapping_on_axes, are_polygons_intersecting, is_point_in_polygon, ) @@ -130,13 +130,16 @@ def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: return False # Bounding box check with cached bounds. It's much cheaper than the - # polygon test and lets that test skip the x and y axes. + # polygon test and covers the x and y axes, which the cached + # separating axes leave out. left1, right1, bottom1, top1 = hit_box1.get_adjusted_bounds() left2, right2, bottom2, top2 = hit_box2.get_adjusted_bounds() if right1 <= left2 or right2 <= left1 or top1 <= bottom2 or top2 <= bottom1: return False - return _are_polygons_intersecting_sat(points1, points2) + # Merging the dicts drops directions both hit boxes share + axes = hit_box1._get_axes() | hit_box2._get_axes() + return _are_polygons_overlapping_on_axes(points1, points2, axes.values()) def _get_nearby_sprites( diff --git a/tests/unit/hitbox/test_hitbox.py b/tests/unit/hitbox/test_hitbox.py index d1cd10531..eb666a567 100644 --- a/tests/unit/hitbox/test_hitbox.py +++ b/tests/unit/hitbox/test_hitbox.py @@ -94,3 +94,83 @@ def get_adjusted_points(self): assert hb.get_adjusted_bounds() == (0.0, 10.0, 0.0, 10.0) hb.offset = 100.0 assert hb.get_adjusted_bounds() == (100.0, 110.0, 0.0, 10.0) + + +@pytest.mark.parametrize( + "angle, expected", + [ + (90.0, [(0.0, 0.0), (10.0, 0.0), (10.0, -10.0), (0.0, -10.0)]), + (180.0, [(0.0, 0.0), (0.0, -10.0), (-10.0, -10.0), (-10.0, 0.0)]), + (270.0, [(0.0, 0.0), (-10.0, 0.0), (-10.0, 10.0), (0.0, 10.0)]), + (-90.0, [(0.0, 0.0), (-10.0, 0.0), (-10.0, 10.0), (0.0, 10.0)]), + (450.0, [(0.0, 0.0), (10.0, 0.0), (10.0, -10.0), (0.0, -10.0)]), + ], +) +def test_right_angle_rotation_is_exact(angle, expected): + """Right angles must not add floating point error to the points""" + rot = hitbox.HitBox(points).create_rotatable(angle=angle) + assert rot.get_adjusted_points() == expected + + +octagon = [(-4.0, -2.0), (-2.0, -4.0), (2.0, -4.0), (4.0, -2.0), + (4.0, 2.0), (2.0, 4.0), (-2.0, 4.0), (-4.0, 2.0)] # fmt: skip + + +def _axis_directions(axes): + """Unit vectors of the axes, rounded and pointing right, for comparing""" + result = set() + for x, y in axes.values(): + length = (x * x + y * y) ** 0.5 + if x < 0: + x, y = -x, -y + result.add((round(x / length, 6), round(y / length, 6))) + return result + + +def test_axes_skip_axis_aligned_and_duplicate_edges(): + # Every edge of a box is axis-aligned + assert hitbox.HitBox(points)._get_axes() == {} + # An octagon's 8 edges only have 2 non axis-aligned directions + axes = hitbox.HitBox(octagon)._get_axes() + assert len(axes) == 2 + assert _axis_directions(axes) == {(0.707107, 0.707107), (0.707107, -0.707107)} + + +def test_axes_follow_scale_and_angle(): + rot = hitbox.HitBox(octagon).create_rotatable() + axes = rot._get_axes() + + # Moving doesn't change the directions, so the cache is kept + rot.position = (100.0, 50.0) + assert rot._get_axes() is axes + + # Non-uniform scale changes the diagonal directions + rot.scale = (2.0, 1.0) + assert _axis_directions(rot._get_axes()) == {(0.447214, 0.894427), (0.447214, -0.894427)} + + # 45 degrees turns the axis-aligned edges into diagonals + rot.scale = (1.0, 1.0) + rot.angle = 45.0 + diagonals = {(0.707107, 0.707107), (0.707107, -0.707107)} + directions = _axis_directions(rot._get_axes()) + assert diagonals <= directions + # and the diagonals into axis-aligned edges. Whether those are skipped + # depends on the platform's sin() and cos(): sin(pi / 4) is + # 0.7071067811865476 on Windows but 0.7071067811865475 on Linux, so the + # rotated normals may be off axis by ~1e-16 and still get tested. + assert directions - diagonals <= {(1.0, 0.0), (0.0, 1.0), (0.0, -1.0)} + + rot.angle = 30.0 + assert len(rot._get_axes()) == 4 + + +def test_axes_subclass_override(): + """Axes come from get_adjusted_points() if a subclass overrides it.""" + + class SkewedHitBox(hitbox.HitBox): + def get_adjusted_points(self): + return [(x + y, y) for x, y in self.points] + + # The skew turns the box's vertical edges into diagonals + axes = SkewedHitBox(points)._get_axes() + assert _axis_directions(axes) == {(0.707107, -0.707107)} diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index 86d8fbc71..45c16ec77 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -349,9 +349,15 @@ def test_cpu_collision_with_lazy_list(window): def _reference_check_for_collision(sprite1, sprite2): - """Collision check using the original separating axis test on every edge.""" + """Collision check using a separating axis test on every edge.""" poly_a = sprite1.hit_box.get_adjusted_points() poly_b = sprite2.hit_box.get_adjusted_points() + # Also test the x and y axes. Testing only edge normals can miss a + # separation when a hit box is concave, which detailed ones can be. + x_a, y_a = zip(*poly_a) + x_b, y_b = zip(*poly_b) + if max(x_a) <= min(x_b) or max(x_b) <= min(x_a) or max(y_a) <= min(y_b) or max(y_b) <= min(y_a): + return False for polygon in (poly_a, poly_b): for i in range(len(polygon)): p1, p2 = polygon[i], polygon[(i + 1) % len(polygon)] @@ -370,20 +376,30 @@ def test_check_for_collision_matches_reference(): arcade.load_texture(":resources:images/tiles/grassMid.png"), # 4 point box arcade.load_texture(":resources:images/items/coinGold.png"), # 8 point octagon arcade.load_texture(":resources:images/space_shooter/laserBlue01.png"), # long & thin + # Detailed hit box, with more points and possibly concave + arcade.load_texture( + ":resources:images/space_shooter/meteorGrey_big1.png", + hit_box_algorithm=arcade.hitbox.algo_detailed, + ), ] - def random_sprite(): + def random_sprite(shared_angle): sprite = arcade.Sprite(rng.choice(textures)) - sprite.scale = (rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1]), - rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1])) # fmt: skip - sprite.angle = rng.choice([0, 0, 90, 180, 30, rng.uniform(0, 360)]) - # Integer positions so exact touches happen - sprite.position = rng.randint(-80, 80), rng.randint(-80, 80) + sprite.scale = (rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5]), + rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5])) # fmt: skip + # Often share an angle, so both hit boxes have parallel edges + if rng.random() < 0.5: + sprite.angle = shared_angle + else: + sprite.angle = rng.choice([0, 0, 90, 180, 30, rng.uniform(0, 360)]) + # Positions on a grid so exact touches happen + sprite.position = rng.randint(-160, 160) / 2, rng.randint(-160, 160) / 2 return sprite results = {True: 0, False: 0} for _ in range(5000): - a, b = random_sprite(), random_sprite() + shared_angle = rng.choice([0, 90, 180, 270, -90, 45, 30, rng.uniform(-720, 720)]) + a, b = random_sprite(shared_angle), random_sprite(shared_angle) expected = _reference_check_for_collision(a, b) assert arcade.check_for_collision(a, b) is expected assert arcade.check_for_collision(b, a) is expected