Unity Tutorial – Full Body FPS Controller – Part 3 : Realistic Ballistic System

This is the third part of a tutorial series about creating a Full Body FPS controller. It covers the physics behind the ballistic system.

Part 1 : Base Character Controller
Part 2 : UpperbodyIK

This tutorial is made with Unity 2019.1.9f1

Let’s dive straight into physics. Here we want to simulate the behavior of the bullet after it leaves the barrel of the weapon and on impact. So we will take a look at External Ballistics and Terminal Ballistics

1 – External Ballistics

In this part we will mainly use the script from this post as the results are very good and it features all the effects we want for the external ballistic simulation.
While the projectile is in flight the major forces that we need to simulate are gravity, drag and if present, wind.
Other smaller effect but needed for a better result are Coriolis, Spin Drift and Eötvös.

1.1 – Gravity

First, the easiest one, gravity. Pretty simple, just add a force that point to the ground. So let’s do that.
In our script we need to get the Rigidbody component as we will modify the velocity.
Create the built in method OnEnable that is inherited from MonoBehaviour.  In this method we use the GetComponent method to get our Rigidbody.
We also need to create a SetInitialParameters method that will prepare some values for future calculation. Call this method in the OnEnable method.

In C# inherited mean that a class is derived from another class and in the process get all public and protected methods and variables.
public class Ballistics : MonoBehaviour
{
    #region Variables
    private Rigidbody m_rb;
    private Vector3 m_gravity;
    #endregion

    #region BuiltIn Methods
    private void OnEnable()
    {
        m_rb = GetComponent<Rigidbody>();
        SetInitialParameters();
    }
    #endregion
    
    #region Custom Methods
    private void SetInitialParameters()
    {
        m_gravity = new Vector3(0, Physics.gravity.y * 
            Time.fixedDeltaTime, 0);
    }
    #endregion

Now we need to update our velocity using the m_gravity value. So create an UpdateVelocity method and call it from the FixedUpdate method as we’re dealing with physics here.
To update the velocity vector we just have to add the m_gravity vector to the current velocity vector.
Also, we need to update the rotation of the projectile accordingly to its velocity. So we use the method Quaternion.LookRotation and use the current velocity as parameter to get a rotation that looks in the direction of the velocity.

    ...

    private void FixedUpdate()
    {
        UpdateVelocity();
    }

    ...

    private void UpdateVelocity()
    {
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);
        m_rb.velocity = m_rb.velocity + m_gravity;
    }

1.2 – Drag

That was pretty simple right ? Now we need to slow the bullet down using drag resistance. Calculating the drag is complex and not very reliable so we will use an empirical method. That means that we use a mathematical model with a pre-defined set of parameters which can approximate the real world results.
In our case the model we will be using is the Pejsa Model with different drag curves (G1, G2, G5, G6, G7, G8). Theses drag curves are optimized for a specific projectile shape. The most popular for standard bullets is the G1. The Pesja model uses 2 parameters that we will call and N. A is a retardation rate and N is a slope constant factor. Both values are used to get a retardation that we will use to get our drag vector.

So how do we convert all of this into C# code ? Well as I said an empirical method need a pre-defined set of parameters. So here we will do something that I don’t advise to do but in this case it’s the best way to have accurate results. We will hard-code our data set.
First we need to define an enum to know which drag curve we want to use. Then we need a float to give the ballistic coefficient value of our bullet. And then we create three new methods : GetSpeed, CalculateRetardation and CalculateDrag. And call them in the FixedUpdate method.

The main issue is that a very small object moves really fast. It makes any calculation really hard as we can consider the bullet moving through a fluid (air in our case). And hydrodynamics calculations are really hard. Many people are still working on the subject today.
    [Header("Ballistic Setthings")]
    public float _ballisticCoefficient;
    public enum gModel { G1, G2, G5, G6, G7, G8 };
    public gModel _bulletGModel;

    private float m_retardation;
    private float m_drag;
    private Vector3 m_vectorDrag;
    private Vector3 m_trueVelocity;

    private const float k_msToFps = 3.2808399f;

    ...

    private void FixedUpdate()
    {
        GetSpeed();
        CalculateRetardation();
        CalculateDrag();
        UpdateVelocity();
    }

    ...

    private void GetSpeed()
    {
        m_trueVelocity = m_rb.velocity * Time.fixedDeltaTime;
    }

    private void CalculateRetardation()
    {
        float velFps = m_rb.velocity.magnitude * k_msToFps;
        float A = -1;
        float N = -1;

        if (_bulletGModel == gModel.G1)
        {
            if (velFps > 4230) { A = 1.477404177730177e-04f; N = 1.9565f; }
            else if (velFps > 3680) { A = 1.920339268755614e-04f; N = 1.925f; }
            else if (velFps > 3450) { A = 2.894751026819746e-04f; N = 1.875f; }
            else if (velFps > 3295) { A = 4.349905111115636e-04f; N = 1.825f; }
            else if (velFps > 3130) { A = 6.520421871892662e-04f; N = 1.775f; }
            else if (velFps > 2960) { A = 9.748073694078696e-04f; N = 1.725f; }
            else if (velFps > 2830) { A = 1.453721560187286e-03f; N = 1.675f; }
            else if (velFps > 2680) { A = 2.162887202930376e-03f; N = 1.625f; }
            else if (velFps > 2460) { A = 3.209559783129881e-03f; N = 1.575f; }
            else if (velFps > 2225) { A = 3.904368218691249e-03f; N = 1.55f; }
            else if (velFps > 2015) { A = 3.222942271262336e-03f; N = 1.575f; }
            else if (velFps > 1890) { A = 2.203329542297809e-03f; N = 1.625f; }
            else if (velFps > 1810) { A = 1.511001028891904e-03f; N = 1.675f; }
            else if (velFps > 1730) { A = 8.609957592468259e-04f; N = 1.75f; }
            else if (velFps > 1595) { A = 4.086146797305117e-04f; N = 1.85f; }
            else if (velFps > 1520) { A = 1.954473210037398e-04f; N = 1.95f; }
            else if (velFps > 1420) { A = 5.431896266462351e-05f; N = 2.125f; }
            else if (velFps > 1360) { A = 8.847742581674416e-06f; N = 2.375f; }
            else if (velFps > 1315) { A = 1.456922328720298e-06f; N = 2.625f; }
            else if (velFps > 1280) { A = 2.419485191895565e-07f; N = 2.875f; }
            else if (velFps > 1220) { A = 1.657956321067612e-08f; N = 3.25f; }
            else if (velFps > 1185) { A = 4.745469537157371e-10f; N = 3.75f; }
            else if (velFps > 1150) { A = 1.379746590025088e-11f; N = 4.25f; }
            else if (velFps > 1100) { A = 4.070157961147882e-13f; N = 4.75f; }
            else if (velFps > 1060) { A = 2.938236954847331e-14f; N = 5.125f; }
            else if (velFps > 1025) { A = 1.228597370774746e-14f; N = 5.25f; }
            else if (velFps > 980) { A = 2.916938264100495e-14f; N = 5.125f; }
            else if (velFps > 945) { A = 3.855099424807451e-13f; N = 4.75f; }
            else if (velFps > 905) { A = 1.185097045689854e-11f; N = 4.25f; }
            else if (velFps > 860) { A = 3.566129470974951e-10f; N = 3.75f; }
            else if (velFps > 810) { A = 1.045513263966272e-08f; N = 3.25f; }
            else if (velFps > 780) { A = 1.291159200846216e-07f; N = 2.875f; }
            else if (velFps > 750) { A = 6.824429329105383e-07f; N = 2.625f; }
            else if (velFps > 700) { A = 3.569169672385163e-06f; N = 2.375f; }
            else if (velFps > 640) { A = 1.839015095899579e-05f; N = 2.125f; }
            else if (velFps > 600) { A = 5.71117468873424e-05f; N = 1.950f; }
            else if (velFps > 550) { A = 9.226557091973427e-05f; N = 1.875f; }
            else if (velFps > 250) { A = 9.337991957131389e-05f; N = 1.875f; }
            else if (velFps > 100) { A = 7.225247327590413e-05f; N = 1.925f; }
            else if (velFps > 65) { A = 5.792684957074546e-05f; N = 1.975f; }
            else if (velFps > 0) { A = 5.206214107320588e-05f; N = 2.000f; }
        }

        if (_bulletGModel == gModel.G2)
        {
            if (velFps > 1674) { A = .0079470052136733f; N = 1.36999902851493f; }
            else if (velFps > 1172) { A = 1.00419763721974e-03f; N = 1.65392237010294f; }
            else if (velFps > 1060) { A = 7.15571228255369e-23f; N = 7.91913562392361f; }
            else if (velFps > 949) { A = 1.39589807205091e-10f; N = 3.81439537623717f; }
            else if (velFps > 670) { A = 2.34364342818625e-04f; N = 1.71869536324748f; }
            else if (velFps > 335) { A = 1.77962438921838e-04f; N = 1.76877550388679f; }
            else if (velFps > 0) { A = 5.18033561289704e-05f; N = 1.98160270524632f; }
        }

        if (_bulletGModel == gModel.G5)
        {
            if (velFps > 1730) { A = 7.24854775171929e-03f; N = 1.41538574492812f; }
            else if (velFps > 1228) { A = 3.50563361516117e-05f; N = 2.13077307854948f; }
            else if (velFps > 1116) { A = 1.84029481181151e-13f; N = 4.81927320350395f; }
            else if (velFps > 1004) { A = 1.34713064017409e-22f; N = 7.8100555281422f; }
            else if (velFps > 837) { A = 1.03965974081168e-07f; N = 2.84204791809926f; }
            else if (velFps > 335) { A = 1.09301593869823e-04f; N = 1.81096361579504f; }
            else if (velFps > 0) { A = 3.51963178524273e-05f; N = 2.00477856801111f; }
        }

        if (_bulletGModel == gModel.G6)
        {
            if (velFps > 3236) { A = 0.0455384883480781f; N = 1.15997674041274f; }
            else if (velFps > 2065) { A = 7.167261849653769e-02f; N = 1.10704436538885f; }
            else if (velFps > 1311) { A = 1.66676386084348e-03f; N = 1.60085100195952f; }
            else if (velFps > 1144) { A = 1.01482730119215e-07f; N = 2.9569674731838f; }
            else if (velFps > 1004) { A = 4.31542773103552e-18f; N = 6.34106317069757f; }
            else if (velFps > 670) { A = 2.04835650496866e-05f; N = 2.11688446325998f; }
            else if (velFps > 0) { A = 7.50912466084823e-05f; N = 1.92031057847052f; }
        }

        if (_bulletGModel == gModel.G7)
        {
            if (velFps > 4200) { A = 1.29081656775919e-09f; N = 3.24121295355962f; }
            else if (velFps > 3000) { A = 0.0171422231434847f; N = 1.27907168025204f; }
            else if (velFps > 1470) { A = 2.33355948302505e-03f; N = 1.52693913274526f; }
            else if (velFps > 1260) { A = 7.97592111627665e-04f; N = 1.67688974440324f; }
            else if (velFps > 1110) { A = 5.71086414289273e-12f; N = 4.3212826264889f; }
            else if (velFps > 960) { A = 3.02865108244904e-17f; N = 5.99074203776707f; }
            else if (velFps > 670) { A = 7.52285155782535e-06f; N = 2.1738019851075f; }
            else if (velFps > 540) { A = 1.31766281225189e-05f; N = 2.08774690257991f; }
            else if (velFps > 0) { A = 1.34504843776525e-05f; N = 2.08702306738884f; }
        }

        if (_bulletGModel == gModel.G8)
        {
            if (velFps > 3571) { A = .0112263766252305f; N = 1.33207346655961f; }
            else if (velFps > 1841) { A = .0167252613732636f; N = 1.28662041261785f; }
            else if (velFps > 1120) { A = 2.20172456619625e-03f; N = 1.55636358091189f; }
            else if (velFps > 1088) { A = 2.0538037167098e-16f; N = 5.80410776994789f; }
            else if (velFps > 976) { A = 5.92182174254121e-12f; N = 4.29275576134191f; }
            else if (velFps > 0) { A = 4.3917343795117e-05f; N = 1.99978116283334f; }
        }

        if (A != -1 && N != -1 && velFps > 0 && velFps < 100000)
        {
            m_retardation = A * Mathf.Pow(velFps, N) / _ballisticCoefficient;
            m_retardation = m_retardation / k_msToFps;
        }
    }

    private void CalculateDrag()
    {
        m_drag = Time.fixedDeltaTime * m_retardation;
        m_vectorDrag = Vector3.Normalize(m_trueVelocity) * m_drag;
    }

    private void UpdateVelocity()
    {
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);
        m_rb.velocity = m_rb.velocity + m_gravity;
        m_rb.velocity = m_rb.velocity - m_vectorDrag;
    }

Yeah it doesn’t look very good, but it’ll do the job. We have a good ballistic model here but not very accurate for long range shots.
For better long range results we need to add the other effects. Let’s start with probably the most known one: Coriolis.

1.3 – Coriolis

Coriolis is a force that is due to the earth’s rotation. This force can deflect moving objects. The effect is different if you are in the northern hemisphere or in the southern hemisphere of the earth. 
This time we aren’t using some mathematical model we use the physic formula to calculate the deflection of the Coriolis effect. The formulas that we will use are the following:

Now let’s transform this equation into code. So we need a few more values, like the muzzle velocity as our initial speed and the current latitude of the bullet. We also need to get some values like the time of flight, the distance traveled and the direction of the bullet.

    ...

    [Tooltip("Feet per second")]
    public float _muzzleVelocity;
    [Tooltip("Degrees from equator")]
    public float _currentLatitude;

    private float m_startTime;
    private float m_timeOfFlight;
    private float m_bulletDirection;
    private float m_distance;

    private Vector3 m_startPosition;
    private Vector3 m_vectorCoriolis;
    private Vector3 m_previousCoriolisDeflection;

    private const float k_fpsToMs = 0.3048f;
    private const float k_omega = 0.000072921159f;
    private const float k_gravity = 9.80665f;

    ...

    private void OnEnable()
    {
        m_rb = GetComponent<Rigidbody>();
        ConvertUnits();
        SetInitialParameters();
    }

    private void FixedUpdate()
    {
        GetSpeed();
        GetTimeOfFlight();
        GetPosition();
        CalculateRetardation();
        CalculateDrag();
        CalculateCoriolis();
        UpdateVelocity();
    }

    ...

    private void ConvertUnits()
    {
        _currentLatitude = Mathf.PI / 180 * _currentLatitude;
        _muzzleVelocity = _muzzleVelocity * k_fpsToMs;
    }

    private void SetInitialParameters()
    {
        m_startTime = Time.time;
        m_startPosition = transform.position;

        m_gravity = new Vector3(0, Physics.gravity.y * Time.fixedDeltaTime, 0);
    }

    private void GetTimeOfFlight()
    {
        m_timeOfFlight = Time.time - m_startTime;
    }

    private void GetPosition()
    {
        m_bulletDirection = Mathf.Atan2(m_rb.velocity.z, m_rb.velocity.x);
        m_distance = Vector3.Distance(transform.position, m_startPosition);
    }

    private void CalculateCoriolis()
    {
        
        float speed = m_distance / m_timeOfFlight;
        float deflectionX = (k_omega * Mathf.Pow(m_distance, 2) * 
            Mathf.Sin(_currentLatitude)) / speed;

        float deflectionY = (1 - 2 * (k_omega * _muzzleVelocity / 
            k_gravity) * Mathf.Cos(_currentLatitude) * Mathf.Sin(m_bulletDirection));

        float drop = m_startPosition.y - transform.position.y;
        deflectionY = deflectionY * drop - drop;

        m_vectorCoriolis = new Vector3(deflectionX, deflectionY, 0);
        m_vectorCoriolis = m_vectorCoriolis - m_previousCoriolisDeflection;
        m_previousCoriolisDeflection = new Vector3(deflectionX, deflectionY, 0);
    }

    private void UpdateVelocity()
    {
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);
        m_rb.velocity = m_rb.velocity + m_gravity;
        m_rb.velocity = m_rb.velocity - m_vectorDrag;
        if (!float.IsNaN(m_vectorCoriolis.x) && 
            !float.IsNaN(m_vectorCoriolis.y) && 
            !float.IsNaN(m_vectorCoriolis.z))
        {
            m_rb.position = m_rb.position + m_vectorCoriolis;
        }
    }

1.4 – Eötvös effect

The Eötvös effect is the vertical “version” of the Coriolis drift. It affects the perceived gravitational force caused by the change in centrifugal acceleration. In simple, when the bullet moves eastbound the bullet drop less than if it goes westbound.  The formula that we will use is this one:

You might recognize the formula as it is based on the same principle of the one we used for Coriolis.
So in code now ! Pretty straight forward as it’s very similar to what we did above. Just add a new CalculateCentripetal method and call it in the FixedUpdate

Centripetal is the name of the force exerted by the Eötvös effect.
    ...

    private Vector3 m_vectorCentripetal;

    ...

    private void FixedUpdate()
    {
        GetSpeed();
        GetTimeOfFlight();
        GetPosition();
        CalculateRetardation();
        CalculateDrag();
        CalculateCoriolis();
        CalculateCentripetal();
        UpdateVelocity();
    }

    ...

    private void CalculateCentripetal()
    {
        float centripetalAcceleration = 2 * k_omega * (_muzzleVelocity / 
            k_gravity) * Mathf.Cos(_currentLatitude) * Mathf.Sin(m_bulletDirection);
        centripetalAcceleration = centripetalAcceleration * Time.fixedDeltaTime;

        m_vectorCentripetal = new Vector3(0, -centripetalAcceleration, 0);
    }

    private void UpdateVelocity()
    {
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);
        m_rb.velocity = m_rb.velocity + m_gravity;
        m_rb.velocity = m_rb.velocity - m_vectorDrag;
        m_rb.velocity = m_rb.velocity + m_vectorCentripetal;
        if (!float.IsNaN(m_vectorCoriolis.x) && 
            !float.IsNaN(m_vectorCoriolis.y) && 
            !float.IsNaN(m_vectorCoriolis.z))
        {
            m_rb.position = m_rb.position + m_vectorCoriolis;
        }
    }

1.5 – Spin Drift

Spin drift is an interaction of the bullet’s mass and aerodynamics with the atmosphere that it is flying in. There are many parameters that affect the magnitude of this effect. Such as bullet length, spin rate, range, air pressure, temperature and humidity.
Before calculating our spin drift we need to compute the gyro stability factor of the bullet. For that we use the method of Bob McCoy. This method is also based from an empirical method. And require a lot of parameters.

Well, I might have lost some of you here, sorry about that 🙂 No worries it is much easier once it’s in C# code.

Compared to that, the formula to calculate the spin drift is quite easy. You just need your gyro stability factor and your time of flight

Now that’s a lot of new parameters to add in our script. We need to add : the bullet mass, the bullet diameter (caliber), the bullet length, the barrel twist, the temperature and the relative humidity. All of theses will be in imperial units. Even if I personally dislike it, it is far easier to find gun related resources in Imperial Units. We will however do some conversion, as some formula (such as the air density) uses the metric system.
In our script we also need a CalculateStabilityFactor method that we will call from the OnEnable method and a CalculateSpinDrift method that we will call from the FixedUpdate method.

    [Tooltip("Grains")]
    public float _bulletMass;
    [Tooltip("Inches")]
    public float _bulletDiameter;
    [Tooltip("Inches")]
    public float _bulletLength;
    [Tooltip("Inches per twist")]
    public float _barrelTwist;
    [Tooltip("Fahrenheit")]
    public float _temperature;
    [Tooltip("Percent")]
    public float _relativeHumidity;
    [Tooltip("In Hg")]
    public float _airPressure;

    private float m_stabilityFactor;

    private Vector3 m_previousDrift;
    private Vector3 m_vectorSpin;

    private const float k_dryAir = 287.058f;
    private const float k_waterVapor = 461.495f;
    private const float k_kgm3Togrin3 = 0.252891f;
    private const float k_HgToPa = 3386.3886666718315f;

    ...

    private void OnEnable()
    {
        m_rb = GetComponent<Rigidbody>();
        ConvertUnits();
        SetInitialParameters();
        CalculateStabilityFactor();
    }

    private void FixedUpdate()
    {
        GetSpeed();
        GetTimeOfFlight();
        GetPosition();
        CalculateRetardation();
        CalculateDrag();
        CalculateSpinDrift();
        CalculateCoriolis();
        CalculateCentripetal();
        UpdateVelocity();
    }

    ...

    private void ConvertUnits()
    {
        _currentLatitude = Mathf.PI / 180 * _currentLatitude;
        _temperature = (_temperature - 32) * 5f / 9f;
        _airPressure = _airPressure * k_HgToPa;
    }

    private void CalculateStabilityFactor()
    {
        float dewPoint = _temperature - ((100f - _relativeHumidity) / 5f);
        float exponent = (7.5f * dewPoint)/(dewPoint + 237.8f);
        float pSat = 6.102f * Mathf.Pow(10, exponent);
        float pv = (_relativeHumidity / 100f) * pSat;
        float pd = _airPressure - pv;
        float temperatureKelvin = _temperature;
        float pAir = k_kgm3Togrin3 * (pd / (k_dryAir * temperatureKelvin)) 
            + (pv / (k_waterVapor * temperatureKelvin));

        float l = _bulletLength / _bulletDiameter;
        m_stabilityFactor = ((8 * Mathf.PI) / (pAir * 
            Mathf.Pow(_barrelTwist, 2) * Mathf.Pow(_bulletDiameter, 5) * 
            0.57f * l)) * ((_bulletMass * Mathf.Pow(_bulletDiameter, 2) / 
            (4.83f * (1 + Mathf.Pow(l, 2)))));
    }

    private void CalculateSpinDrift()
    {
        float spinDrift = 1.25f * (m_stabilityFactor + 1.2f) * Mathf.Pow(m_timeOfFlight, 1.83f);

        m_vectorSpin = new Vector3(spinDrift, 0, 0);
        m_vectorSpin = m_vectorSpin - m_previousDrift;
        m_previousDrift = new Vector3(spinDrift, 0, 0);
    }

    private void UpdateVelocity()
    {
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);
        m_rb.velocity = m_rb.velocity + m_gravity;
        m_rb.velocity = m_rb.velocity - m_vectorDrag;
        m_rb.velocity = m_rb.velocity + m_vectorCentripetal;
        if (!float.IsNaN(m_vectorCoriolis.x) && 
            !float.IsNaN(m_vectorCoriolis.y) && 
            !float.IsNaN(m_vectorCoriolis.z))
        {
            m_rb.position = m_rb.position + m_vectorCoriolis;
        }
        if (!float.IsNaN(m_vectorSpin.x) &&    
            !float.IsNaN(m_vectorSpin.y) &&   
            !float.IsNaN(m_vectorSpin.z))
        {
            m_rb.position = m_rb.position + m_vectorSpin;
        }
    }

1.6 – Wind

Let’s finish our external ballistic part with a simple one. The wind ! For that we just need a wind vector and add it in our existing calculations

    [Tooltip("m/s")]
    public Vector3 _windVect;

    ...

    private void GetSpeed()
    {
        m_trueVelocity = m_rb.velocity + _windVect * Time.fixedDeltaTime;
    }

    private void UpdateVelocity()
    {
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);
        m_rb.velocity = m_rb.velocity + m_gravity;
        m_rb.velocity = m_rb.velocity - m_vectorDrag;
        m_rb.velocity = m_rb.velocity + m_vectorCentripetal;
        if (!float.IsNaN(m_vectorCoriolis.x) && 
            !float.IsNaN(m_vectorCoriolis.y) && 
            !float.IsNaN(m_vectorCoriolis.z))
        {
            m_rb.position = m_rb.position + m_vectorCoriolis;
        }
        if (!float.IsNaN(m_vectorSpin.x) &&
            !float.IsNaN(m_vectorSpin.y) &&
            !float.IsNaN(m_vectorSpin.z))
        {
            m_rb.position = m_rb.position + m_vectorSpin;
        }
        m_rb.velocity = m_rb.velocity + _windVect * Time.fixedDeltaTime;
    }

Now we should have a pretty solid (and realistic) external ballistic for our bullets. Let’s see how bullets behave once they hit something.

2 – Terminal Ballistics

When a bullet hit something, three things can happen. The bullet can be stopped or ricochet or get through. For that we need to detect when the bullet collides with something. And as weird as it may seem, we will use Unity’s colliders and not raycasts for the initial detection. But we need to set the Rigidbody collision detection to Continuous Dynamic.

As we are using Unity’s collisions let’s add a OnCollisionEnter method and create a HandleHit method where we will put our physics stuff.

    [SerializeField]    
    private GameObject m_holePrefab = default;

    ...

    private void OnCollisionEnter(Collision collision)
    {
        HandleHit(collision);
    }

    ...

    private void HandleHit(Collision t_collision)
    {
        Collider t_collider = t_collision.collider;
        Instantiate(m_holePrefab, t_collision.contacts[0].point, 
            Quaternion.identity);
    }

2.1 – Ballistic Materials

Before adding more formulas in our ballistic script we need to know which material we are hitting. For that we will be using a ScriptableObject that contains all the data we need about every material we want. And we will make this scriptable object accessible from anywhere using the Singleton design pattern.
So for calculating terminal ballistics we need the material yield strength and its density. We also need some parameters to have some control over how the bullet will spread after impact.

Singleton is a design pattern. It means that the script can only have one instance. Usually this instance is static so it can be accessed from anywhere in code.
[CreateAssetMenu(fileName = "BallisticData", menuName = "MaterialDatabase", order = 1)]
public class BallisticMaterialDatabase : ScriptableObject
{
    #region Variables
    public BallisticMaterial[] _materials;
    #endregion
}

[System.Serializable]
public class BallisticMaterial
{
    #region Variables
    public string _name;
    public float _yieldStrenght;
    public float _density;
    public float _rndSpread;
    public float _rndSpreadRic;
    #endregion
}

Now we need to make our BallisticMaterialDatabase accessible from anywhere. For that we need a BallisticMaterialManager class that will take in parameter the ScriptableObject. And then this manager will have a static instance accessible from everywhere. We also need a GetMaterialFromName method that we will call from our ballistic scripts to fetch the information we need.

public class BallisticMaterialManager : MonoBehaviour
{
    public static BallisticMaterialManager Instance;
    public BallisticMaterialDatabase _database;

    void Start()
    {
        if (Instance == null)
            Instance = this;
    }

    public BallisticMaterial GetMaterialFromName(string t_name)
    {
        foreach (BallisticMaterial bMat in _database._materials)
        {
            if (bMat._name == t_name)
                return bMat;
        }
        return null;
    }
}

One last thing, we need to know which object use what material so here we can either use a tag system or to allow us more flexibility we can make a little script that will hold the material name for us. I will use this way as if later we want to add multiple layers of different materials we could rework the script instead of breaking everything.

public class BallisticMaterialHolder : MonoBehaviour
{
    #region Variables
    [SerializeField]
    private string m_materialName;

    public string Material
    {
        get { return m_materialName; }
    }
    #endregion
}

Let’s update the Ballistics script by adding our new BallisticMaterial system.

    private void HandleHit(Collision t_collision)
    {
        Collider t_collider = t_collision.collider;
        Instantiate(m_holePrefab, t_collision.contacts[0].point, Quaternion.identity);

        BallisticMaterialHolder bmh = t_collider.GetComponent<BallisticMaterialHolder>();
        BallisticMaterial bm;
        if (bmh != null)
        {
            bm = BallisticMaterialManager.Instance.GetMaterialFromName(bmh.Material);
        }
        else
        {
            bm = BallisticMaterialManager.Instance.GetMaterialFromName("Default");
        }
    }

2.2 – Bullet Penetration

Now we know what we are hitting. So let’s check if the bullet can go through !

For that we will be using formulas from this website. Using this we can calculate how much material the bullet can travel through.

Create a GetPenDist method where we will implement this formula. We need to add some variables such as the bullet area and the mass converted in grams.

    ...

    private float m_area;
    private float m_massGram;

    ...

    private const float k_grTog = 0.0647989f;
    private const float k_inTomm = 25.4f;

    ...

    private void SetInitialParameters()
    {
        ...

        m_area = Mathf.Pow((_bulletDiameter * k_inTomm) / 2, 2) * Mathf.PI;
        m_area /= 100f;

        m_massGram = _bulletMass * k_grTog;
    }

    ...

    private float GetPenDist(BallisticMaterial t_material, float t_bulletVel, float t_dragCoef)
    {
        float vthr = Mathf.Sqrt(2 * t_material._yieldStrenght / 
            (t_dragCoef * (t_material._density * 1000)));

        float Xc = (m_massGram / m_area) * (1 / (t_dragCoef * 
            t_material._density));
        
        return (Xc * Mathf.Log(1 + Mathf.Pow(t_bulletVel / vthr, 2)));
    }

    private void HandleHit(Collision t_collision)
    {
       ...

        float penDist = GetPenDist(bm, m_trueVelocity.magnitude, 1f) / 100.0f;
    }

We know what distance the bullet can travel through the material. So the next logical step is to know if with this given distance the bullet can make it through and if so, where does it exit.
In order to do that we need a method CheckIfGoThrough. This method will shoot raycasts from the point of entry to a point at the maximum distance the bullet can go to. Every single time the raycast hit something we will increment a int by 1. When we reach the target point we do the same in the other way without reseting our int. That means that after this, if the int is pair the bullet can go through but if not the bullet is stuck inside.

We can consider our objects by having multiple faces. So let's imagine that we make a raycast through a cube. The first time we gonna hit 2 faces. The one in front of us and the one behind it on the opposite side. Then if we go back we should again hit 2 faces. If you don't it 4 faces in total that mean that you are still inside the cube. Somewhere between the 2 faces.
    ...

    private Vector3 m_lastPosition;
    private Vector3 m_entryHole;
    private Vector3 m_exitHole;

    ...

     private void UpdateVelocity()
    {
        m_lastPosition = m_rb.velocity;
        if (m_rb.velocity != Vector3.zero)
            m_rb.rotation = Quaternion.LookRotation(m_rb.velocity);

        ...

    }

    ...

    private void HandleHit(Collision t_collision)
    {
        ...

        Vector3 bulletDir = (m_lastPosition - m_rb.position);
        Ray ray = new Ray(m_rb.position, bulletDir.normalized * penDist);
        m_rb.transform.position = CheckIfGoThrough(m_rb.position, ray.GetPoint(penDist));
    }


    private Vector3 CheckIfGoThrough(Vector3 t_start, Vector3 t_goal)
    {
        Vector3 point;
        Vector3 direction = t_goal - t_start;
        direction.Normalize();

        int iterations = 0;
        point = t_start;
        m_exitHole = Vector3.zero;
        m_entryHole = Vector3.zero;
        bool entry = false;
        while (point != t_goal)
        {
            RaycastHit hit;
            if (Physics.Linecast(point, t_goal, out hit))
            {
                if (hit.collider.name != this.name && entry == false)
                {
                    m_entryHole = hit.point;
                    entry = true;
                }
                iterations++;
                point = hit.point + (direction / 100.0f);
            }
            else
            {
                point = t_goal;
            }
        }
        while (point != t_start)
        {
            RaycastHit hit;
            if (Physics.Linecast(point, t_start, out hit))
            {
                if (hit.collider.name != this.name)
                {
                    iterations++;
                    m_exitHole = hit.point;
                }
                point = hit.point + (-direction / 100.0f);
            }
            else
            {
                point = t_start;
            }
        }

        if (iterations % 2 == 1)
        {
            Destroy(this.gameObject);
        }
        if (m_exitHole == Vector3.zero)
        {
            Destroy(this.gameObject);
        }
        return (m_exitHole + (direction / 100.0f));
    }

The last step is, if the bullet can go through, to calculate the new direction and new velocity of the bullet. Because it is really really hard to calculate the exit direction using true physics we are going to use the parameters from our BallisticMaterial to give a little offset to the original direction.
But no worries for the velocity we will use a bit of physics again 🙂

We need to calculate the amount of energy that the bullet lost while traveling through the material.
So we want to know the kinetic energy of the bullet and the change of energy exerted.

    ...   

    private float m_areaMeters;
    private float m_massKg;

    ...

    private void SetInitialParameters()
    {
        ...

        m_areaMeters = m_area * 0.0001f;
        m_massKg = m_massGram * 0.001f;
    }

    ...

    private void HandleHit(Collision t_collision)
    {
        ...

        float newVel = GetNewVelocity(bm, penDist);

        newDir = bulletDir;
        if (bm._rndSpread > 0)
        {
            newDir = (Quaternion.AngleAxis(Random.Range(0f, 360f), 
                bulletDir) * Quaternion.AngleAxis(Random.Range(0f, 
                bm._rndSpread), Vector3.Cross(Vector3.up, bulletDir)) * bulletDir);
        }


        if (float.IsNaN(newVel))
        {
            Destroy(this.gameObject);
            return;
        }
        m_rb.transform.LookAt(newDir);
        m_rb.velocity = newVel * transform.forward;
    }

    private float GetNewVelocity(BallisticMaterial t_bm, float t_penDist)
    {
        float kineticEnergy = (m_massKg * (m_trueVelocity.magnitude * 
            m_trueVelocity.magnitude)) / 2;
        float dx = t_penDist;
        if (m_rb.transform.position != Vector3.zero)
            dx = Vector3.Distance(m_entryHole, m_exitHole);
        float dE = ((m_areaMeters * (kineticEnergy / m_massKg) * 
            (t_bm._density * 1000f)) + (t_bm._yieldStrenght / 3 * m_areaMeters)) * dx;
        kineticEnergy -= (dE - kineticEnergy);
        return Mathf.Sqrt((kineticEnergy) / (m_massKg * 0.5f));
    }

2.3 – Ricochets

For this last part I’m sorry to say that I didn’t find any physic formula or mathematical model that is usable for us. So I made my own custom model that tries to take into account many parameters and add a bit of random to get our result.

We need to calculate the sectional density and the critical angle. Then we will make arbitrary coefficients for the penetration and the angle of impact.

Now we need to edit a bit our HandleHit method as we need to check for a ricochet before checking if the bullet can go through. We also need to create a CheckForRicochet method and calculate the angle of impact.

    ...

    [Tooltip("g/cm3")]
    public float _bulletDensity;

    ...

    private void HandleHit(Collision t_collision)
    {
        Collider t_collider = t_collision.collider;
        Instantiate(m_holePrefab, t_collision.contacts[0].point, Quaternion.identity);

        BallisticMaterialHolder bmh = t_collider.GetComponent<BallisticMaterialHolder>();
        BallisticMaterial bm;
        if (bmh != null)
        {
            bm = BallisticMaterialManager.Instance.GetMaterialFromName(bmh.Material);
        }
        else
        {
            bm = BallisticMaterialManager.Instance.GetMaterialFromName("Default");
        }

        float penDist = GetPenDist(bm, m_trueVelocity.magnitude, 1f) / 100.0f;
        float Aoi = 90f - Vector3.Angle(t_collision.contacts[0].normal * -1, transform.forward);

        float newVel = GetNewVelocity(bm, penDist);
        Vector3 newDir = transform.forward.normalized;

        if (CheckForRicochet(bm, penDist, Aoi))
        {
            float outAngle = Aoi - Mathf.Asin(Mathf.Clamp01(bm._density / 
                _bulletDensity) * Mathf.Sin((90f + Aoi) * Mathf.Deg2Rad)) * Mathf.Rad2Deg;

            outAngle = Random.Range(outAngle * (1 - (newVel / 
                m_trueVelocity.magnitude)), outAngle);

            newDir = Quaternion.AngleAxis(180f, 
                t_collision.contacts[0].normal * -1) * -transform.forward.normalized;
            newDir = Quaternion.AngleAxis(outAngle, transform.right) * newDir;
        }
        else
        {
            Vector3 bulletDir = (m_lastPosition - m_rb.position);
            Ray ray = new Ray(m_rb.position, bulletDir.normalized * penDist);
            m_rb.transform.position = CheckIfGoThrough(m_rb.position, ray.GetPoint(penDist));

            newDir = bulletDir;
            if (bm._rndSpread > 0)
            {
                newDir = (Quaternion.AngleAxis(Random.Range(0f, 360f), 
                   bulletDir) * Quaternion.AngleAxis(Random.Range(0f, 
                    bm._rndSpread), Vector3.Cross(Vector3.up, bulletDir)) * bulletDir);
            }

            if (float.IsNaN(newVel))
            {
                Destroy(this.gameObject);
                return;
            }
        }
        m_rb.transform.LookAt(newDir);
        m_rb.velocity = newVel * transform.forward;
    }

    private bool CheckForRicochet(BallisticMaterial t_material, float t_penDist, float t_angle)
    {
        float SD = _bulletMass / (7000f * _bulletDiameter * _bulletDiameter);

        float penCoef = Mathf.Clamp01(1 - (t_penDist * Mathf.Pow(SD, 0.25f)));
        float CriticalAngle = Mathf.Asin(t_material._density / _bulletDensity) * Mathf.Rad2Deg;
        CriticalAngle = CriticalAngle + (CriticalAngle * penCoef);

        float currentAngleCoef = Mathf.Log(1 + Mathf.Pow(t_angle / CriticalAngle, 2));
        float maxAngleCoef = Mathf.Log(1 + Mathf.Pow(90f / CriticalAngle, 2));
        float angleCoef = currentAngleCoef / maxAngleCoef;

        if (Random.Range(SD, 1f) <= 1 - angleCoef)
            return true;
        return false;
    }

You might see it’s not very complex, it isn’t very realistic either, but I tried to match my results with Arma 3 and it seems that it’s good enough.

3 – Conclusion

Here you have a really good ballistic model. In the next part we will add the weapon system and add this ballistic system to it.
If you can’t wait the next part to play with it you will need a bullet prefab which looks like this:

I filled the ballistic script with some data from a 5.56×45 NATO bullet.
Then you will need a Fire method that looks like this

    public void Fire()
    {
        GameObject bullet = Instantiate(m_bulletPrefab, m_muzzle.position, m_muzzle.rotation);
        Ballistics ballistics = bullet.GetComponent<Ballistics>();
        bullet.GetComponent<Rigidbody>().velocity = 
            ballistics._muzzleVelocity * 0.3048f * m_muzzle.forward;
    }
Some results you can expect

I hope you liked this physic heavy part. If you have any question feel free to come ask me on discord:

https://discord.gg/9JrfnSW