From afd7ceecd7ccfdbc0c17bdfa905e434aa9b7b2cf Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 1 Oct 2026 16:06:14 -0500 Subject: [PATCH] Add get_collision_info for push-out direction and depth get_collision_info(sprite1, sprite2) returns a CollisionInfo with the smallest move that separates two colliding sprites: a unit normal (the direction to move sprite1) and a depth in pixels. It returns None exactly when check_for_collision returns False. It uses the same distance, bounding box, and separating axis stages as check_for_collision, keeping the overlap on each axis instead of only checking for separation. The bounding boxes give the x and y overlaps; the cached edge normals give the rest, divided by their length to get pixels. Ties prefer the y axis, then x, then other edges, and up or right over down or left. Correct for convex hit boxes. check_for_collision is unchanged. get_collision_info costs about 1.2-1.4x as much for overlapping sprites and the same otherwise. Add it to benchmarks/collisions/micro.py. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/__init__.py | 4 + arcade/sprite_list/__init__.py | 4 + arcade/sprite_list/collision.py | 144 ++++++++++++++++++++- benchmarks/collisions/micro.py | 6 +- tests/unit/sprite/test_sprite_collision.py | 140 ++++++++++++++++++++ 6 files changed, 297 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1eca82cb..6f03abf81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Added `arcade.CollisionMethod`, an enum for the `method` argument of `check_for_collision_with_list` and `check_for_collision_with_lists`: `AUTO`, `SPATIAL`, `GPU`, and `SIMPLE`. It's an `IntEnum`, so the numbers `0` to `3` still work. - Added `arcade.has_collision_with_list()` and `arcade.has_collision_with_lists()`, which return `True` as soon as they find a collision. They're faster than checking whether `check_for_collision_with_list()` returns an empty list, by about 18x when many sprites overlap. - Added `arcade.check_for_collision_between_lists(list_a, list_b)`, which returns every colliding `(sprite_a, sprite_b)` pair between two lists, such as bullets and enemies. Passing the same list twice returns each pair once. +- Added `arcade.get_collision_info(sprite1, sprite2)`, which returns a `CollisionInfo` with the smallest move that separates two colliding sprites: a unit `normal` (the direction to move `sprite1`) and a `depth` in pixels, or `None` if they don't collide. For example, `player.position += info.normal * info.depth` pushes a player out of a wall. Correct for convex hit boxes. ### Misc Changes - Sped up several `SpriteList` operations: `swap()` no longer searches the draw order (about 1000x faster at the end of a 10,000 sprite list), `insert()` and item assignment check membership with a dictionary instead of scanning the list, and drawing uploads only the buffer slots in use instead of the whole capacity (moving one sprite and drawing is 1.6-2.3x faster). diff --git a/arcade/__init__.py b/arcade/__init__.py index d6b3aeb6c..b85a49048 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -183,8 +183,10 @@ def configure_logging(level: int | None = None): from .sprite_list import SpriteList from .sprite_list import SpriteSequence +from .sprite_list import CollisionInfo from .sprite_list import CollisionMethod from .sprite_list import check_for_collision +from .sprite_list import get_collision_info from .sprite_list import check_for_collision_with_list from .sprite_list import check_for_collision_with_lists from .sprite_list import check_for_collision_between_lists @@ -314,6 +316,7 @@ def configure_logging(level: int | None = None): "SpriteCircle", "SpriteList", "SpriteSequence", + "CollisionInfo", "CollisionMethod", "SpriteSolidColor", "Text", @@ -331,6 +334,7 @@ def configure_logging(level: int | None = None): "Window", "astar_calculate_path", "check_for_collision", + "get_collision_info", "check_for_collision_with_list", "check_for_collision_with_lists", "check_for_collision_between_lists", diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index a4a00b5fb..07e3fe24e 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -1,10 +1,12 @@ from .sprite_list import SpriteList, SpriteSequence from .spatial_hash import SpatialHash from .collision import ( + CollisionInfo, CollisionMethod, get_distance_between_sprites, get_closest_sprite, check_for_collision, + get_collision_info, check_for_collision_with_list, check_for_collision_with_lists, check_for_collision_between_lists, @@ -20,10 +22,12 @@ "SpriteList", "SpriteSequence", "SpatialHash", + "CollisionInfo", "CollisionMethod", "get_distance_between_sprites", "get_closest_sprite", "check_for_collision", + "get_collision_info", "check_for_collision_with_list", "check_for_collision_with_lists", "check_for_collision_between_lists", diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index c06807c92..8bb1a765b 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,6 +1,9 @@ from collections.abc import Iterable from enum import IntEnum -from typing import TypeVar +from math import hypot +from typing import NamedTuple, TypeVar + +from pyglet.math import Vec2 from arcade.geometry import ( _are_polygons_overlapping_on_axes, @@ -59,6 +62,31 @@ class CollisionMethod(IntEnum): """ +class CollisionInfo(NamedTuple): + """ + How two colliding sprites overlap. Returned by :py:func:`get_collision_info`. + + Moving the first sprite by ``normal * depth`` is the smallest move that + separates the two sprites, leaving their hit boxes just touching:: + + info = arcade.get_collision_info(player, wall) + if info: + player.position += info.normal * info.depth + """ + + normal: Vec2 + """ + A unit vector pointing the way to move the first sprite to separate it + from the second. Swapping the sprites flips its direction. + """ + + depth: float + """ + How far, in pixels, to move the first sprite along :py:attr:`normal` to + separate the sprites. Always greater than zero. + """ + + # Module-level aliases, so the hot path doesn't look up enum members every call _AUTO = CollisionMethod.AUTO _SPATIAL = CollisionMethod.SPATIAL @@ -135,6 +163,120 @@ def check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: return _check_for_collision(sprite1, sprite2) +def get_collision_info(sprite1: BasicSprite, sprite2: BasicSprite) -> CollisionInfo | None: + """ + Check for a collision between two sprites, and find how to separate them. + + This works like :py:func:`check_for_collision`, but instead of ``True`` + it returns a :py:class:`CollisionInfo` with the smallest move that + separates the sprites: the direction to move ``sprite1`` and how far. + This is useful for pushing a sprite out of a wall, or bouncing:: + + info = arcade.get_collision_info(player, wall) + if info: + player.position += info.normal * info.depth + + As with :py:func:`check_for_collision`, sprites that only touch don't + count as colliding. + + .. note:: After moving by exactly ``normal * depth`` the hit boxes touch, + but floating point rounding can leave them overlapping by a + tiny amount. Add a small extra distance if they must not + overlap at all. + + .. warning:: The result is only correct for convex hit boxes. The + detailed hit box algorithm can create concave ones. + + When more than one move is equally small, the y axis is preferred, then + the x axis, then other directions, and moving up or right over moving + down or left. So two identical sprites in the same place are separated + by moving ``sprite1`` up. + + Args: + sprite1: The sprite to separate + sprite2: The sprite to separate it from + + Returns: + A :py:class:`CollisionInfo` if the sprites collide, otherwise ``None``. + """ + if __debug__: + if not isinstance(sprite1, BasicSprite): + raise TypeError("Parameter 1 is not an instance of a Sprite class.") + if isinstance(sprite2, SpriteSequence): + raise TypeError( + "Parameter 2 is a instance of the SpriteList instead of a required " + "Sprite. A list isn't supported here; check each sprite in it instead." + ) + elif not isinstance(sprite2, BasicSprite): + raise TypeError("Parameter 2 is not an instance of a Sprite class.") + + hit_box1 = sprite1._hit_box + hit_box2 = sprite2._hit_box + + # Quick check with circles around each hit box, as in _check_for_collision + radius1 = hit_box1._radius + if radius1 is None: + radius1 = hit_box1._get_radius() + radius2 = hit_box2._radius + if radius2 is None: + radius2 = hit_box2._get_radius() + radius_sum = radius1 + radius2 + diff_x = sprite1._position[0] - sprite2._position[0] + diff_y = sprite1._position[1] - sprite2._position[1] + if diff_x * diff_x + diff_y * diff_y > radius_sum * radius_sum: + return None + + points1 = hit_box1.get_adjusted_points() + points2 = hit_box2.get_adjusted_points() + if not points1 or not points2: + return None + + 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 None + + # Find the smallest overlap. The y and x axes come from the bounds, then + # the hit boxes' other edge directions. For each axis there are two + # ways to move: in the positive direction (sprite1 past the top of + # sprite2's range) or the negative one. Ties keep the earlier choice. + up = top2 - bottom1 + down = top1 - bottom2 + if up <= down: + best_depth, best_x, best_y = up, 0.0, 1.0 + else: + best_depth, best_x, best_y = down, 0.0, -1.0 + + right = right2 - left1 + left = right1 - left2 + if right < best_depth and right <= left: + best_depth, best_x, best_y = right, 1.0, 0.0 + elif left < best_depth and left < right: + best_depth, best_x, best_y = left, -1.0, 0.0 + + axes = hit_box1._get_axes() | hit_box2._get_axes() + for normal_x, normal_y in axes.values(): + projected_1 = [normal_x * px + normal_y * py for px, py in points1] + projected_2 = [normal_x * px + normal_y * py for px, py in points2] + min_1 = min(projected_1) + max_1 = max(projected_1) + min_2 = min(projected_2) + max_2 = max(projected_2) + if max_1 <= min_2 or max_2 <= min_1: + return None + + # The normals aren't unit length, so convert the overlaps to pixels + length = hypot(normal_x, normal_y) + positive = (max_2 - min_1) / length + negative = (max_1 - min_2) / length + if positive < best_depth and positive <= negative: + best_depth, best_x, best_y = positive, normal_x / length, normal_y / length + elif negative < best_depth and negative < positive: + best_depth, best_x, best_y = negative, -normal_x / length, -normal_y / length + + return CollisionInfo(Vec2(best_x, best_y), best_depth) + + def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool: """ Check for collision between two sprites. diff --git a/benchmarks/collisions/micro.py b/benchmarks/collisions/micro.py index 8b0efe9a4..ad8136732 100644 --- a/benchmarks/collisions/micro.py +++ b/benchmarks/collisions/micro.py @@ -13,7 +13,7 @@ import timeit import arcade -from arcade import check_for_collision, check_for_collision_with_list +from arcade import check_for_collision, check_for_collision_with_list, get_collision_info from arcade.geometry import are_polygons_intersecting R = ":resources:images/" @@ -63,6 +63,10 @@ def bench_pairs(): "are_polygons_intersecting box/box", lambda: are_polygons_intersecting(square_a, square_b) ) + print("--- get_collision_info, same pairs") + for label, (a, b) in cases.items(): + bench(label, lambda a=a, b=b: get_collision_info(a, b)) + def bench_changing_pairs(): print("--- check_for_collision, sprites changing every call") diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index b984f78a9..bbda724b9 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -3,6 +3,7 @@ import pytest import arcade +from pyglet.math import Vec2 def test_sprites_at_point(): @@ -534,6 +535,145 @@ def test_gpu_collision_flipped_sprites(window, wall_scale, bullet_scale): assert arcade.check_for_collision_with_list(bullet, walls, method=gpu) == [] +def _reference_min_overlap(sprite1, sprite2): + """Smallest overlap over the x and y axes and every edge normal, in pixels""" + poly_a = sprite1.hit_box.get_adjusted_points() + poly_b = sprite2.hit_box.get_adjusted_points() + axes = [(1.0, 0.0), (0.0, 1.0)] + for polygon in (poly_a, poly_b): + for i in range(len(polygon)): + (x1, y1), (x2, y2) = polygon[i], polygon[(i + 1) % len(polygon)] + length = ((y2 - y1) ** 2 + (x1 - x2) ** 2) ** 0.5 + if length: + axes.append(((y2 - y1) / length, (x1 - x2) / length)) + best = None + for nx, ny in axes: + projected_a = [nx * x + ny * y for x, y in poly_a] + projected_b = [nx * x + ny * y for x, y in poly_b] + overlap = min(max(projected_a) - min(projected_b), max(projected_b) - min(projected_a)) + best = overlap if best is None else min(best, overlap) + return best + + +def _random_convex_sprite(rng, textures, shared_angle): + sprite = arcade.Sprite(rng.choice(textures)) + 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 + if rng.random() < 0.5: + sprite.angle = shared_angle + else: + sprite.angle = rng.choice([0, 0, 90, 180, 30, rng.uniform(0, 360)]) + sprite.position = rng.randint(-160, 160) / 2, rng.randint(-160, 160) / 2 + return sprite + + +def test_get_collision_info_basics(window): + a = arcade.SpriteSolidColor(10, 10) + b = arcade.SpriteSolidColor(10, 10, center_x=8, center_y=1) + # The smallest overlap is 2 pixels in x, so move a left + assert arcade.get_collision_info(a, b) == (Vec2(-1.0, 0.0), 2.0) + # Swapping the sprites flips the normal + assert arcade.get_collision_info(b, a) == (Vec2(1.0, 0.0), 2.0) + + # Sinking into the floor: pushed straight up by exactly the overlap + player = arcade.SpriteSolidColor(10, 10, center_x=20, center_y=3) + floor = arcade.SpriteSolidColor(100, 10) + info = arcade.get_collision_info(player, floor) + assert info.normal == Vec2(0.0, 1.0) + assert info.depth == 7.0 + player.position += info.normal * info.depth + assert player.position == (20, 10) + assert arcade.check_for_collision(player, floor) is False + + # Identical sprites in the same place: moved up + assert arcade.get_collision_info(a, arcade.SpriteSolidColor(10, 10)) == (Vec2(0.0, 1.0), 10.0) + + # Touching or apart: no collision + assert arcade.get_collision_info(a, arcade.SpriteSolidColor(10, 10, center_x=10)) is None + assert arcade.get_collision_info(a, arcade.SpriteSolidColor(10, 10, center_x=50)) is None + + +def test_get_collision_info_diagonal(window): + """A diagonal edge gives a diagonal normal""" + box = arcade.SpriteSolidColor(20, 20) + box.angle = 45 + other = arcade.SpriteSolidColor(20, 20, center_x=12, center_y=12) + info = arcade.get_collision_info(other, box) + assert info.normal.x == pytest.approx(2**-0.5) + assert info.normal.y == pytest.approx(2**-0.5) + assert info.depth == pytest.approx(_reference_min_overlap(other, box)) + + +def test_get_collision_info_type_errors(window): + a = arcade.SpriteSolidColor(10, 10) + with pytest.raises(TypeError): + arcade.get_collision_info("moo", a) + with pytest.raises(TypeError): + arcade.get_collision_info(a, "moo") + with pytest.raises(TypeError): + arcade.get_collision_info(a, arcade.SpriteList()) + + +def test_get_collision_info_matches_check_for_collision(window): + """It finds a collision exactly when check_for_collision does, for any hit box""" + rng = random.Random(77) + textures = [ + arcade.load_texture(":resources:images/tiles/grassMid.png"), + arcade.load_texture(":resources:images/items/coinGold.png"), + arcade.load_texture(":resources:images/space_shooter/laserBlue01.png"), + arcade.load_texture( + ":resources:images/space_shooter/meteorGrey_big1.png", + hit_box_algorithm=arcade.hitbox.algo_detailed, + ), + ] + results = {True: 0, False: 0} + for _ in range(3000): + shared_angle = rng.choice([0, 90, 180, 45, 30, rng.uniform(0, 360)]) + a = _random_convex_sprite(rng, textures, shared_angle) + b = _random_convex_sprite(rng, textures, shared_angle) + expected = arcade.check_for_collision(a, b) + assert (arcade.get_collision_info(a, b) is not None) is expected + assert (arcade.get_collision_info(b, a) is not None) is expected + results[expected] += 1 + assert min(results.values()) > 300 + + +def test_get_collision_info_separates(window): + """For convex hit boxes, moving by normal * depth is the smallest move that separates them""" + rng = random.Random(78) + textures = [ + arcade.load_texture(":resources:images/tiles/grassMid.png"), + arcade.load_texture(":resources:images/items/coinGold.png"), + arcade.load_texture(":resources:images/space_shooter/laserBlue01.png"), + arcade.load_texture( + ":resources:images/animated_characters/female_person/femalePerson_idle.png" + ), + ] + checked = 0 + for _ in range(3000): + shared_angle = rng.choice([0, 90, 180, 45, 30, rng.uniform(0, 360)]) + a = _random_convex_sprite(rng, textures, shared_angle) + b = _random_convex_sprite(rng, textures, shared_angle) + info = arcade.get_collision_info(a, b) + if info is None: + continue + checked += 1 + assert info.depth > 0 + assert info.normal.length() == pytest.approx(1.0) + assert info.depth == pytest.approx(_reference_min_overlap(a, b), abs=1e-6) + + start = a.position + # A little further than depth separates them + a.position = start + info.normal * (info.depth + 1e-6) + assert arcade.check_for_collision(a, b) is False + # A little less doesn't + if info.depth > 1e-5: + a.position = start + info.normal * (info.depth - 1e-6) + assert arcade.check_for_collision(a, b) is True + a.position = start + assert checked > 300 + + def test_check_for_collision_with_list(window): # TODO: Check that the right collision function is called internally a = arcade.SpriteSolidColor(50, 50, color=arcade.csscolor.RED)