mirror of
https://github.com/MirrorNetworking/Mirror.git
synced 2024-11-19 03:20:33 +00:00
00961ccc9c
This allows you to leave rotation out of the NetworkTransform if you only need position. Right now if you don't need sync rotation and set compression to Lots, it can move your object in ways not expected. This forces you to use no compression as a fix. Using more bandwidth for something you don't need. I think this is all that is needed, tested it in my game and it works.
413 lines
17 KiB
C#
413 lines
17 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 UnityEngine;
|
|
|
|
namespace Mirror
|
|
{
|
|
public abstract class NetworkTransformBase : NetworkBehaviour
|
|
{
|
|
// rotation compression. not public so that other scripts can't modify
|
|
// it at runtime. alternatively we could send 1 extra byte for the mode
|
|
// each time so clients know how to decompress, but the whole point was
|
|
// to save bandwidth in the first place.
|
|
// -> can still be modified in the Inspector while the game is running,
|
|
// but would cause errors immediately and be pretty obvious.
|
|
[Tooltip("Compresses 16 Byte Quaternion into None=12, Much=3, Lots=2 Byte")]
|
|
[SerializeField] Compression compressRotation = Compression.Much;
|
|
public enum Compression { None, Much, Lots , NoRotation }; // easily understandable and funny
|
|
|
|
// server
|
|
Vector3 lastPosition;
|
|
Quaternion lastRotation;
|
|
|
|
// client
|
|
public class DataPoint
|
|
{
|
|
public float timeStamp;
|
|
public Vector3 position;
|
|
public Quaternion rotation;
|
|
public float movementSpeed;
|
|
}
|
|
// interpolation start and goal
|
|
DataPoint start;
|
|
DataPoint goal;
|
|
|
|
// local authority send time
|
|
float lastClientSendTime;
|
|
|
|
// target transform to sync. can be on a child.
|
|
protected abstract Transform targetComponent { get; }
|
|
|
|
// serialization is needed by OnSerialize and by manual sending from authority
|
|
static void SerializeIntoWriter(NetworkWriter writer, Vector3 position, Quaternion rotation, Compression compressRotation)
|
|
{
|
|
// serialize position
|
|
writer.Write(position);
|
|
|
|
// serialize rotation
|
|
// writing quaternion = 16 byte
|
|
// writing euler angles = 12 byte
|
|
// -> quaternion->euler->quaternion always works.
|
|
// -> gimbal lock only occurs when adding.
|
|
Vector3 euler = rotation.eulerAngles;
|
|
if (compressRotation == Compression.None)
|
|
{
|
|
// write 3 floats = 12 byte
|
|
writer.Write(euler.x);
|
|
writer.Write(euler.y);
|
|
writer.Write(euler.z);
|
|
}
|
|
else if (compressRotation == Compression.Much)
|
|
{
|
|
// write 3 byte. scaling [0,360] to [0,255]
|
|
writer.Write(FloatBytePacker.ScaleFloatToByte(euler.x, 0, 360, byte.MinValue, byte.MaxValue));
|
|
writer.Write(FloatBytePacker.ScaleFloatToByte(euler.y, 0, 360, byte.MinValue, byte.MaxValue));
|
|
writer.Write(FloatBytePacker.ScaleFloatToByte(euler.z, 0, 360, byte.MinValue, byte.MaxValue));
|
|
}
|
|
else if (compressRotation == Compression.Lots)
|
|
{
|
|
// write 2 byte, 5 bits for each float
|
|
writer.Write(FloatBytePacker.PackThreeFloatsIntoUShort(euler.x, euler.y, euler.z, 0, 360));
|
|
}
|
|
}
|
|
|
|
public override bool OnSerialize(NetworkWriter writer, bool initialState)
|
|
{
|
|
SerializeIntoWriter(writer, targetComponent.transform.position, targetComponent.transform.rotation, compressRotation);
|
|
return true;
|
|
}
|
|
|
|
// 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.position
|
|
// -> elapsed based on send interval hoping that it roughly matches
|
|
static float EstimateMovementSpeed(DataPoint from, DataPoint to, Transform transform, float sendInterval)
|
|
{
|
|
Vector3 delta = to.position - (from != null ? from.position : transform.position);
|
|
float elapsed = from != null ? to.timeStamp - from.timeStamp : sendInterval;
|
|
return elapsed > 0 ? delta.magnitude / elapsed : 0; // avoid NaN
|
|
}
|
|
|
|
// 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
|
|
position = reader.ReadVector3()
|
|
};
|
|
|
|
// deserialize rotation
|
|
if (compressRotation == Compression.None)
|
|
{
|
|
// read 3 floats = 16 byte
|
|
float x = reader.ReadSingle();
|
|
float y = reader.ReadSingle();
|
|
float z = reader.ReadSingle();
|
|
temp.rotation = Quaternion.Euler(x, y, z);
|
|
}
|
|
else if (compressRotation == Compression.Much)
|
|
{
|
|
// read 3 byte. scaling [0,255] to [0,360]
|
|
float x = FloatBytePacker.ScaleByteToFloat(reader.ReadByte(), byte.MinValue, byte.MaxValue, 0, 360);
|
|
float y = FloatBytePacker.ScaleByteToFloat(reader.ReadByte(), byte.MinValue, byte.MaxValue, 0, 360);
|
|
float z = FloatBytePacker.ScaleByteToFloat(reader.ReadByte(), byte.MinValue, byte.MaxValue, 0, 360);
|
|
temp.rotation = Quaternion.Euler(x, y, z);
|
|
}
|
|
else if (compressRotation == Compression.Lots)
|
|
{
|
|
// read 2 byte, 5 bits per float
|
|
float[] xyz = FloatBytePacker.UnpackUShortIntoThreeFloats(reader.ReadUInt16(), 0, 360);
|
|
temp.rotation = Quaternion.Euler(xyz[0], xyz[1], xyz[2]);
|
|
}
|
|
|
|
temp.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, targetComponent.transform, 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,
|
|
position = targetComponent.transform.position,
|
|
rotation = targetComponent.transform.rotation,
|
|
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.position, goal.position);
|
|
float newDistance = Vector3.Distance(goal.position, temp.position);
|
|
|
|
start = goal;
|
|
|
|
// teleport / lag / obstacle detection: only continue at current
|
|
// position if we aren't too far away
|
|
if (Vector3.Distance(targetComponent.transform.position, start.position) < oldDistance + newDistance)
|
|
{
|
|
start.position = targetComponent.transform.position;
|
|
start.rotation = targetComponent.transform.rotation;
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
void CmdClientToServerSync(byte[] payload)
|
|
{
|
|
// deserialize payload
|
|
NetworkReader reader = new NetworkReader(payload);
|
|
DeserializeFromReader(reader);
|
|
|
|
// server-only mode does no interpolation to save computations,
|
|
// but let's set the position directly
|
|
if (isServer && !isClient)
|
|
ApplyPositionAndRotation(goal.position, goal.rotation);
|
|
|
|
// set dirty so that OnSerialize broadcasts it
|
|
SetDirtyBit(1UL);
|
|
}
|
|
|
|
// 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;
|
|
return difference > 0 ? elapsed / difference : 0; // avoid NaN
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static Vector3 InterpolatePosition(DataPoint start, DataPoint goal, Vector3 currentPosition)
|
|
{
|
|
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.position, speed * Time.deltaTime);
|
|
}
|
|
return currentPosition;
|
|
}
|
|
|
|
static Quaternion InterpolateRotation(DataPoint start, DataPoint goal, Quaternion defaultRotation)
|
|
{
|
|
if (start != null)
|
|
{
|
|
float t = CurrentInterpolationFactor(start, goal);
|
|
return Quaternion.Slerp(start.rotation, goal.rotation, t);
|
|
}
|
|
return defaultRotation;
|
|
}
|
|
|
|
// 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 HasMovedOrRotated()
|
|
{
|
|
// moved or rotated?
|
|
bool moved = lastPosition != targetComponent.transform.position;
|
|
bool rotated = lastRotation != targetComponent.transform.rotation;
|
|
|
|
// 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;
|
|
if (change)
|
|
{
|
|
lastPosition = targetComponent.transform.position;
|
|
lastRotation = targetComponent.transform.rotation;
|
|
}
|
|
return change;
|
|
}
|
|
|
|
// set position carefully depending on the target component
|
|
void ApplyPositionAndRotation(Vector3 position, Quaternion rotation)
|
|
{
|
|
targetComponent.transform.position = position;
|
|
if (Compression.NoRotation != compressRotation)
|
|
{
|
|
targetComponent.transform.rotation = rotation;
|
|
}
|
|
}
|
|
|
|
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
|
|
SetDirtyBit(HasMovedOrRotated() ? 1UL : 0UL);
|
|
}
|
|
|
|
// 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 && hasAuthority)
|
|
{
|
|
// check only each 'syncInterval'
|
|
if (Time.time - lastClientSendTime >= syncInterval)
|
|
{
|
|
if (HasMovedOrRotated())
|
|
{
|
|
// serialize
|
|
NetworkWriter writer = new NetworkWriter();
|
|
SerializeIntoWriter(writer, targetComponent.transform.position, targetComponent.transform.rotation, compressRotation);
|
|
|
|
// send to server
|
|
CmdClientToServerSync(writer.ToArray());
|
|
}
|
|
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 (!hasAuthority)
|
|
{
|
|
// received one yet? (initialized?)
|
|
if (goal != null)
|
|
{
|
|
// teleport or interpolate
|
|
if (NeedsTeleport())
|
|
{
|
|
ApplyPositionAndRotation(goal.position, goal.rotation);
|
|
}
|
|
else
|
|
{
|
|
ApplyPositionAndRotation(InterpolatePosition(start, goal, targetComponent.transform.position),
|
|
InterpolateRotation(start, goal, targetComponent.transform.rotation));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static void DrawDataPointGizmo(DataPoint data, Color color)
|
|
{
|
|
// use a little offset because transform.position might be in
|
|
// the ground in many cases
|
|
Vector3 offset = Vector3.up * 0.01f;
|
|
|
|
// draw position
|
|
Gizmos.color = color;
|
|
Gizmos.DrawSphere(data.position + offset, 0.5f);
|
|
|
|
// draw forward and up
|
|
Gizmos.color = Color.blue; // like unity move tool
|
|
Gizmos.DrawRay(data.position + offset, data.rotation * Vector3.forward);
|
|
|
|
Gizmos.color = Color.green; // like unity move tool
|
|
Gizmos.DrawRay(data.position + offset, data.rotation * Vector3.up);
|
|
}
|
|
|
|
static void DrawLineBetweenDataPoints(DataPoint data1, DataPoint data2, Color color)
|
|
{
|
|
Gizmos.color = Color.white;
|
|
Gizmos.DrawLine(data1.position, data2.position);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|