Split FollowerEntity logic into three steps (MovementUpdate, custom logic, FinalizeMovement) - UCC integration

Hi!
I will try to describe my problem in great detail because I’m probably not the only one who will benefit from the answer. Thanks in advance :slight_smile:

I want to integrate FollowerEntity into the Ultimate Character Controller (UCC) asset. UCC already has built-in integration with other components implementing IAstarAI (RichAI, AILerp, etc.), which is based on the steps in this order:

  1. Awake() → canMove = false
  2. Update() → calling IAstarAI.MovementUpdate(), modifying the returned values and assigning them to the variables Vector2 m_inputVector and Vector3 m_DeltaRotation.
/// <summary>
/// Updates the ability.
/// </summary>
public override void Update()
{
    m_IAstarAIAgent.MovementUpdate(Time.fixedDeltaTime, out m_NextPosition, out m_NextRotation);
    var localVelocity = m_Transform.InverseTransformDirection((m_NextPosition - m_CharacterLocomotion.Position) / Time.fixedDeltaTime);
    localVelocity.y = 0;
    // Only move if a path exists.
    m_InputVector = Vector2.zero;
    if (m_IAstarAIAgent.hasPath && localVelocity.sqrMagnitude > 0.01f && m_IAstarAIAgent.remainingDistance > 0.01f) {
        // Only normalize if the magnitude is greater than 1. This will allow the character to walk.
        if (localVelocity.sqrMagnitude > 1) {
            localVelocity.Normalize();
        }
        m_InputVector.x = localVelocity.x;
        m_InputVector.y = localVelocity.z;
    }

    var rotation = m_NextRotation * Quaternion.Inverse(m_Transform.rotation);
    m_DeltaRotation.y = Utility.MathUtility.ClampInnerAngle(rotation.eulerAngles.y);

    base.Update();
}
  1. Some internal UCC logic, takes previously cached m_inputVector and m_DeltaRotation, and modifies them, and saves the final movement in m_CharacterLocomotion.Position and m_CharacterLocomotion.Rotation.
  2. LateUpdate() → calling IAstarAI.FinalizeMovement() with m_CharacterLocomotion.Position and m_CharacterLocomotion.Rotation.
/// <summary>
/// Notify IAStarAI of the final position and rotation after the movement is complete.
/// </summary>
public override void LateUpdate()
{
    m_IAstarAIAgent.FinalizeMovement(m_CharacterLocomotion.Position, m_CharacterLocomotion.Rotation);
}

From the documentation, it seems that FollowerEntity does not support MovementUpdate or FinalizeMovement functions, instead it gives the ability to modify the movement using MovementOverrides callbacks. I tried to somehow logically split the existing logic, but unfortunately, it does not work out. I also took inspiration from the MecanimBridge component from A* Pathfinding Project examples.

MecanimBridge code:

void MovementOverride (Entity entity, float dt, ref LocalTransform localTransform, ref AgentCylinderShape shape, ref AgentMovementPlane movementPlane, ref DestinationPoint destination, ref MovementState movementState, ref MovementSettings movementSettings, ref MovementControl movementControl, ref ResolvedMovement resolvedMovement) {
	var desiredVelocity = math.normalizesafe(resolvedMovement.targetPoint - localTransform.Position) * resolvedMovement.speed;
	var currentRotation = movementPlane.value.ToPlane(localTransform.Rotation);

	var deltaRotationSpeed = AstarMath.DeltaAngle(currentRotation, resolvedMovement.targetRotation);
	deltaRotationSpeed = Mathf.Sign(deltaRotationSpeed) * Mathf.Clamp01(Mathf.Abs(deltaRotationSpeed) / math.max(0.001f, dt * resolvedMovement.rotationSpeed));
	deltaRotationSpeed = -deltaRotationSpeed * resolvedMovement.rotationSpeed;

	smoothedRotationSpeed = Mathf.Lerp(smoothedRotationSpeed, deltaRotationSpeed, angularVelocitySmoothing > 0 ? dt / angularVelocitySmoothing : 1);

	// Calculate the desired velocity relative to the character (+Z = forward, +X = right)
	var localDesiredVelocity = localTransform.InverseTransformDirection(desiredVelocity);
	localDesiredVelocity.y = 0;

	smoothedVelocity = Vector3.Lerp(smoothedVelocity, localDesiredVelocity, velocitySmoothing > 0 ? dt / velocitySmoothing : 1);
	if (smoothedVelocity.magnitude < 0.4f) {
		smoothedVelocity = smoothedVelocity.normalized * 0.4f;
	}

	var normalizedRotationSpeed = movementSettings.follower.maxRotationSpeed > 0 ? Mathf.Rad2Deg * Mathf.Abs(resolvedMovement.rotationSpeed) / movementSettings.follower.maxRotationSpeed : 0;
	var normalizedSpeed = movementSettings.follower.speed * naturalSpeed > 0 ? resolvedMovement.speed / naturalSpeed : 0;

	// Combine the normalized rotation speed and normalized speed such that either of them being large, results in the input magnitude being large.
	// This is to ensure that even if the agent wants to almost rotate on the spot, the input magnitude will still be large.
	var inputMagnitude = Mathf.Min(1, Mathf.Sqrt(normalizedSpeed*normalizedSpeed + normalizedRotationSpeed*normalizedRotationSpeed));
	anim.SetFloat(InputMagnitudeKeyHash, inputMagnitude);
	anim.SetFloat(XAxisKeyHash, smoothedRotationSpeed);
	anim.SetFloat(YAxisKeyHash, smoothedVelocity.z);

	// Calculate how much the agent should rotate during this frame
	var nextPosition = localTransform.Position;
	var nextRotation = localTransform.Rotation;

	// Apply rotational root motion
	nextRotation = anim.deltaRotation * nextRotation;

	nextPosition += (float3)anim.deltaPosition;

	resolvedMovement.targetPoint = nextPosition;
	resolvedMovement.targetRotation = movementPlane.value.ToPlane(nextRotation);
	// target rotation speed?
	resolvedMovement.speed = math.length(nextPosition - localTransform.Position) / math.max(0.001f, dt);
}

I currently have this:

private Vector2 _inputVector;
private Vector3 _deltaRotation;

public float velocitySmoothing = 1;
public float angularVelocitySmoothing = 1;
float smoothedRotationSpeed;
Vector3 smoothedVelocity;

private Vector3 newDeltaRotation;
private Vector2 newInputVector;

private bool newValues;
private Vector3 locomotionPosition;
private Quaternion locomotionRotation;

/// <summary>
///     Initialize the default values.
/// </summary>
public override void Awake()
{
	base.Awake();

	if (_astarAI is not FollowerEntity entity)
	{
		_astarAI.canMove = false;
	}
	else
	{
		_isFollowerEntityAI = true;
		_followerEntity = entity;
		_followerEntity.movementOverrides.AddBeforeMovementCallback(OverrideMovement);
	}
}

/// <summary>
///     Updates the ability.
/// </summary>
public override void Update()
{
	if (_isFollowerEntityAI)
	{
		newValues = false;
		_inputVector = newInputVector;
		_deltaRotation = newDeltaRotation;
		
		// newPositionVector = float3.zero;
		// newRotationValue = 0f;
		
		base.Update();
		return;
	}
	
	// The rest of the code that supports other AIMovement than FollowerEntity.
	// ...
}

/// <summary>
///     Notify IAStarAI of the final position and rotation after the movement is complete.
/// </summary>
public override void LateUpdate()
{
	if (_isFollowerEntityAI)
	{
		locomotionPosition = m_CharacterLocomotion.Position;
		locomotionRotation = m_CharacterLocomotion.Rotation;
		syncMovement = true;
		
		return;
	}

	_astarAI.FinalizeMovement(m_CharacterLocomotion.Position, m_CharacterLocomotion.Rotation);
}

private void OverrideMovement(Entity entity, float dt, ref LocalTransform localTransform, ref AgentCylinderShape shape, ref AgentMovementPlane movementPlane, ref DestinationPoint destination, ref MovementState movementState, ref MovementSettings movementSettings, ref MovementControl movementControl, ref ResolvedMovement resolvedMovement)
{
	float3 desiredVelocity = math.normalizesafe(resolvedMovement.targetPoint - localTransform.Position) * resolvedMovement.speed;
	float currentRotation = movementPlane.value.ToPlane(localTransform.Rotation);

	float deltaRotationSpeed = AstarMath.DeltaAngle(currentRotation, resolvedMovement.targetRotation);
	deltaRotationSpeed = Mathf.Sign(deltaRotationSpeed) * Mathf.Clamp01(Mathf.Abs(deltaRotationSpeed) / math.max(0.001f, dt * resolvedMovement.rotationSpeed));
	deltaRotationSpeed = -deltaRotationSpeed * resolvedMovement.rotationSpeed;

	smoothedRotationSpeed = Mathf.Lerp(smoothedRotationSpeed, deltaRotationSpeed, angularVelocitySmoothing > 0 ? dt / angularVelocitySmoothing : 1);

	// Calculate the desired velocity relative to the character (+Z = forward, +X = right)
	float3 localDesiredVelocity = localTransform.InverseTransformDirection(desiredVelocity);
	localDesiredVelocity.y = 0;

	smoothedVelocity = Vector3.Lerp(smoothedVelocity, localDesiredVelocity, velocitySmoothing > 0 ? dt / velocitySmoothing : 1);
	if (smoothedVelocity.magnitude < 0.4f) {
		smoothedVelocity = smoothedVelocity.normalized * 0.4f;
	}

	float rotationChange = smoothedRotationSpeed * dt;
	newDeltaRotation.y = rotationChange;
	newInputVector = new Vector2(smoothedRotationSpeed, smoothedVelocity.z);

	newValues = true;

	if (syncMovement)
	{
		// ... ?
		resolvedMovement.targetPoint = (float3)locomotionPosition;
		resolvedMovement.targetRotation = movementPlane.value.ToPlane(locomotionRotation);
		resolvedMovement.speed = math.length(locomotionPosition - (Vector3)localTransform.Position) / math.max(0.001f, dt);
		syncMovement = false;
	}
}

Result (enable fullscreen):
https://i.imgur.com/0yBEIcC.mp4

It doesn’t work properly. I tried to split it logically like this:

  1. Awake() → canMove = true
  2. Update() → updated values _inputVector and _deltaRotation based on the values calculated in the last OverrideMovement() function call.
  3. OverrideMovement() → calculates values newInputVector and newDeltaRotation, which will then be used in point 1. and updates resolvedMovement with the values cached in the last LateUpdate() call
  4. LateUpdate() → caches locomotionPosition and locomotionRotation, so that OverrideMovement can use them to update its position.

Maybe I should break it down differently or maybe I’m close to a solution, but I’m just assigning the wrong values? I’m very interested in the answer, as I’m sure anyone who wants to use FollowerEntity with a UCC asset will.

Thanks again

Trying to wrap my head around all this (I have never used UCC lol) but I am seeing that FollowerEntity uses FinalizeMovement() as well.

An important note from the docs on that page:

To be called as the last step when you are handling movement manually.

What do you make of this? Again, I’m trying to wrap my head around all this :slight_smile:

Thanks for your reply!
UCC is quite popular among Character Controllers, it helps focus on other stuff in the game other than handling movement/items, etc. I will try to explain all the necessary details because I am really keen to solve this problem for my personal projects :smiley:

UCC is based on a list of abilities (attack, crouch, movement, item pick-up, etc.). One of the abilities is precisely AI Navigation, which I am trying to modify in such a way that it supports not only A* algorithms, which support MovementUpdate and FinalizeMovement functions, but also precisely FollowerEntity, which does not support these functions.

I will repeat what I wrote in the first post, but more briefly:

Based on the built-in UCC ability, I see it’s working like this:

  1. Get the target movement of the agent using the MovementUpdate function and save the result.
  2. Process the result of the target movement internally by UCC (different loop than Update, but always after Update and before LateUpdate).
  3. LateUpdate get the processed values from point 2. and calls FinalizeMovement with these values.

UCC → Every ability has Update/LateUpdate functions, and they should not be confused with Unity Update/LateUpdate functions, a coincidence of names.

Now I’m trying to refactor this ability to make it work with FollowerEntity.

I am seeing that FollowerEntity uses FinalizeMovement() as well.

Where do you see that in my previous code? I know that FinalizeMovement cannot be called for FolloweEntity.

In my first post, I extracted all necessary code snippets (to simplify a little bit, hah), but for a better context, this is built-in UCC ability which I’m trying to add a support for FollowerEntity
InputVector and DeltaRotation is used internally by UCC in point 2.

[Tooltip("The distance away from the destination when the agent is stopping.")]
[SerializeField] protected float m_StoppingDistance = 0.2f;

private IAstarAI m_IAstarAIAgent;

private Vector2 m_InputVector;
private Vector3 m_DeltaRotation;

private Vector3 m_NextPosition;
private Quaternion m_NextRotation;
private bool m_PrevEnabled = true;

public override Vector2 InputVector { get { return m_InputVector; } }
public override Vector3 DeltaRotation { get { return m_DeltaRotation; } }
public override bool HasArrived { get { return m_IAstarAIAgent.remainingDistance <= m_StoppingDistance; } }

/// <summary>
/// Updates the ability.
/// </summary>
public override void Update()
{
    m_IAstarAIAgent.MovementUpdate(Time.fixedDeltaTime, out m_NextPosition, out m_NextRotation);
    var localVelocity = m_Transform.InverseTransformDirection((m_NextPosition - m_CharacterLocomotion.Position) / Time.fixedDeltaTime);
    localVelocity.y = 0;
    // Only move if a path exists.
    m_InputVector = Vector2.zero;
    if (m_IAstarAIAgent.hasPath && localVelocity.sqrMagnitude > 0.01f && m_IAstarAIAgent.remainingDistance > 0.01f) {
        // Only normalize if the magnitude is greater than 1. This will allow the character to walk.
        if (localVelocity.sqrMagnitude > 1) {
            localVelocity.Normalize();
        }
        m_InputVector.x = localVelocity.x;
        m_InputVector.y = localVelocity.z;
    }

    var rotation = m_NextRotation * Quaternion.Inverse(m_Transform.rotation);
    m_DeltaRotation.y = Utility.MathUtility.ClampInnerAngle(rotation.eulerAngles.y);

    base.Update();
}

/// <summary>
/// Notify IAStarAI of the final position and rotation after the movement is complete.
/// </summary>
public override void LateUpdate()
{
    m_IAstarAIAgent.FinalizeMovement(m_CharacterLocomotion.Position, m_CharacterLocomotion.Rotation);
}

Thanks for that! Helped me better compartmentalize this.

I think I see now- I saw FinalizeMovement is in FollowerEntity, but it’s private… I seeeee…

Going back to your original post now,

I think you’re on the right path here-- what happened with the overrides? What issues cropped up? Or was it just completely non-functional?

I think you’re on the right path here-- what happened with the overrides? What issues cropped up? Or was it just completely non-functional?

There was a video I posted a link to in the original post:
https://i.imgur.com/0yBEIcC.mp4
It’s moving, but really strangely.

As you can also see from the code I pasted in the first post, I use boolean flags there (newValues, syncMovement) to somehow keep this order Update (MovementUpdate)->internal processing->LateUpdate (FinalizeMovement). I don’t know if this is the optimal approach because there might be values from old frames in the current processing (OverrideMovement).

So I’m wondering if this is down to the OverrideMovement() code? Unfortuantely I can’t tell from the code snippets-- for example, you’re doing resolvedMovement.targetPoint - localTransform.Position but are those both world position so you’re getting the right direction in the first place?

I’d say start looking there, and find some way to verify that code is spitting out the right values? If you’re having troubles with that, feel free to send the project file my way and I can dig around as well.