From 30a25568a0403bcc2d546734095d25ee4825efb1 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 1 Oct 2026 11:16:28 -0500 Subject: [PATCH] Add CollisionMethod enum for collision list methods check_for_collision_with_list and check_for_collision_with_lists took a bare method number from 0 to 3. Add arcade.CollisionMethod (AUTO, SPATIAL, GPU, SIMPLE) and document the choices on it. It's an IntEnum, so existing code passing numbers keeps working. The two functions duplicated the logic that picks which sprites to check; move it into one helper, _get_sprites_to_check(). Behavior is unchanged, including SPATIAL falling back to the GPU when the list has no spatial hash. Add tests covering which path each method takes (spatial hash, every sprite, or GPU, including the WebGL fallback) for both enum members and plain numbers, through both functions. The same cases pass on the previous code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + arcade/__init__.py | 2 + arcade/sprite_list/__init__.py | 2 + arcade/sprite_list/collision.py | 124 ++++++++++++--------- tests/unit/sprite/test_sprite_collision.py | 68 +++++++++++ 5 files changed, 145 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2d372612..ab3d7924c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ### New Features - Added `HitBox.get_adjusted_bounds()`, which returns the cached `(left, right, bottom, top)` bounds of the adjusted hit box points. +- 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. ### 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). diff --git a/arcade/__init__.py b/arcade/__init__.py index 0abdf7502c..9301430e2d 100644 --- a/arcade/__init__.py +++ b/arcade/__init__.py @@ -183,6 +183,7 @@ def configure_logging(level: int | None = None): from .sprite_list import SpriteList from .sprite_list import SpriteSequence +from .sprite_list import CollisionMethod from .sprite_list import check_for_collision from .sprite_list import check_for_collision_with_list from .sprite_list import check_for_collision_with_lists @@ -310,6 +311,7 @@ def configure_logging(level: int | None = None): "SpriteCircle", "SpriteList", "SpriteSequence", + "CollisionMethod", "SpriteSolidColor", "Text", "TextPool", diff --git a/arcade/sprite_list/__init__.py b/arcade/sprite_list/__init__.py index f8b93309bf..c2c1802c3a 100644 --- a/arcade/sprite_list/__init__.py +++ b/arcade/sprite_list/__init__.py @@ -1,6 +1,7 @@ from .sprite_list import SpriteList, SpriteSequence from .spatial_hash import SpatialHash from .collision import ( + CollisionMethod, get_distance_between_sprites, get_closest_sprite, check_for_collision, @@ -16,6 +17,7 @@ "SpriteList", "SpriteSequence", "SpatialHash", + "CollisionMethod", "get_distance_between_sprites", "get_closest_sprite", "check_for_collision", diff --git a/arcade/sprite_list/collision.py b/arcade/sprite_list/collision.py index 58f4051400..48e196b2d2 100644 --- a/arcade/sprite_list/collision.py +++ b/arcade/sprite_list/collision.py @@ -1,4 +1,5 @@ from collections.abc import Iterable +from enum import IntEnum from arcade.geometry import ( _are_polygons_overlapping_on_axes, @@ -14,6 +15,51 @@ from .sprite_list import SpriteSequence +class CollisionMethod(IntEnum): + """ + How :py:func:`check_for_collision_with_list` and + :py:func:`check_for_collision_with_lists` find the sprites to check. + + This is an :py:class:`~enum.IntEnum`, so the numbers ``0`` to ``3`` + used before it was added still work. + + While the GPU method is very fast when you can't use spatial hashing, + it's also very slow if you call it many times per frame. Which method + is best depends entirely on your use case. + + The GPU isn't used on WebGL: wherever a method would use it, every + sprite is checked instead. + """ + + AUTO = 0 + """ + Use the sprite list's spatial hash if it has one. Otherwise check every + sprite if there are 1500 or fewer, or use the GPU if there are more. + """ + + SPATIAL = 1 + """ + Use the sprite list's spatial hash. If it doesn't have one, use the GPU. + """ + + GPU = 2 + """ + Use the GPU to find sprites near the sprite being checked, then check + those. + """ + + SIMPLE = 3 + """ + Check every sprite in the list. + """ + + +# Module-level aliases, so the hot path doesn't look up enum members every call +_AUTO = CollisionMethod.AUTO +_SPATIAL = CollisionMethod.SPATIAL +_SIMPLE = CollisionMethod.SIMPLE + + def get_distance_between_sprites(sprite1: SpriteType, sprite2: SpriteType) -> float: """ Returns the distance between the center of two given sprites @@ -149,10 +195,28 @@ def _get_nearby_sprites( return sprite_list.get_nearby_sprites_gpu(sprite.position, sprite.size) +def _get_sprites_to_check( + sprite: BasicSprite, + sprite_list: SpriteSequence[SpriteType], + method: CollisionMethod | int, +) -> Iterable[SpriteType]: + """Get the sprites in a list to check for collisions, using ``method``.""" + if sprite_list.spatial_hash is not None and (method == _AUTO or method == _SPATIAL): + return sprite_list.spatial_hash.get_sprites_near_sprite(sprite) + if ( + method == _SIMPLE + or (method == _AUTO and len(sprite_list) <= 1500) + or get_window().ctx._gl_api == "webgl" + ): + return sprite_list + # GPU transform - Not on WebGL + return _get_nearby_sprites(sprite, sprite_list) + + def check_for_collision_with_list( sprite: BasicSprite, sprite_list: SpriteSequence[SpriteType], - method: int = 0, + method: CollisionMethod | int = CollisionMethod.AUTO, ) -> list[SpriteType]: """ Check for a collision between a sprite, and a list of sprites. @@ -163,16 +227,8 @@ def check_for_collision_with_list( sprite_list: SpriteList to check against method: - Collision check method. Defaults to 0. - - - 0: auto-select. (spatial if available, GPU if 1500+ sprites, else simple) - - 1: Spatial Hashing if available, - - 2: GPU based - - 3: Simple check-everything. - - Note that while the GPU method is very fast when you cannot use spatial hashing, - it's also very slow if you are calling this function many times per frame. - What method is the most appropriate depends entirely on your use case. + How to find the sprites to check. See :py:class:`CollisionMethod`. + Defaults to :py:attr:`CollisionMethod.AUTO`. Returns: List of sprites colliding, or an empty list. @@ -186,23 +242,9 @@ def check_for_collision_with_list( if not isinstance(sprite_list, SpriteSequence): raise TypeError(f"Parameter 2 is a {type(sprite_list)} instead of expected SpriteList.") - sprites_to_check: Iterable[SpriteType] - # Spatial - if sprite_list.spatial_hash is not None and (method == 1 or method == 0): - sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif ( - method == 3 - or (method == 0 and len(sprite_list) <= 1500) - or get_window().ctx._gl_api == "webgl" - ): - sprites_to_check = sprite_list - else: - # GPU transform - Not on WebGL - sprites_to_check = _get_nearby_sprites(sprite, sprite_list) - return [ sprite2 - for sprite2 in sprites_to_check + for sprite2 in _get_sprites_to_check(sprite, sprite_list, method) if sprite is not sprite2 and _check_for_collision(sprite, sprite2) ] @@ -216,7 +258,7 @@ def check_for_collision_with_list( def check_for_collision_with_lists( sprite: BasicSprite, sprite_lists: Iterable[SpriteSequence[SpriteType]], - method=0, + method: CollisionMethod | int = CollisionMethod.AUTO, ) -> list[SpriteType]: """ Check for a collision between a Sprite, and a list of SpriteLists. @@ -227,16 +269,8 @@ def check_for_collision_with_lists( sprite_lists: SpriteLists to check against method: - Collision check method. Defaults to 0. - - - 0: auto-select. (spatial if available, GPU if 1500+ sprites, else simple) - - 1: Spatial Hashing if available, - - 2: GPU based - - 3: Simple check-everything. - - Note that while the GPU method is very fast when you cannot use spatial hashing, - it's also very slow if you are calling this function many times per frame. - What method is the most appropriate depends entirely on your use case. + How to find the sprites to check. See :py:class:`CollisionMethod`. + Defaults to :py:attr:`CollisionMethod.AUTO`. Returns: List of sprites colliding, or an empty list. @@ -249,23 +283,9 @@ def check_for_collision_with_lists( ) sprites: list[SpriteType] = [] - sprites_to_check: Iterable[SpriteType] for sprite_list in sprite_lists: - # Spatial - if sprite_list.spatial_hash is not None and (method == 1 or method == 0): - sprites_to_check = sprite_list.spatial_hash.get_sprites_near_sprite(sprite) - elif ( - method == 3 - or (method == 0 and len(sprite_list) <= 1500) - or get_window().ctx._gl_api == "webgl" - ): - sprites_to_check = sprite_list - else: - # GPU transform - Not on WebGL - sprites_to_check = _get_nearby_sprites(sprite, sprite_list) - - for sprite2 in sprites_to_check: + for sprite2 in _get_sprites_to_check(sprite, sprite_list, method): if sprite is not sprite2 and _check_for_collision(sprite, sprite2): sprites.append(sprite2) diff --git a/tests/unit/sprite/test_sprite_collision.py b/tests/unit/sprite/test_sprite_collision.py index b1198489ef..f7ebb5ce73 100644 --- a/tests/unit/sprite/test_sprite_collision.py +++ b/tests/unit/sprite/test_sprite_collision.py @@ -228,6 +228,74 @@ def test_check_for_collision_hit_box_bigger_than_texture(window): assert arcade.check_for_collision(a, b) is False +def test_collision_method_values(): + """CollisionMethod is an IntEnum, so the old numbers still work""" + assert arcade.CollisionMethod.AUTO == 0 + assert arcade.CollisionMethod.SPATIAL == 1 + assert arcade.CollisionMethod.GPU == 2 + assert arcade.CollisionMethod.SIMPLE == 3 + assert arcade.CollisionMethod(2) is arcade.CollisionMethod.GPU + + +# (method, spatial hash, sprite count, webgl, expected path) +COLLISION_METHOD_PATHS = [ + (arcade.CollisionMethod.AUTO, True, 10, False, "spatial"), + (arcade.CollisionMethod.AUTO, False, 10, False, "simple"), + (arcade.CollisionMethod.AUTO, False, 1501, False, "gpu"), + (arcade.CollisionMethod.SPATIAL, True, 10, False, "spatial"), + (arcade.CollisionMethod.SPATIAL, False, 10, False, "gpu"), + (arcade.CollisionMethod.GPU, True, 10, False, "gpu"), + (arcade.CollisionMethod.GPU, False, 10, False, "gpu"), + (arcade.CollisionMethod.SIMPLE, True, 10, False, "simple"), + (arcade.CollisionMethod.SIMPLE, False, 1501, False, "simple"), + (arcade.CollisionMethod.GPU, False, 10, True, "simple"), + (arcade.CollisionMethod.AUTO, False, 1501, True, "simple"), + (arcade.CollisionMethod.AUTO, True, 1501, True, "spatial"), +] + + +@pytest.mark.parametrize("use_int", [False, True], ids=["enum", "int"]) +@pytest.mark.parametrize("method, spatial, count, webgl, expected", COLLISION_METHOD_PATHS) +def test_collision_method_paths( + window, monkeypatch, use_int, method, spatial, count, webgl, expected +): + """Each method finds the sprites to check the expected way""" + from arcade.sprite_list import collision + + sprite = arcade.SpriteSolidColor(10, 10) + sprite_list = arcade.SpriteList(use_spatial_hash=spatial) + for i in range(count): + sprite_list.append(arcade.SpriteSolidColor(10, 10, center_x=i * 20)) + + calls = [] + monkeypatch.setattr(collision, "_get_nearby_sprites", lambda *args: calls.append("gpu") or []) + if spatial: + near = sprite_list.spatial_hash.get_sprites_near_sprite + monkeypatch.setattr( + sprite_list.spatial_hash, + "get_sprites_near_sprite", + lambda s: calls.append("spatial") or near(s), + ) + if webgl: + + class FakeWindow: + class ctx: + _gl_api = "webgl" + + monkeypatch.setattr(collision, "get_window", lambda: FakeWindow) + + m = int(method) if use_int else method + hits = arcade.check_for_collision_with_list(sprite, sprite_list, method=m) + assert calls == ([] if expected == "simple" else [expected]) + if expected != "gpu": + # The sprite at the origin overlaps the first sprite in the list + assert hits == [sprite_list[0]] + + calls.clear() + arcade.check_for_collision_with_lists(sprite, [sprite_list], method=m) + assert calls == ([] if expected == "simple" else [expected]) + + 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)