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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- 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.
- 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.
- 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.

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

### 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).
- Sped up collision checks further by caching each hit box's distinct edge directions. Parallel edges (such as opposite sides of the default octagon hit boxes, or matching edges on two sprites with the same angle) are only tested once, and the cache is kept when a sprite moves. Two unrotated octagon hit boxes go from 12.0 to 4.6 µs, and two rotated 30° from 20.8 to 6.9 µs.
- 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.

## 4.0.0.dev7
Expand Down
28 changes: 28 additions & 0 deletions arcade/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Point in polygon function from https://www.geeksforgeeks.org/how-to-check-if-a-given-point-lies-inside-a-polygon/
"""

from collections.abc import Iterable
from sys import maxsize as sys_int_maxsize

from arcade.types import Point2, Point2List
Expand Down Expand Up @@ -73,6 +74,33 @@ def _are_polygons_intersecting_sat(poly_a: Point2List, poly_b: Point2List) -> bo
return True


def _are_polygons_overlapping_on_axes(
poly_a: Point2List, poly_b: Point2List, axes: Iterable[Point2]
) -> bool:
"""
Separating axis test for two polygons using a given set of axes.

Like :py:func:`_are_polygons_intersecting_sat`, but the caller provides
the axes to test, so they can be cached and duplicates removed.

Args:
poly_a: List of points that define the first polygon.
poly_b: List of points that define the second polygon.
axes: The axes to project onto. They don't need to be unit vectors.

Returns:
``True`` if the polygons overlap on every axis, ``False`` otherwise
"""
for normal_x, normal_y in axes:
projected_a = [normal_x * px + normal_y * py for px, py in poly_a]
projected_b = [normal_x * px + normal_y * py for px, py in poly_b]

if max(projected_a) <= min(projected_b) or max(projected_b) <= min(projected_a):
return False

return True


def is_point_in_box(p: Point2, q: Point2, r: Point2) -> bool:
"""
Checks if point ``q`` is inside the box defined by ``p`` and ``r``.
Expand Down
162 changes: 157 additions & 5 deletions arcade/hitbox/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from math import cos, radians, sin
from math import cos, hypot, radians, sin
from typing import Any

from PIL.Image import Image
Expand All @@ -10,6 +10,86 @@

__all__ = ["HitBoxAlgorithm", "HitBox", "RotatableHitBox"]

#: Maps a rounded unit normal to an edge normal with that direction. See _add_axis().
Axes = dict[tuple[float, float], tuple[float, float]]

# Unit vectors are rounded to this many decimal places when checking
# whether two edges are parallel.
_AXIS_DECIMALS = 9

# Cosine and sine for 0, 90, 180 and 270 degrees clockwise
_RIGHT_ANGLE_ROTATIONS = ((1.0, 0.0), (0.0, -1.0), (-1.0, 0.0), (0.0, 1.0))


def _get_rotation(angle: float) -> tuple[float, float]:
"""
Get the cosine and sine used to rotate points clockwise by ``angle`` degrees.

Right angles give exact values. Otherwise ``sin(radians(180))`` is
about 1.2e-16 instead of 0, and rotated hit boxes pick up tiny errors
that can decide whether exactly touching sprites collide.
"""
quarter_turns, remainder = divmod(angle, 90)
if remainder == 0:
return _RIGHT_ANGLE_ROTATIONS[int(quarter_turns) % 4]
rad = radians(-angle)
return cos(rad), sin(rad)


def _add_axis(axes: Axes, normal_x: float, normal_y: float) -> None:
"""
Add an edge normal to a set of separating axes.

Normals are keyed by their rounded unit vector, with the sign chosen so
parallel edges facing either way share a key. The stored normal is not
normalized: projecting onto it keeps exact arithmetic where possible,
so exactly touching edges stay exactly touching. Zero-length edges and
axis-aligned normals are skipped: the bounding box check done before
the separating axis test already covers the x and y axes.
"""
# Only skip exactly axis-aligned normals. Nearly axis-aligned ones
# still matter for exactly touching edges.
if normal_x == 0 or normal_y == 0:
return
length = hypot(normal_x, normal_y)
key_x = round(normal_x / length, _AXIS_DECIMALS)
key_y = round(normal_y / length, _AXIS_DECIMALS)
if key_x < 0 or (key_x == 0 and key_y < 0):
key_x, key_y = -key_x, -key_y
axes[(key_x, key_y)] = (normal_x, normal_y)


def _axes_from_points(points: Point2List) -> Axes:
"""Get the distinct, non axis-aligned edge normals of a polygon."""
axes: Axes = {}
if not points:
return axes
prev_x, prev_y = points[-1]
for x, y in points:
_add_axis(axes, y - prev_y, prev_x - x)
prev_x, prev_y = x, y
return axes


def _edge_directions(points: Point2List) -> list[Point2]:
"""Get one edge vector for each distinct edge direction of a polygon."""
directions: dict[tuple[float, float], Point2] = {}
if not points:
return []
prev_x, prev_y = points[-1]
for x, y in points:
edge_x, edge_y = x - prev_x, y - prev_y
prev_x, prev_y = x, y
length = hypot(edge_x, edge_y)
if length == 0:
continue
key_x = round(edge_x / length, _AXIS_DECIMALS)
key_y = round(edge_y / length, _AXIS_DECIMALS)
if key_x < 0 or (key_x == 0 and key_y < 0):
key_x, key_y = -key_x, -key_y
directions.setdefault((key_x, key_y), (edge_x, edge_y))
return list(directions.values())


class HitBoxAlgorithm:
"""
Expand Down Expand Up @@ -127,6 +207,12 @@ def __init__(
self._adjusted_bounds: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
self._adjusted_bounds_points: Point2List | None = None

# Separating axes, see _get_axes()
self._edge_directions: list[Point2] = []
self._edge_directions_points: Point2List | None = None
self._axes: Axes = {}
self._axes_key: tuple[Any, ...] | None = None

@property
def points(self) -> Point2List:
"""
Expand Down Expand Up @@ -214,6 +300,68 @@ def get_adjusted_bounds(self) -> tuple[float, float, float, float]:
self._adjusted_bounds_points = points
return self._adjusted_bounds

def _get_axes(self) -> Axes:
"""
Get the separating axes to test this hit box against another.

These are the distinct edge normals of the adjusted points, without
the x and y axes. They are cached, and since the directions don't
depend on position, moving the hit box doesn't recalculate them.
"""
if type(self).get_adjusted_points is not HitBox.get_adjusted_points:
return self._get_axes_from_adjusted_points()
return self._get_transformed_axes(0.0)

def _get_transformed_axes(self, angle: float) -> Axes:
"""
Get the axes by scaling and rotating the raw edge directions.

This must apply the same transform as get_adjusted_points(),
without the offset.
"""
points = self._points
scale = self._scale
key = self._axes_key
if key is not None and key[0] is points and key[1] == scale and key[2] == angle:
return self._axes

# Scaling and rotation keep parallel edges parallel, so duplicate
# directions only need removing once for the raw points.
if self._edge_directions_points is not points:
self._edge_directions = _edge_directions(points)
self._edge_directions_points = points

scale_x, scale_y = scale
axes: Axes = {}
if angle:
rad_cos, rad_sin = _get_rotation(angle)
for edge_x, edge_y in self._edge_directions:
x = edge_x * scale_x
y = edge_y * scale_y
_add_axis(axes, x * rad_sin + y * rad_cos, y * rad_sin - x * rad_cos)
else:
for edge_x, edge_y in self._edge_directions:
_add_axis(axes, edge_y * scale_y, -edge_x * scale_x)

self._axes = axes
self._axes_key = (points, scale, angle)
return axes

def _get_axes_from_adjusted_points(self) -> Axes:
"""
Get the axes from the adjusted points.

Used for subclasses that override get_adjusted_points(), since they
may transform the points differently.
"""
points = self.get_adjusted_points()
key = self._axes_key
if key is not None and key[0] is points and key[1] is None:
return self._axes
self._axes = _axes_from_points(points)
self._axes_key = (points, None, None)
return self._axes

@property
def scale(self) -> tuple[float, float]:
"""
Expand Down Expand Up @@ -316,6 +464,11 @@ def angle(self, angle: float):
self._angle = angle
self._adjusted_cache_dirty = True

def _get_axes(self) -> Axes:
if type(self).get_adjusted_points is not RotatableHitBox.get_adjusted_points:
return self._get_axes_from_adjusted_points()
return self._get_transformed_axes(self._angle)

def get_adjusted_points(self) -> Point2List:
"""
Return the offset, scaled, & rotated points of this hitbox.
Expand All @@ -326,19 +479,18 @@ def get_adjusted_points(self) -> Point2List:
if not self._adjusted_cache_dirty:
return self._adjusted_points

rad = radians(-self._angle)
angle = self._angle
scale_x, scale_y = self._scale
position_x, position_y = self._position
rad_cos = cos(rad)
rad_sin = sin(rad)
rad_cos, rad_sin = _get_rotation(angle) if angle else (1.0, 0.0)

def _adjust_point(point) -> Point2:
x, y = point

x *= scale_x
y *= scale_y

if rad:
if angle:
rot_x = x * rad_cos - y * rad_sin
rot_y = x * rad_sin + y * rad_cos
x = rot_x
Expand Down
9 changes: 6 additions & 3 deletions arcade/sprite_list/collision.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from collections.abc import Iterable

from arcade.geometry import (
_are_polygons_intersecting_sat,
_are_polygons_overlapping_on_axes,
are_polygons_intersecting,
is_point_in_polygon,
)
Expand Down Expand Up @@ -130,13 +130,16 @@ def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool:
return False

# Bounding box check with cached bounds. It's much cheaper than the
# polygon test and lets that test skip the x and y axes.
# polygon test and covers the x and y axes, which the cached
# separating axes leave out.
left1, right1, bottom1, top1 = hit_box1.get_adjusted_bounds()
left2, right2, bottom2, top2 = hit_box2.get_adjusted_bounds()
if right1 <= left2 or right2 <= left1 or top1 <= bottom2 or top2 <= bottom1:
return False

return _are_polygons_intersecting_sat(points1, points2)
# Merging the dicts drops directions both hit boxes share
axes = hit_box1._get_axes() | hit_box2._get_axes()
return _are_polygons_overlapping_on_axes(points1, points2, axes.values())


def _get_nearby_sprites(
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/hitbox/test_hitbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,83 @@ def get_adjusted_points(self):
assert hb.get_adjusted_bounds() == (0.0, 10.0, 0.0, 10.0)
hb.offset = 100.0
assert hb.get_adjusted_bounds() == (100.0, 110.0, 0.0, 10.0)


@pytest.mark.parametrize(
"angle, expected",
[
(90.0, [(0.0, 0.0), (10.0, 0.0), (10.0, -10.0), (0.0, -10.0)]),
(180.0, [(0.0, 0.0), (0.0, -10.0), (-10.0, -10.0), (-10.0, 0.0)]),
(270.0, [(0.0, 0.0), (-10.0, 0.0), (-10.0, 10.0), (0.0, 10.0)]),
(-90.0, [(0.0, 0.0), (-10.0, 0.0), (-10.0, 10.0), (0.0, 10.0)]),
(450.0, [(0.0, 0.0), (10.0, 0.0), (10.0, -10.0), (0.0, -10.0)]),
],
)
def test_right_angle_rotation_is_exact(angle, expected):
"""Right angles must not add floating point error to the points"""
rot = hitbox.HitBox(points).create_rotatable(angle=angle)
assert rot.get_adjusted_points() == expected


octagon = [(-4.0, -2.0), (-2.0, -4.0), (2.0, -4.0), (4.0, -2.0),
(4.0, 2.0), (2.0, 4.0), (-2.0, 4.0), (-4.0, 2.0)] # fmt: skip


def _axis_directions(axes):
"""Unit vectors of the axes, rounded and pointing right, for comparing"""
result = set()
for x, y in axes.values():
length = (x * x + y * y) ** 0.5
if x < 0:
x, y = -x, -y
result.add((round(x / length, 6), round(y / length, 6)))
return result


def test_axes_skip_axis_aligned_and_duplicate_edges():
# Every edge of a box is axis-aligned
assert hitbox.HitBox(points)._get_axes() == {}
# An octagon's 8 edges only have 2 non axis-aligned directions
axes = hitbox.HitBox(octagon)._get_axes()
assert len(axes) == 2
assert _axis_directions(axes) == {(0.707107, 0.707107), (0.707107, -0.707107)}


def test_axes_follow_scale_and_angle():
rot = hitbox.HitBox(octagon).create_rotatable()
axes = rot._get_axes()

# Moving doesn't change the directions, so the cache is kept
rot.position = (100.0, 50.0)
assert rot._get_axes() is axes

# Non-uniform scale changes the diagonal directions
rot.scale = (2.0, 1.0)
assert _axis_directions(rot._get_axes()) == {(0.447214, 0.894427), (0.447214, -0.894427)}

# 45 degrees turns the axis-aligned edges into diagonals
rot.scale = (1.0, 1.0)
rot.angle = 45.0
diagonals = {(0.707107, 0.707107), (0.707107, -0.707107)}
directions = _axis_directions(rot._get_axes())
assert diagonals <= directions
# and the diagonals into axis-aligned edges. Whether those are skipped
# depends on the platform's sin() and cos(): sin(pi / 4) is
# 0.7071067811865476 on Windows but 0.7071067811865475 on Linux, so the
# rotated normals may be off axis by ~1e-16 and still get tested.
assert directions - diagonals <= {(1.0, 0.0), (0.0, 1.0), (0.0, -1.0)}

rot.angle = 30.0
assert len(rot._get_axes()) == 4


def test_axes_subclass_override():
"""Axes come from get_adjusted_points() if a subclass overrides it."""

class SkewedHitBox(hitbox.HitBox):
def get_adjusted_points(self):
return [(x + y, y) for x, y in self.points]

# The skew turns the box's vertical edges into diagonals
axes = SkewedHitBox(points)._get_axes()
assert _axis_directions(axes) == {(0.707107, -0.707107)}
Loading
Loading