Is it possible to control NetworkTransform Interpolation time?

Hello!

In my game, I am synchronizing player position using ClientNetworkTransform with Interpolation enabled. For regular movement this works perfectly fine and smoothens out everything.

However, when the character moves very quickly - for example dashing forward in a very short amount of time - the interpolation takes too long to keep up.
This makes the move look way too slow for all the non-authoritative clients.
I have tried to manually disable and enable this option which does help, but will lead to the transform essentially snapping in and out at those moments.

So I wanted to ask if there is a way to simply speed up the interpolation dynamically if for example the delta between the authority and non-authority exceeds a certain threshold?

1 Like

Will get an answer with a script in the next couple of days… you can accomplish what you are trying to do but it requires overriding the NetworkTransform.Update method and controlling when the non-authority side updates.
The example I will provide you should provide you with a framework to expand upon.

1 Like

Here is a dash example you can throw onto a box or the like and try out:

using System.Collections.Generic;
using Unity.Netcode;
using Unity.Netcode.Components;
using UnityEngine;


#if UNITY_EDITOR
using UnityEditor;
// This bypases the default custom editor for NetworkTransform
// and lets you modify your custom NetworkTransform's properties
// within the inspector view
[CustomEditor(typeof(PlayerMoveWithDash), true)]
public class PlayerMoveWithDashEditor : Editor
{
}
#endif
public class PlayerMoveWithDash : NetworkTransform
{
    // Normal movement speed
    public float Speed = 4.0f;

    // Normal roation speed
    public float RotSpeed = 1.0f;

    // Dash speed
    public float DashSpeed = 6.0f;

    // Distance to dash
    public float DashDistance = 4.0f;

    // Delta threshold when player has reached final dash point
    public float DashDelta = 0.25f;

    public AuthorityModes AuthorityMode;

    public enum AuthorityModes
    {
        Owner,
        Server
    }

    public enum PlayerStates
    {
        Normal,
        PendingDash,
        Dash
    }

    protected override bool OnIsServerAuthoritative()
    {
        return AuthorityMode == AuthorityModes.Server;
    }

    public class PlayerStateUpate : INetworkSerializable
    {
        public PlayerStates PlayerState;
        public Vector3 StartPos;
        public Vector3 EndPos;

        protected virtual void OnNetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
        {
        }

        public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
        {
            serializer.SerializeValue(ref PlayerState);
            if (PlayerState == PlayerStates.Dash)
            {
                serializer.SerializeValue(ref StartPos);
                serializer.SerializeValue(ref EndPos);
            }
            OnNetworkSerialize(serializer);
        }
    }

    private NetworkVariable<PlayerStateUpate> m_PlayerState = new NetworkVariable<PlayerStateUpate>(default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
    private List<PlayerStateUpate> m_PendingStates = new List<PlayerStateUpate>(new PlayerStateUpate[] { new PlayerStateUpate() { PlayerState = PlayerStates.Normal } });

    public override void OnNetworkSpawn()
    {
        if (IsOwner)
        {
            var temp = transform.position;
            temp.y = 0.5f;
            transform.position = temp;
        }
        else
        {
            m_PlayerState.OnValueChanged += OnPlayerStateChanged;
        }
        base.OnNetworkSpawn();
    }

    public override void OnNetworkDespawn()
    {
        m_PlayerState.OnValueChanged -= OnPlayerStateChanged;
        base.OnNetworkDespawn();
    }

    private void OnPlayerStateChanged(PlayerStateUpate previous, PlayerStateUpate current)
    {
        m_PendingStates.Add(current);
    }

    /// <summary>
    /// Override the OnAuthorityPushTransformState to apply dash values before sending player state update.
    /// This assures the local player's position is the most currently known position to other players.
    /// </summary>
    /// <param name="networkTransformState">The most current state sent to the client</param>
    protected override void OnAuthorityPushTransformState(ref NetworkTransformState networkTransformState)
    {
        var pendingState = m_PendingStates[m_PendingStates.Count - 1];

        // If we have a pending dash, then apply the dash values
        if (pendingState.PlayerState == PlayerStates.PendingDash)
        {
            var targetPosition = transform.position + (transform.forward * DashDistance);
            // Used by other players (nonauthority instances) to assure when they begin dashing
            // they are starting from a synchronized position.
            pendingState.StartPos = transform.position;
            // Used by both authority and nonauthority instances to determine when they have reached
            // a "close enough" distance, determined by DashDelta, to end the player's dash sequence.
            pendingState.EndPos = targetPosition;
            // Apply the updated state and mark it dirty
            m_PlayerState.Value = pendingState;
            m_PlayerState.SetDirty(true);
            // Change the local state to Dash
            pendingState.PlayerState = PlayerStates.Dash;
            // Apply these changes back into our pending states list
            m_PendingStates[m_PendingStates.Count - 1] = pendingState;
        }
        base.OnAuthorityPushTransformState(ref networkTransformState);
    }

    /// <summary>
    /// Normal player movement
    /// </summary>
    private void PlayerMove()
    {
        transform.position = Vector3.Lerp(transform.position, transform.position + Input.GetAxis("Vertical") * Speed * transform.forward, Time.fixedDeltaTime);
        var rotation = transform.rotation;
        var euler = rotation.eulerAngles;
        euler.y += Input.GetAxis("Horizontal") * 90 * RotSpeed * Time.fixedDeltaTime;
        rotation.eulerAngles = euler;
        transform.rotation = rotation;
    }

    /// <summary>
    /// Dash state update invoked by both the authority and the nonauthority instances
    /// </summary>
    /// <param name="pendingState"></param>
    private void PlayerDash(ref PlayerStateUpate pendingState)
    {
        transform.position = Vector3.Lerp(transform.position, pendingState.EndPos, Time.fixedDeltaTime * DashSpeed);
        var distance = Vector3.Distance(pendingState.EndPos, transform.position);
        if (distance <= DashDelta)
        {
            pendingState.PlayerState = PlayerStates.Normal;
        }
    }

    /// <summary>
    /// The Authority's primary update
    /// </summary>
    private void AuthorityStateUpdate()
    {
        var pendingState = m_PendingStates[m_PendingStates.Count - 1];
        if (Input.GetKeyDown(KeyCode.Space) && pendingState.PlayerState == PlayerStates.Normal)
        {
            m_PendingStates.Add(new PlayerStateUpate() { PlayerState = PlayerStates.PendingDash });
            Interpolate = false;
        }

        switch (pendingState.PlayerState)
        {
            case PlayerStates.PendingDash:
                {
                    // Nudge the authority instance to kick off a state update
                    // See OnAuthorityPushTransformState
                    var position = transform.position;
                    position += transform.forward * (PositionThreshold * 2);
                    transform.position = position;
                    break;
                }
            case PlayerStates.Dash:
                {
                    // Apply the dash
                    PlayerDash(ref pendingState);
                    // If the pending state was set back to normal, then
                    // remove that pending state (the normal state will always remain)
                    if (pendingState.PlayerState == PlayerStates.Normal)
                    {
                        m_PendingStates.Remove(pendingState);
                        Interpolate = true;
                    }
                    break;
                }
            case PlayerStates.Normal:
                {
                    PlayerMove();
                    break;
                }
        }
    }

    /// <summary>
    /// The nonauthority's primary update
    /// </summary>
    private void NonAuthorityStateUpdate()
    {
        var pendingState = m_PendingStates[m_PendingStates.Count - 1];
        switch(pendingState.PlayerState)
        {
            case PlayerStates.PendingDash:
                {
                  
                    var distance = Vector3.Distance(pendingState.StartPos, transform.position);
                    // Nonauthority will wait until it has interpolated to the StartPos
                    if (pendingState.PlayerState == PlayerStates.PendingDash && distance <= PositionThreshold)
                    {
                        // Once reached, start the dash sequence
                        pendingState.PlayerState = PlayerStates.Dash;
                        // Apply the state's changes
                        m_PendingStates[m_PendingStates.Count - 1] = pendingState;
                    }
                    break;
                }
            case PlayerStates.Dash:
                {
                    // Dash until we have reached the EndPos
                    PlayerDash(ref pendingState);
                    // If we reached the end position, the current pending state
                    // will be Normal and we remove it from the pendings states.
                    if (pendingState.PlayerState == PlayerStates.Normal)
                    {
                        m_PendingStates.Remove(pendingState);
                    }
                    break;
                }
        }
    }

    protected override void Update()
    {
        // If not spawned or the authority, exit early
        if (!IsSpawned || CanCommitToTransform)
        {
            return;
        }
        // If non-authority's current state is Normal, then just interpolate to the
        // authority's last sent state values.
        var pendingState = m_PendingStates[m_PendingStates.Count - 1];
        if (pendingState.PlayerState == PlayerStates.Normal)
        {
            base.Update();
        }
    }

    private void FixedUpdate()
    {
        // If not spawned, exit early
        if (!IsSpawned)
        {
            return;
        }
        // If we can commit to transform we are the authority
        if (CanCommitToTransform)
        {
            // Run authority update
            AuthorityStateUpdate();
        }
        else
        {
            // Otherwise, run the nonauthority update
            NonAuthorityStateUpdate();
        }
    }
}

The other components I used with the above NetworkTransform derived component:
9529813--1344862--upload_2023-12-14_12-58-14.png

The script is not an “end all be all” but should give you an idea of how you can accomplish synchronized motion that requires you to “bend the rules of interpolation” (so to speak).

Let me know if this helps you with your project?

1 Like

Thank you so much!!! This helps a lot and it looks exactly as smooth and fast as I wanted it to.
I was kind of hoping that it could be done in a more simple/straightforward manner but at least the result is great haha

So essentially I need to take steps to signal to all non-authorities when I am about to do a non-interpolated move. They will then ramp up to the most current position and replicate the same state-driven code locally with up-to-date parameters before going back to interpolating.

Cool, thanks again!

1 Like

Yeah,
For motion that goes outside of the bounds of the network tick bound interpolation, you definitely need to incorporate some form of state management and “override and control” the NetworkTransform’s update on non-owner instances. Once you mess around with this kind of approach, things will start to fall into place…especially why I recommend handling the “motion model” side of things on a derived NetworkTransform.
Either case, glad you find that example useful!
:wink:

any update for a simpler way ? what is the use of networkTransform.SetMaxInterpolationBound() ?
(I tried to set a small value like 0.1 but see no change)

This will set the maximum interpolation boundary for lerping where if you set something like 0.1 then when the statue update is being processed in the BufferedLinearInterpolators it will set the t (time) parameter in the Lerp method (typically a Vector3.Lerp but if using quaternions then it would be a Quaternion.Lerp).

We do have plans on updating the BufferedLinearInterpolator as there are some improvements and known edge case issues with it.

1 Like

I’m testing the last update

com.unity.netcode.gameobjects 2.3

however, I can’t see any effects when tweaking interpolation settings in NetworkTransform inspector while in Multiplayer Play Mode. The interpolation looks always at the slowest speed settings, whatever mode.
Enabling On/Off interpolation with main toggle works though.
Do I miss something ?

Make sure you are modifying the non-authority instance side. The maximum interpolation time is not a value that is synchronized.