Skip to content

Instantly share code, notes, and snippets.

@Ikalou
Created December 26, 2023 16:51
Show Gist options
  • Save Ikalou/0a13eef2346935c498b0a1ff9b421a02 to your computer and use it in GitHub Desktop.
Save Ikalou/0a13eef2346935c498b0a1ff9b421a02 to your computer and use it in GitHub Desktop.
NetworkTransformClassic.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 UnityEngine;
namespace Mirror
{
public class NetworkTransformClassic : NetworkBehaviour
{
// ScaleFloatToByte( -1f, -1f, 1f, byte.MinValue, byte.MaxValue) => 0
// ScaleFloatToByte( 0f, -1f, 1f, byte.MinValue, byte.MaxValue) => 127
// ScaleFloatToByte(0.5f, -1f, 1f, byte.MinValue, byte.MaxValue) => 191
// ScaleFloatToByte( 1f, -1f, 1f, byte.MinValue, byte.MaxValue) => 255
private static byte ScaleFloatToByte(float value, float minValue, float maxValue, byte minTarget, byte maxTarget)
{
// note: C# byte - byte => int, hence so many casts
int targetRange = maxTarget - minTarget; // max byte - min byte only fits into something bigger
float valueRange = maxValue - minValue;
float valueRelative = value - minValue;
return (byte)(minTarget + (byte)(valueRelative/valueRange * targetRange));
}
// ScaleByteToFloat( 0, byte.MinValue, byte.MaxValue, -1, 1) => -1
// ScaleByteToFloat(127, byte.MinValue, byte.MaxValue, -1, 1) => -0.003921569
// ScaleByteToFloat(191, byte.MinValue, byte.MaxValue, -1, 1) => 0.4980392
// ScaleByteToFloat(255, byte.MinValue, byte.MaxValue, -1, 1) => 1
private static float ScaleByteToFloat(byte value, byte minValue, byte maxValue, float minTarget, float maxTarget)
{
// note: C# byte - byte => int, hence so many casts
float targetRange = maxTarget - minTarget;
byte valueRange = (byte)(maxValue - minValue);
byte valueRelative = (byte)(value - minValue);
return minTarget + (valueRelative/(float)valueRange * targetRange);
}
// eulerAngles have 3 floats, putting them into 2 bytes of [x,y],[z,0]
// would be a waste. instead we compress into 5 bits each => 15 bits.
// so a ushort.
private static ushort PackThreeFloatsIntoUShort(float u, float v, float w, float minValue, float maxValue)
{
// 5 bits max value = 1+2+4+8+16 = 31 = 0x1F
byte lower = ScaleFloatToByte(u, minValue, maxValue, 0x00, 0x1F);
byte middle = ScaleFloatToByte(v, minValue, maxValue, 0x00, 0x1F);
byte upper = ScaleFloatToByte(w, minValue, maxValue, 0x00, 0x1F);
ushort combined = (ushort)(upper << 10 | middle << 5 | lower);
return combined;
}
// see PackThreeFloatsIntoUShort for explanation
private static float[] UnpackUShortIntoThreeFloats(ushort combined, float minTarget, float maxTarget)
{
byte lower = (byte)(combined & 0x1F);
byte middle = (byte)((combined >> 5) & 0x1F);
byte upper = (byte)(combined >> 10); // nothing on the left, no & needed
// note: we have to use 4 bits per float, so between 0x00 and 0x0F
float u = ScaleByteToFloat(lower, 0x00, 0x1F, minTarget, maxTarget);
float v = ScaleByteToFloat(middle, 0x00, 0x1F, minTarget, maxTarget);
float w = ScaleByteToFloat(upper, 0x00, 0x1F, minTarget, maxTarget);
return new[]{u, v, w};
}
// 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, Some=6, Much=3, Lots=2 Byte")]
[SerializeField] Compression compressRotation = Compression.Much;
private enum Compression { None, Much, Lots }; // easily understandable and funny
// server
Vector3 lastPosition;
Quaternion lastRotation;
// client
private 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.
private Transform targetComponent { get { return transform; } }
private bool interpolate = true;
// 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(ScaleFloatToByte(euler.x, 0, 360, byte.MinValue, byte.MaxValue));
writer.Write(ScaleFloatToByte(euler.y, 0, 360, byte.MinValue, byte.MaxValue));
writer.Write(ScaleFloatToByte(euler.z, 0, 360, byte.MinValue, byte.MaxValue));
}
else if (compressRotation == Compression.Lots)
{
// write 2 byte, 5 bits for each float
writer.Write(PackThreeFloatsIntoUShort(euler.x, euler.y, euler.z, 0, 360));
}
}
public override void OnSerialize(NetworkWriter writer, bool initialState)
{
SerializeIntoWriter(writer, targetComponent.position, targetComponent.rotation, compressRotation);
}
// 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
temp.position = reader.ReadVector3();
// deserialize rotation
if (compressRotation == Compression.None)
{
// read 3 floats = 16 byte
float x = reader.ReadFloat();
float y = reader.ReadFloat();
float z = reader.ReadFloat();
temp.rotation = Quaternion.Euler(x, y, z);
}
else if (compressRotation == Compression.Much)
{
// read 3 byte. scaling [0,255] to [0,360]
float x = ScaleByteToFloat(reader.ReadByte(), byte.MinValue, byte.MaxValue, 0, 360);
float y = ScaleByteToFloat(reader.ReadByte(), byte.MinValue, byte.MaxValue, 0, 360);
float z = 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 = UnpackUShortIntoThreeFloats(reader.ReadUShort(), 0, 360);
temp.rotation = Quaternion.Euler(xyz[0], xyz[1], xyz[2]);
}
// timestamp
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, 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.position,
rotation=targetComponent.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.position, start.position) < oldDistance + newDistance)
{
start.position = targetComponent.position;
start.rotation = targetComponent.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
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;
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.position;
bool rotated = lastRotation != targetComponent.rotation;
// save last for next frame to compare
lastPosition = targetComponent.position;
lastRotation = targetComponent.rotation;
return moved || rotated;
}
// set position carefully depending on the target component
void ApplyPositionAndRotation(Vector3 position, Quaternion rotation)
{
targetComponent.position = position;
targetComponent.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
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 && isOwned && connectionToServer != null)
{
// check only each 'syncInterval'
if (Time.time - lastClientSendTime >= syncInterval)
{
if (HasMovedOrRotated())
{
// serialize
NetworkWriter writer = new NetworkWriter();
SerializeIntoWriter(writer, targetComponent.position, targetComponent.rotation, compressRotation);
// send to server
CmdClientToServerSync(writer.ToArray());
}
lastClientSendTime = Time.time;
}
}
// apply interpolation on client for all players
// except for local player if he has authority and handles it himself
if (!(isLocalPlayer && isOwned))
{
// received one yet? (initialized?)
if (goal != null)
{
// teleport or interpolate
if (!interpolate || NeedsTeleport())
{
ApplyPositionAndRotation(goal.position, goal.rotation);
}
else
{
ApplyPositionAndRotation(InterpolatePosition(start, goal, targetComponent.position),
InterpolateRotation(start, goal, targetComponent.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;
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);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment