13 October 2020

ShipBasher Development Log 13: The GPU Bullet Collision Saga, Episode 3: for(int i = r; i < r % z + r; i += r - z > i ? 1 : z % (r + z)){ z -= r; r += i; r = r % z + i > 0 ? i : r - z % r + z; z -= r; }

That "code" in the title isn't what's actually in the game (in fact I doubt it'd even compile, let alone do anything useful), but it is a caricature of how my code was starting to look to me at one point during the long debugging process I mentioned undertaking at the end of the last post.

See, I had also upgraded the compute shader one more time with a fourth compute buffer - this one representing bullets that the physics engine had confirmed actually did hit a ship. As of now I'm still just having them bounce off, but pretty soon I'm going to want them to stop existing, or in more precise terms as far as my code is concerned, become inactive so they stop being displayed and hitting things. Maybe sometimes I'll want bullets to survive and have secondary impacts (or penetrate through modules) but the option to deactivate bullets that have impacted is important. This buffer gets filled up on the CPU end of things, and then at the end of each frame, after the collisions have all been addressed, it gets sent to the GPU containing copies of all these bullets, notably including their indices in the original buffer so that the compute shader knows which of the original bullets have now become inactive.

Here, I'll recap with a flowchart that might help or might just make this all even more confusing:


Rounded rectangles represent the game systems related to GPU bullets; ellipses are tasks the systems can do, and the clouds surround the tasks belonging to a given system. The parallelograms represent the four compute buffers, and the arrows represent how each system affects each buffer or other system.

As shown here, the persistent data for the bullets is stored in the GPU's memory, rather than the CPU's as is the case for most of the game's data. Note how in order for the game to function, communication must occur back and forth between the CPU and GPU.

Because GPU code doesn't deal in pointers the way CPU code does, any time a data structure has to travel between one and the other, the data itself gets sent as a copy. Thus when, for example, the compute shader adds a bullet that has entered a bounding sphere, the bullet in the main buffer remains where it is, unchanged, while the new buffer entry is a copy of that bullet's data. In order to keep track of which data belongs to which bullet, I simply include an ID in that data. The first bullet that ever gets fired is given ID number 0, then the next 1, and so on up to the maximum number of bullets I allow in the game configuration, e.g. if I allow 10000 bullets then the last one is number 9999. After that, if I fire a "new" bullet, what actually happens is I overwrite bullet 0, which is bound to have either hit something or drifted off into deep space by this point. This is a common programming concept called a ring buffer or circular buffer, which is a type of object pool.

When a bullet enters a bounding sphere and a copy is added to the corresponding buffer and in turn sent to the CPU and the physics engine, unlike the bullets in the main buffer, that copy only exists for that frame. It's a trivial task for the GPU to regenerate it as it iterates through every bullet every frame anyway, so this has insignificant performance impact. In order for anything to happen, the physics engine must give a positive result when the bullet is evaluated that frame; otherwise it goes away when the buffer gets reset and thus nothing happens to the main buffer.

If a collision is detected, then that bullet's data is copied once again, this time into the buffer representing bullets that have hit something and must either be redirected or deactivated. Each type of bullet is handled by a corresponding instance of the bullet manager script and its own associated set of compute buffers, and for each type of bullet I can configure whether to allow ricochets or delete bullets after impact; based on which option applies, the data copied into the "impactor" buffer can contain a modified velocity or a flag indicating that it represents a bullet that should be deactivated. All bullets that have struck something are added to this buffer and then the buffer is read by the compute shader.

Because each bullet's complete data is copied every time, not only is its important position and velocity perserved, but also its original ID from when it was fired. Thus when the compute shader receives all the impacted bullets, it can match their IDs with the corresponding IDs in the main buffer and change those bullets accordingly, altering their velocities or deactivating them. In the end, bullets and the things that have happened to them persist frame after frame as long as they are needed.

I'm almost to the point where I explain the big problem I faced. There's just one more thing to explain. To prevent this becoming too much of a textbook page, here's another picture:

Another screenshot of my debugging process. The yellow specks are the debug lines for bullets that have bounced off the target ship and now entered the test ship's bounding sphere, which I temporarily made larger. You may notice something suspicious going on here that I'll address in the next entry.

Back on topic, I mentioned above that I assigned an ID to each bullet as it was fired. Unfortunately it's not quite as simple as slapping on a number during the firing function. If I were to individually update members of the compute buffer as bullets were fired, it would cause lots of little data packets to have to be sent to the GPU  - possibly lots every frame if the overall firing rate is high, as is the case with a rapid-fire gun or a large number of guns firing at once. Due to the way computers are designed, this would cause soul-crushing lag. Rather, the optimal way to go about his would be to gather up all the bullets that have been fired in a given frame in a nice little array and then at the end of the frame send one packet containing that array to the GPU - so that's what I did. Thus the firing function didn't count up numbers in the buffer itself, but rather the number of bullets that had been fired that frame. I called this number R.

What this means of course is that when I send the array of new bullets, I also need to tell the compute shader where to start changing bullets in its compute buffer, so I added a second counter value, which I called Z. Every time a bullet was fired, I would add one to R and to Z. At the end of every frame, once all the new bullets were ready to submit, R would reset to zero, but Z would remain as-is, tracking how many bullets had been fired ever, or at least since the last time I had made a full loop of the ring buffer. By doing math (see title) with R and Z, I could discern where in the compute buffer to start making changes and how many entries to update, and I could even check whether I had run past the end of the compute buffer and needed to go back to the beginning. Soon after I had implemented this, I had bullets happily whizzing out of the gun barrels frame after frame with no invalid array index exceptions or whatever, and I washed my hands of this and shifted gears to things like the collision detection that's been the focus of the last few devlogs.

But then I noticed something very, very strange. At high rates of fire, everything seemed perfect, but if I happened on a whim to switch to a low rate of fire, only a few bullets per second... bullets would float through ships unimpeded for a short time and then bounce off empty space as if it were the ship!

What's going on here?!? There aren't a lot of bullets visible so I drew some arrows to make it clearer - the bullets (blue crosses) are traveling up from bottom right, passing through the target ship, and then bouncing near the top and going off to bottom left even though there isn't anything at the top! No, there were no invisible or inactive colliders or anything simple like that.

When I first noticed bullets occasionally ignoring collisions, I figured it was the physics engine being unreliable and played around with how I did collision detection queries. No luck.

It seemed like the bounce was occurring not only at the exact rate of fire, but at the exact time firing occurred, so I investigated my weapon and module classes in extreme detail. No luck.

I even investigated my compute shader, geometry shader, and bullet rendering script. Swallowing the huge performance impact, I had Unity draw little blue debug lines on every single bullet all the time - those are the blue crosses visible above. No luck!

I was starting to go nuts and getting increasingly tempted to give up and resign myself to releasing a buggy game (okay I'm sure it'll be buggy anyway, but I should at least try to address the ones I do catch, right?)... but eventually I did figure it out. Notice that pink line? That's the debug line I have Unity draw to represent the new velocity a bullet is given when it impacts a ship - a line that only appears during the frame during which that impact occurs. The bullet bouncing off empty space is occurring exactly when, and only when, the next bullet actually strikes the ship!

It turns out that the second counter, Z, which tracks cumulative bullets, incremented after each bullet was fired and then was used as the start index for the compute buffer. Thus Z would begin at 0, I'd fire bullet 0, then Z would become 1. I'd fire bullet 10 and then Z would become 11. Thus when it was time to update the compute buffer, I'd say to start at index 11 rather than index 10 - every bullet would get assigned to the index just after its ID, and thus, as these IDs traveled back and forth during collision detection and eventually the compute shader compared the bullet IDs to the buffer indices, every time a bullet hit something the compute shader would go and edit the index matching that bullet's ID, i.e. the bullet just before - in other words, each bullet would bounce if the bullet just after it hit a ship. Yes that probably sounds a little confusing and it threw me for a loop (pun not planned but welcome) too.

Once I had that figured out, I went and re-did my counting system to be much more sensible. Now, R increments with every bullet, but Z does not - rather, Z stays put while the new bullets array is built and then increments by R afterward. Problem solved, though my head hurt.

Of course, after every bug there's another bug...

No comments:

Post a Comment

Sorry this isn't a real post :C

I've entered graduate school and, as I expected, suddenly become far busier than I had been before, leaving no time to maintain my blog,...