From 9081b2e3d2b0b2544135b0e7e0004ed5f72716fc Mon Sep 17 00:00:00 2001 From: mischa Date: Sat, 24 Feb 2024 16:50:16 +0100 Subject: [PATCH] feat: LagCompensator as convenience component that wraps all the Lag Compensation logic --- .../LagCompensation/LagCompensator.cs | 195 ++++++++++++++++++ .../LagCompensation/LagCompensator.cs.meta | 11 + 2 files changed, 206 insertions(+) create mode 100644 Assets/Mirror/Components/LagCompensation/LagCompensator.cs create mode 100644 Assets/Mirror/Components/LagCompensation/LagCompensator.cs.meta diff --git a/Assets/Mirror/Components/LagCompensation/LagCompensator.cs b/Assets/Mirror/Components/LagCompensation/LagCompensator.cs new file mode 100644 index 000000000..a4c0cdb6e --- /dev/null +++ b/Assets/Mirror/Components/LagCompensation/LagCompensator.cs @@ -0,0 +1,195 @@ +// Add this component to a Player object with collider. +// Automatically keeps a history for lag compensation. +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Mirror +{ + public struct Capture3D : Capture + { + public double timestamp { get; set; } + public Vector3 position; + public Vector3 size; + + public Capture3D(double timestamp, Vector3 position, Vector3 size) + { + this.timestamp = timestamp; + this.position = position; + this.size = size; + } + + public void DrawGizmo() + { + Gizmos.DrawWireCube(position, size); + } + + public static Capture3D Interpolate(Capture3D from, Capture3D to, double t) => + new Capture3D( + 0, // interpolated snapshot is applied directly. don't need timestamps. + Vector3.LerpUnclamped(from.position, to.position, (float)t), + Vector3.LerpUnclamped(from.size, to.size, (float)t) + ); + + public override string ToString() => $"(time={timestamp} pos={position} size={size})"; + } + + public class LagCompensator : NetworkBehaviour + { + [Header("Components")] + [Tooltip("The collider to keep a history of.")] + public Collider col; // assign this in inspector + + [Header("Settings")] + public LagCompensationSettings lagCompensationSettings = new LagCompensationSettings(); + double lastCaptureTime; + + // lag compensation history of + readonly Queue> history = new Queue>(); + + [Header("Debugging")] + public Color historyColor = Color.white; + + protected virtual void Update() + { + // only capture on server + if (!NetworkServer.active) return; + + // capture lag compensation snapshots every interval. + // NetworkTime.localTime because Unity 2019 doesn't have 'double' time yet. + if (NetworkTime.localTime >= lastCaptureTime + lagCompensationSettings.captureInterval) + { + lastCaptureTime = NetworkTime.localTime; + Capture(); + } + } + + protected virtual void Capture() + { + // capture current state + Capture3D capture = new Capture3D( + NetworkTime.localTime, + col.bounds.center, + col.bounds.size + ); + + // insert into history + LagCompensation.Insert(history, lagCompensationSettings.historyLimit, NetworkTime.localTime, capture); + } + + protected virtual void OnDrawGizmos() + { + // draw history + Gizmos.color = historyColor; + LagCompensation.DrawGizmos(history); + } + + // sampling //////////////////////////////////////////////////////////// + // sample the sub-tick (=interpolated) history of this object for a hit test. + // 'viewer' needs to be the player who fired! + // for example, if A fires at B, then call B.Sample(viewer, point, tolerance). + [Server] + public virtual bool Sample(NetworkConnectionToClient viewer, out Capture3D sample) + { + // never trust the client: estimate client time instead. + // https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking + // the estimation is very good. the error is as low as ~6ms for the demo. + // note that passing 'rtt' is fine: EstimateTime halves it to latency. + double estimatedTime = LagCompensation.EstimateTime(NetworkTime.localTime, viewer.rtt, NetworkClient.bufferTime); + + // sample the history to get the nearest snapshots around 'timestamp' + if (LagCompensation.Sample(history, estimatedTime, lagCompensationSettings.captureInterval, out Capture3D resultBefore, out Capture3D resultAfter, out double t)) + { + // interpolate to get a decent estimation at exactly 'timestamp' + sample = Capture3D.Interpolate(resultBefore, resultAfter, t); + return true; + } + else Debug.Log($"CmdClicked: history doesn't contain {estimatedTime:F3}"); + + sample = default; + return false; + } + + // convenience tests /////////////////////////////////////////////////// + // there are multiple different ways to check a hit against the sample: + // - raycasting + // - bounds.contains + // - increasing bounds by tolerance and checking contains + // - threshold to bounds.closestpoint + // let's offer a few solutions directly and see which users prefer. + + // bounds check: checks distance to closest point on bounds in history @ -rtt. + // 'viewer' needs to be the player who fired! + // for example, if A fires at B, then call B.Sample(viewer, point, tolerance). + // this is super simple and fast, but not 100% physically accurate since we don't raycast. + [Server] + public virtual bool BoundsCheck( + NetworkConnectionToClient viewer, + Vector3 hitPoint, + float toleranceDistance, + out float distance, + out Vector3 nearest) + { + // first, sample the history at -rtt of the viewer. + if (Sample(viewer, out Capture3D capture)) + { + // now that we know where the other player was at that time, + // we can see if the hit point was within tolerance of it. + // TODO consider rotations??? + // TODO consider original collider shape?? + Bounds bounds = new Bounds(capture.position, capture.size); + nearest = bounds.ClosestPoint(hitPoint); + distance = Vector3.Distance(nearest, hitPoint); + return distance <= toleranceDistance; + } + nearest = hitPoint; + distance = 0; + return false; + } + + // raycast check: creates a collider the sampled position and raycasts to hitPoint. + // 'viewer' needs to be the player who fired! + // for example, if A fires at B, then call B.Sample(viewer, point, tolerance). + // this is physically accurate (checks against walls etc.), with the cost + // of a runtime instantiation. + // + // originPoint: where the player fired the weapon. + // hitPoint: where the player's local raycast hit. + // tolerance: scale up the sampled collider by % in order to have a bit of a tolerance. + // 0 means no extra tolerance, 0.05 means 5% extra tolerance. + // layerMask: the layer mask to use for the raycast. + [Server] + public virtual bool RaycastCheck( + NetworkConnectionToClient viewer, + Vector3 originPoint, + Vector3 hitPoint, + float tolerancePercent, + int layerMask, + out RaycastHit hit) + { + // first, sample the history at -rtt of the viewer. + if (Sample(viewer, out Capture3D capture)) + { + // instantiate a real physics collider on demand. + // TODO rotation?? + // TODO different collier types?? + GameObject temp = new GameObject("LagCompensatorTest"); + temp.transform.position = capture.position; + BoxCollider tempCollider = temp.AddComponent(); + tempCollider.size = capture.size * (1 + tolerancePercent); + + // raycast + Vector3 direction = hitPoint - originPoint; + float maxDistance = direction.magnitude * 2; + bool result = Physics.Raycast(originPoint, direction, out hit, maxDistance, layerMask); + + // cleanup + Destroy(temp); + return result; + } + + hit = default; + return false; + } + } +} diff --git a/Assets/Mirror/Components/LagCompensation/LagCompensator.cs.meta b/Assets/Mirror/Components/LagCompensation/LagCompensator.cs.meta new file mode 100644 index 000000000..d4912e490 --- /dev/null +++ b/Assets/Mirror/Components/LagCompensation/LagCompensator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a898831dd60c4cdfbfd9a6ea5702ed01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3} + userData: + assetBundleName: + assetBundleVariant: