Search Unity

Bullet hit detection strategies & performance...

Discussion in 'Entity Component System' started by lclemens, Jul 12, 2020.

  1. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    I'm sure I'm not the only one wondering what the most performant and cleanest method for handling projectile collisions in ECS is. It's an interesting topic. Lots of good guys with guns, lots of bad guys with guns, and LOTS of bullets!

    For this analysis, we're assuming the physics shapes are simple spheres, rectangles, or other primitives. We don't care about hitting specific body parts - just that the bullet hits the primitive.

    So far, here are the strategies I've encountered:

    1) Use Unity Physics triggers - Using ITriggerEventsJob and triggers, check all collisions and look for bullets hitting things. One good thing is that use of triggers means this code only gets executed when objects collide, but I suspect that unity physics has to a lot of extra collision handling under the hood. [(+)only runs whenever things collide, (-)the unity physics system has more work every frame]
    Examples:
    DOTS-Shmup3D-sample-master - https://github.com/Unity-Technologi...Assets/Scripts/BaseSystem/CollisionManager.cs
    shaefsky - https://forum.unity.com/threads/handling-collisions.734033/#post-4903118

    2) Make a system with a brute-force nested loop "for every target" and "for every bullet" and checks to see if they are close enough to a target. This appears computationally heavy, but then again, perhaps the other methods have to loop through the same number of entities anyway and it's just hidden under the physics raycast/collision systems. Most of the examples I found using this method are from a couple of years ago - before unity.physics existed. [(-)runs every frame, (-)bullet * target iterations, (+)no extra work for unity physics]
    Examples:
    AngryDots - https://github.com/UnityTechnologie...Assets/Scripts/ECS/Systems/CollisionSystem.cs
    IntroToECSFinal - https://www.raywenderlich.com/7630142-entity-component-system-for-unity-getting-started
    Unity-Official-ECS-EntityComponentSystemSamples - https://github.com/lkihk/Unity-Offi...hooter/Hybrid/Assets/GameCode/DamageSystem.cs

    3) Make a system that loops through every bullet and does a forward raycast, while moving the bullet at the same time. I like this method since it kills two birds with one stone (movement and collision), and because raycasting supposedly pretty fast. If a raycast internally needs to check against everything in the scene, then this method would be the same speed as Method 2. It seems like this method is the most popular one at the moment, but maybe that's because it's easier (not necessarily faster), or perhaps it's because the physics package is relatively new. [(-)runs every frame, (?)one raycast per bullet, (+)no extra loop for bullet movement required].
    Examples:
    DotsOfTheDead - https://github.com/illogika-studio/...ripts/Systems/ProjectileHitDetectionSystem.cs
    Opeth001 - https://forum.unity.com/threads/collision-detection-projectiles.735992/#post-4908380

    4) Visual Effects Graph - There are a couple of guys on Reddit talking about using VFX Graph with baked textures for bullets because apparently VFXG has some sort of physics collider capability that is separate from unity physics. The GPU does most of the work. This method looks fast, but for me personally, I don't have enough details to reproduce what they're talking about. I'm sure there are some shader gurus around who could figure it out. [(-)doesn't work with unity colliders/shapes, (-)not many details on what they did, (+)runs fast even without ECS, (-)probably doesn't work on older mobile platforms]
    Example: grvChA - https://www.reddit.com/r/Unity3D/comments/era9dt/bullets_system_using_unity_visual_effects_graph/

    For completeness, I also found couple of other projects with solutions that don't really apply:
    SpaceShooterECS used some sort of hash table voodoo... I have no idea what the heck is going on in that code.
    SurvivalShooterECS just does an instant-hit raycast (no projectiles)

    Unfortunately I don't know enough about the internal workings of the physics package to make a determination about which one is best.

    Any insights for performance pros/cons? Methods I missed? Has anyone done any performance testing to compare the methods or know of someone who has tested some of them?
     
  2. Opeth001

    Opeth001

    Joined:
    Jan 28, 2017
    Posts:
    1,113
    the third approach is very fast (for my case) i have a maximum of 100 projectile per frame which is very low.
    to create collision Events im using a commandBuffer within a bursted Job and for this low events count it fits my needs.

    for my point of view the only problem this approach can have is the creation of thousands of Event Entities per frame.
    so if you are concerned with this case you should take a look at @tertle 's Event System package.
     
    lclemens likes this.
  3. burningmime

    burningmime

    Joined:
    Jan 25, 2014
    Posts:
    845
    I don't think anything involving physics (#1 and #3) does an O(N^2) lookup. For static meshes, they use a BVH/octree. If everything is moving, they probably use spatial hashing (and/or BIH) for the broad phase and only do the O(N^2) comparison for objects that are already close together.
     
    lclemens likes this.
  4. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    #3 except the normal flow is you track current/last position and raycast from current to last. You don't know how far a bullet is going to travel in any single frame so forward can either hit before the bullet arrives or not hit at all.
     
    lclemens likes this.
  5. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    That's good to know. I really like tertle's event system. I'll investigate a way to use his event system and batch create all bullets per frame.
     
  6. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    Thanks for the info. I had a hunch the broad phase might be able to improve things and I didn't know that physics uses tree pruning. I had to google spatial hashing just now and yes that definitely looks better than O(N^2). It's nice to have all this confirmed by someone in-the-know!
     
  7. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    That's good to know... I hadn't thought of that but it makes sense. The DotsOfTheDead example does what you stated - it raycasts from the current position to the previous position. I think Opeth001's algorithm accounts for that too, except he's doing the raycast from the current to the next predicted position to detect the collision before it has a chance to overshoot.
     
  8. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    4,255
    Unity uses two BVH structures, one for static objects and one for dynamic objects. These are true BVH structures and not some more primitive spatial hashing techniques. Now onto the different approaches:
    1) Pros: Logic already written by Unity. Code has been heavily optimized using the dual BVH structures. Cons: The filtering and processing of trigger events has to be done single-threaded.
    2) Pros: Easy to implement. Cons: Super slow and difficult to multithread.
    3) Pros: Cheaper than a trigger object. Heavy logic already written by Unity. Code has been heavily optimized using the dual BVH structures. Can order hits. Cons: Doesn't work for large bullets.
    4) Pros: Highest throughput. Cons: Requires GPU compute support. Has on average a 3 frame latency to get the results back to the CPU.

    While I doubt my solution is the fastest for your game, I wrote a solution that generates a CapsuleCollider scaled based on the movement every frame and queries the capsule collider against other objects it can collide against (I have big bullets). This is in a custom physics solution where colliders can be allocated in place (blobless) and instead of having two BVH structures, I can have an acceleration structure per type of object so I don't have to do post-event filtering. It also allows safe processing of events in parallel.

    Granted, a SphereCast solution that uses a ray vs Minkowski sum would be faster. Unity.Physics doesn't use that technique for spherecasts, and I haven't gotten around to implementing it myself yet.
     
  9. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    In practice you have other concerns that will likely be more challenging then the physics. Like rendering. Every feature level package in DOTS has it's own quirks that you really need to find before building out any one area too much. I would create a minimal yet complete end to end flow and use that as a base. As opposed to say putting a lot of time into optimizing the physics part before touching rendering. The transform systems has gotcha's also like hierarchy with a lot of moving objects. You want to discover things like that early not late.
     
    lclemens likes this.
  10. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    Thanks - you brought up some good pro/con points. That's pretty cool that you did a custom physics solution - that's beyond my capabilities at this moment. At this point, I think I have enough info so tomorrow morning I'll start implementing option 3 and see how it goes. I'm sure there's a good joke in here somewhere about having big bullets o_O
     
  11. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    That's good advice, and I concur. I won't get crazy with physics optimization, but I thought it was worth spending a day deciding on the best projectile strategy since it's a big part of my game and it has repercussions on how some of the other systems interact.
     
  12. hippocoder

    hippocoder

    Digital Ape

    Joined:
    Apr 11, 2010
    Posts:
    29,723
    Sorry to bump this subject but my query is for a number of bullets that need to hit static and dynamic colliders, but also function with prediction with dots netcode. Am I right in choosing method (3) above?

    The bullets have a slower travel time and can be dodged.

    As this topic is old, I want to see if any new information has come to light since before I implement it with Entities 0.50. Thanks!
     
  13. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    I can share my bullets job I've used in this demo

    There's a bunch of stuff in there that I would do differently & more cleanly if I re-wrote it today, but overall my hit detection strategy would be the same as this. This is basically like approach 3, except you can specify a radius for your bullet collision. If radius is 0f, it does a raycast; otherwise it does a spherecast. It also shows how to do filtering on detected hits, in a way that's more complex than simply relying on physics layers (useful for ignoring the specific gun that shot this bullet, or for going through surfaces based on the perpenticularity of the hit for example).

    But as you can see, despite every single bullet doing raycasts every frame and despite checking every hit to see if the entity is ignored, it's still extremely fast. The reason why I haven't written this as a Entities.ForEach is because of the "TmpRaycastHits" and "TmpColliderCastHits" collections that need to be unique to every thread

    It would be hard for me to recommend any other approach, because if you don't do some form of raycast from prevPosition to currentPosition, you won't have accurate hit point/normal info, and you're gonna start missing collisions as soon as your projectile is somewhat fast-moving. At a fixedUpdate rate of 60 and a bullet radius of 0.05f, if your bullets move any faster than a speed of 6 units/sec, they are going to start being at risk of missing collisions if you don't use a raycast approach (because that's the speed at which they would advance by a distance equal to their whole sphere diameter in one fixedUpdate). Unity.Physics does use Continuous Collision Detection for rigidbodies moved with velocity, but for something that moves fast it's gonna give you some very large imprecisions, and I'm fairly certain it would perform much worse

    Code (CSharp):
    1. [BurstCompile]
    2. public struct ProjectileJob : IJobEntityBatchWithIndex
    3. {
    4.     public float DeltaTime;
    5.     public EntityCommandBuffer.ParallelWriter DestructionCommandBuffer;
    6.     public EntityCommandBuffer.ParallelWriter HitEventsCommandBuffer;
    7.     [ReadOnly]
    8.     public PhysicsWorld PhysicsWorld;
    9.  
    10.     [ReadOnly]
    11.     public EntityTypeHandle EntityType;
    12.     [ReadOnly]
    13.     public ComponentTypeHandle<Projectile> ProjectileType;
    14.     public ComponentTypeHandle<ProjectileMoving> ProjectileMovingType;
    15.     public ComponentTypeHandle<Translation> TranslationType;
    16.     public ComponentTypeHandle<Rotation> RotationType;
    17.     [ReadOnly]
    18.     public BufferTypeHandle<ProjectileIgnoredEntity> IgnoreEntityBuffer;
    19.     [ReadOnly]
    20.     public BufferFromEntity<HitEvent> HitEventsBufferFromEntity;
    21.  
    22.     [NativeDisableContainerSafetyRestriction]
    23.     private NativeList<Unity.Physics.RaycastHit> TmpRaycastHits;
    24.     [NativeDisableContainerSafetyRestriction]
    25.     private NativeList<ColliderCastHit> TmpColliderCastHits;
    26.  
    27.     public unsafe void Execute(ArchetypeChunk batchInChunk, int batchIndex, int indexOfFirstEntityInQuery)
    28.     {
    29.         NativeArray<Entity> chunkEntities = batchInChunk.GetNativeArray(EntityType);
    30.         NativeArray<Projectile> chunkProjectiles = batchInChunk.GetNativeArray(ProjectileType);
    31.         NativeArray<ProjectileMoving> chunkProjectileMovings = batchInChunk.GetNativeArray(ProjectileMovingType);
    32.         NativeArray<Translation> chunkTranslations = batchInChunk.GetNativeArray(TranslationType);
    33.         NativeArray<Rotation> chunkRotations = batchInChunk.GetNativeArray(RotationType);
    34.         BufferAccessor<ProjectileIgnoredEntity> ignoreEntityBufferAccessor = batchInChunk.GetBufferAccessor(IgnoreEntityBuffer);
    35.  
    36.         if (!TmpRaycastHits.IsCreated)
    37.         {
    38.             TmpRaycastHits = new NativeList<Unity.Physics.RaycastHit>(24, Allocator.Temp);
    39.         }
    40.         if (!TmpColliderCastHits.IsCreated)
    41.         {
    42.             TmpColliderCastHits = new NativeList<ColliderCastHit>(24, Allocator.Temp);
    43.         }
    44.  
    45.         for (int i = 0; i < batchInChunk.Count; i++)
    46.         {
    47.             Entity entity = chunkEntities[i];
    48.             Projectile projectile = chunkProjectiles[i];
    49.             ProjectileMoving projectileMoving = chunkProjectileMovings[i];
    50.             Translation translation = chunkTranslations[i];
    51.             Rotation rotation = chunkRotations[i];
    52.             DynamicBuffer<ProjectileIgnoredEntity> ignoredEntitiesBuffer = ignoreEntityBufferAccessor[i];
    53.  
    54.             // Init
    55.             if (projectileMoving.LifetimeCounter == 0f)
    56.             {
    57.                 translation.Value = projectile.StartPointSimulation.pos;
    58.                 rotation.Value = projectile.StartPointSimulation.rot;
    59.  
    60.                 float3 moveDirection = math.mul(rotation.Value, math.forward());
    61.  
    62.                 projectileMoving.Velocity = moveDirection * projectileMoving.Speed;
    63.                 projectileMoving.SimulationToVisualsPositionOffset = projectile.StartPointVisual.pos - projectile.StartPointSimulation.pos;
    64.             }
    65.  
    66.             projectileMoving.LifetimeCounter += DeltaTime;
    67.  
    68.             float moveLength = math.length(projectileMoving.Velocity) * DeltaTime;
    69.             if (projectileMoving.MaxRange > 0f)
    70.             {
    71.                 moveLength = math.clamp(moveLength, 0f, projectileMoving.MaxRange - projectileMoving.DistanceTraveledCounter);
    72.             }
    73.             float3 movementDirection = math.normalizesafe(projectileMoving.Velocity);
    74.  
    75.             // Hit detection
    76.             CommonCastHit commonHit = default;
    77.             commonHit.Fraction = float.MaxValue;
    78.             CollisionFilter filter = new CollisionFilter();
    79.             filter.BelongsTo = projectileMoving.BelongsTo.Value;
    80.             filter.CollidesWith = projectileMoving.CollidesWith.Value;
    81.             if (projectileMoving.Radius > 0f)
    82.             {
    83.                 TmpColliderCastHits.Clear();
    84.                 if (PhysicsWorld.SphereCastAll(translation.Value, projectileMoving.Radius, movementDirection, moveLength, ref TmpColliderCastHits, filter, QueryInteraction.IgnoreTriggers))
    85.                 {
    86.                     for (int hitIndex = 0; hitIndex < TmpColliderCastHits.Length; hitIndex++)
    87.                     {
    88.                         ColliderCastHit hit = TmpColliderCastHits[hitIndex];
    89.                         if (hit.Fraction < commonHit.Fraction)
    90.                         {
    91.                             bool hitValid = true;
    92.                             for (int ignoreIndex = 0; ignoreIndex < ignoredEntitiesBuffer.Length; ignoreIndex++)
    93.                             {
    94.                                 if (hit.Entity == ignoredEntitiesBuffer[ignoreIndex].Entity)
    95.                                 {
    96.                                     hitValid = false;
    97.                                 }
    98.                             }
    99.  
    100.                             if (hitValid)
    101.                             {
    102.                                 commonHit = new CommonCastHit(hit);
    103.                             }
    104.                         }
    105.                     }
    106.                 }
    107.             }
    108.             else
    109.             {
    110.                 TmpRaycastHits.Clear();
    111.                 if (PhysicsUtilities.RaycastAll(in PhysicsWorld, translation.Value, movementDirection, moveLength, ref TmpRaycastHits, filter, true, true))
    112.                 {
    113.                     for (int hitIndex = 0; hitIndex < TmpRaycastHits.Length; hitIndex++)
    114.                     {
    115.                         RaycastHit hit = TmpRaycastHits[hitIndex];
    116.                         if (hit.Fraction < commonHit.Fraction)
    117.                         {
    118.                             bool hitValid = true;
    119.                             for (int ignoreIndex = 0; ignoreIndex < ignoredEntitiesBuffer.Length; ignoreIndex++)
    120.                             {
    121.                                 if (hit.Entity == ignoredEntitiesBuffer[ignoreIndex].Entity)
    122.                                 {
    123.                                     hitValid = false;
    124.                                 }
    125.                             }
    126.  
    127.                             if (hitValid)
    128.                             {
    129.                                 commonHit = new CommonCastHit(hit);
    130.                             }
    131.                         }
    132.                     }
    133.                 }
    134.             }
    135.  
    136.             float hitDistance = moveLength;
    137.             if (commonHit.Entity != Entity.Null)
    138.             {
    139.                 hitDistance = commonHit.Fraction * moveLength;
    140.                 projectileMoving.Hit = commonHit;
    141.  
    142.                 if (HitEventsBufferFromEntity.HasComponent(commonHit.Entity))
    143.                 {
    144.                     HitEventsCommandBuffer.AppendToBuffer(batchIndex, commonHit.Entity, new HitEvent(projectileMoving.Damage));
    145.                 }
    146.             }
    147.  
    148.             if (math.lengthsq(projectileMoving.Velocity) > 0f)
    149.             {
    150.                 rotation.Value = Maths.CreateRotationPointingTo(projectileMoving.Velocity);
    151.             }
    152.             translation.Value += movementDirection * hitDistance;
    153.             projectileMoving.DistanceTraveledThisFrame = hitDistance;
    154.             projectileMoving.DistanceTraveledCounter += hitDistance;
    155.  
    156.             // todo; custom gravity?
    157.             projectileMoving.Velocity += math.up() * -9.81f * projectileMoving.GravityFactor * DeltaTime;
    158.  
    159.             // Destruction (deferred)
    160.             if (projectile.AutoDestroy)
    161.             {
    162.                 if (commonHit.Entity != Entity.Null ||
    163.                     projectileMoving.LifetimeCounter >= projectileMoving.MaxLifetime ||
    164.                     projectileMoving.DistanceTraveledCounter >= projectileMoving.MaxRange)
    165.                 {
    166.                     DestructionCommandBuffer.DestroyEntity(batchIndex, entity);
    167.                 }
    168.             }
    169.  
    170.             chunkProjectileMovings[i] = projectileMoving;
    171.             chunkTranslations[i] = translation;
    172.             chunkRotations[i] = rotation;
    173.         }
    174.     }
    175. }
     
    Last edited: Apr 28, 2022
  14. hippocoder

    hippocoder

    Digital Ape

    Joined:
    Apr 11, 2010
    Posts:
    29,723
    Thank you! Digging through now and I can see the process, very helpful!
     
    bb8_1 likes this.
  15. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    I agree with PhilSA in that I haven't found anything superior to Approach 3 as of yet and I wrote that way back in July 2020. I'm still using it today for my linear projectiles and I know a lot of other guys in these forums use the approach as well. As @burningmime said, this approach uses the physics system's internal BHV/Oct-tree so it's quite a bit faster than searching through every entity for every projectile.

    Of course, the one gotcha is that you need to be using Unity.Physics already for that approach to work. If you're already using it, perfect. Also using the physics calculate AABB is a super fast way of detecting nearby targets. However... I would not advise using unity.physics for the sole purpose of making your projectiles faster because the physics systems take a large amount of horsepower by themselves. I lost 20% of my frame-time just by including unity.physics and putting colliders on the things I want to track.

    If I was not using unity.physics, I would probably use a home-grown quad tree to reduce the search space. There are several examples in these forums that work pretty well.
     
    hippocoder and bb8_1 like this.
  16. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    Do you mean you have colliders on your bullets?

    But more generally, if that's not already the case, you could try setting your PhysicsBody to "Kinematic" instead of "Static" for moving objects
     
  17. lclemens

    lclemens

    Joined:
    Feb 15, 2020
    Posts:
    760
    No I don't have colliders on the bullets. But in order for raycasting to work, all the things that a bullet can hit need colliders - moving enemies, props, buildings, terrain, etc.