mirror of
https://github.com/MirrorNetworking/Mirror.git
synced 2024-11-17 18:40:33 +00:00
InputAccumulator (Reliable)
This commit is contained in:
parent
ff56210a36
commit
fd15eb7794
8
Assets/Mirror/Core/Prediction.meta
Normal file
8
Assets/Mirror/Core/Prediction.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82c86372b0b95431398582e3a0095370
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
72
Assets/Mirror/Core/Prediction/ClientInput.cs
Normal file
72
Assets/Mirror/Core/Prediction/ClientInput.cs
Normal file
@ -0,0 +1,72 @@
|
||||
using System;
|
||||
|
||||
namespace Mirror
|
||||
{
|
||||
public struct ClientInput
|
||||
{
|
||||
// inputs need a unique ID for acknowledgement from the server.
|
||||
// TODO this can be optimized later, for now let's stay with uint.
|
||||
public uint inputId;
|
||||
|
||||
// TODO this definitely needs to be optimized later. maybe with hash?
|
||||
public string input;
|
||||
|
||||
// serialized parameters for the input, i.e. position.
|
||||
// TODO NONALLOC/POOLED
|
||||
public NetworkWriter parameters;
|
||||
|
||||
public double timestamp;
|
||||
|
||||
// UNRELIABLE:
|
||||
// keep track of how many times we attempted to send this input unreliably
|
||||
// this is useful to detect issues.
|
||||
// public int sendAttempts;
|
||||
|
||||
// UNRELIABLE:
|
||||
// inputs are sent over unreliable.
|
||||
// server will tell us when it received an input with inputId.
|
||||
// in that case, set acked and don't retransmit.
|
||||
// public bool acked;
|
||||
|
||||
public ClientInput(uint inputId, string input, NetworkWriter parameters, double timestamp)
|
||||
{
|
||||
this.inputId = inputId;
|
||||
this.input = input;
|
||||
this.parameters = parameters;
|
||||
this.timestamp = timestamp;
|
||||
// UNRELIABLE:
|
||||
// this.sendAttempts = 0;
|
||||
// this.acked = false;
|
||||
}
|
||||
}
|
||||
|
||||
// add NetworkReader/Writer extensions for ClientInput type
|
||||
public static class InputAccumulatorSerialization
|
||||
{
|
||||
public static void WriteClientInput(this NetworkWriter writer, ClientInput input)
|
||||
{
|
||||
writer.WriteUInt(input.inputId);
|
||||
writer.WriteString(input.input);
|
||||
writer.WriteArraySegmentAndSize(input.parameters);
|
||||
writer.WriteDouble(input.timestamp);
|
||||
}
|
||||
|
||||
public static ClientInput ReadClientInput(this NetworkReader reader)
|
||||
{
|
||||
uint inputId = reader.ReadUInt();
|
||||
string input = reader.ReadString();
|
||||
ArraySegment<byte> parameters = reader.ReadArraySegmentAndSize();
|
||||
double timestamp = reader.ReadDouble();
|
||||
|
||||
// wrap parameter bytes in a writer.
|
||||
// if there were no parameters, 'parameters' is default/null.
|
||||
// in that case, don't copy anything otherwise we get a nullref.
|
||||
NetworkWriter writer = new NetworkWriter();
|
||||
if (parameters.Array != null)
|
||||
{
|
||||
writer.WriteBytes(parameters.Array, parameters.Offset, parameters.Count);
|
||||
}
|
||||
return new ClientInput(inputId, input, writer, timestamp);
|
||||
}
|
||||
}
|
||||
}
|
11
Assets/Mirror/Core/Prediction/ClientInput.cs.meta
Normal file
11
Assets/Mirror/Core/Prediction/ClientInput.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 946abd35216042cd91d557c096a1a397
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
234
Assets/Mirror/Core/Prediction/InputAccumulator.cs
Normal file
234
Assets/Mirror/Core/Prediction/InputAccumulator.cs
Normal file
@ -0,0 +1,234 @@
|
||||
// Input Accumulator for prediction.
|
||||
//
|
||||
// everything goes over reliable channel for now: make it work, then make it fast!
|
||||
//
|
||||
// in the future we want to send client inputs to the server immediately over
|
||||
// unreliable. some will get lost, so we always want to send the last N inputs
|
||||
// at once.
|
||||
//
|
||||
// based on Overwatch GDC talk: https://www.youtube.com/watch?v=zrIY0eIyqmI
|
||||
//
|
||||
// usage:
|
||||
// - inherit and customize this for your player
|
||||
// - add the component to the player prefab
|
||||
// - channel all inputs through this component
|
||||
// for example, when firing call GetComponent<InputAccumulator>().Fire()
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Mirror
|
||||
{
|
||||
public abstract class InputAccumulator : NetworkBehaviour
|
||||
{
|
||||
uint nextInputId = 1;
|
||||
|
||||
// history limit as byte to enforce max 255 (which saves bandwidth)
|
||||
[Tooltip("Keep this many inputs to sync to server in a batch, and to apply reconciliation.\nDon't change this at runtime!")]
|
||||
public byte historyLimit = 64;
|
||||
|
||||
// UNRELIABLE:
|
||||
// [Tooltip("How many times the client will attempt to (unreliably) send an input before giving up.")]
|
||||
// public int attemptLimit = 16;
|
||||
|
||||
// input history with both acknowledged and unacknowledged inputs.
|
||||
// => unacknowledged inputs are still being resent
|
||||
// => acknowledged are kept for later in case of reconciliation
|
||||
internal readonly Queue<ClientInput> history = new Queue<ClientInput>();
|
||||
|
||||
double lastSendTime;
|
||||
|
||||
// record input by name, i.e. "Fire".
|
||||
// make sure to use const strings like "Fire" to avoid allocations.
|
||||
// "Fire{i}" would allocate.
|
||||
|
||||
// returns true if there was space in history, false otherwise.
|
||||
// if it returns false, it's best not to apply the player input to the world.
|
||||
protected bool RecordInput(string inputName, NetworkWriter parameters)
|
||||
{
|
||||
// keep history limit
|
||||
if (history.Count >= historyLimit)
|
||||
{
|
||||
// the oldest entry is only safe to dequeue if the server acked it.
|
||||
// otherwise the server wouldn't never receive & apply it.
|
||||
// if (!history.Peek().acked)
|
||||
// {
|
||||
// // best to warn and drop it.
|
||||
// Debug.LogWarning($"Input {inputName} on {name} with netId={netId} will be dropped because history is full and the oldest input hasn't been acknowledged yet.");
|
||||
// return false;
|
||||
// }
|
||||
|
||||
history.Dequeue();
|
||||
}
|
||||
|
||||
// record it with a new input id
|
||||
ClientInput input = new ClientInput(nextInputId, inputName, parameters, NetworkTime.time);
|
||||
nextInputId += 1;
|
||||
history.Enqueue(input);
|
||||
|
||||
// send it to the server over reliable for now.
|
||||
// in the future, N inputs will be squashed into one unreliable message.
|
||||
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
|
||||
{
|
||||
writer.Write(input);
|
||||
CmdSendInput(writer);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// called on server after receiving a new client input.
|
||||
// TODO pass batch remoteTime? server knows this but may be easier to pass here too.
|
||||
protected abstract void ApplyInputOnServer(string inputName);
|
||||
|
||||
[Command]
|
||||
void CmdSendInput(ArraySegment<byte> serializedInput)
|
||||
{
|
||||
// deserialize input
|
||||
using (NetworkReaderPooled reader = NetworkReaderPool.Get(serializedInput))
|
||||
{
|
||||
ClientInput input = reader.ReadClientInput();
|
||||
|
||||
// UNRELIABLE
|
||||
// send ack message to client.
|
||||
// at the moment this is sent reliabily.
|
||||
// TODO keep history of acked so we don't send twice?
|
||||
// client may still send it a few times before it gets ack.
|
||||
// TargetSendInputAck(input.inputId);
|
||||
|
||||
// process the input on server
|
||||
ApplyInputOnServer(input.input);
|
||||
}
|
||||
}
|
||||
|
||||
// UNRELIABLE
|
||||
/*
|
||||
// squash all history inputs and send them to the server in one unreliable message
|
||||
protected void Flush()
|
||||
{
|
||||
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
|
||||
{
|
||||
// write each client input that the server hasn't acknowledged yet.
|
||||
// we are also flagging each input's send attempts.
|
||||
// which means that we need to iterate with dequeue/enqueue again.
|
||||
// foreach can't modify while iterating.
|
||||
|
||||
// foreach (ClientInput input in history)
|
||||
// {
|
||||
// if (!input.acked)
|
||||
// writer.WriteClientInput(input);
|
||||
// }
|
||||
|
||||
int count = history.Count;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
ClientInput input = history.Dequeue();
|
||||
if (!input.acked)
|
||||
{
|
||||
writer.WriteClientInput(input);
|
||||
input.sendAttempts += 1;
|
||||
|
||||
// give up after too many attempts
|
||||
if (input.sendAttempts > attemptLimit)
|
||||
{
|
||||
// TODO maybe this should disconnect?
|
||||
Debug.LogWarning($"Input {input.input} on {name} with netId={netId} will be dropped because it was sent {input.sendAttempts} times and never acknowledged by the server.");
|
||||
continue; // continue to the next, don't Enqueue
|
||||
}
|
||||
}
|
||||
history.Enqueue(input);
|
||||
}
|
||||
|
||||
CmdSendInputBatch(writer.ToArraySegment());
|
||||
}
|
||||
}
|
||||
|
||||
bool IsNewInputId(uint inputId)
|
||||
{
|
||||
// TODO faster
|
||||
foreach (ClientInput input in history)
|
||||
{
|
||||
if (input.inputId == inputId)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// prediction should send input immediately over unreliable.
|
||||
// latency is key.
|
||||
// we batch together the last N inputs to make up for unreliable loss.
|
||||
// [Command(channel = Channels.Unreliable)]
|
||||
void CmdSendInputBatch(ArraySegment<byte> inputBatch)
|
||||
{
|
||||
// deserialize inputs
|
||||
using (NetworkReaderPooled reader = NetworkReaderPool.Get(inputBatch))
|
||||
{
|
||||
// read each client input
|
||||
while (reader.Remaining > 0)
|
||||
{
|
||||
ClientInput input = reader.ReadClientInput();
|
||||
|
||||
// UDP messages may arrive twice.
|
||||
// only process and apply the same input once though!
|
||||
if (IsNewInputId(input.inputId))
|
||||
{
|
||||
// TODO for unreliable, we need to ensure inputs are applied in same order!
|
||||
|
||||
// send ack message to client.
|
||||
// at the moment this is sent reliabily.
|
||||
// TODO keep history of acked so we don't send twice?
|
||||
// client may still send it a few times before it gets ack.
|
||||
TargetSendInputAck(input.inputId);
|
||||
|
||||
// process the input on server
|
||||
ApplyInputOnServer(input.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// acknowledge an inputId, which flags it as acked on the client.
|
||||
// client will then stop sending it to the server.
|
||||
// standalone function (not rpc) for easier testing.
|
||||
// TODO batch & optimize to minimize bandwidth later
|
||||
internal void AcknowledgeInput(uint inputId)
|
||||
{
|
||||
// we can't modify Queue elements while iterating.
|
||||
// we'll have to deqeueue + enqueue each of them once for now.
|
||||
// TODO faster lookup?
|
||||
int count = history.Count;
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
ClientInput input = history.Dequeue();
|
||||
if (input.inputId == inputId)
|
||||
{
|
||||
// flag as acked, but keep in history for reconciliation later
|
||||
input.acked = true;
|
||||
}
|
||||
history.Enqueue(input);
|
||||
}
|
||||
}
|
||||
|
||||
// server sends acknowledgements for received inputs to client
|
||||
// TODO unreliable? this isn't latency sensitive though.
|
||||
// with reliable, at least we can guarantee it's gonna be delivered
|
||||
[TargetRpc]
|
||||
void TargetSendInputAck(uint inputId) => AcknowledgeInput(inputId);
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (isLocalPlayer)
|
||||
{
|
||||
// UNRELIABLE
|
||||
// TODO we don't have OnSerializeUnreliable yet.
|
||||
// send manually for now
|
||||
// if (NetworkTime.time >= lastSendTime + syncInterval)
|
||||
// {
|
||||
// Flush();
|
||||
// lastSendTime = NetworkTime.time;
|
||||
// }
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
11
Assets/Mirror/Core/Prediction/InputAccumulator.cs.meta
Normal file
11
Assets/Mirror/Core/Prediction/InputAccumulator.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc1d73b5baeb84a84abe9f06e8fa3fd0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
3
Assets/Mirror/Tests/Editor/Prediction.meta
Normal file
3
Assets/Mirror/Tests/Editor/Prediction.meta
Normal file
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dbcea55c1a74b4bb1d690db23aeae2a
|
||||
timeCreated: 1692350915
|
186
Assets/Mirror/Tests/Editor/Prediction/InputAccumulatorTests.cs
Normal file
186
Assets/Mirror/Tests/Editor/Prediction/InputAccumulatorTests.cs
Normal file
@ -0,0 +1,186 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Mirror.Tests.Prediction
|
||||
{
|
||||
class MockInputAccumulator : InputAccumulator
|
||||
{
|
||||
public List<string> serverInputs = new List<string>();
|
||||
|
||||
// expose protected functions for testing
|
||||
public new bool RecordInput(string inputName, NetworkWriter parameters)
|
||||
=> base.RecordInput(inputName, parameters);
|
||||
|
||||
// public new void Flush()
|
||||
// => base.Flush();
|
||||
|
||||
protected override void ApplyInputOnServer(string inputName)
|
||||
=> serverInputs.Add(inputName);
|
||||
}
|
||||
|
||||
public class InputAccumulatorTests : MirrorTest
|
||||
{
|
||||
MockInputAccumulator serverComp;
|
||||
MockInputAccumulator clientComp;
|
||||
const int Limit = 4;
|
||||
|
||||
[SetUp]
|
||||
public override void SetUp()
|
||||
{
|
||||
base.SetUp();
|
||||
NetworkServer.Listen(1);
|
||||
ConnectClientBlockingAuthenticatedAndReady(out NetworkConnectionToClient connectionToClient);
|
||||
|
||||
CreateNetworkedAndSpawnPlayer(
|
||||
out _, out _, out serverComp,
|
||||
out _, out _, out clientComp,
|
||||
connectionToClient);
|
||||
serverComp.historyLimit = Limit;
|
||||
clientComp.historyLimit = Limit;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public override void TearDown()
|
||||
{
|
||||
base.TearDown();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InputAccumulatorSerialization_WithoutParameters()
|
||||
{
|
||||
// write an input without any parameters
|
||||
NetworkWriter parameters = new NetworkWriter();
|
||||
NetworkWriter writer = new NetworkWriter();
|
||||
ClientInput input = new ClientInput(42, "Fire", parameters, 2.0);
|
||||
writer.WriteClientInput(input);
|
||||
|
||||
// read
|
||||
NetworkReader reader = new NetworkReader(writer);
|
||||
ClientInput result = reader.ReadClientInput();
|
||||
|
||||
// check
|
||||
Assert.That(result.inputId, Is.EqualTo(input.inputId));
|
||||
Assert.That(result.input, Is.EqualTo(input.input));
|
||||
Assert.That(result.parameters.Position, Is.EqualTo(0));
|
||||
Assert.That(result.timestamp, Is.EqualTo(input.timestamp));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InputAccumulatorSerialization_WithParameters()
|
||||
{
|
||||
// write an input with a few parameters
|
||||
NetworkWriter parameters = new NetworkWriter();
|
||||
parameters.WriteVector3(new Vector3(1, 2, 3));
|
||||
|
||||
NetworkWriter writer = new NetworkWriter();
|
||||
ClientInput input = new ClientInput(42, "Fire", parameters, 2.0);
|
||||
writer.WriteClientInput(input);
|
||||
|
||||
// read
|
||||
NetworkReader reader = new NetworkReader(writer);
|
||||
ClientInput result = reader.ReadClientInput();
|
||||
|
||||
// check
|
||||
Assert.That(result.inputId, Is.EqualTo(input.inputId));
|
||||
Assert.That(result.input, Is.EqualTo(input.input));
|
||||
NetworkReader parametersReader = new NetworkReader(input.parameters);
|
||||
Assert.That(parametersReader.Remaining, Is.EqualTo(4 * 3)); // sizeof(Vector3)
|
||||
Assert.That(parametersReader.ReadVector3(), Is.EqualTo(new Vector3(1, 2, 3)));
|
||||
Assert.That(result.timestamp, Is.EqualTo(input.timestamp));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record()
|
||||
{
|
||||
// record a few
|
||||
Assert.That(clientComp.RecordInput("Fire", new NetworkWriter()), Is.True);
|
||||
Assert.That(clientComp.RecordInput("Jump", new NetworkWriter()), Is.True);
|
||||
ClientInput[] history = clientComp.history.ToArray();
|
||||
Assert.That(history.Length, Is.EqualTo(2));
|
||||
Assert.That(history[0].input, Is.EqualTo("Fire"));
|
||||
Assert.That(history[0].inputId, Is.EqualTo(1));
|
||||
Assert.That(history[1].input, Is.EqualTo("Jump"));
|
||||
Assert.That(history[1].inputId, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
/*
|
||||
// recording more inputs than 'limit' should drop input if not acked.
|
||||
[Test]
|
||||
public void RecordOverLimit_Unacked()
|
||||
{
|
||||
// fill to the limit
|
||||
for (int i = 0; i < Limit; ++i)
|
||||
Assert.That(clientComp.RecordInput($"Fire{i}", new NetworkWriter()), Is.True);
|
||||
|
||||
// try to record another while the oldest is still unacknowledged.
|
||||
// input should be dropped, not inserted.
|
||||
Assert.That(clientComp.RecordInput("Extra", new NetworkWriter()), Is.False);
|
||||
ClientInput[] history = clientComp.history.ToArray();
|
||||
Assert.That(history.Length, Is.EqualTo(Limit));
|
||||
Assert.That(history[0].input, Is.EqualTo("Fire0"));
|
||||
}
|
||||
|
||||
// recording more inputs than 'limit' should drop the oldest (if acked).
|
||||
[Test]
|
||||
public void RecordOverLimit_Acked()
|
||||
{
|
||||
// fill to the limit
|
||||
for (int i = 0; i < Limit; ++i)
|
||||
Assert.That(clientComp.RecordInput($"Fire{i}", new NetworkWriter()), Is.True);
|
||||
|
||||
// acknowledge the oldest
|
||||
uint oldestId = clientComp.history.Peek().inputId;
|
||||
clientComp.AcknowledgeInput(oldestId);
|
||||
|
||||
// try to record another while the oldest is acknowledged.
|
||||
// input should be inserted, and oldest dropped.
|
||||
Assert.That(clientComp.RecordInput("Extra", new NetworkWriter()), Is.True);
|
||||
ClientInput[] history = clientComp.history.ToArray();
|
||||
Assert.That(history.Length, Is.EqualTo(Limit));
|
||||
Assert.That(history[0].input, Is.EqualTo("Fire1")); // first one is gone
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MaxResendAttempts()
|
||||
{
|
||||
// insert a few
|
||||
Assert.That(clientComp.RecordInput("Fire", new NetworkWriter()), Is.True);
|
||||
Assert.That(clientComp.RecordInput("Jump", new NetworkWriter()), Is.True);
|
||||
ClientInput[] history = clientComp.history.ToArray();
|
||||
Assert.That(history.Length, Is.EqualTo(2));
|
||||
|
||||
// set one of them to max resends
|
||||
// can't access queue [i] directly, use dequeue+enqueue instead
|
||||
ClientInput oldest = clientComp.history.Dequeue();
|
||||
oldest.sendAttempts = clientComp.attemptLimit;
|
||||
clientComp.history.Enqueue(oldest);
|
||||
|
||||
// flush should remove the one with too many attempts
|
||||
clientComp.Flush();
|
||||
history = clientComp.history.ToArray();
|
||||
Assert.That(history.Length, Is.EqualTo(1));
|
||||
Assert.That(history[0].input, Is.EqualTo("Jump"));
|
||||
}
|
||||
*/
|
||||
|
||||
[Test]
|
||||
public void ClientInputGetSyncedToServer()
|
||||
{
|
||||
// insert a few
|
||||
Assert.That(clientComp.RecordInput("Fire", new NetworkWriter()), Is.True);
|
||||
Assert.That(clientComp.RecordInput("Jump", new NetworkWriter()), Is.True);
|
||||
|
||||
// flush to server
|
||||
// clientComp.Flush();
|
||||
ProcessMessages();
|
||||
|
||||
// server should've received the inputs
|
||||
// note there's a small chance for unreliable messages to
|
||||
// get dropped or arrive out of order, even on localhost.
|
||||
Assert.That(serverComp.serverInputs.Count, Is.EqualTo(2));
|
||||
Assert.That(serverComp.serverInputs.Contains("Fire")); // UDP order isn't guaranteed
|
||||
Assert.That(serverComp.serverInputs.Contains("Jump")); // UDP order isn't guaranteed
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c0ad56084da4aacb0b0e5366ac48f31
|
||||
timeCreated: 1692350921
|
Loading…
Reference in New Issue
Block a user