// base class for "Hybrid" sync components. // inspired by the Quake networking model, but made to scale. // https://www.jfedor.org/quake3/ using System; using UnityEngine; namespace Mirror { public abstract class NetworkBehaviourHybrid : NetworkBehaviour { // Is this a client with authority over this transform? // This component could be on the player object or any object that has been assigned authority to this client. protected bool IsClientWithAuthority => isClient && authority; [Tooltip("Occasionally send a full reliable state to delta compress against. This only applies to Components with SyncMethod=Unreliable.")] public int baselineRate = 1; public float baselineInterval => baselineRate < int.MaxValue ? 1f / baselineRate : 0; // for 1 Hz, that's 1000ms protected double lastBaselineTime; protected double lastDeltaTime; // delta compression needs to remember 'last' to compress against. byte lastSerializedBaselineTick = 0; byte lastDeserializedBaselineTick = 0; [Tooltip("Enable to send all unreliable messages twice. Only useful for extremely fast-paced games since it doubles bandwidth costs.")] public bool unreliableRedundancy = false; [Tooltip("When sending a reliable baseline, should we also send an unreliable delta or rely on the reliable baseline to arrive in a similar time?")] public bool baselineIsDelta = true; // change detection: we need to do this carefully in order to get it right. // // DONT just check changes in UpdateBaseline(). this would introduce MrG's grid issue: // server start in A1, reliable baseline sent to client // server moves to A2, unreliabe delta sent to client // server moves to A1, nothing is sent to client becuase last baseline position == position // => client wouldn't know we moved back to A1 // // INSTEAD: every update() check for changes since baseline: // UpdateDelta() keeps sending only if changed since _baseline_ // UpdateBaseline() resends if there was any change in the period since last baseline. // => this avoids the A1->A2->A1 grid issue above bool changedSinceBaseline = false; [Header("Debug")] public bool debugLog = false; public virtual void ResetState() { lastSerializedBaselineTick = 0; lastDeserializedBaselineTick = 0; changedSinceBaseline = false; } // user callbacks ////////////////////////////////////////////////////// protected abstract void OnSerializeBaseline(NetworkWriter writer); protected abstract void OnDeserializeBaseline(NetworkReader reader, byte baselineTick); protected abstract void OnSerializeDelta(NetworkWriter writer); protected abstract void OnDeserializeDelta(NetworkReader reader, byte baselineTick); // implementations must store the current baseline state when requested: // - implementations can use this to compress deltas against // - implementations can use this to detect changes since baseline // this is called whenever a baseline was sent. protected abstract void StoreState(); // implementations may compare current state to the last stored state. // this way we only need to send another reliable baseline if changed since last. // this is called every syncInterval, not every baseline sync interval. // (see comments where this is called). protected abstract bool StateChanged(); // user callback in case drops due to baseline mismatch need to be logged/visualized/debugged. protected virtual void OnDrop(byte lastBaselineTick, byte baselineTick, NetworkReader reader) {} // rpcs / cmds ///////////////////////////////////////////////////////// // reliable baseline. // include owner in case of server authority. [ClientRpc(channel = Channels.Reliable)] void RpcServerToClientBaseline(ArraySegment data) { // baseline is broadcast to all clients. // ignore if this object is owned by this client. if (IsClientWithAuthority) return; // host mode: baseline Rpc is also sent through host's local connection and applied. // applying host's baseline as last deserialized would overwrite the owner client's data and cause jitter. // in other words: never apply the rpcs in host mode. if (isServer) return; using (NetworkReaderPooled reader = NetworkReaderPool.Get(data)) { // deserialize // save last deserialized baseline tick number to compare deltas against lastDeserializedBaselineTick = reader.ReadByte(); OnDeserializeBaseline(reader, lastDeserializedBaselineTick); } } // unreliable delta. // include owner in case of server authority. [ClientRpc(channel = Channels.Unreliable)] void RpcServerToClientDelta(ArraySegment data) { // delta is broadcast to all clients. // ignore if this object is owned by this client. if (IsClientWithAuthority) return; // host mode: baseline Rpc is also sent through host's local connection and applied. // applying host's baseline as last deserialized would overwrite the owner client's data and cause jitter. // in other words: never apply the rpcs in host mode. if (isServer) return; // deserialize using (NetworkReaderPooled reader = NetworkReaderPool.Get(data)) { // deserialize byte baselineTick = reader.ReadByte(); // ensure this delta is for our last known baseline. // we should never apply a delta on top of a wrong baseline. if (baselineTick != lastDeserializedBaselineTick) { OnDrop(lastDeserializedBaselineTick, baselineTick, reader); // this can happen if unreliable arrives before reliable etc. // no need to log this except when debugging. if (debugLog) Debug.Log($"[{name}] Client: received delta for wrong baseline #{baselineTick}. Last was {lastDeserializedBaselineTick}. Ignoring."); return; } OnDeserializeDelta(reader, baselineTick); } } [Command(channel = Channels.Reliable)] // reliable baseline void CmdClientToServerBaseline(ArraySegment data) { // deserialize using (NetworkReaderPooled reader = NetworkReaderPool.Get(data)) { // deserialize lastDeserializedBaselineTick = reader.ReadByte(); OnDeserializeBaseline(reader, lastDeserializedBaselineTick); } } [Command(channel = Channels.Unreliable)] // unreliable delta void CmdClientToServerDelta(ArraySegment data) { using (NetworkReaderPooled reader = NetworkReaderPool.Get(data)) { // deserialize byte baselineTick = reader.ReadByte(); // ensure this delta is for our last known baseline. // we should never apply a delta on top of a wrong baseline. if (baselineTick != lastDeserializedBaselineTick) { OnDrop(lastDeserializedBaselineTick, baselineTick, reader); // this can happen if unreliable arrives before reliable etc. // no need to log this except when debugging. if (debugLog) Debug.Log($"[{name}] Server: received delta for wrong baseline #{baselineTick} from: {connectionToClient}. Last was {lastDeserializedBaselineTick}. Ignoring."); return; } OnDeserializeDelta(reader, baselineTick); } } // update server /////////////////////////////////////////////////////// protected virtual void UpdateServerBaseline(double localTime) { // send a reliable baseline every 1 Hz if (localTime < lastBaselineTime + baselineInterval) return; // only sync if changed since last reliable baseline if (!changedSinceBaseline) return; // save bandwidth by only transmitting what is needed. // -> ArraySegment with random data is slower since byte[] copying // -> Vector3? and Quaternion? nullables takes more bandwidth byte frameCount = (byte)Time.frameCount; // perf: only access Time.frameCount once! using (NetworkWriterPooled writer = NetworkWriterPool.Get()) { // serialize writer.WriteByte(frameCount); OnSerializeBaseline(writer); // send (no need for redundancy since baseline is reliable) RpcServerToClientBaseline(writer); } // save the last baseline's tick number. // included in baseline to identify which one it was on client // included in deltas to ensure they are on top of the correct baseline lastSerializedBaselineTick = frameCount; lastBaselineTime = NetworkTime.localTime; // perf. & bandwidth optimization: // send a delta right after baseline to avoid potential head of // line blocking, or skip the delta whenever we sent reliable? // for example: // 1 Hz baseline // 10 Hz delta // => 11 Hz total if we still send delta after reliable // => 10 Hz total if we skip delta after reliable // in that case, skip next delta by simply resetting last delta sync's time. if (baselineIsDelta) lastDeltaTime = localTime; // request to store last baseline state (i.e. position) for change detection. StoreState(); // baseline was just sent after a change. reset change detection. changedSinceBaseline = false; if (debugLog) Debug.Log($"[{name}] Server: sent baseline #{lastSerializedBaselineTick} to: {connectionToClient} at time: {localTime}"); } protected virtual void UpdateServerDelta(double localTime) { // broadcast to all clients each 'sendInterval' // (client with authority will drop the rpc) // NetworkTime.localTime for double precision until Unity has it too // // IMPORTANT: // snapshot interpolation requires constant sending. // DO NOT only send if position changed. for example: // --- // * client sends first position at t=0 // * ... 10s later ... // * client moves again, sends second position at t=10 // --- // * server gets first position at t=0 // * server gets second position at t=10 // * server moves from first to second within a time of 10s // => would be a super slow move, instead of a wait & move. // // IMPORTANT: // DO NOT send nulls if not changed 'since last send' either. we // send unreliable and don't know which 'last send' the other end // received successfully. // // Checks to ensure server only sends snapshots if object is // on server authority(!clientAuthority) mode because on client // authority mode snapshots are broadcasted right after the authoritative // client updates server in the command function(see above), OR, // since host does not send anything to update the server, any client // authoritative movement done by the host will have to be broadcasted // here by checking IsClientWithAuthority. // TODO send same time that NetworkServer sends time snapshot? if (localTime < lastDeltaTime + syncInterval) return; // look for changes every unreliable sendInterval! // every reliable interval isn't enough, this would cause MrG's grid issue: // server start in A1, reliable baseline sent to clients // server moves to A2, unreliabe delta sent to clients // server moves back to A1, nothing is sent to clients because last baseline position == position // => clients wouldn't know we moved back to A1 // every update works, but it's unnecessary overhead since sends only happen every sendInterval // every unreliable sendInterval is the perfect place to look for changes. if (StateChanged()) changedSinceBaseline = true; // only sync on change: // unreliable isn't guaranteed to be delivered so this depends on reliable baseline. if (!changedSinceBaseline) return; using (NetworkWriterPooled writer = NetworkWriterPool.Get()) { // serialize writer.WriteByte(lastSerializedBaselineTick); OnSerializeDelta(writer); // send (with optional redundancy to make up for message drops) RpcServerToClientDelta(writer); if (unreliableRedundancy) RpcServerToClientDelta(writer); } lastDeltaTime = localTime; if (debugLog) Debug.Log($"[{name}] Server: sent delta for #{lastSerializedBaselineTick} to: {connectionToClient} at time: {localTime}"); } protected virtual void UpdateServerSync() { // server broadcasts all objects all the time. // -> not just ServerToClient: ClientToServer need to be broadcast to others too // perf: only grab NetworkTime.localTime property once. double localTime = NetworkTime.localTime; // broadcast UpdateServerBaseline(localTime); UpdateServerDelta(localTime); } // update client /////////////////////////////////////////////////////// protected virtual void UpdateClientBaseline(double localTime) { // send a reliable baseline every 1 Hz if (localTime < lastBaselineTime + baselineInterval) return; // only sync if changed since last reliable baseline if (!changedSinceBaseline) return; // save bandwidth by only transmitting what is needed. // -> ArraySegment with random data is slower since byte[] copying // -> Vector3? and Quaternion? nullables takes more bandwidth byte frameCount = (byte)Time.frameCount; // perf: only access Time.frameCount once! using (NetworkWriterPooled writer = NetworkWriterPool.Get()) { // serialize writer.WriteByte(frameCount); OnSerializeBaseline(writer); // send (no need for redundancy since baseline is reliable) CmdClientToServerBaseline(writer); } // save the last baseline's tick number. // included in baseline to identify which one it was on client // included in deltas to ensure they are on top of the correct baseline lastSerializedBaselineTick = frameCount; lastBaselineTime = NetworkTime.localTime; // perf. & bandwidth optimization: // send a delta right after baseline to avoid potential head of // line blocking, or skip the delta whenever we sent reliable? // for example: // 1 Hz baseline // 10 Hz delta // => 11 Hz total if we still send delta after reliable // => 10 Hz total if we skip delta after reliable // in that case, skip next delta by simply resetting last delta sync's time. if (baselineIsDelta) lastDeltaTime = localTime; // request to store last baseline state (i.e. position) for change detection. // IMPORTANT // OnSerialize(initial) is called for the spawn payload whenever // someone starts observing this object. we always must make // this the new baseline, otherwise this happens: // - server broadcasts baseline @ t=1 // - server broadcasts delta for baseline @ t=1 // - ... time passes ... // - new observer -> OnSerialize sends current position @ t=2 // - server broadcasts delta for baseline @ t=1 // => client's baseline is t=2 but receives delta for t=1 _!_ StoreState(); // baseline was just sent after a change. reset change detection. changedSinceBaseline = false; if (debugLog) Debug.Log($"[{name}] Client: sent baseline #{lastSerializedBaselineTick} at time: {localTime}"); } protected virtual void UpdateClientDelta(double localTime) { // send to server each 'sendInterval' // NetworkTime.localTime for double precision until Unity has it too // // IMPORTANT: // snapshot interpolation requires constant sending. // DO NOT only send if position changed. for example: // --- // * client sends first position at t=0 // * ... 10s later ... // * client moves again, sends second position at t=10 // --- // * server gets first position at t=0 // * server gets second position at t=10 // * server moves from first to second within a time of 10s // => would be a super slow move, instead of a wait & move. // // IMPORTANT: // DO NOT send nulls if not changed 'since last send' either. we // send unreliable and don't know which 'last send' the other end // received successfully. if (localTime < lastDeltaTime + syncInterval) return; // look for changes every unreliable sendInterval! // every reliable interval isn't enough, this would cause MrG's grid issue: // client start in A1, reliable baseline sent to server and other clients // client moves to A2, unreliabe delta sent to server and other clients // client moves back to A1, nothing is sent to server because last baseline position == position // => server / other clients wouldn't know we moved back to A1 // every update works, but it's unnecessary overhead since sends only happen every sendInterval // every unreliable sendInterval is the perfect place to look for changes. if (StateChanged()) changedSinceBaseline = true; // only sync on change: // unreliable isn't guaranteed to be delivered so this depends on reliable baseline. if (!changedSinceBaseline) return; using (NetworkWriterPooled writer = NetworkWriterPool.Get()) { // serialize writer.WriteByte(lastSerializedBaselineTick); OnSerializeDelta(writer); // send (with optional redundancy to make up for message drops) CmdClientToServerDelta(writer); if (unreliableRedundancy) CmdClientToServerDelta(writer); } lastDeltaTime = localTime; if (debugLog) Debug.Log($"[{name}] Client: sent delta for #{lastSerializedBaselineTick} at time: {localTime}"); } protected virtual void UpdateClientSync() { // client authority, and local player (= allowed to move myself)? if (IsClientWithAuthority) { // https://github.com/vis2k/Mirror/pull/2992/ if (!NetworkClient.ready) return; // perf: only grab NetworkTime.localTime property once. double localTime = NetworkTime.localTime; UpdateClientBaseline(localTime); UpdateClientDelta(localTime); } } // Update() without LateUpdate() split: otherwise perf. is cut in half! protected virtual void Update() { // if server then always sync to others. if (isServer) UpdateServerSync(); // 'else if' because host mode shouldn't send anything to server. // it is the server. don't overwrite anything there. else if (isClient) UpdateClientSync(); } // OnSerialize(initial) is called every time when a player starts observing us. // note this is _not_ called just once on spawn. // call this from inheriting classes immediately in OnSerialize(). public override void OnSerialize(NetworkWriter writer, bool initialState) { if (initialState) { // always include the tick for deltas to compare against. byte frameCount = (byte)Time.frameCount; // perf: only access Time.frameCount once! writer.WriteByte(frameCount); // IMPORTANT // OnSerialize(initial) is called for the spawn payload whenever // someone starts observing this object. we always must make // this the new baseline, otherwise this happens: // - server broadcasts baseline @ t=1 // - server broadcasts delta for baseline @ t=1 // - ... time passes ... // - new observer -> OnSerialize sends current position @ t=2 // - server broadcasts delta for baseline @ t=1 // => client's baseline is t=2 but receives delta for t=1 _!_ lastSerializedBaselineTick = (byte)Time.frameCount; lastBaselineTime = NetworkTime.localTime; // request to store last baseline state (i.e. position) for change detection. StoreState(); } } // call this from inheriting classes immediately in OnDeserialize(). public override void OnDeserialize(NetworkReader reader, bool initialState) { if (initialState) { // save last deserialized baseline tick number to compare deltas against lastDeserializedBaselineTick = reader.ReadByte(); } } } }