Search Unity

Resolved Commands system updated twice per tick?

Discussion in 'NetCode for ECS' started by PhilSA, Feb 17, 2021.

  1. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    I was seeing some weird behaviour related to my commands, and I ended up attributing this weird behaviour to the fact that the system that handles commands in my game seems to be run twice for every tick. That means this code:
    Code (CSharp):
    1.     [UpdateInGroup(typeof(GhostInputSystemGroup))]
    2.     [AlwaysSynchronizeSystem]
    3.     public class OnlineFPSPlayerCommandsSystem : SystemBase
    4.     {
    5.         ClientSimulationSystemGroup ClientSimulationSystemGroup;
    6.  
    7.         protected override void OnCreate()
    8.         {
    9.             base.OnCreate();
    10.  
    11.             ClientSimulationSystemGroup = World.GetExistingSystem<ClientSimulationSystemGroup>();
    12.  
    13.             RequireSingletonForUpdate<OnlineFPSSystems>();
    14.         }
    15.  
    16.         protected override void OnUpdate()
    17.         {
    18.             if (HasSingleton<CommandTargetComponent>())
    19.             {
    20.                 Entity localCommandsEntity = GetSingleton<CommandTargetComponent>().targetEntity;
    21.  
    22.  
    23.  
    24.                 Entities
    25.                     .WithoutBurst()
    26.                     .ForEach((
    27.                         Entity entity,
    28.                         ref DynamicBuffer<OnlineFPSPlayerCommands> playerCommands,
    29.                         ref OnlineFPSPlayerInputs playerInputs,
    30.                         in OnlineFPSCharacterComponent onlineFPSCharacter) =>
    31.                     {
    32.                         if (entity == localCommandsEntity)
    33.                         {
    34.                             OnlineFPSPlayerCommands newPlayerCommands = default;
    35.                             newPlayerCommands.Tick = ClientSimulationSystemGroup.ServerTick;
    36.                             newPlayerCommands.CameraHorizontalAngles = onlineFPSCharacter.CameraHorizontalAngles;
    37.                             newPlayerCommands.CameraVerticalAngles = onlineFPSCharacter.CameraVerticalAngles;
    38.                             newPlayerCommands.MoveInput = playerInputs.Move;
    39.                             newPlayerCommands.JumpRequested = playerInputs.JumpButton.WasPressed;
    40.  
    41.                             UnityEngine.Debug.Log("Adding cmd at tick " + ClientSimulationSystemGroup.ServerTick + " on entity " + entity + " in world " + World.Name);
    42.  
    43.                             playerCommands.AddCommandData(newPlayerCommands);
    44.                         }
    45.  
    46.                         // Clear fixed-rate inputs
    47.                         playerInputs.JumpButton.FixedStepClear();
    48.                     }).Run();
    49.             }
    50.         }
    51.     }

    ...outputs this during the game:


    Are there reasons for this or should it be considered a bug?
     
    Last edited: Feb 17, 2021
    Krooq likes this.
  2. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    Nevermind, I think I've figured out that this is due to the commands being updated at a dynamic timestep.

    The actual issue this was causing for me was that it made it difficult to detect button presses in commands. This sort of situation kept happening:
    • === Frame 1 ====
    • input system detects jump button was pressed
    • set jumpPressed to true in commands at tick 900
    • === Frame 2 ====
    • input system DOESN'T detect that jump button was just pressed, because that was last frame
    • overwite commands at tick 900, but this time with jumpPressed set to false
    • .....
    • === Frame 2 Prediction update ===
    • run the character logic for tick 900, and avoid jumping because jumpPressed ended up being set to false in our commands at tick 900
    So this results in my character sometimes not properly detecting that I pressed the jump button. Whenever a command is updated a second time for the same tick, it'll break the detection of input presses

    My solution was to write my own detection of "wasPressed" that is dependant on the tick rather than on the frame-to-frame
     
    Last edited: Feb 17, 2021
    Krooq and Lukas_Kastern like this.
  3. Enzi

    Enzi

    Joined:
    Jan 28, 2013
    Posts:
    967
    I had problems with this too and different implementations to avoid this. The reason is that the client update (for inputs) can process the same tick more than once and that threw me off.
    After some forum posts because I didn't fully understood what was happening or if it's a bug, this is the cleanest solution for it:
    Code (CSharp):
    1. if (inputBuffer.GetDataAtTick(serverTick, out var input) && input.Tick != serverTick)
    2.         {
    3.             // new frame, new input
    4.             input = default(PlayerCommandData);
    5.         }
    6.  
    7.         input.Tick = serverTick;
    8.  
    9.         input.jump |= jump;
    10. ...
    11.  
    This way, when the input for tick is already in memory, it gets updated, otherwise it's initialized new with default values.
     
    Krooq likes this.
  4. timjohansson

    timjohansson

    Unity Technologies

    Joined:
    Jul 13, 2016
    Posts:
    473
    The client updating multiple times per tick is not a bug, it's how variable tick rate on the client works.
    The client treats the tick almost as a floating point value (it's stored as uint tick + float fraction of last tick) since it is not running at a fixed time step. So one frame you can conceptually be at tick 9.25, then you will run up to (including) tick 9, followed by tick 10 with deltaTime set to fixedDeltaTime*0.25.
    Next frame you might be at tick 9.5, then we will roll back to tick 9 and run tick 10 with deltaTime set to fixedDeltaTime*0.5.
    To reduce input latency (and because "KeyDown" etc is per frame) we sample the input every frame, but we only send the inputs to the server when the tick (10 in the example) is complete. In other words, we send the input we used when we predicted tick 10 with deltaTime == fixedDeltaTime.

    A pattern we commonly use to deal with "pressed" events is to store the number of times the even has occurred instead of if it was pressed this frame - but that also requires reading the last stored input.
     
  5. Ali_Bakkal

    Ali_Bakkal

    Joined:
    Jan 26, 2013
    Posts:
    90
    @ Do you have an example (pseudo code) when you implement your way to detect the "pressed" events please ?
     
  6. Ali_Bakkal

    Ali_Bakkal

    Joined:
    Jan 26, 2013
    Posts:
    90
    Can you please share your solution based on Tick please ?
     
  7. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    Here's how I handle player commands in my Online FPS sample game. You can look for the part with the comment "// Merge same-tick commands (special input handling for fixed timestep simulation)":

    Code (CSharp):
    1. [AlwaysSynchronizeSystem]
    2. [UpdateInWorld(TargetWorld.Client)]
    3. [UpdateInGroup(typeof(GhostInputSystemGroup))]
    4. public partial class OnlineFPSPlayerCommandsSystem : SystemBase
    5. {
    6.     public FPSInputActions InputActions;
    7.     public ClientSimulationSystemGroup ClientSimulationSystemGroup;
    8.  
    9.     protected override void OnCreate()
    10.     {
    11.         base.OnCreate();
    12.         RequireSingletonForUpdate<NetworkIdComponent>();
    13.         RequireSingletonForUpdate<CommandTargetComponent>();
    14.     }
    15.  
    16.     protected override void OnStartRunning()
    17.     {
    18.         base.OnStartRunning();
    19.  
    20.         ClientSimulationSystemGroup = World.GetExistingSystem<ClientSimulationSystemGroup>();
    21.  
    22.         // Create the input user
    23.         InputActions = new FPSInputActions();
    24.         InputActions.Enable();
    25.         InputActions.DefaultMap.Enable();
    26.     }
    27.  
    28.     protected override void OnUpdate()
    29.     {
    30.         if (!HasSingleton<NetworkIdComponent>())
    31.             return;
    32.  
    33.         FPSInputActions.DefaultMapActions defaultActionsMap = InputActions.DefaultMap;
    34.  
    35.         float deltaTime = Time.DeltaTime;
    36.         float elapsedTime = (float)Time.ElapsedTime;
    37.         uint tick = ClientSimulationSystemGroup.ServerTick;
    38.         int localPlayerId = GetSingleton<NetworkIdComponent>().Value;
    39.  
    40.         // Update commands
    41.         Entities
    42.             .WithoutBurst()
    43.             .ForEach((Entity entity, ref DynamicBuffer<OnlineFPSPlayerCommands> playerCommands, in OnlineFPSPlayer player, in GhostOwnerComponent ghostOwner) =>
    44.             {
    45.                 if (ghostOwner.NetworkId == localPlayerId && HasComponent<OnlineFPSCharacterComponent>(player.ControlledEntity))
    46.                 {
    47.                     OnlineFPSCharacterComponent character = GetComponent<OnlineFPSCharacterComponent>(player.ControlledEntity);
    48.  
    49.                     // Create commands from local input
    50.                     OnlineFPSPlayerCommands newPlayerCommands = default;
    51.                     newPlayerCommands.Tick = tick;
    52.                     newPlayerCommands.MoveInput = Vector2.ClampMagnitude(defaultActionsMap.Move.ReadValue<Vector2>(), 1f); ;
    53.                     newPlayerCommands.LookInput = defaultActionsMap.LookDelta.ReadValue<Vector2>();
    54.                     newPlayerCommands.JumpRequested = defaultActionsMap.Jump.ReadValue<float>() > 0.5f && defaultActionsMap.Jump.triggered;
    55.                     newPlayerCommands.ShootRequested = defaultActionsMap.Shoot.ReadValue<float>() > 0.5f && defaultActionsMap.Shoot.triggered;
    56.                     newPlayerCommands.AimHeld = defaultActionsMap.Aim.ReadValue<float>() > 0.5f;
    57.  
    58.                     // Merge same-tick commands (special input handling for fixed timestep simulation)
    59.                     if (playerCommands.GetDataAtTick(tick, out OnlineFPSPlayerCommands sameTickPreviousCommands))
    60.                     {
    61.                         if (tick == sameTickPreviousCommands.Tick)
    62.                         {
    63.                             // Accumulate lookinput
    64.                             newPlayerCommands.LookInput += sameTickPreviousCommands.LookInput;
    65.  
    66.                             // Merge jump input
    67.                             if (sameTickPreviousCommands.JumpRequested)
    68.                             {
    69.                                 newPlayerCommands.JumpRequested = true;
    70.                             }
    71.                             // Merge shoot input
    72.                             if (sameTickPreviousCommands.ShootRequested)
    73.                             {
    74.                                 newPlayerCommands.ShootRequested = true;
    75.                             }
    76.                         }
    77.                     }
    78.  
    79.                     playerCommands.AddCommandData(newPlayerCommands);
    80.                 }
    81.             }).Run();
    82.     }
    83. }
     
    bb8_1 likes this.
  8. Krooq

    Krooq

    Joined:
    Jan 30, 2013
    Posts:
    196
    @Ali_Bakkal The important take-away here is that you can't treat commands as if they are normal frame-by-frame inputs because there is extra interpolated stuff in-between ticks.
    Essentially you need to consider each input and decide whether it needs to be converted to server based tick-by-tick snapshots.
    My implementation is a little different so I thought I'd share and someone can point out if I did something heinous.

    Code (CSharp):
    1.     [UpdateInGroup(typeof(GhostInputSystemGroup))]
    2.     [AlwaysSynchronizeSystem]
    3.     public partial class CharacterCommandsSystem : SystemBase
    4.     {
    5.         private GhostPredictionSystemGroup _ghostPredictionSystemGroup;
    6.         private uint _lastTick;
    7.  
    8.         protected override void OnCreate()
    9.         {
    10.             _ghostPredictionSystemGroup = World.GetExistingSystem<GhostPredictionSystemGroup>();
    11.             RequireSingletonForUpdate<CommandTargetComponent>();
    12.             RequireSingletonForUpdate<MainCamera>();
    13.             RequireSingletonForUpdate<NetworkIdComponent>();
    14.             RequireForUpdate(GetEntityQuery(typeof(PlayerControls), typeof(LocalGhost)));
    15.         }
    16.  
    17.         protected override void OnUpdate()
    18.         {
    19.             var mainCameraEntity = GetSingletonEntity<MainCamera>();
    20.             var cameraRotation = GetComponent<Rotation>(mainCameraEntity).Value;
    21.  
    22.             var lastTick = _lastTick;
    23.             var tick = _ghostPredictionSystemGroup.PredictingTick;
    24.             _lastTick = tick;
    25.            
    26.             var networkId = GetSingleton<NetworkIdComponent>().Value;
    27.             var playerInputs = GetEntityQuery(typeof(PlayerControls), typeof(LocalGhost)).GetSingleton<PlayerControls>();
    28.  
    29.             // Update commands
    30.             Entities
    31.                 .ForEach((Entity characterEntity, ref DynamicBuffer<CharacterCommands> characterCommandsBuffer, in GhostOwnerComponent ghostOwnerComponent, in AutoCommandTarget autoCommandTarget) =>
    32.                 {
    33.                     if (!autoCommandTarget.Enabled) return;
    34.                     if (ghostOwnerComponent.NetworkId != networkId) return;
    35.                    
    36.                     var newServerTick = tick != lastTick;
    37.                     // Because the client updates each frame but the server only per tick it's possible to overwrite things from previous frames.
    38.                     // For example, discrete inputs like button presses will be overwritten, so their previous value for the same tick needs to be "remembered".
    39.                     // This is easy to do, we just need to |= with the previous value while the tick is the same.
    40.                     // For more continuous inputs, like vectors, it doesn't much matter, so we just overwrite with the new value.
    41.                     characterCommandsBuffer.GetDataAtTick(lastTick, out var tickCommands);
    42.                    
    43.                     tickCommands.Tick = tick;
    44.  
    45.                     var worldUp = new float3(0, 1, 0);
    46.                     // Seems wacky, but since the camera is pointing straight down, up is the direction we are interested in.
    47.                     var cameraUpOnWorldUpPlane = math.normalizesafe(MathUtilities.ProjectOnPlane(MathUtilities.GetUpFromRotation(cameraRotation), worldUp));
    48.                     var cameraRight =  MathUtilities.GetRightFromRotation(cameraRotation);
    49.  
    50.                     tickCommands.WorldMoveVector = playerInputs.Move.y * cameraUpOnWorldUpPlane + playerInputs.Move.x * cameraRight;
    51.                     tickCommands.WorldMoveVector = math.normalizesafe(tickCommands.WorldMoveVector);
    52.                    
    53.                     // Either take new input if the predicting tick is new or merge input with the previous partial tick input
    54.                     tickCommands.InteractRequested = newServerTick ? playerInputs.Interact : tickCommands.InteractRequested || playerInputs.Interact;
    55.  
    56.                     characterCommandsBuffer.AddCommandData(tickCommands);
    57.                 }).WithoutBurst().Run();
    58.         }
     
  9. NikiWalker

    NikiWalker

    Unity Technologies

    Joined:
    May 18, 2021
    Posts:
    316
    It's a nitpick, but I think the simplest you can do is write the following to replace an if / ternary statement with a logical operator.

    input.SomeAction |= Input.GetKey(KeyCode.Whatever);
     
    Krooq and hippocoder like this.