quantum-space-buddies/Mirror/Components/Experimental/NetworkTransformBase.cs
2021-12-27 22:31:23 -08:00

530 lines
22 KiB
C#

// 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
}
}