Blueprint Beginner Question: Projectile ricochet via line trace. Adding a branch resolves a projectile stopping/disappearing issue, but I'm not sure why?

Hi all, I’m very new to Unreal Engine 5 and wanted to try building a basic target shooter game with some realistic projectile mechanics (interesting math + scalable learning opportunity from a hitscan line trace to gravity affected projectiles and surface penetration)

Notes:

  • Projectile logic is built in a Blueprint class component that is tied to a new projectile actor (sphere) that is fired from the weapon. Using first person shooter template with my new projectile actor replacing the original one
  • Purely trying to add basic ricochet at this point, loss of projectile energy, etc… will come later
  • Projectile actor has a 10 second lifetime (specified variable) before the actor is destroyed
  • Loosely referencing this video when I get stuck, but doing the math/modules myself as a learning experience
    *I’ve already implemented an effect from gravity/drag on the projectile
  • In my images below the hit results are the from a line trace ‘Out Hit’. Boolean condition is from the line trace ‘Return Value’ (did the line trace hit an actor?)

Setting the projectile speed super low result in the projectile bouncing (ricocheting) as expected (no loss of energy so it just keeps going)

When ramping this up to higher speeds (realistic bullet velocity) the ricochet is occurring, but appears to be happening sometime after the line trace returns a hit. Since the line trace is based on the vectors of the projectile actor and it’s moving so fast, I’m assuming that when the line trace returns a hit, the projectile actor has already kept traveling through the object before it realises it needs to change direction? (or something along those lines)

To deal with this, I tried making it so that the projectile actor location was instead changed to the location of the line trace impact point before it then applies the ricochet.

This resulted in the projectile actor correctly ricocheting from the point of impact (as found by the line trace, but now the projectile actor appears to disappear or stop dead in it’s tracks after a certain distance (wondering if the projectile stops after some change in delta seconds? Really not sure). Note, the last position variable is calculated by getting the current projectile actor velocity, multiplying it by the delta seconds and adding this to the current projectile actor position. If I set the projectile velocity to extremely low, I can see the projectile actor (sphere moving). In this case it appears to have just disappeared - I can no longer see it at the end of the line trace.

(above is just shooting the projectile with no impact in distance). Interestingly if the projectile ricochets off an object within this amount of time or distance before it disappears, it will continue to exist as expected. The projectile actor appears to disappear before the set 10 second lifetime has elapsed and it is destroyed.

Based on this, I assumed adding a branch might help (if the line trace impacts a point do the ricochet, otherwise just have the projectile travel forwards as normal)

Adding in a branch (condition is the ‘Return Value’ (Hit) boolean from the line trace results in the projectile acting as I would expect. It no longer appears to have ‘such a short lifetime’. Projectile now appears to fire as expected and last for the full 10 second lifetime duration before it is destroyed. The projectile actor can now travel as far as needed in the 10 second lifetime before ricocheting.

This appears to have resolved the issue, but I don’t understand why? Setting the branch to false results in the same issue (projectile disappearing after a short distance or time) as described above. I could understand if the branch was setup to have an “if projectile hit, then do ricochet” and “if projectile doesn’t hit, continue path as normal”, but only one condition is connected? As I said, I’m quite new to this so maybe I’m missing something obvious…

Questions:

  1. Why does adding this branch resolve the issue?
  2. Why does this only work if the branch is set to ‘True’ (Line trace hit an actor), not if it is set to ‘False’? (line trace doesn’t hit an actor)
  3. Why does this branch not need another condition set for the ‘False’ output in order to resolve this issue?

Hopefully my above questions/explanation make sense. Let me know if more detail/information is needed. Any help/insight would be greatly appreciated!

Cheers!

what is going on is that bool that is returned directly from the LineTraceSingleByX() is “did this line hit something” if that is false then the FHitResult (the C++ struct that the blueprint HitResult is) is only the default values (or what has already been set)

  • when ever you create something like a struct (a collection of variables in a known configuration and names) the variables/members it has “should” be given default values, otherwise unless it was set it should be considered to be untrustworthy garbage values

so when you don’t check whether the LineTrace actually hit something (that bool you fed into the branch) and check the values from the HitResult, in the event the Line Hit “nothing” then the values in that HitResult “should” be the defaults (if the value in the struct was never given a default value it will be effectively garbage because alloc/malloc are not trusted to reset the values before handing the memory address, because that would be “slow”)

looking at the FHitResult definition in the C++ (don’t worry about understanding this outright, only being shown for completeness)

struct ENGINE_API FHitResult
{
	GENERATED_BODY()

	/** Face index we hit (for complex hits with triangle meshes). */
	UPROPERTY()
	int32 FaceIndex;

	/**
	 * 'Time' of impact along trace direction (ranging from 0.0 to 1.0) if there is a hit, indicating time between TraceStart and TraceEnd.
	 * For swept movement (but not queries) this may be pulled back slightly from the actual time of impact, to prevent precision problems with adjacent geometry.
	 */
	UPROPERTY()
	float Time;
	 
	/** The distance from the TraceStart to the Location in world space. This value is 0 if there was an initial overlap (trace started inside another colliding object). */
	UPROPERTY()
	float Distance;
	
	/**
	 * The location in world space where the moving shape would end up against the impacted object, if there is a hit. Equal to the point of impact for line tests.
	 * Example: for a sphere trace test, this is the point where the center of the sphere would be located when it touched the other object.
	 * For swept movement (but not queries) this may not equal the final location of the shape since hits are pulled back slightly to prevent precision issues from overlapping another surface.
	 */
	UPROPERTY()
	FVector_NetQuantize Location;

	/**
	 * Location in world space of the actual contact of the trace shape (box, sphere, ray, etc) with the impacted object.
	 * Example: for a sphere trace test, this is the point where the surface of the sphere touches the other object.
	 * @note: In the case of initial overlap (bStartPenetrating=true), ImpactPoint will be the same as Location because there is no meaningful single impact point to report.
	 */
	UPROPERTY()
	FVector_NetQuantize ImpactPoint;

	/**
	 * Normal of the hit in world space, for the object that was swept. Equal to ImpactNormal for line tests.
	 * This is computed for capsules and spheres, otherwise it will be the same as ImpactNormal.
	 * Example: for a sphere trace test, this is a normalized vector pointing in towards the center of the sphere at the point of impact.
	 */
	UPROPERTY()
	FVector_NetQuantizeNormal Normal;

	/**
	 * Normal of the hit in world space, for the object that was hit by the sweep, if any.
	 * For example if a sphere hits a flat plane, this is a normalized vector pointing out from the plane.
	 * In the case of impact with a corner or edge of a surface, usually the "most opposing" normal (opposed to the query direction) is chosen.
	 */
	UPROPERTY()
	FVector_NetQuantizeNormal ImpactNormal;

	/**
	 * Start location of the trace.
	 * For example if a sphere is swept against the world, this is the starting location of the center of the sphere.
	 */
	UPROPERTY()
	FVector_NetQuantize TraceStart;

	/**
	 * End location of the trace; this is NOT where the impact occurred (if any), but the furthest point in the attempted sweep.
	 * For example if a sphere is swept against the world, this would be the center of the sphere if there was no blocking hit.
	 */
	UPROPERTY()
	FVector_NetQuantize TraceEnd;

	/**
	  * If this test started in penetration (bStartPenetrating is true) and a depenetration vector can be computed,
	  * this value is the distance along Normal that will result in moving out of penetration.
	  * If the distance cannot be computed, this distance will be zero.
	  */
	UPROPERTY()
	float PenetrationDepth;

	/** If the hit result is from a collision this will have extra info about the item that hit the second item. */
	UPROPERTY()
	int32 MyItem;

	/** Extra data about item that was hit (hit primitive specific). */
	UPROPERTY()
	int32 Item;

	/** Index to item that was hit, also hit primitive specific. */
	UPROPERTY()
	uint8 ElementIndex;

	/** Indicates if this hit was a result of blocking collision. If false, there was no hit or it was an overlap/touch instead. */
	UPROPERTY()
	uint8 bBlockingHit : 1;

	/**
	 * Whether the trace started in penetration, i.e. with an initial blocking overlap.
	 * In the case of penetration, if PenetrationDepth > 0.f, then it will represent the distance along the Normal vector that will result in
	 * minimal contact between the swept shape and the object that was hit. In this case, ImpactNormal will be the normal opposed to movement at that location
	 * (ie, Normal may not equal ImpactNormal). ImpactPoint will be the same as Location, since there is no single impact point to report.
	 */
	UPROPERTY()
	uint8 bStartPenetrating : 1;

	/**
	 * Physical material that was hit.
	 * @note Must set bReturnPhysicalMaterial on the swept PrimitiveComponent or in the query params for this to be returned.
	 */
	UPROPERTY()
	TWeakObjectPtr<UPhysicalMaterial> PhysMaterial;

	/** Handle to the object hit by the trace. */
	UPROPERTY()
	FActorInstanceHandle HitObjectHandle;

	/** PrimitiveComponent hit by the trace. */
	UPROPERTY()
	TWeakObjectPtr<UPrimitiveComponent> Component;

	/** Name of bone we hit (for skeletal meshes). */
	UPROPERTY()
	FName BoneName;

	/** Name of the _my_ bone which took part in hit event (in case of two skeletal meshes colliding). */
	UPROPERTY()
	FName MyBoneName;


	FHitResult()
	{
		Init();
	}
	
	explicit FHitResult(float InTime)
	{
		Init();
		Time = InTime;
	}

	explicit FHitResult(EForceInit InInit)
	{
		Init();
	}

	explicit FHitResult(ENoInit NoInit)
	{
	}

	explicit FHitResult(FVector Start, FVector End)
	{
		Init(Start, End);
	}

	/** Initialize empty hit result with given time. */
	FORCEINLINE void Init()
	{
		FMemory::Memzero(this, sizeof(FHitResult));
		HitObjectHandle = FActorInstanceHandle();
		Time = 1.f;
		MyItem = INDEX_NONE;
	}

	/** Initialize empty hit result with given time, TraceStart, and TraceEnd */
	FORCEINLINE void Init(FVector Start, FVector End)
	{
		FMemory::Memzero(this, sizeof(FHitResult));
		HitObjectHandle = FActorInstanceHandle();
		Time = 1.f;
		TraceStart = Start;
		TraceEnd = End;
		MyItem = INDEX_NONE;
	}
//...
};

only the bBlockingHit and bStartPenetrating have a default value set, and the constructors (the functions that have the same name as the class/struct) are call-through into Init() functions which do set some values. now if you examine the given LineTrace blueprint node you might notice that you are also setting up some of these values into the the output HitResult.

  • TLDR: so when you ignore the bool for ActorHit and just check the values in the HitResult the values you are reading could be anywhere between factual accurate, the default of the datatype, to random values that can produce a LOT of difficult to track down issues if used.
  • if the bool is false then the line hit nothing. this means either the execution line should end (if this is the last thing in the line), or it should it should join back up with the execution path for when you are done resolving the hit.

in programming (visual scripting with what Unreal calls blueprints is still programming) if a node/function returns a bool as one of the top level pins in most cases that is something along the line of “did the function do what it says”,
You should in most cases do something with the return value. there are situations where you only want the secondary effects of the function, or it is “OK” for the bool to be either state you just want like the math, or some other resultant effect

2 Likes

Curious as to why you are using a line trace to determine ricochet when the projectile itself would return the proper normal to use?

With fast moving actors you need to use a collision component (e.g. sphere collision component) as the parent and then a static mesh (no collision) as child for visual representation. SM’s collisions aren’t “good enough”.

The Projectile Movement Component (PMC) has ricochet (bounce) already implemented. You can take a look at how it’s applied via source.


ProjectileMovementComponent.cpp (line 223)

// Move the component
if (bShouldBounce)
{
	// If we can bounce, we are allowed to move out of penetrations, so use 
	// SafeMoveUpdatedComponent which does that automatically.
	SafeMoveUpdatedComponent( MoveDelta, NewRotation, bSweepCollision, Hit );
}

MovementComponent.cpp
→ SafeMoveUpdatedComponent (line 558)
→ GetPenetrationAdjustment ( line 608 )
→ ResolvePenetrationImpl ( line 624 )

1 Like

Sorry for the late reply, I’ve been away and didn’t have access to my computer.

Thanks for the super detailed answer @gardian206! That’s a great bit of knowledge that would have taken me a very long time to find on my own. What you’ve said makes a lot of sense and hopefully others will find this useful too.

@Rev0verDrive There’s so may ways to do things in unreal, I just took the first method I found that I could get working. I didn’t even know the projectile movement component existed. I’ll definitely have a read/play around with it as (at a glance) it looks like it’ll do basically everything I’m looking for. Thanks for the heads up!

Again, thank you both for your help. I really appreciate the quick replies.

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.