Hitscan penetration

Cheo

Active member
Hello everyone, here are a few modifications to enable penetration for hitscan shots !

First of all, let's start with making penetrable surfaces by adding the following to the Surface Type script :

C#:
        [SerializeField] protected bool m_Penetrable = true;
        [SerializeField] protected float m_PenetrationMod = 0.5f;
        public bool Penetrable { get { return m_Penetrable; } }
        public float PenetrationMod {  get { return m_PenetrationMod; } }

Next, we also have to edit the Surface Type Inspector in order to display these parameters and save them :

Code:
        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();

            EditorGUI.BeginChangeCheck();
            EditorGUILayout.PropertyField(PropertyFromName("m_ImpactEffects"), true);
            EditorGUILayout.PropertyField(PropertyFromName("m_Penetrable"));
            EditorGUILayout.PropertyField(PropertyFromName("m_PenetrationMod"));
            if (EditorGUI.EndChangeCheck()) {
                Shared.Editor.Utility.EditorUtility.RecordUndoDirtyObject(target, "Change Value");
                serializedObject.ApplyModifiedProperties();
            }
        }

Let's then get to the Shootable Weapon script, create these parameters :

C#:
        [SerializeField] protected bool m_Penetrate = true;
        [SerializeField] protected float m_PenetrationMod = 0.5f;
        [SerializeField] protected int m_MaxPenetrations = 5;
        public bool Penetrate { get { return m_Penetrate; } set { m_Penetrate = value; } }
        public float PenetrationMod { get { return m_PenetrationMod; } set { m_PenetrationMod = value; } }
        public int MaxPenetrations { get { return m_MaxPenetrations; } set { m_MaxPenetrations = value; } }
 
And now change the HitscanFire method :

C#:
        private void HitscanFire(float strength)
        {
            // The hitscan should be fired from the center of the camera so the hitscan will always hit the correct crosshairs location.
            var useLookPosition = m_FireInLookSourceDirection && !m_CharacterLocomotion.ActiveMovementType.UseIndependentLook(false);
            var firePoint = useLookPosition ? m_LookSource.LookPosition(true) : m_ShootableWeaponPerspectiveProperties.FirePointLocation.position;
            var fireDirection = FireDirection(firePoint);
            var fireRay = new Ray(firePoint, fireDirection);
            // Prevent the ray between the character and the look source from causing a false collision.
            if (useLookPosition && !m_CharacterLocomotion.FirstPersonPerspective) {
                var direction = m_CharacterTransform.InverseTransformPoint(firePoint);
                direction.y = 0;
                fireRay.origin = fireRay.GetPoint(direction.magnitude);
            }
            var hitCount = Physics.RaycastNonAlloc(fireRay, m_HitscanRaycastHits, m_HitscanFireRange * strength, m_ImpactLayers.value, m_HitscanTriggerInteraction);
            var hasHit = false;

#if UNITY_EDITOR
            if (hitCount == m_MaxHitscanCollisionCount) {
                Debug.LogWarning($"Warning: The maximum number of colliders have been hit by {m_GameObject.name}. Consider increasing the Max Hitscan Collision Count value.", this);
            }
#endif

            int penetrationCount = 0;
            float currentDamage = m_DamageAmount;

            for (int i = 0; i < hitCount; ++i) {
                var closestRaycastHit = QuickSelect.SmallestK(m_HitscanRaycastHits, hitCount, i, m_RaycastHitComparer);
                var hitGameObject = closestRaycastHit.transform.gameObject;
                // The character can't shoot themself.
                if (hitGameObject.transform.IsChildOf(m_CharacterTransform)
#if FIRST_PERSON_CONTROLLER
                    // The cast should not hit any colliders who are a child of the camera.
                    || hitGameObject.GetCachedParentComponent<FirstPersonController.Character.FirstPersonObjects>() != null
#endif
                        ) {
                    continue;
                }

                // The shield can absorb some (or none) of the damage from the hitscan.
                //var damageAmount = m_DamageAmount;

                var damageAmount = currentDamage;



#if ULTIMATE_CHARACTER_CONTROLLER_MELEE
                ShieldCollider shieldCollider;
                if ((shieldCollider = hitGameObject.GetCachedComponent<ShieldCollider>()) != null) {
                    damageAmount = shieldCollider.Shield.Damage(this, damageAmount);
                }
#endif

                // Allow a custom event to be received.
                EventHandler.ExecuteEvent<float, Vector3, Vector3, GameObject, object, Collider>(closestRaycastHit.transform.gameObject, "OnObjectImpact", damageAmount, closestRaycastHit.point, m_ImpactForce * strength * fireDirection, m_Character, this, closestRaycastHit.collider);
                if (m_OnHitscanImpactEvent != null) {
                    m_OnHitscanImpactEvent.Invoke(damageAmount, closestRaycastHit.point, m_ImpactForce * strength * fireDirection, m_Character);
                }

                // If the shield didn't absorb all of the damage then it should be applied to the character.
                if (damageAmount > 0) {
                    var damageTarget = DamageUtility.GetDamageTarget(hitGameObject);
                    if (damageTarget != null) {
                        var pooledDamageData = GenericObjectPool.Get<DamageData>();
                        pooledDamageData.SetDamage(this, damageAmount, closestRaycastHit.point, fireDirection, m_ImpactForce * strength, m_ImpactForceFrames, 0, closestRaycastHit.collider);
                        var damageProcessorModule = m_CharacterLocomotion.gameObject.GetCachedComponent<DamageProcessorModule>();
                        if (damageProcessorModule != null) {
                            damageProcessorModule.ProcessDamage(m_DamageProcessor, damageTarget, pooledDamageData);
                        } else {
                            if (m_DamageProcessor == null) { m_DamageProcessor = DamageProcessor.Default; }
                            m_DamageProcessor.Process(damageTarget, pooledDamageData);
                        }
                        GenericObjectPool.Return(pooledDamageData);
                    } else {
                        // If the damage target exists it will apply a force to the rigidbody in addition to procesing the damage. Otherwise just apply the force to the rigidbody.
                        if (m_ImpactForce > 0 && closestRaycastHit.rigidbody != null && !closestRaycastHit.rigidbody.isKinematic) {
                            closestRaycastHit.rigidbody.AddForceAtPosition((m_ImpactForce * strength * MathUtility.RigidbodyForceMultiplier) * fireDirection, closestRaycastHit.point);
                        }
                    }
                }

                // Spawn a tracer which moves to the hit point.
                if (m_Tracer != null) {
                    SchedulerBase.ScheduleFixed(m_TracerSpawnDelay, AddHitscanTracer, closestRaycastHit.point);
                }

                // An optional state can be activated on the hit object.
                if (!string.IsNullOrEmpty(m_ImpactStateName)) {
                    var characterLocomotion = hitGameObject.GetCachedParentComponent<UltimateCharacterLocomotion>();
                    StateManager.SetState(characterLocomotion != null ? characterLocomotion.gameObject : hitGameObject, m_ImpactStateName, true);
                    // If the timer isn't -1 then the state should be disabled after a specified amount of time. If it is -1 then the state
                    // will have to be disabled manually.
                    if (m_ImpactStateDisableTimer != -1) {
                        StateManager.DeactivateStateTimer(hitGameObject, m_ImpactStateName, m_ImpactStateDisableTimer);
                    }
                }

                // The surface manager will apply effects based on the type of bullet hit.
                SurfaceManager.SpawnEffect(closestRaycastHit, m_SurfaceImpact, m_CharacterLocomotion.GravityDirection, m_CharacterLocomotion.TimeScale, m_Item.GetVisibleObject());

                hasHit = true;
                penetrationCount++;

                

                if (m_Penetrate)
                {
                    if (hitGameObject.GetComponent<SurfaceIdentifier>())
                    {
                        if (hitGameObject.GetComponent<SurfaceIdentifier>().SurfaceType.Penetrable)
                        {
                            currentDamage *= hitGameObject.GetComponent<SurfaceIdentifier>().SurfaceType.PenetrationMod;
                        }
                        else
                        {
                            break;
                        }
                    }
                    else
                    {
                        currentDamage *= m_PenetrationMod;
                    }
                }

                if (m_Penetrate && penetrationCount >= m_MaxPenetrations || !m_Penetrate)
                {
                    break;
                }
            }

            // A tracer should still be spawned if no object was hit.
            if (!hasHit && m_Tracer != null) {
                SchedulerBase.ScheduleFixed(m_TracerSpawnDelay, AddHitscanTracer,
                    MathUtility.TransformPoint(firePoint, Quaternion.LookRotation(fireDirection), new Vector3(0, 0, m_TracerDefaultLength)));
            }
        }
 
Lastly, we have to edit the Shootable Weapon Inspector as well, I personally added these under the "Impact" foldout :

C#:
                    EditorGUILayout.PropertyField(PropertyFromName("m_Penetrate"));
                    EditorGUILayout.PropertyField(PropertyFromName("m_PenetrationMod"));
                    EditorGUILayout.PropertyField(PropertyFromName("m_MaxPenetrations"));

And that's it ! Here is an example from my current project (kinda weird I know) :


I initially struggled to apprehend the creation of this mechanic, but once I found this for loop in Shootable Weapon the idea naturally arose ! It really isn't that complicated, and I think Opsive should add it to UCC ! Anyway don't hesitate to try it out for yourself and share your thoughts and ideas !
 
Hi, if I remember correctly I implemented it in my last project on Unity 2021.2.3f1 and UCC 2.4.3. No changes were made to the SurfaceType and ShootableWeapon in the latest updates, so you should be good to go ! Please let me know if you managed to reproduce it and are satisfied with the result !
 
Thanks for this, figured out how to get it integrated with V3 UCC. Pretty much all the same except for the last step has to be split between the new HitscanShooter script and the SimpleDamage script. I added a feature for penetration cost on surfaces as well. Not sure if its perfect, but is working well so far.

ImpactCollisionData.cs
Add this variable to pass data between HitscanShooter and SimpleDamage
C#:
public float DamageReductionMult = 1;

SurfaceType.cs
Add these variables, includes feature so that different surfaces can have a different penetration cost
C#:
        [SerializeField] protected bool m_Penetrable = false;
        [SerializeField] protected float m_PenetrationDamageMod = 0.5f;
        [SerializeField] protected int m_PenetrationCost = 1;

        public bool Penetrable { get { return m_Penetrable; } }
        public float PenetrationDamageMod { get { return m_PenetrationDamageMod; } }
        public int PenetrationCost { get { return m_PenetrationCost; } }

SurfaceTypeInspector.cs
Update this function
C#:
        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();

            EditorGUI.BeginChangeCheck();
            EditorGUILayout.PropertyField(PropertyFromName("m_ImpactEffects"), true);
            //Penetration
            EditorGUILayout.PropertyField(PropertyFromName("m_Penetrable"));
            EditorGUILayout.PropertyField(PropertyFromName("m_PenetrationDamageMod"));
            EditorGUILayout.PropertyField(PropertyFromName("m_PenetrationCost"));
            if (EditorGUI.EndChangeCheck()) {
                Shared.Editor.Utility.EditorUtility.RecordUndoDirtyObject(target, "Change Value");
                serializedObject.ApplyModifiedProperties();
            }
        }

HitscanShooter.cs
Add these variables and update this function
C#:
        [SerializeField] protected bool m_Penetrate = true;
        [SerializeField] protected int m_MaxPenetrations = 5;
        public bool Penetrate { get { return m_Penetrate; } set { m_Penetrate = value; } }
        public int MaxPenetrations { get { return m_MaxPenetrations; } set { m_MaxPenetrations = value; } }

        protected virtual void HitscanFire(ShootableUseDataStream dataStream, ShootableAmmoData ammoData)
        {
            // The hitscan should be fired from the center of the camera so the hitscan will always hit the correct crosshairs location.
            var firePoint = GetFirePoint(dataStream);
            var fireDirection = GetFireDirection(firePoint, dataStream);

            m_ShootableFireData.FirePoint = firePoint;
            m_ShootableFireData.FireDirection = fireDirection;

            var projectileData = GetProjectileDataToFire(dataStream, firePoint, fireDirection, ammoData);
            if (!projectileData.AmmoData.Valid) {
                // Dry fire.
                return;
            }

            // Prevent the ray between the character and the look source from causing a false collision.
            var useLookPosition = m_FireInLookSourceDirection && !CharacterLocomotion.ActiveMovementType.UseIndependentLook(false);
            var fireRay = new Ray(firePoint, fireDirection);
            if (useLookPosition && !CharacterLocomotion.FirstPersonPerspective) {
                var direction = CharacterTransform.InverseTransformPoint(firePoint);
                direction.y = 0;
                fireRay.origin = fireRay.GetPoint(direction.magnitude);
            }

            var strength = dataStream.TriggerData.Force;
            var hitCount = Physics.RaycastNonAlloc(fireRay, m_HitscanRaycastHits, m_HitscanFireRange * strength, m_ImpactLayers.value, m_HitscanTriggerInteraction);
            var hasHit = false;

#if UNITY_EDITOR
            if (hitCount == m_MaxHitscanCollisionCount) {
                Debug.LogWarning(
                    $"Warning: The maximum number of colliders have been hit by {GameObject.name}. Consider increasing the Max Hitscan Collision Count value.",
                    ShootableAction);
            }
#endif
            int penetrationCount = 0;

            for (int i = 0; i < hitCount; ++i) {
                var closestRaycastHit = QuickSelect.SmallestK(m_HitscanRaycastHits, hitCount, i, m_RaycastHitComparer);
                var hitGameObject = closestRaycastHit.transform.gameObject;
                // The character can't shoot themself.
                if (hitGameObject.transform.IsChildOf(CharacterTransform)
#if FIRST_PERSON_CONTROLLER
                    // The cast should not hit any colliders who are a child of the camera.
                    || hitGameObject.GetCachedParentComponent<FirstPersonController.Character.FirstPersonObjects>() !=
                    null
#endif
                   ) { continue; }

                var impactData = m_ShootableImpactCallbackContext.ImpactCollisionData;
                impactData.Reset();
                impactData.Initialize();
                impactData.SetRaycast(closestRaycastHit);
                impactData.SetImpactSource(ShootableAction);
                {
                    impactData.ImpactDirection = fireDirection;
                    impactData.ImpactStrength = strength;
                }

                ShootableAction.OnFireImpact(m_ShootableImpactCallbackContext);

                // Spawn a tracer which moves to the hit point.
                if (m_Tracer != null) {
                    Scheduler.ScheduleFixed(m_TracerSpawnDelay, AddHitscanTracer, closestRaycastHit.point);
                }

                hasHit = true;

                var penetrateCount = 1;
                var penetrateCount = 1;
                if (m_Penetrate && hitGameObject.TryGetComponent<SurfaceIdentifier>(out var surface))
                {
                    if (surface.SurfaceType.Penetrable)
                    {
                        //Divide the damage mod of each surface by the max penetrations to ensure it always can hit its desired penetration count
                        impactData.DamageReductionMult -= surface.SurfaceType.PenetrationDamageMod / m_MaxPenetrations;
                        penetrateCount = surface.SurfaceType.PenetrationCost;
                    }
                    else
                    {
                        penetrateCount = m_MaxPenetrations;
                    }
                }
                penetrationCount += penetrateCount;

                if (m_Penetrate && penetrationCount >= m_MaxPenetrations || !m_Penetrate)
                {
                    break;
                }
            }
            //Reset the damage reduction for the next shot
            m_ShootableImpactCallbackContext.ImpactCollisionData.DamageReductionMult = 1;
            // A tracer should still be spawned if no object was hit.
            if (!hasHit && m_Tracer != null) {
                Scheduler.ScheduleFixed(m_TracerSpawnDelay, AddHitscanTracer, MathUtility.TransformPoint(firePoint, Quaternion.LookRotation(fireDirection),
                                            new Vector3(0, 0, m_TracerDefaultLength)));
            }
        }

SimpleDamage.cs
Add this after all other damage modification done in OnImpactInternal() function (careful which one, there are 3 in the same file, its the last one in SimpleDamage class)
C#:
            // The shield can absorb some (or none) of the damage from the hitscan.
            var shieldCollider = impactData.ImpactCollider.gameObject.GetCachedComponent<ShieldCollider>();
            if (shieldCollider != null) {
                damageAmount = shieldCollider.ShieldAction.Damage(ctx, damageAmount);
            }

            //This is the addition to reduce damage from penetration
            if (ctx.ImpactCollisionData.DamageReductionMult < 1)
            {
                damageAmount *= ctx.ImpactCollisionData.DamageReductionMult;
            }

            var impactForceMagnitude = impactForce * impactData.ImpactStrength;
            var impactDirectionalForce = impactForceMagnitude * impactData.ImpactDirection;
 
Last edited:
Hello, I'm very glad to see that you updated my addition for UCC3, but I've had some incoherent results when pasting all of your code in a fresh project, the damage is either applied equally at its default value for all penetrated objects, or keeps getting reduced over time, meaning that if you shoot a single object again and again the damage keeps getting lower.

I need to ask you where exaclty did you put the last piece of code in Simple Damage, and whether you are using Use Context Data and/or Set Damage Impact Data ?

One last thing, I had to paste the values you added to Surface Type Inspector above the Impact Effects list, because they wouldn't be displayed otherwise. I'm using Unity 2022.3.8f1 and UCC 3.0.15 btw.

Thanks again for picking this up, hope you can answer my questions so we can make sure to have a perfect penetration feature.
 
Hey @Cheo I updated where the damage reduction should be applied. Couldn't put the whole thing as the post hit the word limit but it should give better context and warns about which OnImpactInternal should be updated.

If you are getting reduced damage on different shots on the same object you may have missed this in HitscanShooter. It resets the damage reduction every new bullet. The idea is that a single bullet can penetrate multiple things and the damage reduction accumulates. This can act odd if you have many colliders with surface types on your object. I had to make bullets only hit character colliders and not the capsule to work properly on them.
C#:
            //Reset the damage reduction for the next shot
            m_ShootableImpactCallbackContext.ImpactCollisionData.DamageReductionMult = 1;

Maybe there is a better way to pass the damage reduction to SimpleDamage?
 
Alright, it works now ! For anyone trying this out, dont give Penetration Damage Mod a high value, the lower it is the more objects the bullet can penetrate. A default value of 0.5 for it is too high btw, it limits the penetration count to 2. I had kept things simpler in my original script by directly using a modifier to multiply the damage value, but your way of first subtracting it to 1 works as well, everyone can adjust as they please.

Thanks again @Woods for updating this !
 
Ah I think I found the issue @Cheo I was working on a high penetration weapon and it was having issues getting it to go through some things. It was caused by that penetration default value. I fixed it in original post, the change is in HitscanShooter.HitScanFire(), now the PenetrationDamageMod doesn't prevent the weapon from reaching max penetrations and the damage reduction from each hit is scaled to the max penetrations. This results in less damage reduction per penetration so you might want to increase PenetrationDamageMod closer to 1.
C#:
var penetrateCount = 1;
                if (m_Penetrate && hitGameObject.TryGetComponent<SurfaceIdentifier>(out var surface))
                {
                    if (surface.SurfaceType.Penetrable)
                    {
                        //Divide the damage mod of each surface by the max penetrations to ensure it always can hit its desired penetration count
                        impactData.DamageReductionMult -= surface.SurfaceType.PenetrationDamageMod / m_MaxPenetrations;
                        penetrateCount = surface.SurfaceType.PenetrationCost;
                    }
                    else
                    {
                        penetrateCount = m_MaxPenetrations;
                    }
                }
 
Last edited:
Top