Skip to content

Commit b92dfe2

Browse files
pvcravenPaul V Cravenclaude
authored
Defer spatial hash updates until the next query (#2977)
Moving, rotating or resizing a sprite in a hashed SpriteList removed it from the spatial hash and added it again, on every change. SpatialHash now only marks it as moved; the next query updates each moved sprite once and skips sprites still in the same cells. Moving 5,000 hashed sprites went from 36 to 8 ms per frame (39 to 22 ms with a collision check after); queries with nothing moving cost the same. contents and buckets_for_sprite become properties that apply pending moves first, so code reading them still sees current data. Also corrects the performance tips, which said hashing doubles the cost of moving, and fixes a benchmark that used removed method names. Co-authored-by: Paul V Craven <paul.craven@optimizley.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5561a22 commit b92dfe2

5 files changed

Lines changed: 194 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
7575

7676
### Misc Changes
7777
- Ruff's pyupgrade rules are on for `arcade` and `tests`. Type annotations use `list`, `dict`, `X | Y` and `collections.abc` instead of the deprecated `typing` aliases, mostly in `arcade.gl` ([#2676](https://github.com/pythonarcade/arcade/issues/2676)). Nothing changes at runtime.
78+
- Sped up moving sprites in a `SpriteList` with a spatial hash ([#1568](https://github.com/pythonarcade/arcade/issues/1568)). Moving, rotating or resizing a sprite used to remove it from the hash and add it again every time. Now it's only marked as moved, and the next collision check updates each moved sprite once, skipping any still in the same cells. Moving 5,000 hashed sprites with `center_x += 1` and `center_y += 1` went from 36 to 8 ms per frame, or from 39 to 22 ms with a collision check after. Collision checks with nothing moving cost the same. `SpatialHash.contents` and `buckets_for_sprite` are now properties that apply pending moves first.
7879
- `draw_lines`, `draw_points`, `draw_line_strip`, `draw_polygon_filled` and `draw_polygon_outline` raise a `ValueError` naming the first point that isn't 2 numbers, such as `point_list[2] is 7, but each point must be 2 numbers, such as (x, y)` ([#2215](https://github.com/pythonarcade/arcade/issues/2215)). Before, a bad point raised a confusing error like `'int' object is not iterable`, and in `draw_lines`, `draw_points` and `draw_line_strip` a point with 1 or 3 numbers silently shifted every number after it. The check costs one length comparison per call, and the points are now converted faster: drawing 1,000 lines or points went from about 137 to 90 µs per call.
7980
- The card game tutorial no longer passes `hit_box_algorithm="None"` to `Sprite`, an Arcade 2 argument that `Sprite` silently ignores. Its text no longer says hit box calculation is slow: loading all 52 cards with their default hit boxes takes about 40 ms.
8081
- Removed the docs build's workaround for Sphinx not copying changed CSS files (`util/sphinx_static_file_temp_fix.py` and its `.ENABLE_DEVMACHINE_SPHINX_STATIC_FIX` switch). Sphinx fixed it upstream, and the pinned Sphinx 9.1.0 copies changed CSS on incremental builds and with `make.py serve` ([#2266](https://github.com/pythonarcade/arcade/issues/2266)).

‎arcade/sprite_list/spatial_hash.py‎

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,14 @@ class SpatialHash(ReadOnlySpatialHash[SpriteType]):
6666
"""A data structure best for collision checks with non-moving sprites.
6767
6868
It subdivides space into a grid of squares, each with sides of length
69-
:py:attr:`cell_size`. Moving a sprite from one place to another is the
70-
same as removing and adding it. Although moving a few can be okay, it
71-
can quickly add up and slow down a game.
69+
:py:attr:`cell_size`.
70+
71+
Moving a sprite only marks it as moved. The next query, such as a
72+
collision check, puts the moved sprites in their new squares, skipping
73+
any still in the same squares. So a sprite that moves several times in
74+
a frame is updated once, and moving sprites costs nothing until
75+
something checks for collisions. Moving many sprites still adds up and
76+
can slow down a game.
7277
7378
Args:
7479
cell_size:
@@ -91,10 +96,41 @@ def __init__(self, cell_size: int) -> None:
9196
width and height.
9297
"""
9398
# Buckets of sprites per cell
94-
self.contents: dict[IPoint, set[SpriteType]] = {}
99+
self._contents: dict[IPoint, set[SpriteType]] = {}
95100
# All the buckets a sprite is in.
96101
# This is used to remove a sprite from the spatial hash.
97-
self.buckets_for_sprite: dict[SpriteType, list[set[SpriteType]]] = {}
102+
self._buckets_for_sprite: dict[SpriteType, list[set[SpriteType]]] = {}
103+
# The min and max cells each sprite was added to, to skip moves
104+
# that stay in the same cells
105+
self._cells_for_sprite: dict[SpriteType, tuple[IPoint, IPoint]] = {}
106+
# Sprites that moved since the last query
107+
self._moved: set[SpriteType] = set()
108+
109+
@property
110+
def contents(self) -> dict[IPoint, set[SpriteType]]:
111+
"""The sprites in each cell, keyed by cell coordinates."""
112+
self._update_moved()
113+
return self._contents
114+
115+
@property
116+
def buckets_for_sprite(self) -> dict[SpriteType, list[set[SpriteType]]]:
117+
"""The cell buckets each sprite is in."""
118+
self._update_moved()
119+
return self._buckets_for_sprite
120+
121+
def _update_moved(self) -> None:
122+
"""Put sprites that moved since the last query in their new cells."""
123+
if not self._moved:
124+
return
125+
moved = self._moved
126+
self._moved = set()
127+
cells_for_sprite = self._cells_for_sprite
128+
for sprite in moved:
129+
cells = cells_for_sprite.get(sprite)
130+
# Skip sprites removed since, and moves within the same cells
131+
if cells is not None and self._get_cell_bounds(sprite) != cells:
132+
self.remove(sprite)
133+
self.add(sprite)
98134

99135
def hash(self, point: IPoint) -> IPoint:
100136
"""Convert world coordinates to cell coordinates"""
@@ -105,8 +141,10 @@ def hash(self, point: IPoint) -> IPoint:
105141

106142
def reset(self):
107143
"""Clear all the sprites from the spatial hash."""
108-
self.contents.clear()
109-
self.buckets_for_sprite.clear()
144+
self._contents.clear()
145+
self._buckets_for_sprite.clear()
146+
self._cells_for_sprite.clear()
147+
self._moved.clear()
110148

111149
def _get_cell_bounds(self, sprite: BasicSprite) -> tuple[IPoint, IPoint]:
112150
"""Get the min and max cells covered by a sprite's hit box."""
@@ -123,30 +161,33 @@ def add(self, sprite: SpriteType) -> None:
123161
Args:
124162
sprite: The sprite to add
125163
"""
126-
min_point, max_point = self._get_cell_bounds(sprite)
164+
min_point, max_point = cells = self._get_cell_bounds(sprite)
127165
buckets: list[set[SpriteType]] = []
166+
contents = self._contents
128167

129168
# Iterate over the rectangular region adding the sprite to each cell
130169
for i in range(min_point[0], max_point[0] + 1):
131170
for j in range(min_point[1], max_point[1] + 1):
132171
# Add sprite to the bucket
133-
bucket = self.contents.setdefault((i, j), set())
172+
bucket = contents.setdefault((i, j), set())
134173
bucket.add(sprite)
135174
# Collect all the buckets we added to
136175
buckets.append(bucket)
137176

138177
# Keep track of which buckets the sprite is in
139-
self.buckets_for_sprite[sprite] = buckets
178+
self._buckets_for_sprite[sprite] = buckets
179+
self._cells_for_sprite[sprite] = cells
140180

141181
def move(self, sprite: SpriteType) -> None:
142182
"""
143-
Shortcut to remove and re-add a sprite.
183+
Mark a sprite as moved.
184+
185+
It's put in its new cells at the next query, if they changed.
144186
145187
Args:
146188
sprite: The sprite to move
147189
"""
148-
self.remove(sprite)
149-
self.add(sprite)
190+
self._moved.add(sprite)
150191

151192
def remove(self, sprite: SpriteType) -> None:
152193
"""
@@ -156,20 +197,23 @@ def remove(self, sprite: SpriteType) -> None:
156197
sprite: The sprite to remove
157198
"""
158199
# Remove the sprite from all the buckets it is in
159-
for bucket in self.buckets_for_sprite[sprite]:
200+
for bucket in self._buckets_for_sprite[sprite]:
160201
bucket.remove(sprite)
161202

162203
# Delete the sprite from the bucket tracker
163-
del self.buckets_for_sprite[sprite]
204+
del self._buckets_for_sprite[sprite]
205+
del self._cells_for_sprite[sprite]
206+
self._moved.discard(sprite)
164207

165208
# NOTE: The query methods below use contents.get() rather than
166209
# setdefault() so that looking at an empty cell doesn't create a bucket
167210
# for it. Otherwise the dict grows with every cell ever queried.
168211

169212
def get_sprites_near_sprite(self, sprite: BasicSprite) -> set[SpriteType]:
213+
self._update_moved()
170214
min_point, max_point = self._get_cell_bounds(sprite)
171215
close_by_sprites: set[SpriteType] = set()
172-
contents = self.contents
216+
contents = self._contents
173217

174218
# Iterate over the all the covered cells and collect the sprites
175219
for i in range(min_point[0], max_point[0] + 1):
@@ -181,9 +225,10 @@ def get_sprites_near_sprite(self, sprite: BasicSprite) -> set[SpriteType]:
181225
return close_by_sprites
182226

183227
def get_sprites_near_point(self, point: Point) -> set[SpriteType]:
228+
self._update_moved()
184229
hash_point = self.hash((trunc(point[0]), trunc(point[1])))
185230
# Return a copy of the set.
186-
return set(self.contents.get(hash_point, ()))
231+
return set(self._contents.get(hash_point, ()))
187232

188233
def get_sprites_near_rect(self, rect: Rect) -> set[SpriteType]:
189234
left, right, bottom, top = rect.lrbt
@@ -193,7 +238,8 @@ def get_sprites_near_rect(self, rect: Rect) -> set[SpriteType]:
193238
# hash the minimum and maximum points
194239
min_point, max_point = self.hash(min_point), self.hash(max_point)
195240
close_by_sprites: set[SpriteType] = set()
196-
contents = self.contents
241+
self._update_moved()
242+
contents = self._contents
197243

198244
# Iterate over the all the covered cells and collect the sprites
199245
for i in range(min_point[0], max_point[0] + 1):
@@ -211,4 +257,4 @@ def count(self) -> int:
211257
# changing the truthiness of the class instance.
212258
# if spatial_hash will be False if it is empty.
213259
# For backwards compatibility, we'll keep it as a property.
214-
return len(self.buckets_for_sprite)
260+
return len(self._buckets_for_sprite)

‎benchmarks/spatial_hash/add_remove_vs_move.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,20 @@
1717
def add_remove():
1818
sh = arcade.SpatialHash(CELL_SIZE)
1919
for sprite in sprites:
20-
sh.insert_object_for_box(sprite)
20+
sh.add(sprite)
2121
for sprite in sprites:
22-
sh.remove_object(sprite)
23-
sh.insert_object_for_box(sprite)
22+
sh.remove(sprite)
23+
sh.add(sprite)
2424

2525

2626
def move():
2727
sh = arcade.SpatialHash(CELL_SIZE)
2828
for sprite in sprites:
29-
sh.insert_object_for_box(sprite)
29+
sh.add(sprite)
3030
for sprite in sprites:
3131
sh.move(sprite)
32+
# Moves are applied at the next query
33+
sh.get_sprites_near_point((0, 0))
3234

3335

3436
res_1 = timeit.timeit(add_remove, number=100, globals=globals())

‎doc/programming_guide/performance_tips.rst‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,17 +220,23 @@ examples are linked below in :ref:`collision_performance_spatial_hashing_example
220220

221221
The Catch
222222
"""""""""
223-
Spatial hashing doubles the cost of moving or resizing sprites.
223+
Spatial hashing makes moving, rotating or resizing sprites cost more.
224224

225-
However, this doesn't mean we can't *ever* move or resize a sprite!
226-
Instead, it means we have to be careful about when and how much we
227-
do so. This is because moving and resizing now consists of:
225+
Moving a sprite only marks it as moved. The next collision check puts
226+
every moved sprite back in the right grid squares, which means:
228227

229-
#. Remove it from the internal list of every grid square it is currently in
230-
#. Add it again by re-computing its new location
228+
#. Working out which grid squares the sprite's hit box now covers
229+
#. If they changed, removing it from its old squares and adding it to the
230+
new ones
231231

232-
If we only move a few sprites in the list now and then, it can work out.
233-
When in doubt, test it and see if it works for your specific use case.
232+
So a sprite that moves several times in a frame is only updated once, and
233+
one that stays in the same squares isn't moved at all. Even so, moving
234+
5,000 sprites every frame and then checking for a collision took about
235+
22 ms in a hashed list, compared with 8 ms in an unhashed one.
236+
237+
This doesn't mean we can't *ever* move a sprite in a hashed list! If we
238+
only move a few sprites now and then, it works out well. When in doubt,
239+
test it and see if it works for your specific use case.
234240

235241
.. _collision_performance_spatial_hashing_examples:
236242

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Moving a sprite in a spatial hash is applied at the next query."""
2+
3+
import arcade
4+
from arcade.sprite_list.spatial_hash import SpatialHash
5+
from arcade.types.rect import LRBT
6+
7+
8+
def make_list(*positions):
9+
sprite_list = arcade.SpriteList(use_spatial_hash=True, spatial_hash_cell_size=32)
10+
sprites = []
11+
for position in positions:
12+
sprite = arcade.SpriteSolidColor(8, 8, color=arcade.color.RED)
13+
sprite.position = position
14+
sprite_list.append(sprite)
15+
sprites.append(sprite)
16+
return sprite_list, sprites
17+
18+
19+
def count_adds(spatial_hash: SpatialHash | None):
20+
"""Count calls to add() on one spatial hash."""
21+
assert spatial_hash is not None
22+
calls = []
23+
original = spatial_hash.add
24+
25+
def add(sprite):
26+
calls.append(sprite)
27+
original(sprite)
28+
29+
spatial_hash.add = add
30+
return calls
31+
32+
33+
def test_query_finds_moved_sprite():
34+
sprite_list, (sprite,) = make_list((10, 10))
35+
spatial_hash = sprite_list.spatial_hash
36+
37+
sprite.position = 500, 500
38+
39+
assert spatial_hash.get_sprites_near_point((10, 10)) == set()
40+
assert spatial_hash.get_sprites_near_point((500, 500)) == {sprite}
41+
assert spatial_hash.get_sprites_near_rect(LRBT(480, 520, 480, 520)) == {sprite}
42+
43+
sprite.position = 10, 10
44+
other = arcade.SpriteSolidColor(8, 8)
45+
other.position = 12, 12
46+
assert spatial_hash.get_sprites_near_sprite(other) == {sprite}
47+
48+
49+
def test_collision_check_sees_moved_sprite():
50+
sprite_list, (sprite,) = make_list((10, 10))
51+
player = arcade.SpriteSolidColor(16, 16)
52+
player.position = 300, 300
53+
assert arcade.check_for_collision_with_list(player, sprite_list) == []
54+
55+
sprite.position = 302, 298
56+
assert arcade.check_for_collision_with_list(player, sprite_list) == [sprite]
57+
58+
59+
def test_several_moves_update_once():
60+
sprite_list, (sprite,) = make_list((10, 10))
61+
adds = count_adds(sprite_list.spatial_hash)
62+
63+
# Moving the x and y separately, then rotating, used to re-add the
64+
# sprite three times
65+
sprite.center_x = 200
66+
sprite.center_y = 200
67+
sprite.angle = 45
68+
assert adds == []
69+
70+
sprite_list.spatial_hash.get_sprites_near_point((200, 200))
71+
assert adds == [sprite]
72+
73+
74+
def test_move_within_same_cells_is_skipped():
75+
sprite_list, (sprite,) = make_list((10, 10))
76+
adds = count_adds(sprite_list.spatial_hash)
77+
78+
sprite.center_x += 1
79+
assert sprite_list.spatial_hash.get_sprites_near_point((11, 10)) == {sprite}
80+
assert adds == []
81+
82+
83+
def test_remove_after_move():
84+
sprite_list, (sprite, _other) = make_list((10, 10), (100, 100))
85+
sprite.position = 500, 500
86+
sprite_list.remove(sprite)
87+
88+
spatial_hash = sprite_list.spatial_hash
89+
assert spatial_hash.get_sprites_near_point((500, 500)) == set()
90+
assert spatial_hash.get_sprites_near_point((10, 10)) == set()
91+
assert spatial_hash.count == 1
92+
93+
94+
def test_contents_include_moves():
95+
sprite_list, (sprite,) = make_list((10, 10))
96+
sprite.position = 500, 500
97+
98+
spatial_hash = sprite_list.spatial_hash
99+
occupied = {cell for cell, bucket in spatial_hash.contents.items() if bucket}
100+
assert occupied == {(15, 15)}
101+
assert spatial_hash.buckets_for_sprite[sprite] == [{sprite}]
102+
103+
104+
def test_reset_forgets_moves():
105+
sprite_list, (sprite,) = make_list((10, 10))
106+
sprite.position = 500, 500
107+
sprite_list.spatial_hash.reset()
108+
assert sprite_list.spatial_hash.get_sprites_near_point((500, 500)) == set()

0 commit comments

Comments
 (0)