Skip to content

Cache distinct separating axes per hit box - #2895

Merged
pvcraven merged 5 commits into
developmentfrom
perf/sat-unique-axes
Oct 1, 2026
Merged

pvcraven merged 5 commits into
developmentfrom
perf/sat-unique-axes

Conversation

@pvcraven

Copy link
Copy Markdown
Member

Stacked on #2894. This targets perf/collision-aabb so the diff only shows this change. Please merge #2894 first; GitHub will then retarget this to development.

Summary

After #2894, the polygon test still projected onto every non axis-aligned edge normal of both hit boxes, about 1 µs per axis. Many of those axes are duplicates:

  • Opposite sides of a polygon are parallel, e.g. the default octagon hit box has 4 diagonal edges but only 2 directions.
  • Two sprites with the same angle (all unrotated sprites, for example) share directions.

This PR caches each hit box's distinct edge directions and tests each direction only once per pair.

Pair Axes tested before Axes tested now
Two unrotated octagons 8 2
Two octagons, both at 30° 16 4

How it works

  • Per hit box cache. HitBox._get_axes() returns a dict mapping a rounded unit direction to an edge normal. Merging the two hit boxes' dicts (axes1 | axes2) drops directions they share.
  • Kept when moving. Directions don't depend on position. Duplicates are removed once from the raw points, and the handful of remaining directions are scaled and rotated when scale or angle changes (both keep parallel edges parallel). So a moving, non-rotating sprite never recomputes its axes, and a rotating one only transforms 2–4 vectors.
  • Subclasses stay correct. If a subclass overrides get_adjusted_points(), the axes come from its adjusted points instead of assuming the built-in transform.
  • Exact touches stay exact. Rounded unit vectors are only used as dict keys to spot parallel edges. Projections use the unnormalized normal (e.g. (4.5, -2.25)), which keeps exact arithmetic for common sizes and positions. An earlier version projected onto unit normals, and rounding then turned some exact touches into collisions.

Right angles now give exact coordinates

sin(radians(180)) is about 1.2e-16, not 0, so a hit box rotated 90°/180°/270° had coordinates like 15.999999999999998. Whether two exactly touching sprites collided could depend on that noise, and it differed from the unrotated case. RotatableHitBox now uses exact cosine/sine values for multiples of 90°. This was needed so that nearly axis-aligned normals don't appear at right angles, but it's also a small accuracy improvement on its own (e.g. sprite.left on a tile rotated 90° is now exact).

Benchmarks

µs per call, best of 5, same script against perf/collision-aabb (#2894) and this branch.

Case #2894 This PR
octagon / octagon, overlapping 11.96 4.55
octagon / octagon, both rotated 30°, overlapping 20.83 6.89
octagon / box, overlapping 6.32 4.15
octagon / octagon, one moving each call 15.53 7.85
meteor vs 200 randomly rotated meteors, brute force 171.7 124.2
coin vs 900 densely packed coins, brute force 405.0 347.0
moving player vs 400 tiles, spatial hash 16.2 13.1
worst case: one octagon rotating every call 20.31 17.95
worst case: both octagons rotating every call 28.19 23.63
box / box, overlapping (no axes before or after) 2.31 2.03

Even when the cache is rebuilt on every call (rotating sprites), it's still faster than before.

get_adjusted_points() recompute cost: angle 0 is ~6% faster (sine/cosine are no longer computed when unused), 90° is unchanged, and other angles are ~0.1 µs (~4%) slower because of the right-angle check.

Correctness

  • Stress test: 200,000 random sprite pairs (box, octagon, long thin, player, and a detailed/concave meteor hit box; ±scale on each axis; 60% sharing an angle from 0/90/180/270/-90/45/30/450/random; positions on a half/quarter-pixel grid, so exact touches are common), both argument orders. 0 mismatches against a full SAT on every edge plus the x/y axes.
  • The earlier reference had a gap for concave shapes: it used only edge normals, which can miss a separation for concave hit boxes, and those can come from the detailed algorithm. The one pair that disagreed had bounding boxes that don't overlap at all, so this PR's answer was the correct one (Speed up sprite collision with cached bounds and a leaner SAT #2894 gives the same answer). The reference in test_check_for_collision_matches_reference now also checks the x and y axes, and the test now includes the detailed meteor hit box and same-angle pairs.
  • New hit box tests:
    • Exact points at 90/180/270/-90/450°.
    • A box has 0 axes and an octagon has 2.
    • The cache is kept when moving and recomputed for scale/angle changes, including 45°, where diagonals become axis-aligned.
    • The subclass fallback.
  • Deliberate bugs are caught: making the axes ignore rotation, or rotate the wrong way, makes the tests fail.
  • Full unit suite: 1095 passed. The 3 failures (test_draw_primitives, test_render_sprite_solid_pixels, test_get_image) also fail on development on my machine. Ruff is clean, and mypy reports no errors in the changed files.

Known approximation

Two edges count as parallel if their unit directions agree to 9 decimal places, so a pair of edges less than ~1e-9 radians apart shares one test. That could only change the result when the sprites are within about a billionth of their size of touching.

🤖 Generated with Claude Code

Paul V Craven and others added 4 commits September 30, 2026 15:55
After the quick distance check, _check_for_collision now compares the
cached (left, right, bottom, top) bounds of both hit boxes before
running the separating axis test. Because the bounds check already
covers the x and y axes, the SAT skips horizontal and vertical edges.
Two axis-aligned boxes need no SAT at all, and the default octagon hit
boxes only test their 4 diagonal edges.

- Add HitBox.get_adjusted_bounds(), cached against the adjusted point
  list so subclasses overriding get_adjusted_points() stay correct.
- are_polygons_intersecting() does its own bounding box check and uses
  the same SAT helper.
- Skipping zero-length edges fixes polygons with repeated points (e.g.
  closed polygons) never intersecting.
- left/right/bottom/top stay uncached: using the cached bounds there
  made reading a single bound after a move ~50% slower.

Checks that reach the polygon test are about 2-4x faster. New tests
compare results with the original SAT on thousands of random polygons
and sprite pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each hit box now caches its distinct, non axis-aligned edge normals.
Parallel edges (opposite sides of the default octagon hit boxes, or
matching edges on two sprites with the same angle) are tested once, by
merging the two hit boxes' axis dicts.

The directions are worked out once from the raw points, then scaled and
rotated when scale or angle changes. They don't depend on position, so
moving sprites keep the cache. Subclasses that override
get_adjusted_points() fall back to axes from the adjusted points.

Axes are keyed by rounded unit vectors, but projections use the
unnormalized normals so exact touches stay exact. Only exactly
axis-aligned normals are skipped.

Right-angle rotations now use exact cosine and sine values, so hit boxes
at 90/180/270 degrees get exact coordinates instead of ~1e-16 errors
that could change the result for exactly touching sprites.

The random reference test now includes a detailed (possibly concave) hit
box and same-angle pairs. Its reference also checks the x and y axes,
since testing only edge normals misses some separations for concave
shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-axes

# Conflicts:
#	CHANGELOG.md
#	arcade/hitbox/base.py
#	arcade/sprite_list/collision.py
#	tests/unit/hitbox/test_hitbox.py
#	tests/unit/sprite/test_sprite_collision.py
@pvcraven
pvcraven changed the base branch from perf/collision-aabb to development September 30, 2026 21:31
sin(pi / 4) is 0.7071067811865476 on Windows but 0.7071067811865475 on
Linux (glibc). At 45 degrees the rotated octagon diagonals only cancel
to exactly axis-aligned normals on Windows; on Linux they're ~1e-16 off
axis and are kept as axes to test. Both are correct, so the test now
accepts either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit 4cdfe03 into development Oct 1, 2026
7 checks passed
pvcraven added a commit that referenced this pull request Oct 1, 2026
These are cleaned-up versions of the scripts used to measure and check
the recent collision changes (#2892-#2895):

- collisions/micro.py: per-call timing of check_for_collision on single
  pairs, on sprites moving or rotating every call, and of
  check_for_collision_with_list.
- collisions/hit_box.py: hit box bounds properties and adjusted point
  recalculation.
- collisions/compare_reference.py: correctness check comparing
  check_for_collision with a simple reference on random sprite pairs.
  Exits with an error on any mismatch.
- spatial_hash/queries.py: query timing, and a check that queries don't
  grow the spatial hash.

Add a README for the collisions folder covering what each script does and
how to compare two versions.

Co-authored-by: Paul V Craven <paul.craven@optimizley.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven deleted the perf/sat-unique-axes branch October 1, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant