Skip to content

Repository files navigation

A Collision Tutorial

This is a collision tutorial demonstrating some basic collision optimizations, with the primary goal of being approachable to new blind programmers. Put another way, there's no graphics here. I got tired of having this conversation over and over, so figured I'd finally just get sample code with explanations going on. The rest of this README explains what's going on here and all the optimizations performed.

If you're trying to use Python and you need axis-aligned bounding box collision, you can drop this tutorial into your project and use it as-is. It has full test coverage and is reasonably efficient.

people are always like "What's better than the two nested for loops". What's better than the two nested for loops is this, and a number of more advanced optimizations not described here.

there are benchmarks at the bottom of this file proving that the code here is fast enough for most practical use cases. Note that if you try to feed a tilemap to it without some preprocessing, it's going to be unhappy. You can either process collisions with a tilemap yourself, or see the notes at the bottom of the file as to how you might go about extending this for that use case.

You'll want to clone this repository and follow along in the code. I don't paste most of it here because this README is already going to be long enough. There's actually not that much code, just enough that trying to put little 5 line examples inline isn't going to cut it.

I don't intend to maintain this, but am happy enough if someone else wants to turn it into a real package. In practice, though, properly building a quadtree functions much better than what is presented here. This code would make a good stepping stone to being able to understand quadtrees, as this is something like half the implementation of one.

Code was developed against Python 3.7 on Windows. If you want to run the tests, create a virtualenv and do:

pip install -r requirements.txt
pytest

Preliminaries, Motivation, and Defining terms

Defining the Problem

You've got:

  • Some number of axis-aligned bounding boxes, hereafter AABB. This means boxes that can't rotate. The sides are always either north-south or east-west.
  • They're spread out pretty evenly, and all generally around the same size.
  • You want it to be fast.

This doesn't work on anything but AABB. Spheres, no. Rotating boxes, no. More complex polygons, no. The basic idea does generalize, but the code here doesn't get into it for simplicity.

What's Wrong with nested for loops?

most people wanting to do AABB collisions start with something like this:

for a in boxes:
for b in boxes:
check(a, b)

Check will be something like "for all corners of box a, see if they're in box b". I'm not going to write pseudocode for that here because it's lengthy and wrong, as we will see below.

The above is something called O(n^2), which means in the worst case it takes n^2 checks to figure out if the list of boxes has collisions. Additionally, the above is Omega(n^2), which means that it always takes n^2 operations. Put another way, 5 boxes is 25 checks, 100 boxes is 10000 checks, and 1000 boxes is 1000000 checks. We can do significantly better.

This tutorial will talk heavily about how many operations things take in the worst case, because that's important here. Much of this code looks at first glance as though it should be slower, but one of the primary lessons this tutorial should teach is that code complexity doesn't at all equate to performance. What I am about to present is an order of magnitude faster than the above algorithm.

What's Wrong with the Basic Box Check

As stated above, many developers start by checking if all the corners of box a are inside box b and vice versa. But consider two boxes overlapping to form a cross, for example:

a = Box(x = -100, y = 0, width = 200, height = 1)
b = Box(x = 0, y = -100, width = 1, height = 200)

These boxes intersect at the origin, but don't have any corners inside thew other box.

Additionally, the naive check is 16 comparisons without optimization. There's nothing like slow and incorrect to really cause a problem.

Some Basic Python Performance Tips

Before we get started, it's worth noting the following. I don't have sources handy for these; they just come from my experience. I don't even promise they're all correct:

  • Local variables are the fastest kind of variable.
  • Attribute lookups are faster than attribute lookups and computing with the attribute.
  • Allocation is heavily optimized. You can make lists and throw them out all day long, and Python will mostly be fine with this. There are other problems with lists, but continually asking the OS for memory isn't one of them.
  • Python won't inline or anything like that unless it's Cython. Cython won't inline a lot. Pypy will be very good at inlining, but running Pypy everywhere isn't practical.

Anyway, with this out of the way, let's get started.

Defining Your Box

Every box in this code (collision_tutorial.box.Box) has the following:

  • x and y, the bottom left corner.
  • x2 and y2, the upper right corner.
  • cx and cy, the center of the box.
  • width and height, the width and height.
  • half_width, half_height: half the width and height (see below).
  • stationary, whether or not we expect the box to move around.
  • manager, an internal property that lets boxes link to a BoxManager, which we'll discuss near the bottom of this document,.

Each of x, cx, x2, etc. are used as part of a different optimization, below. We cache the values on the object to prevent the cost of recomputing. half_width and half_height are two very useful values for the collision check. stationary and manager are used in the stationary box optimization, which can be used to make levels with mostly stationary objects function much faster.

To use this code correctly, always go through the public methods. Don't manipulate the properties. If you read the box module, you can see that there's some basic math going on to recompute them when it moves. Just doing box.x = 12 will not work. Also, you can't change a box from stationary to not stationary after creation. We'll talk about stationary later.

For completeness, the box constructor is:

class Box:
def __init__(
self,
x: float,
y: float,
width: float,
height: float,
userdata: Any = None,
stationary: bool = False,
) -> None:

Userdata can be used to associate the box with something in your app.

A Better Collision Check

This is a bit hard to explain without a graphic, but the correct collision check for boxes is:

abs(a.cx - b.cx) <= (a.half_width + b.half_width)
and abs(a.cy - b.cy) <= (a-half_height + b.half_height)

It is easy to see how this works by considering what happens on the x axis when two boxes overlap left to right. Continue the vertical sides downward, and you'll get two line segments on the X axis, like a shadow. These line segments won't overlap if the boxes collide, because that means they're far apart enough in the x direction to not be touching. You can easily tell if two line segments on the X axis intersect by just finding out if their centers are close enough.

But that's not enough. It's possible that two boxes with the same X don't collide in Y, so we do the same check on the Y axis. If they're close enough together that both the X axis and Y axis show them as colliding, they have to be colliding--there's no third dimension they can be apart on.

You might have to work at visualizing this. If there's any part of this tutorial that you might want to take my word for, it's probably this section.

Optimization 0: A Digression Into Generators

Assume that you have 100 objects and they're all colliding for some reason. This is going to generate 10000 (or 5000, we'll get to that) pairs. Naively, you might do:

list.append((a, b))
return list

But this will build lists of 10000 items that you immediately throw away. Fortunately, we can do better. This function is a generator:

def gen():
yield 1
yield 2
yield 3

And the following:

for i in gen():
print(i)

Will print the numbers 1 to 3. At no point does this build an in-memory list. In fact, if you just do:

gen()

Nothing much happens. Generators stop at the yield statements and wait for you to ask for more values. If you don't, they just stop. In addition, they never build stuff up in memory: if gen() wanted to yield 10000 items, it'd use the same amount of memory as it did for 3.

If you want to know more, look up a tutorial on generators. The two most important facts for using this code are these:

  • The only thing you can do with a generator is for i in gen() or other operations that access the next item. You can't index them, etc.
  • You can get from a generator to a list (or set, or whatever else) by just doing list(gen()).

Most of the code here uses generators to avoid large lists.

Optimization 1: Get Good Base Algorithms

no matter what we do, we'll eventually have to check a list of boxes and there won't be any choice but to use the inefficient algorithm. Most of the trick here is making those lists smaller. But since we have no choice, we'll need the inefficient algorithms in here somewhere so that we can handle that. Additionally, we probably want tests. To get that, we can test against the boring algorithm that we know is right. I'll say more on testing later.

In collision_tutorial.base_algorithms, you will find two functions:

  • check_exhaustive is the naive check where you do 2 nested for loops.
  • check_deduplicated is a better version that cuts the comparisons in half.

Let's first consider check_exhaustive:

def check_exhaustive(boxes: List[Box]) -> Iterable[Tuple[Box, Box]]:
for a in boxes:
for b in boxes:
# If the centers of the boxes are close enough together that they overlap in the x and y axis both, they overlap.
# See the readme for the edge cases with box detection: in particular, it's not sufficient to check if one of the corners
# is inside the other box.
if (
a is not b
and abs(a.cx - b.cx) <= (a.half_width + b.half_width)
and abs(a.cy - b.cy) <= (a.half_height + b.half_height)
):
yield (a, b)

This is the boring algorithm with the 2 nested loops. If boxes 1 and 2 collide, you get (1, 2) and (2, 1). It's also the worst algorithm you'll see here: for x items, it takes x^2 checks. But it's even worse than that: we also have to check to make sure that we're not going to return (2, 2) for example--we're also checking boxes against themselves.

It might seem like there's no point to this function. But we can point at it and easily say "Yes, this one works". If the output of the others doesn't match it, something is broken. Unfortunately, "match" is one of those funny words with more complexity: as I keep saying, testing is near the end of this document, and I'll cover it there.

Next, we have:

def check_deduplicated(boxes: List[Box]) -> Iterable[Tuple[Box, Box]]:
# Save the len function call.
l = len(boxes)
for i in range(l):
# The inner loop only does l to the end of the boxes.
for j in range(i + 1, l):
a = boxes[i]
b = boxes[j]
if abs(a.cx - b.cx) <= (a.half_width + b.half_width) and abs(
a.cy - b.cy
) <= (a.half_height + b.half_height):
yield (a, b)

Which cuts the comparisons in half. It is worth considering why this works. Suppose we have boxes a, b, and c. The loop will first check a against b and c. When the outer loop gets to b, we've already checked b against a (because we checked a against b last time). So we avoid doing that. And when the outer loop gets to c, we've checked c against b and a already, when we did a and b against c previously. The same pattern holds for bigger examples.

Additionally, we get to drop the check to make sure that we aren't going to return (a, a).

Now our 100 objects case is only 5000 checks. This is the version that we use moving forward: if we have to check a list of boxes and can't use any other tricks, it's the best we've got to throw at the problem.

So How Can We Do Better: An introduction To Partitioning

The big trick of this entire thing is to divide the list of boxes into smaller sublists. Suppose that we have 100 objects, with our 5000 comparisons from the above optimized function, but we can somehow divide our 100 objects into two lists of 50 each. Then we have:

2 * (50 * 50 / 2) = 2500

Which is again half the comparisons. But this gets even better--what if we can divide it into 4 lists of 25? In that case:

4 * (25 * 25 / 2) = 2 * 25 * 25 = 1250

Which is a 4th of the original checks. But how about an extreme case? What if we can get it down to just 5? Well:

20 * (5 * 5 / 2) = 10 * 5 * 5 = 250

In the genral case, we can do exactly this. To understand how, we'll use one of those really lame analogies that textbooks love to come up with in order to supposedly hold your interest, only in this case it's to make up for a diagram.

Suppose that you've got the standard archetypical medieval town with 4 gates and two really boring roads that meet in the middle, and it's under siege from the west and the east with two really big armies of boxes. For the sake of argument, let's say they're 500 boxes each. Our naive collision detection algorithm would check the west army against everyone in the west army, then against everyone in the east army. Then it'll check everyone in the east army against themselves, then against the west army.

But hang on, there's a big town in the middle. There's no way the west army can collide with the east unless the town goes away first. The town divides the world into two partitions: everything to the west of it, and everything to the east. So instead of checking everything with everything else, we can handle the partitions separately. In doing so, we split the list.

In practice, what we actually want to do, though, is split based on a point. To do this, consider that the two main streets of this town actually divide the world into 4 quadrants: the northwest, northeast, southwest, and southeast. Anyone that's not standing on one of those streets has to be in one of those 4 partitions. Everyone in the northwest partition only needs to be checked against the people in that partition.

But there is one really important edge case. Anyone in the center is going to be in all 4 partitions. And anyone standing on one of the roads is going to be in at least two of them. To understand why, consider a really tall box standing on the west street. It's going to have part of itself in the southwest and part of itself in the northwest. So what we're about to do must account for this, and allow objects to be in more than one partition at once.

Optimization 2: partition the world

This is where I stop pasting code because it's too much. To see this in practice, read collision_tutorial.partitioner.

To partition the world well, we want to pick a center point of all the boxes, then divide the world into 4 quadrants.

To get the center, you can just do an average of all the x values for the x coordinate, then all the y values for the y coordinate. This isn't perfect, but it's good enough to be going on with in most cases.

Then, build 4 lists, where some boxes can be in up to all of them, as described above. After that, check the lists individually and combine the pairs, and you've got a useful subdivision.

it's worth taking a moment to point out that this can go wrong, however. Imagine that for some reason every box is colliding with every other box. In that case, what actually happens is you get 4 partitions containing all the boxes, then check them all. One of the key insights for optimizing is to realize that in the general case you only care about the usual cases. While the worst case might happen and it's bad if it does, any game which has every object clumped together colliding with every other object in such a fashion as to make partitioning useless probably has other problems.

Optimization 3: Partition Recursively

You've partitioned once, but all the partitions are still really big. You can just re-run your partitioning algorithm over and over until they're small enough, or until you give up.

The way this tutorial does this is by fixing the number of iterations and the minimum partition size. I didn't put a lot of thought into optimizing these numbers. In particular, more than 2 or 3 repetitions is probably much better, and playing around with the maximum partition size is probably also a good way to get more or less performance. But this is only a demo, so I didn't put in the time.

It's important to note that raising the number of iterations makes the worst case of everything overlapping and not being able to partition at all worse. Again, this shouldn't ever happen in practice without other problems, but if it does then iterating deeper into the tree without dealing with this will magnify the problem. There are a lot of ways to deal with this, for example you can detect that partitions didn't shrink because one of them is the same length as the input, or you could say "any partition which doesn't divide equally doesn't get partitioned further". Or any other number of heuristics. But again, this is a tutorial, though admittedly one that can be used as-is, not a fully featured library.

Stateful can be Better: an Introduction to BoxManager

So far, we've just had a big list of boxes that you get from somewhere and pass to this tutorial code. But that's kind of inefficient, in the sense that we're rebuilding the partitions every time and we aren't really taking advantage of any information we have. There's a lot that can be done if we take one step further and introduce something that can hold state. For example, we might avoid repartitioning on every tick, cache some of the collisions, only run things close to the player, and keep statistics on what's going on in order to change behavior at runtime. Most of these aren't implemented here because this is a tutorial, at this point a refrain in these later parts of the document.

The way the manager works is as follows: you .register() your boxes and .remove(box) them to get rid of it. This has the downside that your boxes won't be garbage collected unless you remember to remove them. But it lets the manager be aware of movement when it happens via box.move, which lets us implement:

Optimization 4: The Stationary Box Optimization

let's say that your level has lots of power-ups and scenery, none of which moves often, for example platforms. It's really a shame that we're checking them against everything including all the other stationary stuff. Here's how to do better (see box_manager.BoxManager).

For the first run, and any run where something stationary has moved, execute the normal partitioning algorithm. Then, for every item that run would return, add the pair to the stationary cache if a.stationary and b.stationary. The stationary cache is a cache of all pairs of stationary objects which have collided with each other. We invalidate it whenever a stationary object moves because stationary objects shouldn't move often.

Next, if the stationary cache is valid, start by yielding everything in the stationary cache. Then, modify the partitioning algorithm as follows.

We know that we want to check every nonstationary box against every stationary box as well as every nonstationary box against any other nonstationary box. Consider the optimized base algorithm check_deduplicated. if we have a list that looks like:

n, n, n, s, s

Where n is nonstationary and s is stationary, then by the time we get to the first stationary box, we've checked all the n against all the s as well as all the other n. We can thus stop at the first stationary box and abort the algorithm early.

To get the list in this order you simply do a sort partition.sort(key=lambda o: o.stationary) since true : false in Python. This puts all the stationary ones at the right end and lets the above trick function.

This actually means that we can consider all stationary boxes free in the grand scheme of things. This leads to the second-to-last tweak to this algorithm: we can raise the partition size so that the average number of nonstationary boxes in the partition is of a specificed size. For example, if 30% of boxes are stationary and we want a partition size of 10 disregarding this algorithm, we can use a partition size of around 15. See the code for specifically how this is done, but as an overview, if 30% of boxes are stationary, 70% aren't, and 70% of 15 is 10.5.

The last optimization here is that we maintain a count of stationary boxes. If there currently aren't any, we don't bother with any of this and fall back to the partitioner. One good future direction for the bored and/or interested is to modify this so that you can specify a minimum number of stationary boxes before the optimization kicks in. In particular, a very good way of doing this would be to specify a percent, and then to tweak the code in a real project to find the number that optimizes the most. The reason you'd not want to use an absolute value here is that there's a big difference between 10 out of 100 stationary boxes and 10 out of 1000: in the latter case not optimizing at all might be better.

So How Fast is it?

Obviously there's no point to any of this if it isn't fast. Consequently I've prepared some benchmarks. To duplicate these, either run benchmark.py or benchmark_cython.py against a Python interpreter of your choice. I've run these on a machine with an Intel I7-8700 at 3.20 GHZ. They work by producing lists of random boxes (fixed using a seed, or put another way every run uses the same list), then running them through one of the above algorithms. We report 3 values for each combination: the number of objects, the average time per iteration, and the estimate of how many times you can run it in a second. Results follow:

CPython 3.7:

Benchmarking exhaustive

objectstime/iterationnumber per second
09.230000000000349e-06108342.36186348453
1000.0014479800000000002690.6172737192502
2000.00560564178.391762581971
3000.01288022000000000177.63842543062152
4000.02377962999999999642.052798971220334
5000.03595727527.81078377046092
6000.05417583500000000618.458414161959848
7000.07062171514.159950661067917
8000.0930809999999999710.74333107723381
9000.119518795000000028.366884890363895
10000.145094650000000056.892052877208083

Benchmarking deduplicated

objectstime/iterationnumber per second
09.715000000021234e-06102933.6078227292
1000.00083358999999996191199.630513801804
2000.0033079450000000677302.3024868914022
3000.007542199999999966132.58730874280775
4000.01377500499999992972.59525495635066
5000.0216601900000000646.16764672886052
6000.0315149749999999831.730946954582723
7000.0435099849999999422.983230171189472
8000.05708023999999998317.519197536660677
9000.0747808900000000713.37240035522443
10000.0914696199999999910.932591608011492

Benchmarking partitioned

objectstime/iterationnumber per second
01.326999999999856e-0575357.950263761
1000.00014227499999996957028.641714990084
2000.00039446500000011042535.0791578459944
3000.00084900500000006931177.8493648446338
4000.0014417249999999272693.6135532088647
5000.0020571550000001437486.1082417221503
6000.0029712799999998653336.55528930294196
7000.00376171499999991265.8361943953819
8000.004964475000000057201.4311684518481
9000.006271730000000098159.44563940092837
10000.007463165000000061133.99140981071594

Benchmarking manager, stationary probability of 0.1

objectstime/iterationnumber per second
01.1761999999997386e-0585019.55449755333
1000.000158240999999996746319.474725260967
2000.00041832800000001672390.4687231071316
3000.00089476499999999961117.6118869200297
4000.0014634570000000124683.3135514060143
5000.0021618819999999774462.55993620373846
6000.0030425200000000173328.67491421584555
7000.0038541129999999767259.46307230743
8000.005040613999999976198.38852965134896
9000.006513566999999974153.52571025983215
10000.007667062999999991130.42804004610386

Benchmarking manager, stationary probability 0.5

objectstime/iterationnumber per second
09.511999999993747e-06105130.36164851318
1000.000130206000000008257680.137628065809
2000.00031606499999998763163.9061585434615
3000.00072530900000000291378.7227236943097
4000.0011827139999999758845.5129473397799
5000.0016807730000000022594.9643408122326
6000.002458053000000007406.8260529777011
7000.0031407310000000164318.3972138970179
8000.004085332999999984244.7780878572209
9000.004965090999999973201.40617765112572
10000.0061083219999999725163.71108137390343

Benchmarking manager, stationary probability 0.9

objectstime/iterationnumber per second
01.2840999999994551e-0577875.55486336144
1007.873599999999925e-0512700.670595407557
2000.00022896899999999224367.4034476284305
3000.00057070899999999371752.20646599232
4000.00059704999999997451674.901599531099
5000.00081112199999999751232.8601616033138
6000.0010551440000000057947.7379390869821
7000.0013082740000000114764.3658744269101
8000.0016301750000000225613.4310733510121
9000.002132263999999999468.985078770734
10000.002292611999999998436.18370661934983

Python 3.7, using Cython's pyximport:

Benchmarking exhaustive

objectstime/iterationnumber per second
01.3274999999990379e-0575329.56685504518
1000.00073978500000000391351.7440878092889
2000.0030827599999999843324.38464233349504
3000.006709530000000008149.04173615737596
4000.01221223000000000181.88512663125407
5000.0193174149999999751.76676071824318
6000.02775129499999997436.034354432829204
7000.0378127699999999826.44609215352381
8000.04979114500000001620.083892427057055
9000.0638455615.662796285285932
10000.0803742349999999312.441798046351556

Benchmarking deduplicated

objectstime/iterationnumber per second
09.905000000021146e-06100959.11155960274
1000.00044663499999995082238.9647027217084
2000.0017581250000000103568.7877710629188
3000.004134990000000016241.83855341850793
4000.007526799999999945132.85858532178446
5000.01195867000000001583.62133916229804
6000.0181122700000000455.21119108758857
7000.02465475999999995340.56011901961333
8000.03230004499999994430.959709189259698
9000.0414349849999999824.13419481146187
10000.0516120249999999319.37532968334417

Benchmarking partitioned

objectstime/iterationnumber per second
09.7850000000399e-06102197.24067408506
1008.562999999996989e-0511678.150181015433
2000.000227854999999976834388.756007110231
3000.00051756499999999761932.1244674582026
4000.00086225000000004211159.7564511452028
5000.001243015000000014804.4955209711779
6000.0017815149999999668561.3200001122744
7000.002223234999999946449.7950059260602
8000.0029538050000000203338.5463833936205
9000.0036914900000000195270.89332491757926
10000.004461195000000018224.15518711914544

Benchmarking manager, stationary probability of 0.1

objectstime/iterationnumber per second
09.353000000000834e-06106917.56655617565
1009.707999999999828e-0510300.782859497505
2000.000249752000000000874003.9719401646294
3000.00055619000000000091797.9467448174157
4000.00091006700000001221098.8201967547297
5000.0013459169999999964742.987866265158
6000.0019186559999999986521.1981720537714
7000.002395215000000004417.4990554083865
8000.0030832639999999857324.3316174028577
9000.003758543000000003266.06054526980245
10000.004506795999999991221.8871233577029

Benchmarking manager, stationary probability 0.5

objectstime/iterationnumber per second
09.721000000002533e-06102870.075095128
1008.009299999999442e-0512485.485622964174
2000.00019334300000000585172.155185344026
3000.000467172999999991852140.5346627480985
4000.00075853999999999651318.322039707866
5000.0010443330000000017957.5489810242503
6000.0015038229999999864664.9718750145523
7000.001967157999999998508.34757553790854
8000.0024634839999999867405.9291637372134
9000.0030715640000000023325.5670401137659
10000.003707680999999994269.71036612912536

Benchmarking manager, stationary probability 0.9

objectstime/iterationnumber per second
09.867999999997323e-06101337.65707339595
1004.6362000000002014e-0521569.38872352264
2000.00013182799999999167585.641897017808
3000.000358803999999999242787.036933813453
4000.00040867999999999682446.9022217872366
5000.0005552289999999971801.0586622816988
6000.0007463800000000021339.8001018248042
7000.00090292699999999121107.509244933433
8000.0011542209999999998866.3852069924219
9000.0014966019999999958668.1803178132883
10000.0016205809999999942617.0626460510173

Pypy3.7:

Benchmarking exhaustive

objectstime/iterationnumber per second
01.5499999999999998e-06645161.2903225807
1000.000267204999999999963742.4449392788315
2000.000233935000000000064274.691687862012
3000.00052381909.1256204658264
4000.00070299999999999981422.4751066856334
5000.00111719895.1028920774444
6000.0015581500000000006641.7867342682024
7000.002094955477.3372220405689
8000.0026505649999999993377.2780520379618
9000.00345516289.42219752486136
10000.0042033750000000005237.90406518571382

Benchmarking deduplicated

objectstime/iterationnumber per second
02.6250000000005436e-06380952.3809523021
1000.00025155999999999793975.194784544476
2000.000140309999999999047127.075760815386
3000.000344189999999999752905.3720328888135
4000.0005977350000000021672.9821743749264
5000.000691291446.5709036728435
6000.00097373500000000051026.9734578709808
7000.0013067949999999995765.2309658362639
8000.0017704700000000019564.8217704903212
9000.0026494650000000036377.43468964489006
10000.0026495250000000024377.4261424217545

Benchmarking partitioned

objectstime/iterationnumber per second
01.7700000000009374e-06564971.7514121302
1000.00069275999999999781443.50135689128
2000.00027284999999999673665.0174088327367
3000.00067613000000000251479.0055166905718
4000.001389275000000001719.7998956290146
5000.00060885999999999991642.4136911605297
6000.00035436500000000092821.9491202573545
7000.0010669499999999999937.2510426917851
8000.00045732999999999472186.604858636021
9000.00056979500000000081755.0171552926906
10000.0005006249999999991997.5031210986306

Benchmarking manager, stationary probability of 0.1

objectstime/iterationnumber per second
01.8980000000001773e-06526870.3898840393
1000.00043280800000000012310.493336537217
2000.00038573500000000042592.453368245036
3000.0006300160000000011587.2612759041015
4000.000340407000000000362937.6599188618297
5000.00035997700000000022777.955258252609
6000.000476273999999999842099.6317245955065
7000.00050020199999999961999.1923263001763
8000.00066088600000000051513.1202658249672
9000.00070523299999999851417.971081897759
10000.00082808699999999961207.6025828204047

Benchmarking manager, stationary probability 0.5

objectstime/iterationnumber per second
04.627000000001491e-06216122.75772631896
1000.000303557000000000963294.274221974775
2000.00025892799999999833862.0774887227594
3000.000301921000000000573312.124694870506
4000.000219829000000000274548.990351591458
5000.000370804000000000132696.842536757963
6000.000410083999999999472438.5247900430186
7000.0004026100000000012483.793249049943
8000.00047705400000000122096.1987531809764
9000.00069770899999999971433.2622912990953
10000.00058379699999999921712.9241842626827

Benchmarking manager, stationary probability 0.9

objectstime/iterationnumber per second
04.5849999999991734e-06218102.50817888338
1000.00012580099999999877949.0624080890475
2000.00013230199999999977558.464724645148
3000.000129521000000001347720.755707568577
4000.000236967999999999624219.9790689038255
5000.00027181499999999973678.972830785649
6000.000239675999999999784172.299270682091
7000.000281681999999998653550.102597965098
8000.00035239999999999942837.6844494892216
9000.00056409900000000011772.7384732112623
10000.000405853000000000452463.9463056821037

Testing: How Do We Know if it Works?

For something like this, I like to use a tool called Hypothesis, which can intelligently generate tests with input data for you, and even go so far as to hunt for and print example broken programs. I'm not going to provide a full overview of it here, but if you look at the tests directory, it's pretty straightforward stuff.

The one complexity we have with the tests is that we need to deal with the fact that we might get (a, b) from one algorithm, then have the other report (b, a). There's a few ways of dealing with this, two of which are used here. For the case of testing the check_exhaustive we duplicate the pairs returned by check_deduplicated and then check that test. For the BoxManager tests, we flip the pairs so that id(a) <= ~id(b)`. Either case yields sets which are equal if the collisions are equal.

By linking this to the base algorithm check_exhaustive, we can verify that this works, even better than if a human tested it by hand. The only point of failure is if check_exhaustive itself isn't working. AAdmittedly, I should probably introduce more manual testing around that, but haven't for lack of time. Though i'm not generally interested in maintaining this code, if you uncovera bug and submit a PR with a fix and/or better tests, i'll be happy to accept it.

Future Directions

I've mentioned a few future directions above. I'll go ahead and reiterate some of them here, as well as provide a few more, in case someone wants to turn this into a proper library.

  • Cythonize the entire thing properly. pyximport isn't redistributable and has horrible performance. This code is already fast enough to be used in many practical projects, but cythonizing it properly by converting it to pyx with proper Cython typing and etc. can probably get an order of magnitude performance improvement out of it.
  • Figure out how to deal with the worst cases of partitioning.
  • Figure out better values for some of the parameters, in particular the maximum partitionsize and the number of iterations.
  • Figure out better heuristics for stopping partitioning early, since iteration count is really kind of a hack.
  • Optimize the partitioner to not create as many lists.
  • Figure out better heuristics for when to apply and when not to apply the stationary object optimization.
  • Some better manual tests for the base case probably won't uncover bugs, but they certainly can't hurt anything.

Bonus: tilemaps

You have two choices with tilemaps. The first, and easiest, is to simply handle it yourself.

The second is to combine impassable rectangles into boxes, for example a 2 by 2 square of dirt becomes a 2 by 2 box. Then you pass those boxes to this code. I'm not going to provide an algorithm for that: it's a bit hard to write and I don't have the time. But essentially you find all impassable tiles that aren't part of a box yet, and then you keep trying to extend the rectangle in the x and/or y direction as much as possible in a loop. You'd want to cache the output: this is as slow as it sounds.

But at runtime, as the above benchmarks show, you're not going to get more than 1000 boxes or so. It's practical, but not if you throw thousands of 1-tiel boxes at it.

You might be saying "but other languages can", but that's not exactly true: you'll be able to push it a bit further, but not far enough. You'll also have ram issues, since boxes are actually 6 or 7 values.

About

A collision tutorial without graphics, demonstrating basic optimizations in a format that new programmers can probably understand

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages