Skip to content

Instantly share code, notes, and snippets.

@FleshMobProductions
Last active September 15, 2023 07:05
Show Gist options
  • Save FleshMobProductions/716a05559f43fa079542f4211625ca2b to your computer and use it in GitHub Desktop.
Save FleshMobProductions/716a05559f43fa079542f4211625ca2b to your computer and use it in GitHub Desktop.
Raycast-based projectile tracking system - to make sure that objects receive collision callbacks even if projectiles move too fast and builtin physics detection doesn't report a hit
using UnityEngine;
namespace FMPUtils.Projectile
{
/// <summary>
/// Add this component to objects that should receive projectile impacts
/// </summary>
[RequireComponent(typeof(Collider))]
public class ProjectileImpactReceiver : MonoBehaviour
{
public delegate void ProjectileCollisionHandler(TrackedProjectile projectile, Collider projectileCollider, Vector3 hitPoint, Vector3 hitNormal);
/// <summary>
/// passed projectileCollider argument might be null if the hit comes from a raycast result
/// </summary>
public event ProjectileCollisionHandler Collided;
[SerializeField, Tooltip("If true, the projectile will not be destroyed on hit")]
private bool letProjectilePassThrough = false;
public bool LetProjectilePassThrough => letProjectilePassThrough;
// The current configuration has the issue that we might get callbacks from the same projectile multiple times,
// once through the raycastHit in the ProjectileManager and once here.
// It might be better to handle the OnCollisionEnter callback in the TrackedProjectile class instead and try to query
// for the ProjectileImpactReceiver there
// Other fixes might include not requiring colliders on the bullets or setting the colliders to a layer that doesn't
// interact with the environment
private void OnCollisionEnter(Collision collision)
{
var trackedProjectile = collision.collider.GetComponent<TrackedProjectile>();
if (trackedProjectile != null && collision.contactCount > 0)
{
var contact = collision.GetContact(0);
ReceiveImpact(trackedProjectile, collision.collider, contact.point, contact.normal);
if (!letProjectilePassThrough)
{
ProjectileManager.DestroyProjectile(trackedProjectile);
}
}
}
public void ReceiveImpact(TrackedProjectile projectile, Collider projectileCollider, Vector3 hitPoint, Vector3 hitNormal)
{
Collided?.Invoke(projectile, projectileCollider, hitPoint, hitNormal);
}
}
}
using System;
using System.Collections.Generic;
using UnityEngine;
using Unity.Collections;
using Unity.Jobs;
namespace FMPUtils.Projectile
{
public class ProjectileManager : MonoBehaviour
{
[Serializable]
public enum RaycastHitStrategy
{
SingleHit = 0,
MultiHit = 1
}
private static ProjectileManager instance;
public static ProjectileManager Instance => instance;
private static readonly int initialCapacity = 200;
private static readonly int maxCapacity = 1000;
private static readonly int projectileThresholdForJobUse = 50;
private static readonly int maxRaycastHits = 10;
private static readonly Predicate<TrackedProjectile> IsProjectileNull = (TrackedProjectile p) => p == null;
private static readonly RaycastHitDistanceComparer raycastHitDistanceComparer = new RaycastHitDistanceComparer();
[SerializeField]
private LayerMask projectileHitLayers = ~0; // By default use all layers
[SerializeField]
private RaycastHitStrategy hitDetectionStrategy = RaycastHitStrategy.SingleHit;
[SerializeField]
private bool disableRaycastJobs = false;
private List<TrackedProjectile> _projectiles = new List<TrackedProjectile>(initialCapacity);
private TrackedProjectile[] _projectilesSnapshot = new TrackedProjectile[maxCapacity];
private RaycastHit[] _temporaryHits = new RaycastHit[maxRaycastHits];
private RaycastHit[] _jobRaycastResults;
private void Awake()
{
if (instance != null)
{
Destroy(gameObject);
}
else
{
instance = this;
}
}
public bool AddTrackedProjectile(TrackedProjectile projectile)
{
if (_projectiles.Count >= maxCapacity) return false;
// For a performance optimization, the contains check can be skipped
if (!_projectiles.Contains(projectile))
{
_projectiles.Add(projectile);
return true;
}
return false;
}
public void RemoveTrackedProjectile(TrackedProjectile projectile)
{
_projectiles.Remove(projectile);
}
// In order to avoid double iterations, we call the update methods individually for the job requirement check branch
private void Update()
{
_projectiles.RemoveAll(IsProjectileNull);
// Accessing an index on _projectiles throws an error sometimes, when _projectiles doesn't have enough elements anymore,
// because when the destruction code queries a projectile for destroy, OnDisable is called which
// removes the projectile from the tracked projectiles, which is an issue honestly.
// For that reason we make a snapshot of projectiles to process. Iterating _projectiles backwards doesn't really work either
// because in the jobs setup, we rely on collection indices for the same TrackedProjectile object to stay consistent between
// scheduling the raycastCommands and evaluating the results.
// Counting in reverse would work for the non job-based logic, but I want to keep behaviour consistent.
// If the job-based logic is iterating in ascending order, the non-job code should do the same for projectile processing
int projectilesCount = _projectiles.Count;
for (int i = 0; i < projectilesCount; i++)
{
_projectilesSnapshot[i] = _projectiles[i];
}
int layerMask = projectileHitLayers;
bool useMultiHits = hitDetectionStrategy == RaycastHitStrategy.MultiHit;
if (!disableRaycastJobs && projectilesCount >= projectileThresholdForJobUse)
{
// https://docs.unity3d.com/ScriptReference/RaycastCommand.html
// The result for a command at index N in the command buffer will be stored at index N * maxHits in the results buffer
var commands = new NativeArray<RaycastCommand>(projectilesCount, Allocator.TempJob);
int maxHits = useMultiHits ? maxRaycastHits : 1;
int resultsCount = projectilesCount * maxHits;
// results' first invalid result per raycast is identified by the collider being null
var results = new NativeArray<RaycastHit>(resultsCount, Allocator.TempJob);
for (int i = 0; i < projectilesCount; i++)
{
var projectile = _projectilesSnapshot[i];
projectile.UpdatePositions();
var rayInputs = projectile.GetRaycastInputs();
// TODO: check for identical current and prev positions here and skip casting if there was no delta movement
commands[i] = new RaycastCommand(rayInputs.origin, rayInputs.direction, rayInputs.distance, layerMask, maxHits);
}
int minCommandsPerJob = 1;
JobHandle handle = RaycastCommand.ScheduleBatch(commands, results, minCommandsPerJob);
// Wait for the batch processing job to complete
handle.Complete();
// It might be faster to copy everything into a regular array and then iterate, because indexing a native array is pretty slow
// since native code interop has to happen I believe
// For all results of a single raycast, which has [maxHits] results, the vaid result raycastHits go until the first
// raycastHit.collider in the array is null. colliders afterwards are not initialized so there is no guarantee that they are also null
// Managed and native array need to have the same length
if (_jobRaycastResults == null || _jobRaycastResults.Length != resultsCount)
{
_jobRaycastResults = new RaycastHit[resultsCount];
}
results.CopyTo(_jobRaycastResults);
for (int i = 0; i < projectilesCount; i++)
{
int currentHitsStartIndex = i * maxHits;
var projectile = _projectilesSnapshot[i];
if (useMultiHits)
{
int validHitCount = 0;
for (int hitIndex = 0; hitIndex < maxHits; hitIndex++)
{
ref RaycastHit hit = ref _jobRaycastResults[currentHitsStartIndex + hitIndex];
if (hit.collider == null)
{
validHitCount = hitIndex;
break;
}
}
if (validHitCount > 0)
{
ProcessProjectileMultiHit(projectile, _jobRaycastResults, currentHitsStartIndex, validHitCount);
}
}
else
{
ProcessProjectileSingleHit(projectile, _jobRaycastResults[currentHitsStartIndex]);
}
}
commands.Dispose();
results.Dispose();
}
else
{
for (int i = 0; i < projectilesCount; i++)
{
var projectile = _projectilesSnapshot[i];
projectile.UpdatePositions();
if (projectile.HasDeltaMovement())
{
var rayInputs = projectile.GetRaycastInputs();
if (!useMultiHits && Physics.Raycast(rayInputs.origin, rayInputs.direction, out RaycastHit hitInfo, rayInputs.distance, layerMask))
{
ProcessProjectileSingleHit(projectile, hitInfo);
}
else if (useMultiHits)
{
int hitCount = Physics.RaycastNonAlloc(rayInputs.origin, rayInputs.direction, _temporaryHits, rayInputs.distance, layerMask);
ProcessProjectileMultiHit(projectile, _temporaryHits, 0, hitCount);
}
}
}
}
}
private bool ProcessHitAndCheckDestroyCondition(TrackedProjectile projectile, RaycastHit hit)
{
// Check for null because when we do a RaycastCommand job, we might get raycast hits that are invalid
// which is indicated by the collider being null
if (hit.collider != null)
{
var impactReceiver = hit.collider.GetComponent<ProjectileImpactReceiver>();
if (impactReceiver != null)
{
impactReceiver.ReceiveImpact(projectile, projectile.Collider, hit.point, hit.normal);
if (!impactReceiver.LetProjectilePassThrough)
{
return true;
}
}
}
return false;
}
// Could be inlined above to avoid RaycastHit struct copy costs
private void ProcessProjectileSingleHit(TrackedProjectile projectile, RaycastHit hitInfo)
{
if (ProcessHitAndCheckDestroyCondition(projectile, hitInfo))
{
DestroyProjectile(projectile);
}
}
private void ProcessProjectileMultiHit(TrackedProjectile projectile, RaycastHit[] hits, int hitStartIndex, int hitCount)
{
SortRaycastHitsAfterDistance(hits, hitStartIndex, hitCount);
int endIndex = hitStartIndex + hitCount;
for (int hitIndex = hitStartIndex; hitIndex < endIndex; hitIndex++)
{
if (ProcessHitAndCheckDestroyCondition(projectile, hits[hitIndex]))
{
DestroyProjectile(projectile);
break;
}
}
}
public static void DestroyProjectile(TrackedProjectile projectile)
{
Destroy(projectile.gameObject);
}
private void SortRaycastHitsAfterDistance(RaycastHit[] hits, int startIndex, int hitCount)
{
Array.Sort(hits, startIndex, hitCount, raycastHitDistanceComparer);
}
}
}
using UnityEngine;
namespace FMPUtils.Projectile.Examples
{
public class ProjectileTestCaster : MonoBehaviour
{
[SerializeField]
private Camera mainCamera;
[SerializeField, Tooltip("If not assigned, will generate a projectile procedurally")]
private GameObject projectilePrefab;
[SerializeField, Range(1f, 20f)]
private float projectileLifetime = 5f;
[SerializeField, Range(1f, 2000f), Tooltip("Speed assigned to the projectile rigidbody when shooting")]
private float projectileSpeed = 10f;
private void Update()
{
if (Input.GetMouseButtonDown(0))
{
if (mainCamera == null)
mainCamera = Camera.main;
Ray castRay = mainCamera.ScreenPointToRay(Input.mousePosition);
GameObject projectileInstance = null;
Rigidbody projectileRigidbody = null;
Quaternion projectileRotation = Quaternion.LookRotation(castRay.direction);
if (projectilePrefab != null)
{
projectileInstance = Instantiate(projectilePrefab, castRay.origin, projectileRotation);
projectileRigidbody = projectileInstance.GetComponent<Rigidbody>();
}
else
{
projectileInstance = GameObject.CreatePrimitive(PrimitiveType.Sphere);
projectileInstance.transform.SetPositionAndRotation(castRay.origin, projectileRotation);
projectileInstance.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f);
projectileInstance.AddComponent<TrackedProjectile>();
projectileRigidbody = projectileInstance.AddComponent<Rigidbody>();
}
Destroy(projectileInstance, projectileLifetime);
if (projectileRigidbody != null)
{
projectileRigidbody.isKinematic = false;
if (projectileRigidbody.IsSleeping())
{
projectileRigidbody.WakeUp();
}
projectileRigidbody.velocity = castRay.direction * projectileSpeed;
}
}
}
}
}
using System.Collections.Generic;
using UnityEngine;
namespace FMPUtils.Projectile
{
public class RaycastHitDistanceComparer : IComparer<RaycastHit>
{
public int Compare(RaycastHit x, RaycastHit y)
{
return x.distance.CompareTo(y.distance);
}
}
}
using UnityEngine;
namespace FMPUtils.Projectile.Examples
{
[RequireComponent(typeof(ProjectileImpactReceiver))]
public class ReceiveImpactLogger : MonoBehaviour
{
private ProjectileImpactReceiver impactReceiver;
private void Awake() => impactReceiver = GetComponent<ProjectileImpactReceiver>();
private void OnEnable()
{
if (impactReceiver != null)
impactReceiver.Collided += OnProjectileCollision;
}
private void OnDisable()
{
if (impactReceiver != null)
impactReceiver.Collided -= OnProjectileCollision;
}
private void OnProjectileCollision(TrackedProjectile projectile, Collider projectileCollider, Vector3 hitPoint, Vector3 hitNormal)
{
Debug.Log($"Projectile {projectile.name} collided with {name} at point {hitPoint}");
}
}
}
using UnityEngine;
namespace FMPUtils.Projectile
{
/// <summary>
/// Add this component to the projectile prefabs in the scene
/// </summary>
//[RequireComponent(typeof(Collider))]
public class TrackedProjectile : MonoBehaviour
{
private static readonly float sqrdDistanceThresholdForPrevPositionUpdate = 0.00001f;
private static readonly float rayStartToleranceDistance = 0.01f;
public Vector3 CurrentPosition => _currentPosition;
public Vector3 PreviousPosition => _previousPosition;
public Collider Collider { get; private set; }
private Vector3 _currentPosition;
private Vector3 _previousPosition;
private void Awake()
{
this.Collider = GetComponent<Collider>();
}
private void OnEnable()
{
ResetTrackedPositions();
ProjectileManager.Instance?.AddTrackedProjectile(this);
}
public void ResetTrackedPositions()
{
_currentPosition = transform.position;
_previousPosition = _currentPosition;
}
private void OnDisable()
{
ProjectileManager.Instance?.RemoveTrackedProjectile(this);
}
public ProjectileRayInputs GetRaycastInputs()
{
ProjectileRayInputs rayInputs;
rayInputs.origin = _previousPosition;
Vector3 prevToCurrent = _currentPosition - _previousPosition;
// Don't call .normalized because it would have to calculate the magnitude again
rayInputs.distance = prevToCurrent.magnitude;
if (rayInputs.distance > Mathf.Epsilon)
{
rayInputs.direction = prevToCurrent / rayInputs.distance;
// if distance is valid, append a bit of distance along the start point of the ray, so we can avoid some missed
// for objects that move against the projectile direction
rayInputs.distance += rayStartToleranceDistance;
rayInputs.origin -= rayInputs.direction * rayStartToleranceDistance;
}
else
rayInputs.direction = Vector3.zero;
return rayInputs;
}
public void UpdatePositions()
{
// Possible optimization, maybe we can use something like transform jobs?
Vector3 updatedPosition = transform.position;
if ((updatedPosition - _previousPosition).sqrMagnitude > sqrdDistanceThresholdForPrevPositionUpdate)
{
_previousPosition = _currentPosition;
}
_currentPosition = updatedPosition;
}
public bool HasDeltaMovement()
{
return _currentPosition.x != _previousPosition.x
|| _currentPosition.y != _previousPosition.y
|| _currentPosition.z != _previousPosition.z;
}
}
public struct ProjectileRayInputs
{
public Vector3 origin;
public Vector3 direction;
public float distance;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment