Skip to content

Instantly share code, notes, and snippets.

@Ikalou
Created December 26, 2023 16:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Ikalou/318c4e4e91acd5b39ec934f1ebfff686 to your computer and use it in GitHub Desktop.
Save Ikalou/318c4e4e91acd5b39ec934f1ebfff686 to your computer and use it in GitHub Desktop.
NetworkTransformClassic11.cs
// 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
{
public abstract class NetworkTransformBase : NetworkBehaviour
{
[Header("Authority")]
[Tooltip("Set to true if moves come from owner client, set to false if moves always come from server")]
public bool clientAuthority;
/// <summary>
/// We need to store this locally on the server so clients can't request Authority when ever they like
/// </summary>
bool clientAuthorityBeforeTeleport;
// 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 IsClientWithAuthority => isOwned && clientAuthority;
// 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.")]
public float localPositionSensitivity = .01f;
[Tooltip("If rotation exceeds this angle, it will be transmitted on the network")]
public float localRotationSensitivity = .01f;
[Tooltip("Changes to the transform must exceed these values to be transmitted on the network.")]
public float localScaleSensitivity = .01f;
[Header("Compression")]
[Tooltip("Enables smallest-three quaternion compression, which is lossy. Great for 3D, not great for 2D where minimal sprite rotations would look wobbly.")]
public bool compressRotation; // disabled by default to not break 2D projects
[Header("Interpolation")]
[Tooltip("Set to true if position should be interpolated, false is ideal for grid bassed movement")]
public bool interpolatePosition = true;
[Tooltip("Set to true if rotation should be interpolated, false is ideal for instant turning, common in retro 2d style games")]
public bool interpolateRotation = true;
[Tooltip("Set to true if scale should be interpolated, false is ideal for instant sprite flipping.")]
public bool interpolateScale = true;
[Header("Synchronization")]
// It should be very rare cases that people want to continuously sync scale, true by default to not break previous projects that use it
// Users in most scenarios are best to send change of scale via cmd/rpc, syncvar/hooks, only once, and when required. Saves instant 12 bytes (25% of NT bandwidth!)
[Tooltip("Set to false to not continuously send scale data, and save bandwidth.")]
public bool syncScale = true;
// target transform to sync. can be on a child.
protected abstract Transform targetComponent { get; }
// server
Vector3 lastPosition;
Quaternion lastRotation;
Vector3 lastScale;
// client
public class DataPoint
{
public float timeStamp;
// use local position/rotation for VR support
public Vector3 localPosition;
public Quaternion localRotation;
public Vector3 localScale;
public float movementSpeed;
}
// interpolation start and goal
DataPoint start;
DataPoint goal;
// local authority send time
float lastClientSendTime;
// serialization is needed by OnSerialize and by manual sending from authority
// public only for tests
public static void SerializeIntoWriter(NetworkWriter writer, Vector3 position, Quaternion rotation, Vector3 scale, bool compressRotation, bool syncScale)
{
// serialize position, rotation, scale
// => compress rotation from 4*4=16 to 4 bytes
// => less bandwidth = better CCU tests / scale
writer.WriteVector3(position);
if (compressRotation)
{
// smalles three compression for 3D
writer.WriteUInt(Compression.CompressQuaternion(rotation));
}
else
{
// uncompressed for 2D
writer.WriteQuaternion(rotation);
}
if (syncScale) { writer.WriteVector3(scale); }
}
public override void OnSerialize(NetworkWriter writer, bool initialState)
{
// use local position/rotation/scale for VR support
SerializeIntoWriter(writer, targetComponent.localPosition, targetComponent.localRotation, targetComponent.localScale, compressRotation, syncScale);
}
// 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 != null ? from.localPosition : transform.localPosition);
float elapsed = from != null ? to.timeStamp - from.timeStamp : sendInterval;
// avoid NaN
return elapsed > 0 ? delta.magnitude / elapsed : 0;
}
// serialization is needed by OnSerialize and by manual sending from authority
void DeserializeFromReader(NetworkReader reader)
{
// put it into a data point immediately
DataPoint temp = new DataPoint
{
// deserialize position, rotation, scale
// (rotation is optionally compressed)
localPosition = reader.ReadVector3(),
localRotation = compressRotation
? Compression.DecompressQuaternion(reader.ReadUInt())
: reader.ReadQuaternion(),
// use current target scale, so we can check boolean and reader later, to see if the data is actually sent.
localScale = targetComponent.localScale,
timeStamp = Time.time
};
if (syncScale)
{
// Reader length is checked here, 12 is used as thats the current Vector3 (3 floats) amount.
// In rare cases people may do mis-matched builds, log useful warning message, and then do not process missing scale data.
if (reader.Remaining >= 12)
temp.localScale = reader.ReadVector3();
else
Debug.LogWarning("Reader length does not contain enough data for a scale, please check that both server and client builds syncScale booleans match.", this);
}
// movement speed: based on how far it moved since last time
// has to be calculated before 'start' is overwritten
temp.movementSpeed = EstimateMovementSpeed(goal, temp, targetComponent, syncInterval);
// 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 == null)
{
start = new DataPoint
{
timeStamp = Time.time - syncInterval,
// local position/rotation for VR support
localPosition = targetComponent.localPosition,
localRotation = targetComponent.localRotation,
localScale = targetComponent.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;
// teleport / lag / obstacle detection: only continue at current
// position if we aren't too far away
//
// local position/rotation for VR support
if (Vector3.Distance(targetComponent.localPosition, start.localPosition) < oldDistance + newDistance)
{
start.localPosition = targetComponent.localPosition;
start.localRotation = targetComponent.localRotation;
start.localScale = targetComponent.localScale;
}
}
// set new destination in any case. new data is best data.
goal = temp;
}
public override void OnDeserialize(NetworkReader reader, bool initialState)
{
// deserialize
DeserializeFromReader(reader);
}
// local authority client sends sync message to server for broadcasting
[Command(channel = Channels.Unreliable)]
void CmdClientToServerSync(ArraySegment<byte> payload)
{
// Ignore messages from client if not in client authority mode
if (!clientAuthority)
return;
// deserialize payload
using (NetworkReaderPooled networkReader = NetworkReaderPool.Get(payload))
DeserializeFromReader(networkReader);
// 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);
// set dirty so that OnSerialize broadcasts it
SetDirty();
}
// where are we in the timeline between start and goal? [0,1]
static float CurrentInterpolationFactor(DataPoint start, DataPoint goal)
{
if (start != null)
{
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 : 0;
}
return 0;
}
Vector3 InterpolatePosition(DataPoint start, DataPoint goal, Vector3 currentPosition)
{
if (!interpolatePosition)
{
return goal.localPosition;
}
if (start != null)
{
// Option 1: simply interpolate based on time. but stutter
// will happen, it's not that smooth. especially noticeable if
// the camera automatically follows the player
// 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 goal.localRotation;
}
if (start != null)
{
float t = CurrentInterpolationFactor(start, goal);
return Quaternion.Slerp(start.localRotation, goal.localRotation, t);
}
return defaultRotation;
}
Vector3 InterpolateScale(DataPoint start, DataPoint goal, Vector3 currentScale)
{
if (!syncScale)
{
return currentScale;
}
if (!interpolateScale)
{
return goal.localScale;
}
if (interpolateScale && start != null)
{
float t = CurrentInterpolationFactor(start, goal);
return Vector3.Lerp(start.localScale, goal.localScale, t);
}
return currentScale;
}
// 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 != null ? start.timeStamp : Time.time - syncInterval;
float goalTime = goal != null ? goal.timeStamp : Time.time;
float difference = goalTime - startTime;
float timeSinceGoalReceived = Time.time - goalTime;
return timeSinceGoalReceived > difference * 5;
}
// moved since last time we checked it?
bool HasEitherMovedRotatedScaled()
{
// moved or rotated or scaled?
// local position/rotation/scale for VR support
bool moved = Vector3.Distance(lastPosition, targetComponent.localPosition) > localPositionSensitivity;
bool scaled = Vector3.Distance(lastScale, targetComponent.localScale) > localScaleSensitivity;
bool rotated = Quaternion.Angle(lastRotation, targetComponent.localRotation) > localRotationSensitivity;
// 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 change = moved || rotated || scaled;
if (change)
{
// local position/rotation for VR support
lastPosition = targetComponent.localPosition;
lastRotation = targetComponent.localRotation;
lastScale = targetComponent.localScale;
}
return change;
}
// set position carefully depending on the target component
void ApplyPositionRotationScale(Vector3 position, Quaternion rotation, Vector3 scale)
{
// local position/rotation for VR support
targetComponent.localPosition = position;
targetComponent.localRotation = rotation;
targetComponent.localScale = scale;
}
void Update()
{
// if server then always sync to others.
if (isServer)
{
// just use OnSerialize via SetDirtyBit only sync when position
// changed. set dirty bits 0 or 1
if (HasEitherMovedRotatedScaled()) SetDirty();
}
// no 'else if' since host mode would be both
if (isClient)
{
// send to server if we have local authority (and aren't the server)
// -> only if connectionToServer has been initialized yet too
if (!isServer && IsClientWithAuthority)
{
// check only each 'syncInterval'
if (Time.time - lastClientSendTime >= syncInterval)
{
if (HasEitherMovedRotatedScaled())
{
// serialize
// local position/rotation for VR support
using (NetworkWriterPooled writer = NetworkWriterPool.Get())
{
SerializeIntoWriter(writer, targetComponent.localPosition, targetComponent.localRotation, targetComponent.localScale, compressRotation, syncScale);
// send to server
CmdClientToServerSync(writer.ToArraySegment());
}
}
lastClientSendTime = Time.time;
}
}
// apply interpolation on client for all players
// unless this client has authority over the object. could be
// himself or another object that he was assigned authority over
if (!IsClientWithAuthority)
{
// received one yet? (initialized?)
if (goal != null)
{
// 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 = null;
goal = null;
}
else
{
// local position/rotation for VR support
ApplyPositionRotationScale(InterpolatePosition(start, goal, targetComponent.localPosition),
InterpolateRotation(start, goal, targetComponent.localRotation),
InterpolateScale(start, goal, targetComponent.localScale));
}
}
}
}
}
#region Server Teleport (force move player)
/// <summary>
/// Server side teleportation.
/// This method will override this GameObject's current Transform.Position to the Vector3 you have provided
/// and send it to all other Clients to override it at their side too.
/// </summary>
/// <param name="position">Where to teleport this GameObject</param>
[Server]
public void ServerTeleport(Vector3 position)
{
Quaternion rotation = transform.rotation;
ServerTeleport(position, rotation);
}
/// <summary>
/// Server side teleportation.
/// This method will override this GameObject's current Transform.Position and Transform.Rotation
/// to the Vector3 you have provided
/// and send it to all other Clients to override it at their side too.
/// </summary>
/// <param name="position">Where to teleport this GameObject</param>
/// <param name="rotation">Which rotation to set this GameObject</param>
[Server]
public void ServerTeleport(Vector3 position, Quaternion rotation)
{
// 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(position, rotation);
// tell all clients about new values
RpcTeleport(position, rotation, clientAuthorityBeforeTeleport);
}
void DoTeleport(Vector3 newPosition, Quaternion newRotation)
{
Transform t = transform;
t.position = newPosition;
t.rotation = newRotation;
// Since we are overriding the position we don't need a goal and start.
// Reset them to null for fresh start
goal = null;
start = null;
lastPosition = newPosition;
lastRotation = newRotation;
}
[ClientRpc]
void RpcTeleport(Vector3 newPosition, Quaternion newRotation, bool isClientAuthority)
{
DoTeleport(newPosition, newRotation);
// only send finished if is owner and is ClientAuthority on server
if (isOwned && isClientAuthority)
CmdTeleportFinished();
}
/// <summary>
/// This RPC will be invoked on server after client finishes overriding the position.
/// </summary>
[Command]
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
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);
// like unity move tool
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);
}
// draw the data points for easier debugging
void OnDrawGizmos()
{
// draw start and goal points
if (start != null)
DrawDataPointGizmo(start, Color.gray);
if (goal != null)
DrawDataPointGizmo(goal, Color.white);
// draw line between them
if (start != null && goal != null)
DrawLineBetweenDataPoints(start, goal, Color.cyan);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment