feat: Experimental Network Transform (#1990)

* feat: Experimental Network Transform

* Removed lastClientSendTime and lastServerSendTime
This commit is contained in:
MrGadget 2020-06-12 10:48:34 -04:00 committed by GitHub
parent b962d71944
commit 7e2b7338a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 507 additions and 0 deletions

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bfbf2a1f2b300c5489dcab219ef2846e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,12 @@
using UnityEngine;
namespace Mirror.Experimental
{
[DisallowMultipleComponent]
[AddComponentMenu("Network/Experimental/NetworkTransformExperimental")]
[HelpURL("https://mirror-networking.com/docs/Components/NetworkTransform.html")]
public class NetworkTransform : NetworkTransformBase
{
protected override Transform targetTransform => transform;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 741bbe11f5357b44593b15c0d11b16bd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,436 @@
// 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();
void FixedUpdate()
{
// if server then always sync to others.
if (isServer)
{
// let the clients know that this has moved
if (HasEitherMovedRotatedScaled())
{
RpcMove(targetTransform.localPosition, targetTransform.localRotation, targetTransform.localScale);
}
}
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)
{
if (!isServer)
{
if (HasEitherMovedRotatedScaled())
{
// serialize
// local position/rotation for VR support
// send to server
CmdClientToServerSync(targetTransform.transform.localPosition, targetTransform.transform.localRotation, targetTransform.transform.localScale);
}
}
}
else if (goal.isValid)
{
// 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]
void CmdClientToServerSync(Vector3 position, Quaternion rotation, Vector3 scale)
{
// Ignore messages from client if not in client authority mode
if (!clientAuthority)
return;
// deserialize payload
SetGoal(position, rotation, 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, rotation, scale);
}
[ClientRpc]
void RpcMove(Vector3 position, Quaternion rotation, Vector3 scale)
{
if (hasAuthority && excludeOwnerUpdate) return;
if (!isServer)
SetGoal(position, rotation, 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 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
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea7c690c4fbf8c4439726f4c62eda6d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,18 @@
using UnityEngine;
namespace Mirror.Experimental
{
/// <summary>
/// A component to synchronize the position of child transforms of networked objects.
/// <para>There must be a NetworkTransform on the root object of the hierarchy. There can be multiple NetworkTransformChild components on an object. This does not use physics for synchronization, it simply synchronizes the localPosition and localRotation of the child transform and lerps towards the recieved values.</para>
/// </summary>
[AddComponentMenu("Network/Experimental/NetworkTransformChildExperimentalExperimental")]
[HelpURL("https://mirror-networking.com/docs/Components/NetworkTransformChild.html")]
public class NetworkTransformChild : NetworkTransformBase
{
[Header("Target")]
public Transform target;
protected override Transform targetTransform => target;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f65214da13a861f4a8ae309d3daea1c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
assetBundleName:
assetBundleVariant: