Search Unity

  1. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Question Unity Netcode Interpolation Problem in Client Prediction

Discussion in 'Netcode for GameObjects' started by Amitos, Nov 11, 2023.

  1. Amitos

    Amitos

    Joined:
    Feb 13, 2021
    Posts:
    4
    Hello Unity community, I'm currently working on a multiplayer game, and I'm encountering an issue with interpolation in client prediction.
    When I don't interpolate the client's movement (simply set the transform position every tick), the client prediction works perfectly. Although whenever I do try to interpolate the movement, the server reconciles way too often as the interpolation can't get the client to the position it should be at in time, which in turn causes movement to be horribly jittery.

    Interpolation is very important for this as the movement updates 30 times per second (tps = 30), which looks bad with high fps.

    Thank you in advance for any help or guidance you can provide. I appreciate your time and expertise!


    Player movement code (contains client prediction and server reconciliation):

    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4. using Unity.Netcode;
    5.  
    6. public struct InputPayload : INetworkSerializable
    7. {
    8.     public int tick;
    9.     public Vector2 inputVector;
    10.  
    11.     public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    12.     {
    13.         serializer.SerializeValue(ref tick);
    14.         serializer.SerializeValue(ref inputVector);
    15.     }
    16. }
    17.  
    18. public struct StatePayload : INetworkSerializable
    19. {
    20.     public int tick;
    21.     public Vector2 position;
    22.  
    23.     public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    24.     {
    25.         serializer.SerializeValue(ref tick);
    26.         serializer.SerializeValue(ref position);
    27.     }
    28. }
    29.  
    30.  
    31. public class PlayerMovement : NetworkBehaviour
    32. {
    33.     InputActions inputActions;
    34.     EntityStats entityStatsComponent;
    35.  
    36.     //Shared
    37.     private int currentTick;
    38.     private float minTimeBetweenTicks;
    39.     private float serverTickRate;
    40.     private const int BUFFER_SIZE = 1024;
    41.  
    42.     //Client specific
    43.     private StatePayload[] clientStateBuffer;
    44.     private InputPayload[] inputBuffer;
    45.     private StatePayload latestServerState;
    46.     private StatePayload lastProcessedState;
    47.     Vector2 movementInput;
    48.  
    49.     //Server specific
    50.     private StatePayload[] serverStateBuffer;
    51.     private Queue<InputPayload> inputQueue;
    52.  
    53.     private void Awake()
    54.     {
    55.         serverTickRate = NetworkManager.Singleton.NetworkTickSystem.TickRate;
    56.  
    57.         entityStatsComponent = GetComponent<EntityStats>();
    58.     }
    59.  
    60.     void Start()
    61.     {
    62.         minTimeBetweenTicks = 1f / serverTickRate;
    63.  
    64.         clientStateBuffer = new StatePayload[BUFFER_SIZE];
    65.         inputBuffer = new InputPayload[BUFFER_SIZE];
    66.  
    67.         serverStateBuffer = new StatePayload[BUFFER_SIZE];
    68.         inputQueue = new Queue<InputPayload>();
    69.  
    70.         if (IsOwner)
    71.         {
    72.             //update current tick locally for owner and server
    73.             NetworkManager.NetworkTickSystem.Tick += OnTickClient;
    74.             NetworkManager.NetworkTickSystem.Tick += () => currentTick++;
    75.         }
    76.         else if (IsServer)
    77.         {
    78.             NetworkManager.NetworkTickSystem.Tick += OnTickServer;
    79.             NetworkManager.NetworkTickSystem.Tick += () => currentTick++;
    80.         }
    81.     }
    82.  
    83.     void OnEnable()
    84.     {
    85.         inputActions = new InputActions();
    86.         inputActions.Enable();
    87.     }
    88.  
    89.     void OnDisable()
    90.     {
    91.         inputActions.Disable();
    92.     }
    93.  
    94.     void Update()
    95.     {
    96.         if (!IsOwner || !Application.isFocused) return;
    97.  
    98.         movementInput = inputActions.Player.Move.ReadValue<Vector2>().normalized;
    99.     }
    100.  
    101.     //Run on server
    102.     void OnClientInput(InputPayload inputPayload)
    103.     {
    104.         inputQueue.Enqueue(inputPayload);
    105.     }
    106.  
    107.     //Run on client
    108.     void OnServerMovementState(StatePayload serverState)
    109.     {
    110.         latestServerState = serverState;
    111.     }
    112.  
    113.     [ServerRpc]
    114.     void SendToServerServerRpc(InputPayload inputPayload)
    115.     {
    116.         OnClientInput(inputPayload);
    117.     }
    118.  
    119.     [ClientRpc]
    120.     void SendToClientClientRpc(StatePayload statePayload)
    121.     {
    122.         OnServerMovementState(statePayload);
    123.     }
    124.  
    125.     bool ShouldReconcile()
    126.     {
    127.         bool isNewServerState = !latestServerState.Equals(default(StatePayload));
    128.         bool isLastStateUndefinedOrDifferent = lastProcessedState.Equals(default(StatePayload)) ||
    129.             !latestServerState.Equals(lastProcessedState);
    130.  
    131.         return isNewServerState && isLastStateUndefinedOrDifferent;
    132.     }
    133.  
    134.     void OnTickClient()
    135.     {
    136.         if (ShouldReconcile())
    137.         {
    138.             HandleServerReconciliation();
    139.         }
    140.  
    141.         int bufferIndex = currentTick % BUFFER_SIZE;
    142.  
    143.         //Add payload to inputBuffer
    144.         InputPayload inputPayload = new InputPayload
    145.         {
    146.             tick = currentTick,
    147.             inputVector = movementInput
    148.         };
    149.  
    150.         inputBuffer[bufferIndex] = inputPayload;
    151.  
    152.         //Add payload to stateBuffer
    153.         clientStateBuffer[bufferIndex] = ProcessMovement(inputPayload);
    154.  
    155.         //Send input to server
    156.         SendToServerServerRpc(inputPayload);
    157.     }
    158.  
    159.     void OnTickServer()
    160.     {
    161.         //Process the input queue
    162.         int bufferIndex = -1;
    163.  
    164.         while (inputQueue.Count > 0)
    165.         {
    166.             InputPayload inputPayload = inputQueue.Dequeue();
    167.  
    168.             bufferIndex = inputPayload.tick % BUFFER_SIZE;
    169.  
    170.             StatePayload statePayload = ProcessMovement(inputPayload);
    171.             serverStateBuffer[bufferIndex] = statePayload;
    172.         }
    173.  
    174.         if (bufferIndex != -1)
    175.         {
    176.             SendToClientClientRpc(serverStateBuffer[bufferIndex]);
    177.         }
    178.     }
    179.  
    180.     IEnumerator LerpPosition(Vector2 finalPos)
    181.     {
    182.         Vector2 startVal = transform.position;
    183.         float t = 0f;
    184.         float wa = 0;
    185.  
    186.         while (t < 1)
    187.         {
    188.             wa += Time.deltaTime;
    189.             t += Time.deltaTime / (minTimeBetweenTicks - 0.01f);
    190.             transform.position = Vector3.Lerp(startVal, finalPos, t);
    191.             yield return null;
    192.         }
    193.     }
    194.  
    195.     StatePayload ProcessMovement(InputPayload input)
    196.     {
    197.         Vector3 addPos = (entityStatsComponent.MovementSpeed.Value / 2f) * minTimeBetweenTicks * input.inputVector; // /2f in order to scale the movement speed better
    198.         Vector2 finalPos = transform.position + addPos;
    199.  
    200.         //transform.position = finalPos;
    201.  
    202.         StartCoroutine(LerpPosition(finalPos));
    203.  
    204.         return new StatePayload()
    205.         {
    206.             tick = input.tick,
    207.             position = finalPos,
    208.         };
    209.     }
    210.  
    211.     void HandleServerReconciliation()
    212.     {
    213.         lastProcessedState = latestServerState;
    214.  
    215.         int serverStateBufferIndex = latestServerState.tick % BUFFER_SIZE;
    216.         float positionError = Vector3.Distance(latestServerState.position, clientStateBuffer[serverStateBufferIndex].position);
    217.  
    218.         if (positionError > 0.01f)
    219.         {
    220.             Debug.LogWarning($"We have to reconcile (error {positionError})");
    221.             Debug.Log($"current tick: {currentTick}");
    222.  
    223.             //Rewind & Replay
    224.             transform.position = latestServerState.position;
    225.  
    226.             //Update buffer at index of latest server state
    227.             clientStateBuffer[serverStateBufferIndex] = latestServerState;
    228.  
    229.             //Now re-simulate the rest of the ticks up to the current tick on the client
    230.             int tickToProcess = latestServerState.tick + 1;
    231.  
    232.             while (tickToProcess < currentTick)
    233.             {
    234.                 int bufferIndex = tickToProcess % BUFFER_SIZE;
    235.  
    236.                 //Process new movement with reconciled state
    237.                 StatePayload statePayload = ProcessMovement(inputBuffer[bufferIndex]);
    238.  
    239.                 //Update buffer with recalculated state
    240.                 clientStateBuffer[bufferIndex] = statePayload;
    241.  
    242.                 tickToProcess++;
    243.             }
    244.         }
    245.     }
    246. }
     
  2. NoelStephens_Unity

    NoelStephens_Unity

    Unity Technologies

    Joined:
    Feb 12, 2022
    Posts:
    244
    I will need to review over your code a bit more, but after first pass glance I noticed that via OnTickClient invoking ProcessMovement(inputPayload) it appears that you create a coroutine each time?
    You might think about just queue'ing the finalPos on the client side, during the normal Update, processing the queue (i.e. make it a FIFO queue) where you set local private Vector2 property (i.e. m_TargetPos) from the first item in the queue until you have lerped to that target position...then grabbing the next...lerping toward that... etc.

    The other thing I noticed is that you might think about just using a NetworkVariable that is already tick synchronized.

    Was there a specific reason as to why you are using your own tick count as opposed to the one provided by NGO?

    The last question is: Do you really want to only send a single inputPayload from the client to the server each tick or do you want to send input continually or perhaps a sum of the input over a tick period? I ask this because it looks like the input is being set per Update and so the client is only sending the very last movementInput set via Update...which depending upon what inputActions.Player.Move.ReadValue<Vector2>() is on the Update prior to the next tick is the only value sent to the server (just asking because I don't know what is driving that value).
     
    mishakozlov74 likes this.
  3. Amitos

    Amitos

    Joined:
    Feb 13, 2021
    Posts:
    4
    Firstly, thank you so much for your response!
    Now for the answers:

    1. Yes, I am invoking the coroutine every time in OnTickClient. What I'm trying to do is lerp the positions between the ticks so by the time the next tick begins to execute, the previous coroutine would end and a new one would start.
    2. There is no specific reason as to why I'm using my own tick count actually, I didn't realize NGO provided it.
    3. I'm not sure what the best approach for handling the input is. Currently I'm just using the input in the update prior to the next tick as you mentioned. I'd love to hear suggestions if there are better ways to do it.

    Also, could you explain what you meant by queueing the finalPos on the client during Update? Maybe modify the code to show how it would look like?

    Thanks in advance
     
  4. Amitos

    Amitos

    Joined:
    Feb 13, 2021
    Posts:
    4
    Also I'm not sure what I did wrong, but the position only updates for the server and not for the other clients (not related to interpolation)