Skip to main content

⚙️ Procedural Animation: Trailing Joints

This is my second article on procedural animation. This time I’ll break down a simple “trailing” effect, for joints which “hang loose” and trail behind the primary movement. This is a subtle effect which can create a physical feel without too much actual physics.

Wing Trails

Wings trailing behind the character to create secondary motion.

I use this to produce layered secondary motion out of otherwise simple base animations. Many of them, like the dash, are just single-frame poses!

Limb Trails

Limbs also trail when dashing, to avoid looking stiff. All the motion here is coming from trailing and headlook!

As with IK, Unreal has some okay builtins to do this stuff, but I like writing my own so I can fine-tune them. I’ll be using Unreal C++ snippets for my explanation, but I’ll include a Unity C# listing at the bottom of the article.

Here’s the basic outline of my solution: we configure a relative “rest offset” from a “joint” bone where a “particle” sits when the system is at rest. When the joint moves, an “error” offset is induced between the particle and the target offset, which the particle resolves with a simulation, creating the trailing motion.

Figure 1

(1) The sytem at rest, (2) the joint offset, (3) the particle simulation.

Two-point systems like this only approximate rotational dynamics, but that’s fine! A “good enough” solution that’s simple and easy to configure is more useful than a “scientifically correct” simulation that’s full of weird, hard-to-pick coefficients. Games are theater!

To add a joint animation effect in unreal you need to implement the FAnimNode interface. I’m subtyping FAnimNode_SkeletalControlBase which takes care of most of the boilerplate.

USTRUCT( BlueprintInternalUseOnly )
struct FAnimNode_SimpleTrail : public FAnimNode_SkeletalControlBase

	// Configuration
	UPROPERTY(EditAnywhere, Category = Solver)
	FBoneReference JointBone;       // which bone will act at the "joint"
	UPROPERTY(EditAnywhere, Category = Solver)
	FVector LocalTarget;           // the relative offset from the joint to the target-position
	UPROPERTY(EditAnywhere, Category = Solver)
	float DampSeconds = 0.2f;      // the time it takes for the particle to resolve the target
	UPROPERTY(EditAnywhere, Category = Solver)
	float AngleLimit = 30.0f;      // the maximum allowed angle, in degrees

	// Simulation State
	FVector ParticlePos;     // the "particle" position
	FVector ParticleVel;     // the "particle" speed
	float DeltaTime = 0.0f;  // how many seconds to advance the simulation next?
	bool bReset = true;      // should we "reset" the simulation?

	// FAnimNode interface
	virtual bool NeedsDynamicReset() const override;
	virtual void InitializeBoneReferences( const FBoneContainer& RequiredBones ) override;
	virtual bool IsValidToEvaluate( const USkeleton* Skeleton, const FBoneContainer& RequiredBones ) override;
	virtual void ResetDynamics( ETeleportType InTeleportType ) override;
	virtual void UpdateInternal( const FAnimationUpdateContext& Context ) override;
	virtual void EvaluateSkeletalControl_AnyThread( FComponentSpacePoseContext& Output, TArray<FBoneTransform>&  OutBoneTransforms) override;

Adjusting LocalTarget tunes how much rotation is induced by movement. A smaller value will have a more extreme response to small movements, and a large value will only respond to large movements. Adjusting DampSeconds tunes how quickly the rotation returns to the rest pose (less time = stiffer feel).

I always prioritize picking configuration units people can understand, like distance and time, rather than “multipliers” or “fudge factors.”

The important method is EvaluateSkeletalControl_AnyThread which takes a pose as an input, and writes a list of bone transforms back to the animation system. We’ll breeze past the other methods quickly, they’re not interesting:

bool FAnimNode_SimpleTrail::NeedsDynamicReset() const
	// register for a callback when we need to "restart" the simulation (e.g. due to aniatmion state changes)
	return true;

void FAnimNode_SimpleTrail::InitializeBoneReferences( const FBoneContainer& RequiredBones )
	// map the bone name to a bone-index on a particular skeleton
	JointBone.Initialize( RequiredBones );

bool FAnimNode_SimpleTrail::IsValidToEvaluate( const USkeleton* Skeleton, const FBoneContainer& RequiredBones )
	// confirm our skeleton actually has the joint with the right name and that
	// our local target is nonzero (otherwise we can't convert the offset to a rotation)
	return JointBone.IsValidToEvaluate( RequiredBones ) && LocalTarget.SizeSquared() > 1e-4f;

void FAnimNode_SimpleTrail::ResetDynamics( ETeleportType InTeleportType )
	// set a flag for evaluate to use
	bReset = true;

void FAnimNode_SimpleTrail::UpdateInternal( const FAnimationUpdateContext& Context )
	// save the frame time for the simulation to consume
	Super::UpdateInternal( Context );
	DeltaTime = Context.GetDeltaTime();

Note that we’re often dealing with SizeSquared(), rather than Size() directly, because it’s cheaper to compute.

Before we write EvaluateSkeletalControl_AnyThread lets make a helper function which does the simulation logic. It’s actually really useful for other things, too! You really don’t need to understand the math to use it, though if you’re interested it implements the closed-form solution to the critically-damped spring equation.

void DampSpring( FVector& Pos, FVector& Vel, const FVector& Target, float DampSeconds, float DeltaTime )
	float Omega = 2.0f / Duration;
	float Exp = FMath::Exp( -Omega * DeltaTime );
	FVector Change = Value - Target;
	FVector Temp = ( Vel + Change * Omega ) * DeltaTime;
	Vel = ( Vel - Temp * Omega ) * Exp;
	Pos = Target + ( Change + Temp ) * Exp;

I originally encountered this function in Game Programming Gems 4.

Now we’re ready to evaluate the joint swing. We start by extracting the current bone transform in component-space (“CS”), and converting it to world-space (“WS”), and using that to compute our particle target.

void FAnimNode_SimpleTrail::EvaluateSkeletalControl_AnyThread( FComponentSpacePoseContext& Output, TArray<FBoneTransform>&  OutBoneTransforms )
	FBoneContainer& RequiredBones = Output.Pose.GetPose().GetBoneContainer();
	FCompactPoseBoneIndex BoneIndex = JointBone.GetCompactPoseIndex( RequiredBones );
	FTransform InBoneCS = Output.Pose.GetComponentSpaceTransform( BoneIndex );
	FTransform ToWorld = Output.AnimInstanceProxy->GetComponentTransform();
	FTransform InBoneWS = InBoneCS * ToWorld; // note that FTransform * order is transposed
	FVector ParticleTarget = InBoneWS.TransformPositionNoScale( LocalParticleTarget );

With the target in hand, we have everything we need to update the simulation (note that 1e-5f is just scientific-notion for 0.00001 i.e. “a very small number”).

	if( bReset ) // a reset was requested -- just snap to the target
		ParticlePos = ParticleTarget;
		ParticleVel = FVector::ZeroVector;
		bReset = false;
	else if( DeltaTime > 1e-5f ) // advance the simulation by DeltaTime
		DampSpring( ParticlePos, ParticleVel, ParticleTarget, DampSeconds, DeltaTime );
		DeltaTime = 0.0f;

With the particle and target positions we can compute the “swing” rotation from the target-offset to the particle-offset.

	FVector TargetOffset = ParticleTarget - InBoneWS.GetLocation();
	FVector CurrentOffset = ParticlePos - InBoneWS.GetLocation();
	FQuat Swing = FQuat::FindBetween( TargetOffset, CurrentOffset );

In order to constrain the swing to AngleLimit we have to decompose the quaternion into its Angle and Axis components. Unreal’s ToAxisAndAngle method does this for us.

	FVector SwingAxis;  // the direction of rotation in 3D, like the axle of a wheel
	float SwingRadians; // the "mathy" unit of rotation. pi radians = 180 degrees
	Swing.ToAxisAndAngle( SwingAxis, SwingRadians );

Now we check the angle, and if it’s too big, we adjust the particle position, making sure to cancel any speed to opposes the fixup.

	float LimitRadians = FMath::DegreesToRadians( AngleLimit );
	if( SwingRadians > LimitRadians )
		FQuat FixupRotation( SwingAxis, LimitRadians - SwingRadians );
		FVector FixupPos = InBoneWS.GetLocation() + FixupRotation * CurrentOff;
		FVector FixupOffset = FixupPos - ParticlePos;
		if( ( FixupOffset | ParticleVel ) < 0.0f ) // dot-product check
			ParticleVel -= ParticleVel.ProjectOnTo( FixupOffset );
		ParticlePos = FixupPos;
		Swing = FQuat( SwingAxis, LimitRadians );

Next I check the particle distance and pull it forward or push it back if it’s too-close or too far (this prevents it from getting “stuck” for extended periods of time when you move really fast).

	FVector ParticleOffset = ParticlePos - InBoneWS.GetLocation();
	float DistSq = ParticleOffset.SizeSquared();
	if( DistSq > 1e-5f ) // check to avoid a divide-by-zero edge-case
		float MinDistSq = 0.666f * 0.666f * LocalTarget.SizeSquared();
		float MaxDistSq = 1.333f * 1.333f * LocalTarget.SizeSquared();
		if( DistSq < MinDistSq )
			ParticlePos = InBoneWS.GetLocation() + ParticleOffset * FMath::Sqrt( MinDistSq / DistSq );
		else if( DistSq > MaxDistSq )
			ParticlePos = InBoneWS.GetLocation() + ParticleOffset * FMath::Sqrt( MaxDistSq / DistSq );

I determined with testing that about 1/3 further-out than normal is a limit that looks good in general (animation is full of fun magic numbers like this).

Finally, we compose the final result rotation, convert it from world-space back to component-space, and write it out:

	FQuat ResultWS = Swing * InBoneWS.GetRotation(); // note that FQuat * order is _NOT_ transposed
	FQuat ResultCS = ToWorld.GetRotation().Inverse() * ResultWS;
	OutBoneTransforms.Emplace( BoneIndex, FTransform( ResultCS, InBoneCS.GetLocation(), InBoneCS.GetScale3D() ) );

As always, this is just a base sample from which more complex AnimNodes can be forked.

Unity Code Listing

I’ll implement MonoBehavior for clarity, though you’d get better performance structuring this effect as an AnimationScriptPlayable job.

class TrailingBone
	public Vector3 localTarget;
	public float dampSeconds = 0.25f;
	public float angleLimit = 30.0f;

	Vector3 particlePos;
	Vector3 particleVel;

	A little hack to restore the my rest-pose every update.
	so there isn't a feedback-loop.  If you're downstream from an animation 
	that's over-writing the transform, you can skip this part.
	Quaternion jointRestPose;

	void Start()
		particlePos = transform.TransformPoint( localTarget );
		jointRestPose = transform.localRotation;

	void Update()
		transform.localRotation = jointRestPose;

		// simulation
		var jointPos = transform.position;
		var particleTarget = transform.TransformPoint( localTarget );
		particlePos = Mathf.SmoothDamp( particlePos, particleTarget, ref particleVel, dampSeconds );
		// angle limit
		var targetOffset = particleTarget - jointPos;
		var currentOffset = particlePos - jointPos;
		var swing = Quaternion.FromToRotation( targetOffset, currentOffset );
		swing.ToAngleAxis( out var swingDegrees, out var swingAxis );
		if( swingDegrees > angleLimit )
			var fixupRotation = Quaternion.AngleAxis( swingAxis, angleLimit - swingDegrees );
			var fixupPos = jointPos + fixupRotation * currentOffset;
			var fixupOffset = fixupPos - particlePos;
			if( Vector3.Dot( fixupOffset, particleVel ) < 0f )
				particleVel -= Vector3.Project( particleVel fixupOffset.normalized );
			particlePos = fixupPos;
			swing = QUaternion.AngleAxis( angleLimit, swingAxis );

		// distance limit
		var particleOffset = particlePos - jointPos;
		var distSq = particleOffset.sqrMagnitude;
		if( distSq > Mathf.Epsilon )
			var minDistSq = 0.666f * 0.666f * localTarget.sqrMagnitude;
			var maxDistSq = 1.333f * 1.333f * localTarget.sqrMagnitude;
			if( distSq < minDistSq )
				ParticlePos = jointPos + particleOffset * Mathf.Sqrt( minDistSq / distSq );
			else if( distSq > maxDistSq )
				ParticlePos = jointPos + particleOffset * Mathf.Sqrt( maxDistSq / distSq );

		transform.rotation = swing * transform.rotation;

Thanks for reading! ❤️