The explanation is simple: near pitch = +-90 the frandom() angle matters much less than it does near pitch = 0 (plus the angle offset is apparently applied before the pitch one, however this is less important). For Doomish weapons and stock maps this is unlikely to be a problem, however in other cases it might matter: for example, in case you're making a TC with 3D model enemies and lots of height differences.
Here is a function that calculates an approximation to the 'spherical normal distribution' - the 3D von Mises-Fisher distribution - to overcome this nuisance. My function takes a unit direction vector of the gun (specified Doom-style: as angle and pitch) and returns an angle/pitch offset that you should use in A_FireBullets. See figures 1 and 2 from this file (the algorithm is from there): larger deviations from the gun direction vector are less probable than smaller ones, and all directions for the deviation are equally likely.
The nature of the concentration parameter 't' is quite arcane. However for Dooming purposes you should know that the larger it is, the more precise the gun gets. Experiment with this parameter (it should be positive) and find the optimal value for your weapon. And you can of course give the functions shorter names of your preference.
Code: Select all
version "3.0.0"
class Math
{
// The function that you should use with A_FireBullets or similar codepointers.
// (includes angle/pitch offset conversion for ease of use).
static double, double RandomSphericalOffset(double angle, double pitch, double t = 1.0)
{
Vector3 v = Math.RandomizeDirection(Math.AnglePitchToVector3(angle, pitch), t);
double aangle, ppitch; [aangle, ppitch] = Math.Vector3ToAnglePitch(v);
return aangle - angle, ppitch - pitch;
}
// The main wrapper procedure: it takes a unit vector and shifts it on the sphere a little bit.
// REMINDER: larger t = more precision.
static Vector3 RandomizeDirection(Vector3 v, double t = 1.0) { return RotateWith(vonMisesFisher3D(t), v); }
// This computes a random vector that is close to Z axis (0, 0, 1).
static Vector3 vonMisesFisher3D(double t = 1.0) //Approximation to bivariate normal on the unit sphere.
{
double y = frandom(0, 360) /*not M_2_PI*/, x = frandom(0, 1),
u = 1 + log(x + (1 - x) * exp(-2 * t)) / t, w = sqrt(1 - u * u);
Vector3 q = (w * cos(y), w * sin(y), u);
return q;
}
// This takes the new random vector and the Z axis and shifts them along a large circle so the Z axis aligns with the gun.
static Vector3 RotateWith(Vector3 n, Vector3 newray, Vector3 oldray = (0,0,1))
{
if (newray == oldray) { return n; }
else if (newray == -oldray) { return -n; }
Vector3 k = (oldray cross newray).Unit();
double ctheta = oldray dot newray;
ctheta = clamp(ctheta, -1.0, 1.0);
return n * ctheta + (k cross n) * sqrt(1 - ctheta * ctheta) + k * (k dot n) * (1 - ctheta); //Rodrigues' rotation formula
}
// These are just angle/pitch <-> unit vector conversions.
static double, double Vector3ToAnglePitch(Vector3 v) { return atan2(v.y, v.x), -atan2(v.z, v.xy.length()); }
static Vector3 AnglePitchToVector3(double angle, double pitch) { return (cos(angle) * cos(pitch), sin(angle) * cos(pitch), -sin(pitch)); }
}
class Pistol3D: Pistol replaces Shotgun
{
Default
{
Weapon.SlotNumber 3;
Decal "BulletChip";
}
States
{
Fire:
PISG A 0;
PISG B 1
{
double da, dp;
[da, dp] = Math.RandomSphericalOffset(angle, pitch, 10000.0);
A_FireBullets(da, dp, 1, 5, "BulletPuffInvisible", flags:FBF_EXPLICITANGLE);
}
PISG B 5 A_ReFire;
Goto Ready;
}
}
class BulletPuffInvisible: BulletPuff { Default { +INVISIBLE } }
- copy this code into a pk3, idfa, select the slot 3 weapon, disable the crosshair;
- come close to a tall wall (256mu ones on MAP32 will suffice);
- using mouselook, shoot the slot 3 'pistol' at the upper part of the wall while not moving at all for a few seconds (say 10). The circular pattern of decals would be apparent (and there will be a few 'outliers' - just as on a real shooting range). Remember: the pattern would be circular only when looking exactly from the point where you were shooting from (as it would IRL).
Notes.
1. This was only tested on player weapons, the enemies IIRC have some other pitch convention. However, with proper testing it shouldn't be hard to do that case.
2. In principle all of this is 'easily' converted to DECORATE (for Zandronum or whatnot) if you know your school trigonometry, however I have zero plans to do it myself
3. I'm open to any suggestions on improving performance. The RNG is only called 2 times, however there are also the rotations, conversions and transcendental functions involved.
4. The circular (in coordinates) distribution ackchyually renders as an ellipse in game, unless you modify the pixelratio from the Doom default 1.2 to 1.
5. You can use this code for projectiles, including ones subject to gravity, or hitscan FastProjectile imitations. You can also rebalance the guns for more 'realism' in the following way. When a bullet or a packet of pellets are fired, you 1) calculate the 'random offset of the gun' (that would depend on e. g. player velocity, posture, blood pressure, etc.) and 2) the offsets of the individual pellets relative to the gun (which depend only on the gun, its condition, ammo type and so on).
6. If you need a uniform distribution on the whole sphere rather than one with a peak, you can generate a point on a cylinder that the unit sphere is inscribed in and just project it to the sphere:
Code: Select all
a = frandom(0, 360); z = frandom(-1, 1); return (cos(a), sin(a), z).Unit();
7. Something similar was implemented by dodopod in 2018, for the method see the end of this file (the spread is supposed to be uniform over the cone). However for very accurate weapons it would reject too many random vectors that will generate outside the cone, so for "accuracy < 10" a fallback to spawning the small offset in a cube is forced. And for accuracy exactly 10 degrees it'd have to sample around 400k (miscalculated; it is 126) random points to find a good one. My version will only eat up a few dozen CPU instructions, in any case.