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 @@ -28,6 +28,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- Fixed `SpriteList.preload_textures()` raising `AttributeError` on a lazy sprite list that hadn't been drawn yet. It now preloads into the atlas the list will use.
- Fixed GPU collision checks (`CollisionMethod.GPU`, also used automatically for sprite lists over 1500 sprites without a spatial hash) missing sprites flipped with a negative scale, whose negative width or height made them look smaller than they are.
- Fixed `SpriteList.__setitem__` raising when setting a negative index to the sprite already there, such as `sprite_list[-1] = sprite_list[-1]`.
- Fixed `PhysicsEngineSimple` and `PhysicsEnginePlatformer` leaving a sprite overlapping a wall after it rotated against the wall while moving sideways. The sideways move was measured from where the sprite was before rotating, undoing the move out of the wall.

### New Features
- Added `HitBox.get_adjusted_bounds()`, which returns the cached `(left, right, bottom, top)` bounds of the adjusted hit box points.
Expand All @@ -38,6 +39,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
- Added `arcade.get_collision_info_with_list(sprite, sprite_list)`, which returns a `(sprite, CollisionInfo)` pair for each sprite in the list that `sprite` collides with, deepest overlap first. Added the `sprite_push_out` example, which uses it to push a player out of walls, sliding along rotated ones.

### Misc Changes
- `PhysicsEngineSimple` and `PhysicsEnginePlatformer` now move sprites out of walls exactly, instead of in steps: landing on a floor or ramp and hitting a ceiling stop the sprite exactly at the surface (before, it could stop up to 0.25 px above a floor or 1 px below a ceiling), and rotating into a wall moves the sprite the smallest distance out of it (before, a whole number of pixels). The older searches are kept as a fallback, for example for concave hit boxes. Collisions are also faster: a hard landing went from about 230 to 42 µs, and a ceiling bump from about 104 to 36 µs.
- Sped up several `SpriteList` operations: `swap()` no longer searches the draw order (about 1000x faster at the end of a 10,000 sprite list), `insert()` and item assignment check membership with a dictionary instead of scanning the list, and drawing uploads only the buffer slots in use instead of the whole capacity (moving one sprite and drawing is 1.6-2.3x faster).
- The platformer and simple physics engines and `AStarBarrierList` now use `has_collision_with_list(s)` where they only need to know whether there's a collision. Building an `AStarBarrierList` is about 7-9% faster.
- 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).
Expand Down
149 changes: 140 additions & 9 deletions arcade/physics_engines.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,130 @@
SpriteType,
check_for_collision,
check_for_collision_with_lists,
get_collision_info_with_list,
has_collision_with_lists,
)
from arcade.math import get_distance
from arcade.sprite_list.collision import _get_separation_distance

__all__ = ["PhysicsEngineSimple", "PhysicsEnginePlatformer"]

from arcade.utils import Chain, copy_dunders_unimplemented

# How many times to try moving a sprite out of overlapping walls before
# falling back to the older, slower search
_MAX_PUSHES = 8


def _min_move(distance: float, position: float) -> float:
"""Make sure a move is big enough to change ``position``.

Moving a sprite exactly out of a wall can leave it overlapping by a
rounding error, which still counts as a collision. A move of that size
may be too small to change a large coordinate at all.
"""
return max(distance, 1e-9 * (1.0 + abs(position)))


def _push_out_of_walls(colliding: Sprite, walls: Iterable[SpriteSequence[BasicSprite]]) -> bool:
"""Move a sprite out of the walls it overlaps, using the smallest moves.

Each step moves the sprite out of the wall it overlaps most deeply, as
found by :py:func:`arcade.get_collision_info_with_list`. This is exact
for convex hit boxes, but can fail, for example when wedged between two
walls, or with concave hit boxes.

Args:
colliding:
A sprite to move out of the given list of SpriteLists.
walls:
The walls to move it out of.
Returns:
``True`` if the sprite no longer overlaps any wall.
"""
for _ in range(_MAX_PUSHES):
deepest = None
for wall_list in walls:
hits = get_collision_info_with_list(colliding, wall_list)
if hits and (deepest is None or hits[0][1].depth > deepest.depth):
deepest = hits[0][1]
if deepest is None:
return True
x, y = colliding.position
normal_x, normal_y = deepest.normal
distance = _min_move(deepest.depth, max(abs(x), abs(y)))
colliding.position = x + normal_x * distance, y + normal_y * distance
return not has_collision_with_lists(colliding, walls)


def _move_out_of_sprite(
moving: Sprite, other: BasicSprite, direction_y: float, step: float
) -> None:
"""Move a sprite straight up or down until it no longer collides with another.

Args:
moving:
The sprite to move.
other:
The sprite to move it out of.
direction_y:
``1.0`` to move up, ``-1.0`` to move down.
step:
The step size for the fallback search, if the exact move fails.
"""
original_y = moving.center_y
for _ in range(_MAX_PUSHES):
distance = _get_separation_distance(moving, other, 0.0, direction_y)
if distance == 0.0:
return
y = moving.center_y
moving.center_y = y + direction_y * _min_move(distance, y)

# Fall back to stepping, as older versions did
moving.center_y = original_y
while check_for_collision(moving, other):
moving.center_y += direction_y * step


def _move_out_of_walls(
moving: Sprite,
walls: Iterable[SpriteSequence[BasicSprite]],
direction_y: float,
step: float,
) -> None:
"""Move a sprite straight up or down until it no longer overlaps any wall.

Args:
moving:
The sprite to move.
walls:
The walls to move it out of.
direction_y:
``1.0`` to move up, ``-1.0`` to move down.
step:
The step size for the fallback search, if the exact move fails.
"""
original_y = moving.center_y
for _ in range(_MAX_PUSHES):
hits = check_for_collision_with_lists(moving, walls)
if not hits:
return
distance = max(_get_separation_distance(moving, hit, 0.0, direction_y) for hit in hits)
y = moving.center_y
moving.center_y = y + direction_y * _min_move(distance, y)

# Fall back to stepping, as older versions did
moving.center_y = original_y
while has_collision_with_lists(moving, walls):
moving.center_y += direction_y * step


def _wiggle_until_free(colliding: Sprite, walls: Iterable[SpriteSequence[BasicSprite]]) -> None:
"""Kludge to 'guess' a colliding sprite out of a collision.

This is only used as a fallback, when moving the sprite out of the walls
the smallest distance (see :py:func:`_push_out_of_walls`) fails.

It works by iterating over increasing wiggle sizes of 8 points
around the ``colliding`` sprite's original center position. Each
time it fails to find a free position. Although the wiggle distance
Expand Down Expand Up @@ -101,9 +213,17 @@ def _move_sprite(
A list of other individual sprites the ``moving_sprite``
collided with.
"""
# Whether a collision was resolved this update, which may leave the
# sprite exactly touching a wall
resolved = False

# See if we are starting this turn with a sprite already colliding with us.
if has_collision_with_lists(moving_sprite, can_collide):
_wiggle_until_free(moving_sprite, can_collide)
resolved = True
start = moving_sprite.position
if not _push_out_of_walls(moving_sprite, can_collide):
moving_sprite.position = start
_wiggle_until_free(moving_sprite, can_collide)

original_x, original_y = moving_sprite.position
original_angle = moving_sprite.angle
Expand All @@ -118,10 +238,13 @@ def _move_sprite(
rotating_hit_list = check_for_collision_with_lists(moving_sprite, can_collide)

if len(rotating_hit_list) > 0:
resolved = True
max_distance = (moving_sprite.width + moving_sprite.height) / 2

# Resolve any collisions by this weird kludge
_wiggle_until_free(moving_sprite, can_collide)
# Move out of the walls the smallest distance, or guess if that fails
if not _push_out_of_walls(moving_sprite, can_collide):
moving_sprite.position = original_x, original_y
_wiggle_until_free(moving_sprite, can_collide)
if (
get_distance(original_x, original_y, moving_sprite.center_x, moving_sprite.center_y)
> max_distance
Expand All @@ -130,6 +253,10 @@ def _move_sprite(
moving_sprite.position = original_x, original_y
moving_sprite.angle = original_angle

# Measure the moves below from where rotating left the sprite,
# so the x move doesn't undo moving it out of a wall
original_x, original_y = moving_sprite.position

# --- Move in the y direction
moving_sprite.center_y += moving_sprite.change_y

Expand All @@ -141,16 +268,14 @@ def _move_sprite(
# If we hit a wall, move so the edges are at the same point
if len(hit_list_x) > 0:
if moving_sprite.change_y > 0:
while has_collision_with_lists(moving_sprite, can_collide):
moving_sprite.center_y -= 1
_move_out_of_walls(moving_sprite, can_collide, -1.0, 1)
# print(f"Spot X ({self.player_sprite.center_x}, {self.player_sprite.center_y})"
# f" {self.player_sprite.change_y}")
elif moving_sprite.change_y < 0:
# Reset number of jumps
for item in hit_list_x:
while check_for_collision(moving_sprite, item):
# self.player_sprite.bottom = item.top <- Doesn't work for ramps
moving_sprite.center_y += 0.25
# Move straight up, which also works for ramps
_move_out_of_sprite(moving_sprite, item, 1.0, 0.25)

# NOTE: Not all sprites have velocity
if getattr(item, "change_x", 0.0) != 0:
Expand All @@ -170,7 +295,13 @@ def _move_sprite(
moving_sprite.change_y = min(0.0, getattr(hit_list_x[0], "change_y", 0.0))

# print(f"Spot D ({self.player_sprite.center_x}, {self.player_sprite.center_y})")
moving_sprite.center_y = round(moving_sprite.center_y, 2)
exact_y = moving_sprite.center_y
rounded_y = round(exact_y, 2)
if rounded_y != exact_y:
moving_sprite.center_y = rounded_y
if (resolved or hit_list_x) and has_collision_with_lists(moving_sprite, can_collide):
# Rounding moved it back into a wall it was moved out of
moving_sprite.center_y = exact_y
# print(f"Spot Q ({self.player_sprite.center_x}, {self.player_sprite.center_y})")

# end_time = time.time()
Expand Down
62 changes: 62 additions & 0 deletions arcade/sprite_list/collision.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,68 @@ def _get_collision_info(sprite1: BasicSprite, sprite2: BasicSprite) -> Collision
return CollisionInfo(Vec2(best_x, best_y), best_depth)


def _get_separation_distance(
sprite1: BasicSprite, sprite2: BasicSprite, direction_x: float, direction_y: float
) -> float:
"""
How far ``sprite1`` must move along a direction to stop colliding with ``sprite2``.

Unlike :py:func:`get_collision_info`, the direction is fixed, for
example straight up to land on a slope. Returns ``0.0`` if the sprites
don't collide. Like the other separating axis functions, this is only
exact for convex hit boxes; callers should check the result.

Args:
sprite1: The sprite to move
sprite2: The sprite to move it away from
direction_x: X component of the unit direction to move ``sprite1``
direction_y: Y component of the unit direction to move ``sprite1``
"""
hit_box1 = sprite1._hit_box
hit_box2 = sprite2._hit_box
points1 = hit_box1.get_adjusted_points()
points2 = hit_box2.get_adjusted_points()
if not points1 or not points2:
return 0.0

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 0.0

# Moving a distance t along the direction shifts sprite1's projection on
# an axis by t * (direction . axis). The sprites are separated once they
# are on any one axis, so the answer is the smallest distance that
# separates them on some axis.
best = float("inf")
if direction_x > 0:
best = min(best, (right2 - left1) / direction_x)
elif direction_x < 0:
best = min(best, (right1 - left2) / -direction_x)
if direction_y > 0:
best = min(best, (top2 - bottom1) / direction_y)
elif direction_y < 0:
best = min(best, (top1 - bottom2) / -direction_y)

axes = hit_box1._get_axes() | hit_box2._get_axes()
for normal_x, normal_y in axes.values():
projected_1 = [normal_x * px + normal_y * py for px, py in points1]
projected_2 = [normal_x * px + normal_y * py for px, py in points2]
min_1 = min(projected_1)
max_1 = max(projected_1)
min_2 = min(projected_2)
max_2 = max(projected_2)
if max_1 <= min_2 or max_2 <= min_1:
return 0.0
speed = direction_x * normal_x + direction_y * normal_y
if speed > 0:
best = min(best, (max_2 - min_1) / speed)
elif speed < 0:
best = min(best, (max_1 - min_2) / -speed)

return best


def _check_for_collision(sprite1: BasicSprite, sprite2: BasicSprite) -> bool:
"""
Check for collision between two sprites.
Expand Down
26 changes: 20 additions & 6 deletions tests/unit/physics_engine/test_physics_engine2.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
"""Physics engine tests."""

import copy
import math

import pytest

import arcade

OUT_OF_THE_WAY = (250, 250)

# How far a 10x10 sprite rotated by 1 or 45 degrees sticks out past its
# unrotated edge. Rotating next to a wall moves the sprite exactly this far.
ROTATED_1_OVERHANG = 5 * (math.cos(math.radians(1)) + math.sin(math.radians(1))) - 5
ROTATED_45_OVERHANG = 5 * math.sqrt(2) - 5


def check_rotated_out_of_wall(moving_sprite, wall_list, expected_position):
"""Rotating moved the sprite exactly out of the wall"""
assert moving_sprite.position == pytest.approx(expected_position, abs=1e-6)
assert not arcade.check_for_collision_with_list(moving_sprite, wall_list)


def check_spritelists_prop_clears_instead_of_overwrites(engine, prop_name: str):
"""Some properties are backed by lists which shouldn't be recreated.
Expand Down Expand Up @@ -132,7 +144,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine):
collisions = physics_engine.update()
assert len(collisions) == 1
assert collisions[0] == wall_sprite_1
assert moving_sprite.position == (-1, 0)
check_rotated_out_of_wall(moving_sprite, wall_list, (-ROTATED_1_OVERHANG, 0))

# Check rotation 45 degrees
wall_sprite_1.position = (10, 0)
Expand All @@ -144,7 +156,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine):
collisions = physics_engine.update()
assert len(collisions) == 1
assert collisions[0] == wall_sprite_1
assert moving_sprite.position == (-4, 0)
check_rotated_out_of_wall(moving_sprite, wall_list, (-ROTATED_45_OVERHANG, 0))

# - Rotate, with block to the left
# Check rotation one degree
Expand All @@ -157,7 +169,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine):
collisions = physics_engine.update()
assert len(collisions) == 1
assert collisions[0] == wall_sprite_1
assert moving_sprite.position == (1, 0)
check_rotated_out_of_wall(moving_sprite, wall_list, (ROTATED_1_OVERHANG, 0))

# Check rotation 45 degrees
wall_sprite_1.position = (-10, 0)
Expand All @@ -169,7 +181,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine):
collisions = physics_engine.update()
assert len(collisions) == 1
assert collisions[0] == wall_sprite_1
assert moving_sprite.position == (4, 0)
check_rotated_out_of_wall(moving_sprite, wall_list, (ROTATED_45_OVERHANG, 0))

# - Rotate, with block above
# Check rotation one degree
Expand All @@ -182,7 +194,9 @@ def basic_tests(moving_sprite, wall_list, physics_engine):
collisions = physics_engine.update()
assert len(collisions) == 1
assert collisions[0] == wall_sprite_1
assert moving_sprite.position == (0, -1)
# The engines round y to 2 decimal places, unless that would move the
# sprite back into the wall
check_rotated_out_of_wall(moving_sprite, wall_list, (0, round(-ROTATED_1_OVERHANG, 2)))

# Check rotation 45 degrees
wall_sprite_1.position = (0, 10)
Expand All @@ -194,7 +208,7 @@ def basic_tests(moving_sprite, wall_list, physics_engine):
collisions = physics_engine.update()
assert len(collisions) == 1
assert collisions[0] == wall_sprite_1
assert moving_sprite.position == (0, -4)
check_rotated_out_of_wall(moving_sprite, wall_list, (0, -ROTATED_45_OVERHANG))

# - Rotate, between two blocks
# Check rotation one degree
Expand Down
Loading
Loading