Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions arcade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -310,6 +311,7 @@ def configure_logging(level: int | None = None):
"SpriteCircle",
"SpriteList",
"SpriteSequence",
"CollisionMethod",
"SpriteSolidColor",
"Text",
"TextPool",
Expand Down
2 changes: 2 additions & 0 deletions arcade/sprite_list/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +17,7 @@
"SpriteList",
"SpriteSequence",
"SpatialHash",
"CollisionMethod",
"get_distance_between_sprites",
"get_closest_sprite",
"check_for_collision",
Expand Down
124 changes: 72 additions & 52 deletions arcade/sprite_list/collision.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import Iterable
from enum import IntEnum

from arcade.geometry import (
_are_polygons_overlapping_on_axes,
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)
]

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)

Expand Down
68 changes: 68 additions & 0 deletions tests/unit/sprite/test_sprite_collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading