Skip to content

Commit 5cebfdc

Browse files
pvcravenPaul V Cravenclaude
authored
Use the hit box for the quick collision distance check (#2897)
_check_for_collision rejected far-apart sprites with circles sized from the texture (0.71 * the larger of width and height). A custom hit box bigger than the texture, such as a melee reach area, could reach sprites outside that circle, and those collisions were missed. Each hit box now caches its radius: the distance from its position to its farthest point. It's worked out from the raw points and the scale, so rotating or moving doesn't change it, and it's cleared when the scale changes. It's padded by 1e-6 (relative and absolute), since rounding in the rotated, offset points could otherwise put a point just outside it. Subclasses overriding get_adjusted_points() get an uncached radius from their adjusted points. The radius is usually tighter than the texture estimate and is a single cached read, so brute-force list checks are also about 20-40% faster. Co-authored-by: Paul V Craven <paul.craven@optimizley.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 817ad31 commit 5cebfdc

5 files changed

Lines changed: 155 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
1010
- 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.
1111
- Fixed `are_polygons_intersecting` (and sprite collision checks) always returning `False` when a polygon had a repeated point, such as a closed polygon whose first point is repeated at the end.
1212
- Rotated hit boxes at right angles (90, 180, 270 degrees, etc.) now have exact point coordinates. Before, values like `sin(radians(180))` being about 1.2e-16 instead of 0 left tiny errors that could make exactly touching sprites count as colliding, or not, differently from unrotated ones.
13+
- Fixed sprite collision checks missing collisions when a sprite's hit box is bigger than its texture, such as a custom hit box used as a melee reach area. The quick distance check estimated each sprite's size from its texture; it now uses the actual hit box, cached until the scale changes. This also makes the check tighter for most sprites, so brute-force list checks are about 20-40% faster.
1314

1415
### New Features
1516
- Added `HitBox.get_adjusted_bounds()`, which returns the cached `(left, right, bottom, top)` bounds of the adjusted hit box points.

‎arcade/hitbox/base.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
# whether two edges are parallel.
1818
_AXIS_DECIMALS = 9
1919

20+
# Hit box radii are padded by this relative and absolute amount, so rounding
21+
# in the adjusted points can never put a point outside the radius.
22+
_RADIUS_PADDING_RELATIVE = 1e-6
23+
_RADIUS_PADDING_ABSOLUTE = 1e-6
24+
2025
# Cosine and sine for 0, 90, 180 and 270 degrees clockwise
2126
_RIGHT_ANGLE_ROTATIONS = ((1.0, 0.0), (0.0, -1.0), (-1.0, 0.0), (0.0, 1.0))
2227

@@ -213,6 +218,9 @@ def __init__(
213218
self._axes: Axes = {}
214219
self._axes_key: tuple[Any, ...] | None = None
215220

221+
# Cached by _get_radius() until the scale changes
222+
self._radius: float | None = None
223+
216224
@property
217225
def points(self) -> Point2List:
218226
"""
@@ -300,6 +308,36 @@ def get_adjusted_bounds(self) -> tuple[float, float, float, float]:
300308
self._adjusted_bounds_points = points
301309
return self._adjusted_bounds
302310

311+
def _get_radius(self) -> float:
312+
"""
313+
Get the distance from the position to the farthest adjusted point.
314+
315+
Rotating or moving the hit box doesn't change this, so it's cached
316+
until the scale changes. The result is padded very slightly so
317+
rounding can't put an adjusted point outside it.
318+
"""
319+
if type(self).get_adjusted_points not in (
320+
HitBox.get_adjusted_points,
321+
RotatableHitBox.get_adjusted_points,
322+
):
323+
# A subclass may transform the points differently, so measure
324+
# the adjusted points. Not cached, since they may change at any time.
325+
position_x, position_y = self._position
326+
radius = max(
327+
(hypot(x - position_x, y - position_y) for x, y in self.get_adjusted_points()),
328+
default=0.0,
329+
)
330+
return radius + radius * _RADIUS_PADDING_RELATIVE + _RADIUS_PADDING_ABSOLUTE
331+
332+
cached = self._radius
333+
if cached is not None:
334+
return cached
335+
scale_x, scale_y = self._scale
336+
radius = max((hypot(x * scale_x, y * scale_y) for x, y in self._points), default=0.0)
337+
radius += radius * _RADIUS_PADDING_RELATIVE + _RADIUS_PADDING_ABSOLUTE
338+
self._radius = radius
339+
return radius
340+
303341
def _get_axes(self) -> Axes:
304342
"""
305343
Get the separating axes to test this hit box against another.
@@ -374,6 +412,7 @@ def scale(self) -> tuple[float, float]:
374412
@scale.setter
375413
def scale(self, scale: tuple[float, float]):
376414
self._scale = scale
415+
self._radius = None
377416
self._adjusted_cache_dirty = True
378417

379418
def create_rotatable(

‎arcade/sprite_list/collision.py‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,20 +92,20 @@ def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool:
9292
"""
9393

9494
# NOTE: for speed because attribute look ups are slow.
95-
# Width and height are negative when the sprite is flipped with a negative scale.
9695
sprite1_position = sprite1._position
97-
sprite1_width = abs(sprite1._width)
98-
sprite1_height = abs(sprite1._height)
9996
sprite2_position = sprite2._position
100-
sprite2_width = abs(sprite2._width)
101-
sprite2_height = abs(sprite2._height)
102-
103-
radius_sum = (sprite1_width if sprite1_width > sprite1_height else sprite1_height) + (
104-
sprite2_width if sprite2_width > sprite2_height else sprite2_height
105-
)
106-
107-
# Multiply by half of the theoretical max diagonal length for an estimation of distance
108-
radius_sum *= 0.71 # 1.42 / 2
97+
hit_box1 = sprite1._hit_box
98+
hit_box2 = sprite2._hit_box
99+
100+
# Quick check with circles around each hit box. The radius is cached on
101+
# the hit box until its scale changes.
102+
radius1 = hit_box1._radius
103+
if radius1 is None:
104+
radius1 = hit_box1._get_radius()
105+
radius2 = hit_box2._radius
106+
if radius2 is None:
107+
radius2 = hit_box2._get_radius()
108+
radius_sum = radius1 + radius2
109109
radius_sum_sq = radius_sum * radius_sum
110110

111111
diff_x = sprite1_position[0] - sprite2_position[0]
@@ -122,8 +122,6 @@ def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool:
122122
if distance > radius_sum_sq:
123123
return False
124124

125-
hit_box1 = sprite1.hit_box
126-
hit_box2 = sprite2.hit_box
127125
points1 = hit_box1.get_adjusted_points()
128126
points2 = hit_box2.get_adjusted_points()
129127
if not points1 or not points2:

‎tests/unit/hitbox/test_hitbox.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import math
2+
import random
3+
14
import pytest
25
from arcade import hitbox
36

@@ -174,3 +177,69 @@ def get_adjusted_points(self):
174177
# The skew turns the box's vertical edges into diagonals
175178
axes = SkewedHitBox(points)._get_axes()
176179
assert _axis_directions(axes) == {(0.707107, -0.707107)}
180+
181+
182+
def test_radius():
183+
"""The radius is the distance from the position to the farthest point"""
184+
hb = hitbox.HitBox(points) # (0, 0) to (10, 10), so (10, 10) is farthest
185+
assert hb._get_radius() == pytest.approx(200**0.5, rel=1e-5)
186+
# Never smaller than the true distance, so rounding can't cause misses
187+
assert hb._get_radius() >= 200**0.5
188+
189+
# Cached, and not changed by moving
190+
radius = hb._get_radius()
191+
assert hb._radius == radius
192+
hb.position = (100.0, -50.0)
193+
assert hb._get_radius() == radius
194+
195+
# Recalculated when the scale changes, including negative scales
196+
hb.scale = (2.0, -0.5)
197+
assert hb._radius is None
198+
assert hb._get_radius() == pytest.approx((20**2 + 5**2) ** 0.5, rel=1e-5)
199+
200+
201+
def test_radius_rotatable():
202+
"""Rotation doesn't change the radius"""
203+
rot = hitbox.HitBox(octagon).create_rotatable()
204+
radius = rot._get_radius()
205+
assert radius == pytest.approx(20**0.5, rel=1e-5)
206+
for angle in (30.0, 45.0, 90.0, 217.0):
207+
rot.angle = angle
208+
assert rot._get_radius() == radius
209+
farthest = max(
210+
((x - rot.position[0]) ** 2 + (y - rot.position[1]) ** 2) ** 0.5
211+
for x, y in rot.get_adjusted_points()
212+
)
213+
assert farthest <= radius
214+
215+
216+
def test_radius_empty():
217+
assert hitbox.HitBox([])._get_radius() == pytest.approx(0.0, abs=1e-5)
218+
219+
220+
def test_radius_subclass_override():
221+
"""The radius comes from get_adjusted_points() if a subclass overrides it."""
222+
223+
class StretchedHitBox(hitbox.HitBox):
224+
def get_adjusted_points(self):
225+
px, py = self.position
226+
return [(x * 3 + px, y + py) for x, y in self.points]
227+
228+
hb = StretchedHitBox(points, position=(5.0, 5.0))
229+
assert hb._get_radius() == pytest.approx((30**2 + 10**2) ** 0.5, rel=1e-5)
230+
231+
232+
def test_radius_covers_rounding():
233+
"""Rounding in the adjusted points must never put a point outside the radius"""
234+
rng = random.Random(0)
235+
for _ in range(2000):
236+
pts = [(rng.uniform(-60, 60), rng.uniform(-60, 60)) for _ in range(rng.randint(3, 8))]
237+
rot = hitbox.HitBox(pts).create_rotatable(angle=rng.uniform(0, 360))
238+
rot.scale = (rng.uniform(-3, 3), rng.uniform(-3, 3))
239+
far = rng.choice([1.0, 1e3, 1e5, 1e7])
240+
rot.position = (rng.uniform(-far, far), rng.uniform(-far, far))
241+
242+
radius = rot._get_radius()
243+
px, py = rot.position
244+
for x, y in rot.get_adjusted_points():
245+
assert math.hypot(x - px, y - py) <= radius

‎tests/unit/sprite/test_sprite_collision.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,35 @@ def test_check_for_collision_negative_scale(window, scale_a, scale_b):
199199
assert arcade.check_for_collision(a, b) is False
200200

201201

202+
def test_check_for_collision_hit_box_bigger_than_texture(window):
203+
"""A custom hit box bigger than the texture must still collide."""
204+
a = arcade.SpriteSolidColor(32, 32, color=arcade.csscolor.RED)
205+
# Like a melee reach area around the sprite
206+
a.hit_box = arcade.hitbox.HitBox([(-50, -50), (50, -50), (50, 50), (-50, 50)])
207+
# Overlaps the hit box, but is too far away to touch the texture
208+
b = arcade.SpriteSolidColor(32, 32, center_x=60, color=arcade.csscolor.RED)
209+
assert arcade.check_for_collision(a, b) is True
210+
assert arcade.check_for_collision(b, a) is True
211+
212+
sprite_list = arcade.SpriteList()
213+
sprite_list.append(b)
214+
assert arcade.check_for_collision_with_list(a, sprite_list, method=3) == [b]
215+
sprite_list.enable_spatial_hashing()
216+
assert arcade.check_for_collision_with_list(a, sprite_list, method=1) == [b]
217+
218+
# Still found when rotated, scaled, or flipped
219+
a.angle = 45
220+
b.position = 70, 0
221+
assert arcade.check_for_collision(a, b) is True
222+
a.scale = (-1.5, 1.5)
223+
b.position = 90, 0
224+
assert arcade.check_for_collision(a, b) is True
225+
226+
# And not when it really is out of reach
227+
b.position = 200, 0
228+
assert arcade.check_for_collision(a, b) is False
229+
230+
202231
def test_check_for_collision_with_list(window):
203232
# TODO: Check that the right collision function is called internally
204233
a = arcade.SpriteSolidColor(50, 50, color=arcade.csscolor.RED)
@@ -385,7 +414,11 @@ def test_check_for_collision_matches_reference():
385414

386415
def random_sprite(shared_angle):
387416
sprite = arcade.Sprite(rng.choice(textures))
388-
sprite.scale = (rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5]),
417+
# Sometimes use a custom hit box bigger than the texture
418+
if rng.random() < 0.2:
419+
points = [(x * 2.5, y * 2.5) for x, y in sprite.texture.hit_box_points]
420+
sprite.hit_box = arcade.hitbox.HitBox(points)
421+
sprite.scale =(rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5]),
389422
rng.choice([-1, 1]) * rng.choice([0.25, 0.5, 1, 1.5])) # fmt: skip
390423
# Often share an angle, so both hit boxes have parallel edges
391424
if rng.random() < 0.5:

0 commit comments

Comments
 (0)