Skip to content
Closed
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
3 changes: 3 additions & 0 deletions arcade/sprite/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
from typing import TYPE_CHECKING, List, Iterable, TypeVar

import arcade
Expand Down Expand Up @@ -175,6 +176,7 @@ def scale(self, new_value: float):
if self._texture:
self._width = self._texture.width * self._scale[0]
self._height = self._texture.height * self._scale[1]
self._hit_box_max_dimension = max(self._width, self._height) / 2

self.update_spatial_hash()
for sprite_list in self.sprite_lists:
Expand Down Expand Up @@ -359,6 +361,7 @@ def texture(self, texture: Texture):
self._texture = texture
self._width = texture.width * self._scale[0]
self._height = texture.height * self._scale[1]
self._hit_box_max_dimension = math.sqrt(self._width ** 2 + self._height ** 2)
self.update_spatial_hash()
for sprite_list in self.sprite_lists:
sprite_list._update_texture(self)
Expand Down
2 changes: 1 addition & 1 deletion arcade/sprite/colored.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,4 @@ def __init__(self, radius: int, color: Color, soft: bool = False, **kwargs):
# apply results to the new sprite
super().__init__(texture)
self.color = color_rgba
self._points = self.texture.hit_box_points
self.hit_box = self.texture.hit_box_points
3 changes: 2 additions & 1 deletion arcade/sprite/sprite.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def __init__(
self._width = self._texture.width * scale
self._height = self._texture.height * scale
if not self._hit_box_points:
self._hit_box_points = self._texture.hit_box_points
self.hit_box = self._texture.hit_box_points

# --- Properties ---

Expand Down Expand Up @@ -313,6 +313,7 @@ def set_hit_box(self, points: PointList) -> None:
"""
self._hit_box_points_cache = None
self._hit_box_points = points
self._hit_box_max_dimension = math.sqrt(max([x *x + y * y for x,y in points]))
# self.update_spatial_hash() This needed?

def get_hit_box(self) -> PointList:
Expand Down
68 changes: 47 additions & 21 deletions arcade/sprite_list/spatial_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@
Set,
Dict,
Generic,
TypeVar,
)
from arcade.types import Point, IPoint, Rect
from arcade.sprite import SpriteType

class SpatialHashEntry(Generic[SpriteType], List[Set[SpriteType]]):
"""
Describes the entry for a single sprite in the spatial hash.
Stores a list of all buckets containing the sprite, but also supplemental
fields that make updating the hash efficient.
"""
__slots__ = ("_hash",)

class SpatialHash(Generic[SpriteType]):
"""
Expand All @@ -29,7 +37,7 @@ def __init__(self, cell_size: int) -> None:
self.contents: Dict[IPoint, Set[SpriteType]] = {}
# All the buckets a sprite is in.
# This is used to remove a sprite from the spatial hash.
self.buckets_for_sprite: Dict[SpriteType, List[Set[SpriteType]]] = {}
self.entries: Dict[SpriteType, SpatialHashEntry[SpriteType]] = {}

def hash(self, point: IPoint) -> IPoint:
"""Convert world coordinates to cell coordinates"""
Expand All @@ -38,25 +46,37 @@ def hash(self, point: IPoint) -> IPoint:
point[1] // self.cell_size,
)

def _sprite_hash(self, sprite: SpriteType):
x,y = sprite._position
r = sprite._hit_box_max_dimension
min_point = trunc(x - r), trunc(y - r)
max_point = trunc(x + r), trunc(y + r)

# hash the minimum and maximum points
min_point, max_point = self.hash(min_point), self.hash(max_point)
hash = (min_point, max_point)
return hash

def reset(self):
"""
Clear all the sprites from the spatial hash.
"""
self.contents.clear()
self.buckets_for_sprite.clear()
self.entries.clear()

def add(self, sprite: SpriteType) -> None:
def add(self, sprite: SpriteType, hash = None) -> None:
"""
Add a sprite to the spatial hash.

:param Sprite sprite: The sprite to add
"""
min_point = trunc(sprite.left), trunc(sprite.bottom)
max_point = trunc(sprite.right), trunc(sprite.top)
if hash is None:
hash = self._sprite_hash(sprite)

# hash the minimum and maximum points
min_point, max_point = self.hash(min_point), self.hash(max_point)
buckets: List[Set[SpriteType]] = []
min_point, max_point = hash

buckets = SpatialHashEntry[SpriteType]()
buckets._hash = hash

# Iterate over the rectangular region adding the sprite to each cell
for i in range(min_point[0], max_point[0] + 1):
Expand All @@ -68,29 +88,37 @@ def add(self, sprite: SpriteType) -> None:
buckets.append(bucket)

# Keep track of which buckets the sprite is in
self.buckets_for_sprite[sprite] = buckets
self.entries[sprite] = buckets

def move(self, sprite: SpriteType) -> None:
"""
Shortcut to remove and re-add a sprite.
Removes and re-adds a sprite, but only if the sprite's hash has changed.

:param Sprite sprite: The sprite to move
"""
self.remove(sprite)
self.add(sprite)

def remove(self, sprite: SpriteType) -> None:
hash = self._sprite_hash(sprite)
buckets = self.entries[sprite]
if buckets is not None and buckets._hash == hash:
return
self.remove(sprite, buckets)
# TODO move these optimizations into `add`? So you can `add` repeatedly
# to keep a sprite updated in the hash?
self.add(sprite, hash)

def remove(self, sprite: SpriteType, buckets = None) -> None:
"""
Remove a Sprite.

:param Sprite sprite: The sprite to remove
"""
if buckets is None:
buckets = self.entries[sprite]
# Remove the sprite from all the buckets it is in
for bucket in self.buckets_for_sprite[sprite]:
for bucket in buckets:
bucket.remove(sprite)

# Delete the sprite from the bucket tracker
del self.buckets_for_sprite[sprite]
del self.entries[sprite]

def get_sprites_near_sprite(self, sprite: SpriteType) -> Set[SpriteType]:
"""
Expand All @@ -100,11 +128,9 @@ def get_sprites_near_sprite(self, sprite: SpriteType) -> Set[SpriteType]:
:return: A set of close-by sprites
:rtype: Set
"""
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._sprite_hash(sprite)

close_by_sprites: Set[SpriteType] = set()

# Iterate over the all the covered cells and collect the sprites
Expand Down Expand Up @@ -159,4 +185,4 @@ def count(self) -> int:
# changing the truthiness of the class instance.
# if spatial_hash will be False if it is empty.
# For backwards compatibility, we'll keep it as a property.
return len(self.buckets_for_sprite)
return len(self.entries)
75 changes: 75 additions & 0 deletions benchmarks/collisions/bench-hash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import math
import arcade
import pyglet
import random
import time

SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600

WALL_DIM_MIN = 10
WALL_DIM_MAX = 200
WALLS_COUNT = 10

BULLET_VELOCITY_MIN = 1/60
BULLET_VELOCITY_MAX = 10/60
BULLET_COUNT = 1000

SIMULATE_MINUTES = 1
SIMULATE_FPS = 60

# Predictable randomization so that each benchmark is identical
rng = random.Random(0)

bullets = arcade.SpriteList(use_spatial_hash=True)
walls = arcade.SpriteList()

window = arcade.Window()

# Seed chosen manually to create a wall distribution that looked good enough,
# like something I might create in a game.
rng.seed(2)
for i in range(0, WALLS_COUNT):
wall = arcade.SpriteSolidColor(rng.randint(WALL_DIM_MIN, WALL_DIM_MAX), rng.randint(WALL_DIM_MIN, WALL_DIM_MAX), color=arcade.color.BLACK)
wall.position = rng.randint(0, SCREEN_WIDTH), rng.randint(0, SCREEN_HEIGHT)
walls.append(wall)

for i in range(0, BULLET_COUNT):
# Create a new bullet
new_bullet = arcade.SpriteCircle(color=arcade.color.RED, radius=10)
new_bullet.position = (rng.randint(0, SCREEN_WIDTH), rng.randint(0, SCREEN_HEIGHT))
speed = rng.random() * (BULLET_VELOCITY_MAX - BULLET_VELOCITY_MIN) + BULLET_VELOCITY_MIN
angle = rng.random() * math.pi * 2
new_bullet.velocity = (math.cos(angle) * speed, math.sin(angle) * speed)
# Half of bullets are rotated, to test those code paths
if rng.random() > 0.5:
new_bullet.angle = 45
bullets.append(new_bullet)

for i in range(0, int(SIMULATE_MINUTES * 60 * SIMULATE_FPS)):
pyglet.clock.tick()

window.switch_to()
window.dispatch_events()

# Move all bullets
for bullet in bullets:
bullet.position = (bullet.position[0] + bullet.velocity[0], bullet.position[1] + bullet.velocity[1])

# Check for collisions
bullets_w_collision = []
for wall in walls:
bullets_hit = arcade.check_for_collision_with_list(wall, bullets)
if bullets_hit:
for bullet in bullets_hit:
bullets_w_collision.append(bullet)
for bullet in bullets_w_collision:
# bullets.remove(bullet)
bullet.position = (rng.randint(0, SCREEN_WIDTH), rng.randint(0, SCREEN_HEIGHT))

window.dispatch_event('on_draw')

window.clear(color=arcade.color.WHITE)
walls.draw()
bullets.draw()
window.flip()
11 changes: 6 additions & 5 deletions benchmarks/collisions/bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
# like something I might create in a game.
rng.seed(2)
for i in range(0, WALLS_COUNT):
wall = arcade.SpriteSolidColor(rng.randint(WALL_DIM_MIN, WALL_DIM_MAX), rng.randint(WALL_DIM_MIN, WALL_DIM_MAX), arcade.color.BLACK)
wall = arcade.SpriteSolidColor(rng.randint(WALL_DIM_MIN, WALL_DIM_MAX), rng.randint(WALL_DIM_MIN, WALL_DIM_MAX), color=arcade.color.BLACK)
wall.position = rng.randint(0, SCREEN_WIDTH), rng.randint(0, SCREEN_HEIGHT)
walls.append(wall)

Expand Down Expand Up @@ -58,10 +58,11 @@

# Check for collisions
bullets_w_collision = []
for bullet in bullets:
walls_hit = arcade.check_for_collision_with_list(bullet, walls)
if walls_hit:
bullets_w_collision.append(bullet)
for wall in walls:
bullets_hit = arcade.check_for_collision_with_list(wall, bullets)
if bullets_hit:
for bullet in bullets_hit:
bullets_w_collision.append(bullet)
for bullet in bullets_w_collision:
# bullets.remove(bullet)
bullet.position = (rng.randint(0, SCREEN_WIDTH), rng.randint(0, SCREEN_HEIGHT))
Expand Down
10 changes: 5 additions & 5 deletions tests/unit2/test_spatial_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def test_create():
sh = SpatialHash(cell_size=10)
assert sh.cell_size == 10
assert sh.contents == {}
assert sh.buckets_for_sprite == {}
assert sh.entries == {}
assert sh.count == 0


Expand All @@ -28,7 +28,7 @@ def test_add():
sh.add(arcade.SpriteSolidColor(10, 10, color=arcade.color.RED))
assert sh.count == 4
assert len(sh.contents) == 4
assert len(sh.buckets_for_sprite) == 4
assert len(sh.entries) == 4


def test_add_twice():
Expand All @@ -39,7 +39,7 @@ def test_add_twice():
sh.add(sprite)
assert sh.count == 1
assert len(sh.contents) == 4
assert len(sh.buckets_for_sprite) == 1
assert len(sh.entries) == 1


def test_add_remove():
Expand All @@ -51,13 +51,13 @@ def test_add_remove():
assert len(sh.contents) == 4 # 4 buckets
for cn in sh.contents.values():
assert len(cn) == 1
assert len(sh.buckets_for_sprite) == 1
assert len(sh.entries) == 1
sh.remove(sprite)
assert sh.count == 0
assert len(sh.contents) == 4
for cn in sh.contents.values():
assert len(cn) == 0
assert len(sh.buckets_for_sprite) == 0
assert len(sh.entries) == 0


def test_remove_twice():
Expand Down