Last active
September 15, 2023 07:05
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}"); | |
} | |
} | |
} | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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