using FishNet.Object; using FishNet.Transporting; using FishNet.Utility.Extension; using System.Collections.Generic; using System.Runtime.CompilerServices; using UnityEngine; namespace FishNet.Component.Prediction { internal class PredictedObjectSpectatorSmoother { #region Types. /// /// Data on a goal to move towards. /// private class GoalData { public bool IsActive; /// /// LocalTick this data is for. /// public uint LocalTick; /// /// Data on how fast to move to transform values. /// public RateData Rates = new RateData(); /// /// Transform values to move towards. /// public TransformData Transforms = new TransformData(); public GoalData() { } /// /// Resets values for re-use. /// public void Reset() { LocalTick = 0; Transforms.Reset(); Rates.Reset(); IsActive = false; } /// /// Updates values using a GoalData. /// public void Update(GoalData gd) { LocalTick = gd.LocalTick; Rates.Update(gd.Rates); Transforms.Update(gd.Transforms); IsActive = true; } public void Update(uint localTick, RateData rd, TransformData td) { LocalTick = localTick; Rates = rd; Transforms = td; IsActive = true; } } /// /// How fast to move to values. /// private class RateData { /// /// Rate for position after smart calculations. /// public float Position; /// /// Rate for rotation after smart calculations. /// public float Rotation; /// /// Number of ticks the rates are calculated for. /// If TickSpan is 2 then the rates are calculated under the assumption the transform changed over 2 ticks. /// public uint TickSpan; /// /// Time remaining until transform is expected to reach it's goal. /// internal float TimeRemaining; public RateData() { } /// /// Resets values for re-use. /// public void Reset() { Position = 0f; Rotation = 0f; TickSpan = 0; TimeRemaining = 0f; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Update(RateData rd) { Update(rd.Position, rd.Rotation, rd.TickSpan, rd.TimeRemaining); } /// /// Updates rates. /// public void Update(float position, float rotation, uint tickSpan, float timeRemaining) { Position = position; Rotation = rotation; TickSpan = tickSpan; TimeRemaining = timeRemaining; } } /// /// Data about where a transform should move towards. /// private class TransformData { /// /// Position of the transform. /// public Vector3 Position; /// /// Rotation of the transform. /// public Quaternion Rotation; public void Reset() { Position = Vector3.zero; Rotation = Quaternion.identity; } /// /// Updates this data. /// public void Update(TransformData copy) { Update(copy.Position, copy.Rotation); } /// /// Updates this data. /// public void Update(Vector3 position, Quaternion rotation) { Position = position; Rotation = rotation; } /// /// Updates this data. /// public void Update(Rigidbody rigidbody) { Position = rigidbody.transform.position; Rotation = rigidbody.transform.rotation; } /// /// Updates this data. /// public void Update(Rigidbody2D rigidbody) { Position = rigidbody.transform.position; Rotation = rigidbody.transform.rotation; } } #endregion #region Private. /// /// Current GoalData being used. /// private GoalData _currentGoalData = new GoalData(); /// /// Object to smooth. /// private Transform _graphicalObject; /// /// Sets GraphicalObject. /// /// public void SetGraphicalObject(Transform value) => _graphicalObject = value; /// /// True to move towards position goals. /// private bool _smoothPosition; /// /// True to move towards rotation goals. /// private bool _smoothRotation; /// /// How far in the past to keep the graphical object. /// private uint _interpolation = 4; /// /// Sets the interpolation value to use when the owner of this object. /// /// public void SetInterpolation(uint value) => _interpolation = value; /// /// GoalDatas to move towards. /// private List _goalDatas = new List(); /// /// Rigidbody to use. /// private Rigidbody _rigidbody; /// /// Rigidbody2D to use. /// private Rigidbody2D _rigidbody2d; /// /// Transform state during PreTick. /// private TransformData _preTickTransformdata = new TransformData(); /// /// Type of rigidbody being used. /// private RigidbodyType _rigidbodyType; /// /// Last tick which a reconcile occured. This is reset at the end of a tick. /// private long _reconcileLocalTick = -1; /// /// Called when this frame receives OnPreTick. /// private bool _preTickReceived; /// /// Start position for graphicalObject at the beginning of the tick. /// private Vector3 _graphicalStartPosition; /// /// Start rotation for graphicalObject at the beginning of the tick. /// private Quaternion _graphicalStartRotation; /// /// How far a distance change must exceed to teleport the graphical object. -1f indicates teleport is not enabled. /// private float _teleportThreshold; /// /// PredictedObject which is using this object. /// private PredictedObject _predictedObject; /// /// Cache of GoalDatas to prevent allocations. /// private static Stack _goalDataCache = new Stack(); /// /// Cached localtick for performance. /// private uint _localTick; /// /// Number of ticks to ignore when replaying. /// private uint _ignoredTicks; /// /// Start position of the graphical object in world space. /// private Vector3 _startWorldPosition; #endregion #region Const. /// /// Multiplier to apply to movement speed when buffer is over interpolation. /// private const float OVERFLOW_MULTIPLIER = 0.1f; /// /// Multiplier to apply to movement speed when buffer is under interpolation. /// private const float UNDERFLOW_MULTIPLIER = 0.02f; #endregion public void SetIgnoredTicks(uint value) => _ignoredTicks = value; /// /// Initializes this for use. /// internal void Initialize(PredictedObject po, RigidbodyType rbType, Rigidbody rb, Rigidbody2D rb2d, Transform graphicalObject , bool smoothPosition, bool smoothRotation, float teleportThreshold) { _predictedObject = po; _rigidbodyType = rbType; _rigidbody = rb; _rigidbody2d = rb2d; _graphicalObject = graphicalObject; _startWorldPosition = _graphicalObject.position; _smoothPosition = smoothPosition; _smoothRotation = smoothRotation; _teleportThreshold = teleportThreshold; } /// /// /// Called every frame. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void ManualUpdate() { if (CanSmooth()) MoveToTarget(); } /// /// Called when the TimeManager invokes OnPreTick. /// public void OnPreTick() { if (CanSmooth()) { _localTick = _predictedObject.TimeManager.LocalTick; if (!_preTickReceived) { uint tick = _predictedObject.TimeManager.LocalTick - 1; CreateGoalData(tick, false); } _preTickReceived = true; if (_rigidbodyType == RigidbodyType.Rigidbody) _preTickTransformdata.Update(_rigidbody); else _preTickTransformdata.Update(_rigidbody2d); _graphicalStartPosition = _graphicalObject.position; _graphicalStartRotation = _graphicalObject.rotation; } } /// /// Called when the TimeManager invokes OnPostTick. /// public void OnPostTick() { if (CanSmooth()) { if (!_preTickReceived) { /* During test the Z value for applyImmediately is 5.9. * Then increased 1 unit per tick: 6.9, 7.9. * * When the spectator smoother initializes 5.9 is shown. * Before first starting smoothing the transform needs to be set * back to that. * * The second issue is the first addition to goal datas seems * to occur at 7.9. This would need to be 6.9 to move from the * proper 5.9 starting point. It's probably because pretick is not received * when OnPostTick is called at the 6.9 position. * * Have not validated the above yet but that's the most likely situation since * we know this was initialized at 5.9, which means it would be assumed pretick would * call at 6.9. Perhaps the following is happening.... * * - Pretick. * - Client gets spawn+applyImmediately. * - This also initializes this script at 5.9. * - Simulation moves object to 6.9. * - PostTick. * - This script does not run because _preTickReceived is not set yet. * * - Pretick. Sets _preTickReceived. * - Simulation moves object to 7.9. * - PostTick. * - The first goalData is created for 7.9. * * In writing the theory checks out. * Perhaps the solution could be simple as creating a goal * during pretick if _preTickReceived is being set for * the first time. Might need to reduce tick by 1 * when setting goalData for this; not sure yet. */ _graphicalObject.SetPositionAndRotation(_startWorldPosition, Quaternion.identity); return; } _graphicalObject.SetPositionAndRotation(_graphicalStartPosition, _graphicalStartRotation); CreateGoalData(_predictedObject.TimeManager.LocalTick, true); } } public void OnPreReplay(uint tick) { if (!_preTickReceived) { if (CanSmooth()) { //if (_localTick - tick < _ignoredTicks) // return; CreateGoalData(tick, false); } } } /// /// Called after a reconcile runs a replay. /// public void OnPostReplay(uint tick) { if (CanSmooth()) { if (_reconcileLocalTick == -1) return; CreateGoalData(tick, false); } } /// /// Returns if the graphics can be smoothed. /// /// private bool CanSmooth() { if (_interpolation == 0) return false; if (_predictedObject.IsPredictingOwner() || _predictedObject.IsServer) return false; return true; } /// /// Sets the last tick a reconcile occurred. /// /// public void SetLocalReconcileTick(long value) { _reconcileLocalTick = value; } /// /// Caches a GoalData. /// /// private void StoreGoalData(GoalData gd) { gd.Reset(); _goalDataCache.Push(gd); } /// /// Returns if this transform matches arguments. /// /// private bool GraphicalObjectMatches(Vector3 localPosition, Quaternion localRotation) { bool positionMatches = (!_smoothPosition || _graphicalObject.position == localPosition); bool rotationMatches = (!_smoothRotation || _graphicalObject.rotation == localRotation); return (positionMatches && rotationMatches); } /// /// Returns if there is any change between two datas. /// private bool HasChanged(TransformData a, TransformData b) { return (a.Position != b.Position) || (a.Rotation != b.Rotation); } /// /// Returns if the transform differs from td. /// private bool HasChanged(TransformData td) { Transform rigidbodyTransform; if (_rigidbodyType == RigidbodyType.Rigidbody) rigidbodyTransform = _rigidbody.transform; else rigidbodyTransform = _rigidbody2d.transform; bool changed = (td.Position != rigidbodyTransform.position) || (td.Rotation != rigidbodyTransform.rotation); return changed; } /// /// Sets CurrentGoalData to the next in queue. /// private void SetCurrentGoalData(bool afterMove) { if (_goalDatas.Count == 0) { _currentGoalData.IsActive = false; } else { //if (!afterMove && _goalDatas.Count < _interpolation) // return; //Update current to next. _currentGoalData.Update(_goalDatas[0]); //Store old and remove it. StoreGoalData(_goalDatas[0]); _goalDatas.RemoveAt(0); } } /// /// Moves to a GoalData. Automatically determins if to use data from server or client. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void MoveToTarget(float deltaOverride = -1f) { /* If the current goal data is not active then * try to set a new one. If none are available * it will remain inactive. */ if (!_currentGoalData.IsActive) { SetCurrentGoalData(false); //If still inactive then it could not be updated. if (!_currentGoalData.IsActive) return; } float delta = (deltaOverride != -1f) ? deltaOverride : Time.deltaTime; /* Once here it's safe to assume the object will be moving. * Any checks which would stop it from moving be it client * auth and owner, or server controlled and server, ect, * would have already been run. */ TransformData td = _currentGoalData.Transforms; RateData rd = _currentGoalData.Rates; int queueCount = _goalDatas.Count; /* Begin moving even if interpolation buffer isn't * met to provide more real-time interactions but * speed up when buffer is too large. This should * provide a good balance of accuracy. */ float multiplier; int countOverInterpolation = (queueCount - (int)_interpolation); if (countOverInterpolation > 0) { float overflowMultiplier = (!_predictedObject.IsOwner) ? OVERFLOW_MULTIPLIER : (OVERFLOW_MULTIPLIER * 1f); multiplier = 1f + overflowMultiplier; } else if (countOverInterpolation < 0) { float value = (UNDERFLOW_MULTIPLIER * Mathf.Abs(countOverInterpolation)); const float maximum = 0.9f; if (value > maximum) value = maximum; multiplier = 1f - value; } else { multiplier = 1f; } //Rate to update. Changes per property. float rate; Transform t = _graphicalObject; //Position. if (_smoothPosition) { rate = rd.Position; Vector3 posGoal = td.Position; if (rate == -1f) t.position = td.Position; else if (rate > 0f) t.position = Vector3.MoveTowards(t.position, posGoal, rate * delta * multiplier); } //Rotation. if (_smoothRotation) { rate = rd.Rotation; if (rate == -1f) t.rotation = td.Rotation; else if (rate > 0f) t.rotation = Quaternion.RotateTowards(t.rotation, td.Rotation, rate * delta); } //Subtract time remaining for movement to complete. if (rd.TimeRemaining > 0f) { float subtractionAmount = (delta * multiplier); float timeRemaining = rd.TimeRemaining - subtractionAmount; rd.TimeRemaining = timeRemaining; } //If movement shoudl be complete. if (rd.TimeRemaining <= 0f) { float leftOver = Mathf.Abs(rd.TimeRemaining); //Set to next goal data if available. SetCurrentGoalData(true); //New data was set. if (_currentGoalData.IsActive) { if (leftOver > 0f) MoveToTarget(leftOver); } //No more in buffer, see if can extrapolate. else { /* Everything should line up when * time remaining is <= 0f but incase it's not, * such as if the user manipulated the grapihc object * somehow, then set goaldata active again to continue * moving it until it lines up with the goal. */ if (!GraphicalObjectMatches(td.Position, td.Rotation)) _currentGoalData.IsActive = true; } } } #region Rates. /// /// Sets move rates which will occur instantly. /// private void SetInstantRates(RateData rd) { rd.Update(-1f, -1f, 1, -1f); } /// /// Sets move rates which will occur over time. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void SetCalculatedRates(GoalData prevGoalData, GoalData nextGoalData, Channel channel) { /* Only update rates if data has changed. * When data comes in reliably for eventual consistency * it's possible that it will be the same as the last * unreliable packet. When this happens no change has occurred * and the distance of change woudl also be 0; this prevents * the NT from moving. Only need to compare data if channel is reliable. */ TransformData nextTd = nextGoalData.Transforms; if (channel == Channel.Reliable && HasChanged(prevGoalData.Transforms, nextTd)) { nextGoalData.Rates.Update(prevGoalData.Rates); return; } uint lastTick = prevGoalData.LocalTick; /* How much time has passed between last update and current. * If set to 0 then that means the transform has * settled. */ if (lastTick == 0) lastTick = (nextGoalData.LocalTick - 1); uint tickDifference = (nextGoalData.LocalTick - lastTick); float timePassed = (float)_predictedObject.TimeManager.TicksToTime(tickDifference); RateData nextRd = nextGoalData.Rates; //Distance between properties. float distance; //Position. Vector3 lastPosition = prevGoalData.Transforms.Position; distance = Vector3.Distance(lastPosition, nextTd.Position); //If distance teleports assume rest do. if (_teleportThreshold >= 0f && distance >= _teleportThreshold) { SetInstantRates(nextRd); return; } //Position distance already calculated. float positionRate = (distance / timePassed); //Rotation. distance = prevGoalData.Transforms.Rotation.Angle(nextTd.Rotation, true); float rotationRate = (distance / timePassed); /* If no speed then snap just in case. * 0f could be from floating errors. */ if (positionRate == 0f) positionRate = -1f; if (rotationRate == 0f) rotationRate = -1f; nextRd.Update(positionRate, rotationRate, tickDifference, timePassed); } #endregion /// /// Creates a new goal data for tick. The result will be placed into the goalDatas queue at it's proper position. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public void CreateGoalData(uint tick, bool postTick) { /* It's possible a removed entry would void further * logic so remove excess entires first. */ /* Remove entries which are excessive to the buffer. * This could create a starting jitter but it will ensure * the buffer does not fill too much. The buffer next should * actually get unreasonably high but rather safe than sorry. */ int maximumBufferAllowance = ((int)_interpolation * 8); int removedBufferCount = (_goalDatas.Count - maximumBufferAllowance); //If there are some to remove. if (removedBufferCount > 0) { for (int i = 0; i < removedBufferCount; i++) StoreGoalData(_goalDatas[0 + i]); _goalDatas.RemoveRange(0, removedBufferCount); } uint currentGoalDataTick = _currentGoalData.LocalTick; //Tick has already been interpolated past, no reason to process it. if (tick <= currentGoalDataTick) return; //GoalData from previous calculation. GoalData prevGoalData; int datasCount = _goalDatas.Count; /* Where to insert next data. This could have value * somewhere in the middle of goalDatas if the tick * is a replay rather than post tick. */ int injectionIndex = datasCount + 1; //If being added at the end of a tick rather than from replay. if (postTick) { //Becomes true if transform differs from previous data. bool changed; //If there is no goal data then create one using pretick data. if (datasCount == 0) { prevGoalData = MakeGoalDataFromPreTickTransform(); changed = HasChanged(prevGoalData.Transforms); } //If there's goal datas grab the last, it will always be the tick before. else { prevGoalData = _goalDatas[datasCount - 1]; /* If the tick is not exactly 1 past the last * then there's gaps in the saved values. This can * occur if the transform went idle and the buffer * hasn't emptied out yet. When this occurs use the * preTick data to calculate differences. */ if (tick - prevGoalData.LocalTick != 1) prevGoalData = MakeGoalDataFromPreTickTransform(); changed = HasChanged(prevGoalData.Transforms); } //Nothing has changed so no further action is required. if (!changed) { if (datasCount > 0 && prevGoalData != _goalDatas[datasCount - 1]) StoreGoalData(prevGoalData); return; } } //Not post tick so it's from a replay. else { int prevIndex = -1; /* If the tick is 1 past current goalData * then it's the next in line for smoothing * from the current. * When this occurs use currentGoalData as * the previous. */ if (tick == (currentGoalDataTick + 1)) { prevGoalData = _currentGoalData; injectionIndex = 0; } //When not the next in line find out where to place data. else { if (tick > 0) prevGoalData = GetGoalData(tick - 1, out prevIndex); //Cannot find prevGoalData if tick is 0. else prevGoalData = null; } //If previous goalData was found then inject just past the previous value. if (prevIndex != -1) injectionIndex = prevIndex + 1; /* Should previous goalData be null then it could not be found. * Create a new previous goal data based on rigidbody state * during pretick. */ if (prevGoalData == null) { //Create a goaldata based on information. If it differs from pretick then throw. GoalData gd = RetrieveGoalData(); gd.Transforms.Update(_preTickTransformdata); if (HasChanged(gd.Transforms)) { prevGoalData = gd; } else { StoreGoalData(gd); return; } } /* Previous goal data is not active. * This should not be possible but this * is here as a sanity check anyway. */ else if (!prevGoalData.IsActive) { return; } } //Begin building next goal data. GoalData nextGoalData = RetrieveGoalData(); nextGoalData.LocalTick = tick; //Set next transform data. TransformData nextTd = nextGoalData.Transforms; if (_rigidbodyType == RigidbodyType.Rigidbody) nextTd.Update(_rigidbody); else nextTd.Update(_rigidbody2d); /* Reset properties if smoothing is not enabled * for them. It's less checks and easier to do it * after the nextGoalData is populated. */ if (!_smoothPosition) nextTd.Position = _graphicalStartPosition; if (!_smoothRotation) nextTd.Rotation = _graphicalStartRotation; //Calculate rates for prev vs next data. SetCalculatedRates(prevGoalData, nextGoalData, Channel.Unreliable); /* If injectionIndex would place at the end * then add. to goalDatas. */ if (injectionIndex >= _goalDatas.Count) _goalDatas.Add(nextGoalData); //Otherwise insert into the proper location. else _goalDatas[injectionIndex].Update(nextGoalData); //Makes previous goal data from transforms pretick values. GoalData MakeGoalDataFromPreTickTransform() { GoalData gd = RetrieveGoalData(); //RigidbodyData contains the data from preTick. gd.Transforms.Update(_preTickTransformdata); //No need to update rates because this is just a starting point reference for interpolation. return gd; } } /// /// Returns the GoalData at tick. /// /// private GoalData GetGoalData(uint tick, out int index) { index = -1; if (tick == 0) return null; for (int i = 0; i < _goalDatas.Count; i++) { if (_goalDatas[i].LocalTick == tick) { index = i; return _goalDatas[i]; } } //Not found. return null; } /// /// Returns a GoalData from the cache. /// /// private GoalData RetrieveGoalData() { GoalData result = (_goalDataCache.Count > 0) ? _goalDataCache.Pop() : new GoalData(); result.IsActive = true; return result; } } }