123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529 |
- // vis2k:
- // base class for NetworkTransform and NetworkTransformChild.
- // New method is simple and stupid. No more 1500 lines of code.
- //
- // Server sends current data.
- // Client saves it and interpolates last and latest data points.
- // Update handles transform movement / rotation
- // FixedUpdate handles rigidbody movement / rotation
- //
- // Notes:
- // * Built-in Teleport detection in case of lags / teleport / obstacles
- // * Quaternion > EulerAngles because gimbal lock and Quaternion.Slerp
- // * Syncs XYZ. Works 3D and 2D. Saving 4 bytes isn't worth 1000 lines of code.
- // * Initial delay might happen if server sends packet immediately after moving
- // just 1cm, hence we move 1cm and then wait 100ms for next packet
- // * Only way for smooth movement is to use a fixed movement speed during
- // interpolation. interpolation over time is never that good.
- //
- using System;
- using UnityEngine;
- namespace Mirror.Experimental
- {
- public abstract class NetworkTransformBase : NetworkBehaviour
- {
- // target transform to sync. can be on a child.
- protected abstract Transform targetTransform { get; }
- [Header("Authority")]
- [Tooltip("Set to true if moves come from owner client, set to false if moves always come from server")]
- [SyncVar]
- public bool clientAuthority;
- [Tooltip("Set to true if updates from server should be ignored by owner")]
- [SyncVar]
- public bool excludeOwnerUpdate = true;
- [Header("Synchronization")]
- [Tooltip("Set to true if position should be synchronized")]
- [SyncVar]
- public bool syncPosition = true;
- [Tooltip("Set to true if rotation should be synchronized")]
- [SyncVar]
- public bool syncRotation = true;
- [Tooltip("Set to true if scale should be synchronized")]
- [SyncVar]
- public bool syncScale = true;
- [Header("Interpolation")]
- [Tooltip("Set to true if position should be interpolated")]
- [SyncVar]
- public bool interpolatePosition = true;
- [Tooltip("Set to true if rotation should be interpolated")]
- [SyncVar]
- public bool interpolateRotation = true;
- [Tooltip("Set to true if scale should be interpolated")]
- [SyncVar]
- public bool interpolateScale = true;
- // Sensitivity is added for VR where human players tend to have micro movements so this can quiet down
- // the network traffic. Additionally, rigidbody drift should send less traffic, e.g very slow sliding / rolling.
- [Header("Sensitivity")]
- [Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
- [SyncVar]
- public float localPositionSensitivity = .01f;
- [Tooltip("If rotation exceeds this angle, it will be transmitted on the network")]
- [SyncVar]
- public float localRotationSensitivity = .01f;
- [Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
- [SyncVar]
- public float localScaleSensitivity = .01f;
- [Header("Diagnostics")]
- // server
- public Vector3 lastPosition;
- public Quaternion lastRotation;
- public Vector3 lastScale;
- // client
- // use local position/rotation for VR support
- [Serializable]
- public struct DataPoint
- {
- public float timeStamp;
- public Vector3 localPosition;
- public Quaternion localRotation;
- public Vector3 localScale;
- public float movementSpeed;
- public bool isValid => timeStamp != 0;
- }
- // 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.
- bool IsOwnerWithClientAuthority => hasAuthority && clientAuthority;
- // interpolation start and goal
- public DataPoint start = new DataPoint();
- public DataPoint goal = new DataPoint();
- // We need to store this locally on the server so clients can't request Authority when ever they like
- bool clientAuthorityBeforeTeleport;
- void FixedUpdate()
- {
- // if server then always sync to others.
- // let the clients know that this has moved
- if (isServer && HasEitherMovedRotatedScaled())
- {
- ServerUpdate();
- }
- if (isClient)
- {
- // send to server if we have local authority (and aren't the server)
- // -> only if connectionToServer has been initialized yet too
- if (IsOwnerWithClientAuthority)
- {
- ClientAuthorityUpdate();
- }
- else if (goal.isValid)
- {
- ClientRemoteUpdate();
- }
- }
- }
- void ServerUpdate()
- {
- RpcMove(targetTransform.localPosition, Compression.CompressQuaternion(targetTransform.localRotation), targetTransform.localScale);
- }
- void ClientAuthorityUpdate()
- {
- if (!isServer && HasEitherMovedRotatedScaled())
- {
- // serialize
- // local position/rotation for VR support
- // send to server
- CmdClientToServerSync(targetTransform.localPosition, Compression.CompressQuaternion(targetTransform.localRotation), targetTransform.localScale);
- }
- }
- void ClientRemoteUpdate()
- {
- // teleport or interpolate
- if (NeedsTeleport())
- {
- // local position/rotation for VR support
- ApplyPositionRotationScale(goal.localPosition, goal.localRotation, goal.localScale);
- // reset data points so we don't keep interpolating
- start = new DataPoint();
- goal = new DataPoint();
- }
- else
- {
- // local position/rotation for VR support
- ApplyPositionRotationScale(InterpolatePosition(start, goal, targetTransform.localPosition),
- InterpolateRotation(start, goal, targetTransform.localRotation),
- InterpolateScale(start, goal, targetTransform.localScale));
- }
- }
- // moved or rotated or scaled since last time we checked it?
- bool HasEitherMovedRotatedScaled()
- {
- // Save last for next frame to compare only if change was detected, otherwise
- // slow moving objects might never sync because of C#'s float comparison tolerance.
- // See also: https://github.com/vis2k/Mirror/pull/428)
- bool changed = HasMoved || HasRotated || HasScaled;
- if (changed)
- {
- // local position/rotation for VR support
- if (syncPosition) lastPosition = targetTransform.localPosition;
- if (syncRotation) lastRotation = targetTransform.localRotation;
- if (syncScale) lastScale = targetTransform.localScale;
- }
- return changed;
- }
- // local position/rotation for VR support
- // SqrMagnitude is faster than Distance per Unity docs
- // https://docs.unity3d.com/ScriptReference/Vector3-sqrMagnitude.html
- bool HasMoved => syncPosition && Vector3.SqrMagnitude(lastPosition - targetTransform.localPosition) > localPositionSensitivity * localPositionSensitivity;
- bool HasRotated => syncRotation && Quaternion.Angle(lastRotation, targetTransform.localRotation) > localRotationSensitivity;
- bool HasScaled => syncScale && Vector3.SqrMagnitude(lastScale - targetTransform.localScale) > localScaleSensitivity * localScaleSensitivity;
- // teleport / lag / stuck detection
- // - checking distance is not enough since there could be just a tiny fence between us and the goal
- // - checking time always works, this way we just teleport if we still didn't reach the goal after too much time has elapsed
- bool NeedsTeleport()
- {
- // calculate time between the two data points
- float startTime = start.isValid ? start.timeStamp : Time.time - Time.fixedDeltaTime;
- float goalTime = goal.isValid ? goal.timeStamp : Time.time;
- float difference = goalTime - startTime;
- float timeSinceGoalReceived = Time.time - goalTime;
- return timeSinceGoalReceived > difference * 5;
- }
- // local authority client sends sync message to server for broadcasting
- [Command(channel = Channels.Unreliable)]
- void CmdClientToServerSync(Vector3 position, uint packedRotation, Vector3 scale)
- {
- // Ignore messages from client if not in client authority mode
- if (!clientAuthority)
- return;
- // deserialize payload
- SetGoal(position, Compression.DecompressQuaternion(packedRotation), scale);
- // server-only mode does no interpolation to save computations, but let's set the position directly
- if (isServer && !isClient)
- ApplyPositionRotationScale(goal.localPosition, goal.localRotation, goal.localScale);
- RpcMove(position, packedRotation, scale);
- }
- [ClientRpc(channel = Channels.Unreliable)]
- void RpcMove(Vector3 position, uint packedRotation, Vector3 scale)
- {
- if (hasAuthority && excludeOwnerUpdate) return;
- if (!isServer)
- SetGoal(position, Compression.DecompressQuaternion(packedRotation), scale);
- }
- // serialization is needed by OnSerialize and by manual sending from authority
- void SetGoal(Vector3 position, Quaternion rotation, Vector3 scale)
- {
- // put it into a data point immediately
- DataPoint temp = new DataPoint
- {
- // deserialize position
- localPosition = position,
- localRotation = rotation,
- localScale = scale,
- timeStamp = Time.time
- };
- // movement speed: based on how far it moved since last time has to be calculated before 'start' is overwritten
- temp.movementSpeed = EstimateMovementSpeed(goal, temp, targetTransform, Time.fixedDeltaTime);
- // reassign start wisely
- // first ever data point? then make something up for previous one so that we can start interpolation without waiting for next.
- if (start.timeStamp == 0)
- {
- start = new DataPoint
- {
- timeStamp = Time.time - Time.fixedDeltaTime,
- // local position/rotation for VR support
- localPosition = targetTransform.localPosition,
- localRotation = targetTransform.localRotation,
- localScale = targetTransform.localScale,
- movementSpeed = temp.movementSpeed
- };
- }
- // second or nth data point? then update previous
- // but: we start at where ever we are right now, so that it's perfectly smooth and we don't jump anywhere
- //
- // example if we are at 'x':
- //
- // A--x->B
- //
- // and then receive a new point C:
- //
- // A--x--B
- // |
- // |
- // C
- //
- // then we don't want to just jump to B and start interpolation:
- //
- // x
- // |
- // |
- // C
- //
- // we stay at 'x' and interpolate from there to C:
- //
- // x..B
- // \ .
- // \.
- // C
- //
- else
- {
- float oldDistance = Vector3.Distance(start.localPosition, goal.localPosition);
- float newDistance = Vector3.Distance(goal.localPosition, temp.localPosition);
- start = goal;
- // local position/rotation for VR support
- // teleport / lag / obstacle detection: only continue at current position if we aren't too far away
- // XC < AB + BC (see comments above)
- if (Vector3.Distance(targetTransform.localPosition, start.localPosition) < oldDistance + newDistance)
- {
- start.localPosition = targetTransform.localPosition;
- start.localRotation = targetTransform.localRotation;
- start.localScale = targetTransform.localScale;
- }
- }
- // set new destination in any case. new data is best data.
- goal = temp;
- }
- // try to estimate movement speed for a data point based on how far it moved since the previous one
- // - if this is the first time ever then we use our best guess:
- // - delta based on transform.localPosition
- // - elapsed based on send interval hoping that it roughly matches
- static float EstimateMovementSpeed(DataPoint from, DataPoint to, Transform transform, float sendInterval)
- {
- Vector3 delta = to.localPosition - (from.localPosition != transform.localPosition ? from.localPosition : transform.localPosition);
- float elapsed = from.isValid ? to.timeStamp - from.timeStamp : sendInterval;
- // avoid NaN
- return elapsed > 0 ? delta.magnitude / elapsed : 0;
- }
- // set position carefully depending on the target component
- void ApplyPositionRotationScale(Vector3 position, Quaternion rotation, Vector3 scale)
- {
- // local position/rotation for VR support
- if (syncPosition) targetTransform.localPosition = position;
- if (syncRotation) targetTransform.localRotation = rotation;
- if (syncScale) targetTransform.localScale = scale;
- }
- // where are we in the timeline between start and goal? [0,1]
- Vector3 InterpolatePosition(DataPoint start, DataPoint goal, Vector3 currentPosition)
- {
- if (!interpolatePosition)
- return currentPosition;
- if (start.movementSpeed != 0)
- {
- // Option 1: simply interpolate based on time, but stutter will happen, it's not that smooth.
- // This is especially noticeable if the camera automatically follows the player
- // - Tell SonarCloud this isn't really commented code but actual comments and to stfu about it
- // - float t = CurrentInterpolationFactor();
- // - return Vector3.Lerp(start.position, goal.position, t);
- // Option 2: always += speed
- // speed is 0 if we just started after idle, so always use max for best results
- float speed = Mathf.Max(start.movementSpeed, goal.movementSpeed);
- return Vector3.MoveTowards(currentPosition, goal.localPosition, speed * Time.deltaTime);
- }
- return currentPosition;
- }
- Quaternion InterpolateRotation(DataPoint start, DataPoint goal, Quaternion defaultRotation)
- {
- if (!interpolateRotation)
- return defaultRotation;
- if (start.localRotation != goal.localRotation)
- {
- float t = CurrentInterpolationFactor(start, goal);
- return Quaternion.Slerp(start.localRotation, goal.localRotation, t);
- }
- return defaultRotation;
- }
- Vector3 InterpolateScale(DataPoint start, DataPoint goal, Vector3 currentScale)
- {
- if (!interpolateScale)
- return currentScale;
- if (start.localScale != goal.localScale)
- {
- float t = CurrentInterpolationFactor(start, goal);
- return Vector3.Lerp(start.localScale, goal.localScale, t);
- }
- return currentScale;
- }
- static float CurrentInterpolationFactor(DataPoint start, DataPoint goal)
- {
- if (start.isValid)
- {
- float difference = goal.timeStamp - start.timeStamp;
- // the moment we get 'goal', 'start' is supposed to start, so elapsed time is based on:
- float elapsed = Time.time - goal.timeStamp;
- // avoid NaN
- return difference > 0 ? elapsed / difference : 1;
- }
- return 1;
- }
- #region Server Teleport (force move player)
- /// <summary>
- /// This method will override this GameObject's current Transform.localPosition to the specified Vector3 and update all clients.
- /// <para>NOTE: position must be in LOCAL space if the transform has a parent</para>
- /// </summary>
- /// <param name="localPosition">Where to teleport this GameObject</param>
- [Server]
- public void ServerTeleport(Vector3 localPosition)
- {
- Quaternion localRotation = targetTransform.localRotation;
- ServerTeleport(localPosition, localRotation);
- }
- /// <summary>
- /// This method will override this GameObject's current Transform.localPosition and Transform.localRotation
- /// to the specified Vector3 and Quaternion and update all clients.
- /// <para>NOTE: localPosition must be in LOCAL space if the transform has a parent</para>
- /// <para>NOTE: localRotation must be in LOCAL space if the transform has a parent</para>
- /// </summary>
- /// <param name="localPosition">Where to teleport this GameObject</param>
- /// <param name="localRotation">Which rotation to set this GameObject</param>
- [Server]
- public void ServerTeleport(Vector3 localPosition, Quaternion localRotation)
- {
- // To prevent applying the position updates received from client (if they have ClientAuth) while being teleported.
- // clientAuthorityBeforeTeleport defaults to false when not teleporting, if it is true then it means that teleport
- // was previously called but not finished therefore we should keep it as true so that 2nd teleport call doesn't clear authority
- clientAuthorityBeforeTeleport = clientAuthority || clientAuthorityBeforeTeleport;
- clientAuthority = false;
- DoTeleport(localPosition, localRotation);
- // tell all clients about new values
- RpcTeleport(localPosition, Compression.CompressQuaternion(localRotation), clientAuthorityBeforeTeleport);
- }
- void DoTeleport(Vector3 newLocalPosition, Quaternion newLocalRotation)
- {
- targetTransform.localPosition = newLocalPosition;
- targetTransform.localRotation = newLocalRotation;
- // Since we are overriding the position we don't need a goal and start.
- // Reset them to null for fresh start
- goal = new DataPoint();
- start = new DataPoint();
- lastPosition = newLocalPosition;
- lastRotation = newLocalRotation;
- }
- [ClientRpc(channel = Channels.Unreliable)]
- void RpcTeleport(Vector3 newPosition, uint newPackedRotation, bool isClientAuthority)
- {
- DoTeleport(newPosition, Compression.DecompressQuaternion(newPackedRotation));
- // only send finished if is owner and is ClientAuthority on server
- if (hasAuthority && isClientAuthority)
- CmdTeleportFinished();
- }
- /// <summary>
- /// This RPC will be invoked on server after client finishes overriding the position.
- /// </summary>
- /// <param name="initialAuthority"></param>
- [Command(channel = Channels.Unreliable)]
- void CmdTeleportFinished()
- {
- if (clientAuthorityBeforeTeleport)
- {
- clientAuthority = true;
- // reset value so doesn't effect future calls, see note in ServerTeleport
- clientAuthorityBeforeTeleport = false;
- }
- else
- {
- Debug.LogWarning("Client called TeleportFinished when clientAuthority was false on server", this);
- }
- }
- #endregion
- #region Debug Gizmos
- // draw the data points for easier debugging
- void OnDrawGizmos()
- {
- // draw start and goal points and a line between them
- if (start.localPosition != goal.localPosition)
- {
- DrawDataPointGizmo(start, Color.yellow);
- DrawDataPointGizmo(goal, Color.green);
- DrawLineBetweenDataPoints(start, goal, Color.cyan);
- }
- }
- static void DrawDataPointGizmo(DataPoint data, Color color)
- {
- // use a little offset because transform.localPosition might be in the ground in many cases
- Vector3 offset = Vector3.up * 0.01f;
- // draw position
- Gizmos.color = color;
- Gizmos.DrawSphere(data.localPosition + offset, 0.5f);
- // draw forward and up like unity move tool
- Gizmos.color = Color.blue;
- Gizmos.DrawRay(data.localPosition + offset, data.localRotation * Vector3.forward);
- Gizmos.color = Color.green;
- Gizmos.DrawRay(data.localPosition + offset, data.localRotation * Vector3.up);
- }
- static void DrawLineBetweenDataPoints(DataPoint data1, DataPoint data2, Color color)
- {
- Gizmos.color = color;
- Gizmos.DrawLine(data1.localPosition, data2.localPosition);
- }
- #endregion
- }
- }
|