diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e40fb62..ef0f03015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ### Fixes - 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. ### Misc Changes - 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. diff --git a/arcade/sprite_list/spatial_hash.py b/arcade/sprite_list/spatial_hash.py index f16778d03..f0878b7df 100644 --- a/arcade/sprite_list/spatial_hash.py +++ b/arcade/sprite_list/spatial_hash.py @@ -108,6 +108,15 @@ def reset(self): self.contents.clear() self.buckets_for_sprite.clear() + def _get_cell_bounds(self, sprite: BasicSprite) -> tuple[IPoint, IPoint]: + """Get the min and max cells covered by a sprite's hit box.""" + # One pass over the points instead of using left/right/bottom/top, + # which each fetch and scan the hit box points separately. + x_points, y_points = zip(*sprite.hit_box.get_adjusted_points()) + min_point = self.hash((trunc(min(x_points)), trunc(min(y_points)))) + max_point = self.hash((trunc(max(x_points)), trunc(max(y_points)))) + return min_point, max_point + def add(self, sprite: SpriteType) -> None: """ Add a sprite to the spatial hash. @@ -115,11 +124,7 @@ def add(self, sprite: SpriteType) -> None: Args: sprite: The sprite to add """ - min_point = trunc(sprite.left), trunc(sprite.bottom) - max_point = trunc(sprite.right), trunc(sprite.top) - - # hash the minimum and maximum points - min_point, max_point = self.hash(min_point), self.hash(max_point) + min_point, max_point = self._get_cell_bounds(sprite) buckets: list[set[SpriteType]] = [] # Iterate over the rectangular region adding the sprite to each cell @@ -158,26 +163,28 @@ def remove(self, sprite: SpriteType) -> None: # Delete the sprite from the bucket tracker del self.buckets_for_sprite[sprite] - def get_sprites_near_sprite(self, sprite: BasicSprite) -> set[SpriteType]: - min_point = trunc(sprite.left), trunc(sprite.bottom) - max_point = trunc(sprite.right), trunc(sprite.top) + # NOTE: The query methods below use contents.get() rather than + # setdefault() so that looking at an empty cell doesn't create a bucket + # for it. Otherwise the dict grows with every cell ever queried. - # hash the minimum and maximum points - min_point, max_point = self.hash(min_point), self.hash(max_point) + def get_sprites_near_sprite(self, sprite: BasicSprite) -> set[SpriteType]: + min_point, max_point = self._get_cell_bounds(sprite) close_by_sprites: set[SpriteType] = set() + contents = self.contents # Iterate over the all the covered cells and collect the sprites for i in range(min_point[0], max_point[0] + 1): for j in range(min_point[1], max_point[1] + 1): - bucket = self.contents.setdefault((i, j), set()) - close_by_sprites.update(bucket) + bucket = contents.get((i, j)) + if bucket: + close_by_sprites.update(bucket) return close_by_sprites def get_sprites_near_point(self, point: Point) -> set[SpriteType]: hash_point = self.hash((trunc(point[0]), trunc(point[1]))) # Return a copy of the set. - return set(self.contents.setdefault(hash_point, set())) + return set(self.contents.get(hash_point, ())) def get_sprites_near_rect(self, rect: Rect) -> set[SpriteType]: left, right, bottom, top = rect.lrbt @@ -187,12 +194,14 @@ def get_sprites_near_rect(self, rect: Rect) -> set[SpriteType]: # hash the minimum and maximum points min_point, max_point = self.hash(min_point), self.hash(max_point) close_by_sprites: set[SpriteType] = set() + contents = self.contents # Iterate over the all the covered cells and collect the sprites for i in range(min_point[0], max_point[0] + 1): for j in range(min_point[1], max_point[1] + 1): - bucket = self.contents.setdefault((i, j), set()) - close_by_sprites.update(bucket) + bucket = contents.get((i, j)) + if bucket: + close_by_sprites.update(bucket) return close_by_sprites diff --git a/tests/unit/spritelist/test_spatial_hash.py b/tests/unit/spritelist/test_spatial_hash.py index 91a316f24..f2dd18833 100644 --- a/tests/unit/spritelist/test_spatial_hash.py +++ b/tests/unit/spritelist/test_spatial_hash.py @@ -1,6 +1,7 @@ import pytest import arcade from arcade.sprite_list.spatial_hash import SpatialHash +from arcade.types.rect import LRBT def test_create(): @@ -80,23 +81,65 @@ def test_remove_twice(): sh.remove(sprite) -def get_nearby_sprites(): +def test_get_near_sprite(): """Test getting nearby sprites""" sh = SpatialHash(cell_size=10) - sprite_1 = arcade.SpriteSolidColor(10, 10, center_x=0) - sprite_2 = arcade.SpriteSolidColor(10, 10, center_x=5) + # Covers x from -25 to -15, cells -3 to -2 + sprite_1 = arcade.SpriteSolidColor(10, 10, center_x=-20) + # Covers x from 15 to 25, cells 1 to 2 + sprite_2 = arcade.SpriteSolidColor(10, 10, center_x=20) sh.add(sprite_1) sh.add(sprite_2) - nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_x=-5)) + nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_x=-20)) assert isinstance(nearby_sprites, set) - assert len(nearby_sprites) == 1 - assert nearby_sprites[0] == sprite_1 + assert nearby_sprites == {sprite_1} - nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_x=0)) - assert isinstance(nearby_sprites, set) - assert len(nearby_sprites) == 2 - assert nearby_sprites == set([sprite_1, sprite_2]) + nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_x=20)) + assert nearby_sprites == {sprite_2} + + nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(60, 10, center_x=0)) + assert nearby_sprites == {sprite_1, sprite_2} + + nearby_sprites = sh.get_sprites_near_sprite(arcade.SpriteSolidColor(10, 10, center_y=100)) + assert nearby_sprites == set() + + +@pytest.mark.parametrize("angle", [0, 30, 45, 90]) +def test_cell_bounds_match_sprite_bounds(angle): + """Cell bounds should match those from the sprite's left/right/bottom/top.""" + sh = SpatialHash(cell_size=10) + sprite = arcade.SpriteSolidColor(40, 10, center_x=3, center_y=-7) + sprite.angle = angle + + expected_min = sh.hash((int(sprite.left), int(sprite.bottom))) + expected_max = sh.hash((int(sprite.right), int(sprite.top))) + assert sh._get_cell_bounds(sprite) == (expected_min, expected_max) + + +def test_queries_do_not_add_buckets(): + """Querying empty areas must not create new buckets.""" + sh = SpatialHash(cell_size=10) + sh.add(arcade.SpriteSolidColor(10, 10)) + bucket_count = len(sh.contents) + + far_sprite = arcade.SpriteSolidColor(50, 50, center_x=1000, center_y=1000) + assert sh.get_sprites_near_sprite(far_sprite) == set() + assert sh.get_sprites_near_point((1000, 1000)) == set() + assert sh.get_sprites_near_rect(LRBT(1000, 1100, 1000, 1100)) == set() + + assert len(sh.contents) == bucket_count + + +def test_get_near_point_returns_copy(): + """Modifying the returned set must not change the spatial hash.""" + sh = SpatialHash(cell_size=10) + sprite = arcade.SpriteSolidColor(10, 10) + sh.add(sprite) + + nearby_sprites = sh.get_sprites_near_point((0, 0)) + nearby_sprites.clear() + assert sh.get_sprites_near_point((0, 0)) == {sprite} def test_get_near_point():