Deep Dive: Projectile Spread

Deep Dive: Projectile Spread

A few days ago in the Sauerworld Discord (join here), we talked about the chaingun (aka minigun, aka machine gun) and shotgun damage and how their rays spread around your crosshair. Games like Counter-Strike aim for realistic projectile spread induced by the weapon’s recoil: your crosshair (and with it, projectile vectors) tend to move upwards the longer you burst-fire, and players learn to correct for it by moving the crosshair down. Some weapons in some games also have projectiles spreading outwards around your crosshair, with the spread usually increasing the longer you hold the trigger.

Sauerbraten, in contrast to most other shooter games you may know, has pretty basic projectile spread mechanics. And since Sauer is open-source, we can take a look behind the scenes to understand the intricacies of those mechanics! The following C++ code is taken directly from Sauerbraten’s source code (src/fpsgame/weapon.cpp):

void offsetray(const vec &from, const vec &to, int spread, float range, vec &dest)
{
    vec offset;
    do offset = vec(rndscale(1), rndscale(1), rndscale(1)).sub(0.5f);
    while(offset.squaredlen() > 0.5f*0.5f);
    offset.mul((to.dist(from)/1024)*spread);
    offset.z /= 2;
    dest = vec(offset).add(to);
    if(dest != from)
    {
        vec dir = vec(dest).sub(from).normalize();
        raycubepos(from, dir, dest, range, RAY_CLIPMAT|RAY_ALPHAPOLY);
    }
}

Some things to note before we dive into this function:

  • the function calculates the offset for a single ray (from the straight line, which would be used for example a rifle projectile)
  • it’s the only function to calculate ray offset (= projectile spread) in the source code, which means the overall spread calculation is the same for all weapons that have spreading projectiles (currently, chaingun, shotgun and pistol)
  • however, it takes a spread argument, so the output is not neccessarily the same for all weapons
  • the function is called with the same spread argument for every ray of a weapon, meaning chaingun spread does not increase with time

So what does the code do, exactly? Let’s begin by looking at the function’s input (its arguments) and output:

The first two arguments tell the function from where to where in the map the player is shooting. Then there are the spread and range arguments, which are taken from the weapon’s defined settings. (These weapon settings are set in the source code, and can’t be changed in-game. Other weapon settings would be how much damage a ray deals and how long it takes to reload.) The last argument, dest, is a variable that will hold the destination of the offset ray after the function ran and is actually the output of the function. (In other programming languages, this would be the function’s return value.)

chaingun shooting at flag carrier

From a high-level point of view, the function calculates a single ray’s destination vector by preparing an offset vector which it adds to the vector pointing from the player to the target, i.e. offsetting the ray from the vector connecting from and to. It returns the vector pointing from from to the offset target as the dest vector.

Let’s go through the function step-by-step:

vec offset;
do offset = vec(rndscale(1), rndscale(1), rndscale(1)).sub(0.5f);
while(offset.squaredlen() > 0.5f*0.5f);

The first three lines prepare a vector variable with three coordinates (x, y and z) and try random values between -0.5 and 0.5 for each coordinate, until it finds a vector where x2 + y2 + z2 is greater than 0.25. This basically means the offset vector can point in any direction, but its magnitude is limited to a sphere of radius 0.5. Although this explicitly prevents the case that x2 + y2 + z2 = 0, it does not mean that this function will never produce a ray that goes exactly straight: the offset vector might point parallel to the direction of the shot, so offsetting the ray will only make it point behind or in front of the original target! You might get lucky and get a straight shot even with your chaingun!

offset.mul((to.dist(from)/1024)*spread);

The next line makes it so that long range shots are offset more than short range ones. It uses the shot distance (to.dist(from)), scales it by a magic factor of 1/1024, and then scales it again by the weapon’s spread setting (currently 100 for chaingun, 400 for shotgun, 50 for pistol). The entire offset vector is then scaled by the result of all that scaling of the shot distance.

offset.z /= 2;

This line is very interesting: z is the up-down axis in Sauer (if you jump, your z coordinate increases, if you fall down like a noob on reissen, it decreases). The /= 2 bit means the z component is halved. We will get back to what this means for us later!

dest = vec(offset).add(to);

This part simply defines dest as the position where the offset ray ends (for now), by adding the offset vector to the position vector of the target of the shot.

if(dest != from)
{
...
}

The next bit ensures that the calculated ray doesn’t end where it starts, for reasons I am not sure why. It might have to do with Sauer’s spawn kill protection, but it’s really just my best guess here. For simplicity, let’s assume that will never be the case, so the code inside the braces will be executed next.

vec dir = vec(dest).sub(from).normalize();
raycubepos(from, dir, dest, range, RAY_CLIPMAT|RAY_ALPHAPOLY);

The last two lines of code move the destination (= end) point of the ray along the ray until it collides with something in the world, for example the wall or (ideally) an enemy’s player model. This is done by calculating a normalized vector dir of the ray’s direction from its start (from) and end (dest) vectors, and then relying on the engine to set dest to the point where this vector dir, starting at from intersects with something that would stop a projectile. Essentially, this makes sure the ray doesn’t end in front of the player or goes through her model without hitting or has the ray end somewhere beside the player in the middle of the air.

Now back to why offset.z /= 2 is so interesting here: For you as a player, this line means shots are more accurate when you are at the same height as your target!

If you’re not sure why, think about the sphere of possible offset vectors around the target: when the offset vector’s z component is reduced by the /= 2 operation, the height of the sphere of possible offsets around the target is reduced, so it’s no longer a sphere, really, but more of a pumpkin! At the very end, what matters is the 2D projection of this pumpkin towards the players camera (since the depth component of the offset vector in relation to the player’s camera is irrelevant [the end point of the ray is recalculated after offsetting the shot]). Seen from eye level (that is, perfectly horizontal), the surface area of the sphere of possible offset vectors got smaller by compressing it along the z-axis, but seen from above, it’s still the same size! So the more “from above” a player’s perspective onto the target is, the less they benefit from this height compression of the possible offset vectors. The greater the difference in z-height between the start and end of the shot (i.e. the player and their target), the less likely a ray is to be close enough in the center to count as a hit!

1 Comment

  1. Forsty

    “You might get lucky and get a straight shot even with your chaingun!” :D

    I hadn’t ever thought of how spread+hitscan weps could be more accurate based on the height discrepancy, although I wonder how significant it would really be in applicable game situations.

    Nice article

    Reply

Leave a Reply

Your email address will not be published.