Merged master

This commit is contained in:
MrGadget 2024-04-13 19:00:49 -04:00
commit 83fd6cbc20
188 changed files with 15196 additions and 841 deletions

View File

@ -13,9 +13,9 @@ jobs:
unityVersion:
- 2019.4.40f1
- 2020.3.48f1
- 2021.3.33f1
- 2022.3.18f1
- 2023.2.7f1
- 2021.3.36f1
- 2022.3.24f1
- 2023.2.16f1
steps:
- name: Checkout repository

View File

@ -21,7 +21,7 @@ jobs:
Remove-Item -Recurse -Force Assets\Mirror\Tests.meta
- name: Setup dotnet
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v4
with:
dotnet-version: '3.1.100'

View File

@ -22,17 +22,14 @@ public static void AddDefineSymbols()
HashSet<string> defines = new HashSet<string>(currentDefines.Split(';'))
{
"MIRROR",
"MIRROR_70_OR_NEWER",
"MIRROR_71_OR_NEWER",
"MIRROR_73_OR_NEWER",
"MIRROR_78_OR_NEWER",
"MIRROR_79_OR_NEWER",
"MIRROR_81_OR_NEWER",
"MIRROR_82_OR_NEWER",
"MIRROR_83_OR_NEWER",
"MIRROR_84_OR_NEWER",
"MIRROR_85_OR_NEWER",
"MIRROR_86_OR_NEWER"
"MIRROR_86_OR_NEWER",
"MIRROR_89_OR_NEWER"
};
// only touch PlayerSettings if we actually modified it,

View File

@ -38,8 +38,15 @@ public class NetworkLerpRigidbody : NetworkBehaviour
protected override void OnValidate()
{
base.OnValidate();
Reset();
}
public virtual void Reset()
{
if (target == null)
target = GetComponent<Rigidbody>();
syncDirection = SyncDirection.ClientToServer;
}
void Update()

View File

@ -42,8 +42,15 @@ public class NetworkRigidbody : NetworkBehaviour
protected override void OnValidate()
{
base.OnValidate();
Reset();
}
public virtual void Reset()
{
if (target == null)
target = GetComponent<Rigidbody>();
syncDirection = SyncDirection.ClientToServer;
}
#region Sync vars

View File

@ -40,8 +40,15 @@ public class NetworkRigidbody2D : NetworkBehaviour
protected override void OnValidate()
{
base.OnValidate();
Reset();
}
public virtual void Reset()
{
if (target == null)
target = GetComponent<Rigidbody2D>();
syncDirection = SyncDirection.ClientToServer;
}
#region Sync vars

View File

@ -0,0 +1,196 @@
// Add this component to a Player object with collider.
// Automatically keeps a history for lag compensation.
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Mirror
{
public struct Capture3D : Capture
{
public double timestamp { get; set; }
public Vector3 position;
public Vector3 size;
public Capture3D(double timestamp, Vector3 position, Vector3 size)
{
this.timestamp = timestamp;
this.position = position;
this.size = size;
}
public void DrawGizmo()
{
Gizmos.DrawWireCube(position, size);
}
public static Capture3D Interpolate(Capture3D from, Capture3D to, double t) =>
new Capture3D(
0, // interpolated snapshot is applied directly. don't need timestamps.
Vector3.LerpUnclamped(from.position, to.position, (float)t),
Vector3.LerpUnclamped(from.size, to.size, (float)t)
);
public override string ToString() => $"(time={timestamp} pos={position} size={size})";
}
[DisallowMultipleComponent]
[HelpURL("https://mirror-networking.gitbook.io/docs/manual/general/lag-compensation")]
public class LagCompensator : NetworkBehaviour
{
[Header("Components")]
[Tooltip("The collider to keep a history of.")]
public Collider trackedCollider; // assign this in inspector
[Header("Settings")]
public LagCompensationSettings lagCompensationSettings = new LagCompensationSettings();
double lastCaptureTime;
// lag compensation history of <timestamp, capture>
readonly Queue<KeyValuePair<double, Capture3D>> history = new Queue<KeyValuePair<double, Capture3D>>();
[Header("Debugging")]
public Color historyColor = Color.white;
[ServerCallback]
protected virtual void Update()
{
// capture lag compensation snapshots every interval.
// NetworkTime.localTime because Unity 2019 doesn't have 'double' time yet.
if (NetworkTime.localTime >= lastCaptureTime + lagCompensationSettings.captureInterval)
{
lastCaptureTime = NetworkTime.localTime;
Capture();
}
}
[ServerCallback]
protected virtual void Capture()
{
// capture current state
Capture3D capture = new Capture3D(
NetworkTime.localTime,
trackedCollider.bounds.center,
trackedCollider.bounds.size
);
// insert into history
LagCompensation.Insert(history, lagCompensationSettings.historyLimit, NetworkTime.localTime, capture);
}
protected virtual void OnDrawGizmos()
{
// draw history
Gizmos.color = historyColor;
LagCompensation.DrawGizmos(history);
}
// sampling ////////////////////////////////////////////////////////////
// sample the sub-tick (=interpolated) history of this object for a hit test.
// 'viewer' needs to be the player who fired!
// for example, if A fires at B, then call B.Sample(viewer, point, tolerance).
[ServerCallback]
public virtual bool Sample(NetworkConnectionToClient viewer, out Capture3D sample)
{
// never trust the client: estimate client time instead.
// https://developer.valvesoftware.com/wiki/Source_Multiplayer_Networking
// the estimation is very good. the error is as low as ~6ms for the demo.
// note that passing 'rtt' is fine: EstimateTime halves it to latency.
double estimatedTime = LagCompensation.EstimateTime(NetworkTime.localTime, viewer.rtt, NetworkClient.bufferTime);
// sample the history to get the nearest snapshots around 'timestamp'
if (LagCompensation.Sample(history, estimatedTime, lagCompensationSettings.captureInterval, out Capture3D resultBefore, out Capture3D resultAfter, out double t))
{
// interpolate to get a decent estimation at exactly 'timestamp'
sample = Capture3D.Interpolate(resultBefore, resultAfter, t);
return true;
}
else Debug.Log($"CmdClicked: history doesn't contain {estimatedTime:F3}");
sample = default;
return false;
}
// convenience tests ///////////////////////////////////////////////////
// there are multiple different ways to check a hit against the sample:
// - raycasting
// - bounds.contains
// - increasing bounds by tolerance and checking contains
// - threshold to bounds.closestpoint
// let's offer a few solutions directly and see which users prefer.
// bounds check: checks distance to closest point on bounds in history @ -rtt.
// 'viewer' needs to be the player who fired!
// for example, if A fires at B, then call B.Sample(viewer, point, tolerance).
// this is super simple and fast, but not 100% physically accurate since we don't raycast.
[ServerCallback]
public virtual bool BoundsCheck(
NetworkConnectionToClient viewer,
Vector3 hitPoint,
float toleranceDistance,
out float distance,
out Vector3 nearest)
{
// first, sample the history at -rtt of the viewer.
if (Sample(viewer, out Capture3D capture))
{
// now that we know where the other player was at that time,
// we can see if the hit point was within tolerance of it.
// TODO consider rotations???
// TODO consider original collider shape??
Bounds bounds = new Bounds(capture.position, capture.size);
nearest = bounds.ClosestPoint(hitPoint);
distance = Vector3.Distance(nearest, hitPoint);
return distance <= toleranceDistance;
}
nearest = hitPoint;
distance = 0;
return false;
}
// raycast check: creates a collider the sampled position and raycasts to hitPoint.
// 'viewer' needs to be the player who fired!
// for example, if A fires at B, then call B.Sample(viewer, point, tolerance).
// this is physically accurate (checks against walls etc.), with the cost
// of a runtime instantiation.
//
// originPoint: where the player fired the weapon.
// hitPoint: where the player's local raycast hit.
// tolerance: scale up the sampled collider by % in order to have a bit of a tolerance.
// 0 means no extra tolerance, 0.05 means 5% extra tolerance.
// layerMask: the layer mask to use for the raycast.
[ServerCallback]
public virtual bool RaycastCheck(
NetworkConnectionToClient viewer,
Vector3 originPoint,
Vector3 hitPoint,
float tolerancePercent,
int layerMask,
out RaycastHit hit)
{
// first, sample the history at -rtt of the viewer.
if (Sample(viewer, out Capture3D capture))
{
// instantiate a real physics collider on demand.
// TODO rotation??
// TODO different collier types??
GameObject temp = new GameObject("LagCompensatorTest");
temp.transform.position = capture.position;
BoxCollider tempCollider = temp.AddComponent<BoxCollider>();
tempCollider.size = capture.size * (1 + tolerancePercent);
// raycast
Vector3 direction = hitPoint - originPoint;
float maxDistance = direction.magnitude * 2;
bool result = Physics.Raycast(originPoint, direction, out hit, maxDistance, layerMask);
// cleanup
Destroy(temp);
return result;
}
hit = default;
return false;
}
}
}

View File

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

View File

@ -31,11 +31,12 @@ public class NetworkAnimator : NetworkBehaviour
public Animator animator;
/// <summary>
/// Syncs animator.speed
/// Syncs animator.speed.
/// Default to 1 because Animator.speed defaults to 1.
/// </summary>
[SyncVar(hook = nameof(OnAnimatorSpeedChanged))]
float animatorSpeed;
float previousSpeed;
float animatorSpeed = 1f;
float previousSpeed = 1f;
// Note: not an object[] array because otherwise initialization is real annoying
int[] lastIntParameters;
@ -93,6 +94,11 @@ void Initialize()
void Awake() => Initialize();
void OnEnable() => Initialize();
public virtual void Reset()
{
syncDirection = SyncDirection.ClientToServer;
}
void FixedUpdate()
{
if (!SendMessagesAllowed)

View File

@ -92,5 +92,20 @@ protected override void OnValidate()
Debug.LogWarning($"{name}'s NetworkRigidbody.target {target.name} is missing a Rigidbody", this);
}
}
protected override void OnTeleport(Vector3 destination)
{
base.OnTeleport(destination);
rb.position = transform.position;
}
protected override void OnTeleport(Vector3 destination, Quaternion rotation)
{
base.OnTeleport(destination, rotation);
rb.position = transform.position;
rb.rotation = transform.rotation;
}
}
}

View File

@ -92,5 +92,20 @@ protected override void OnValidate()
Debug.LogWarning($"{name}'s NetworkRigidbody2D.target {target.name} is missing a Rigidbody2D", this);
}
}
protected override void OnTeleport(Vector3 destination)
{
base.OnTeleport(destination);
rb.position = transform.position;
}
protected override void OnTeleport(Vector3 destination, Quaternion rotation)
{
base.OnTeleport(destination, rotation);
rb.position = transform.position;
rb.rotation = transform.rotation.eulerAngles.z;
}
}
}

View File

@ -92,5 +92,20 @@ protected override void OnValidate()
Debug.LogWarning($"{name}'s NetworkRigidbody.target {target.name} is missing a Rigidbody", this);
}
}
protected override void OnTeleport(Vector3 destination)
{
base.OnTeleport(destination);
rb.position = transform.position;
}
protected override void OnTeleport(Vector3 destination, Quaternion rotation)
{
base.OnTeleport(destination, rotation);
rb.position = transform.position;
rb.rotation = transform.rotation;
}
}
}

View File

@ -92,5 +92,20 @@ protected override void OnValidate()
Debug.LogWarning($"{name}'s NetworkRigidbody2D.target {target.name} is missing a Rigidbody2D", this);
}
}
protected override void OnTeleport(Vector3 destination)
{
base.OnTeleport(destination);
rb.position = transform.position;
}
protected override void OnTeleport(Vector3 destination, Quaternion rotation)
{
base.OnTeleport(destination, rotation);
rb.position = transform.position;
rb.rotation = transform.rotation.eulerAngles.z;
}
}
}

View File

@ -332,8 +332,7 @@ protected virtual void OnTeleport(Vector3 destination)
// but server's last delta will have been reset, causing offsets.
//
// instead, simply clear snapshots.
serverSnapshots.Clear();
clientSnapshots.Clear();
ResetState();
// TODO
// what if we still receive a snapshot from before the interpolation?
@ -358,8 +357,7 @@ protected virtual void OnTeleport(Vector3 destination, Quaternion rotation)
// but server's last delta will have been reset, causing offsets.
//
// instead, simply clear snapshots.
serverSnapshots.Clear();
clientSnapshots.Clear();
ResetState();
// TODO
// what if we still receive a snapshot from before the interpolation?

View File

@ -1,4 +1,5 @@
// NetworkTransform V2 by mischa (2021-07)
using System.Collections.Generic;
using UnityEngine;
namespace Mirror
@ -12,6 +13,8 @@ public class NetworkTransformUnreliable : NetworkTransformBase
// Testing under really bad network conditions, 2%-5% packet loss and 250-1200ms ping, 5 proved to eliminate any twitching, however this should not be the default as it is a rare case Developers may want to cover.
[Tooltip("How much time, as a multiple of send interval, has passed before clearing buffers.\nA larger buffer means more delay, but results in smoother movement.\nExample: 1 for faster responses minimal smoothing, 5 covers bad pings but has noticable delay, 3 is recommended for balanced results,.")]
public float bufferResetMultiplier = 3;
[Tooltip("Detect and send only changed data, such as Position X and Z, not the full Vector3 of X Y Z. Lowers network data at cost of extra calculations.")]
public bool changedDetection = true;
[Header("Sensitivity"), Tooltip("Sensitivity of changes needed before an updated state is sent over the network")]
public float positionSensitivity = 0.01f;
@ -25,6 +28,7 @@ public class NetworkTransformUnreliable : NetworkTransformBase
// Used to store last sent snapshots
protected TransformSnapshot lastSnapshot;
protected bool cachedSnapshotComparison;
protected Changed cachedChangedComparison;
protected bool hasSentUnchangedPosition;
// update //////////////////////////////////////////////////////////////
@ -61,7 +65,9 @@ protected virtual void CheckLastSendTime()
// This fixes previous issue of, if sendIntervalMultiplier = 1, we send every frame,
// because intervalCounter is always = 1 in the previous version.
if (sendIntervalCounter == sendIntervalMultiplier)
// Changing == to >= https://github.com/MirrorNetworking/Mirror/issues/3571
if (sendIntervalCounter >= sendIntervalMultiplier)
sendIntervalCounter = 0;
// timeAsDouble not available in older Unity versions.
@ -109,36 +115,68 @@ void UpdateServerBroadcast()
// send snapshot without timestamp.
// receiver gets it from batch timestamp to save bandwidth.
TransformSnapshot snapshot = Construct();
cachedSnapshotComparison = CompareSnapshots(snapshot);
if (cachedSnapshotComparison && hasSentUnchangedPosition && onlySyncOnChange) { return; }
if (compressRotation)
if (changedDetection)
{
RpcServerToClientSyncCompressRotation(
cachedChangedComparison = CompareChangedSnapshots(snapshot);
if ((cachedChangedComparison == Changed.None || cachedChangedComparison == Changed.CompressRot) && hasSentUnchangedPosition && onlySyncOnChange) { return; }
SyncData syncData = new SyncData(cachedChangedComparison, snapshot);
RpcServerToClientSync(syncData);
if (cachedChangedComparison == Changed.None || cachedChangedComparison == Changed.CompressRot)
{
hasSentUnchangedPosition = true;
}
else
{
hasSentUnchangedPosition = false;
UpdateLastSentSnapshot(cachedChangedComparison, snapshot);
}
}
else
{
cachedSnapshotComparison = CompareSnapshots(snapshot);
if (cachedSnapshotComparison && hasSentUnchangedPosition && onlySyncOnChange) { return; }
if (compressRotation)
{
RpcServerToClientSyncCompressRotation(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? Compression.CompressQuaternion(snapshot.rotation) : default(uint?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
}
else
{
RpcServerToClientSync(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? Compression.CompressQuaternion(snapshot.rotation) : default(uint?),
syncRotation && rotationChanged ? snapshot.rotation : default(Quaternion?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
}
else
{
RpcServerToClientSync(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? snapshot.rotation : default(Quaternion?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
}
);
}
if (cachedSnapshotComparison)
{
hasSentUnchangedPosition = true;
}
else
{
hasSentUnchangedPosition = false;
lastSnapshot = snapshot;
if (cachedSnapshotComparison)
{
hasSentUnchangedPosition = true;
}
else
{
hasSentUnchangedPosition = false;
// Fixes https://github.com/MirrorNetworking/Mirror/issues/3572
// This also fixes https://github.com/MirrorNetworking/Mirror/issues/3573
// with the exception of Quaternion.Angle sensitivity has to be > 0.16.
// Unity issue, we are leaving it as is.
if (positionChanged) lastSnapshot.position = snapshot.position;
if (rotationChanged) lastSnapshot.rotation = snapshot.rotation;
if (positionChanged) lastSnapshot.scale = snapshot.scale;
}
}
}
}
@ -205,36 +243,67 @@ void UpdateClientBroadcast()
// send snapshot without timestamp.
// receiver gets it from batch timestamp to save bandwidth.
TransformSnapshot snapshot = Construct();
cachedSnapshotComparison = CompareSnapshots(snapshot);
if (cachedSnapshotComparison && hasSentUnchangedPosition && onlySyncOnChange) { return; }
if (compressRotation)
if (changedDetection)
{
CmdClientToServerSyncCompressRotation(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? Compression.CompressQuaternion(snapshot.rotation) : default(uint?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
cachedChangedComparison = CompareChangedSnapshots(snapshot);
if ((cachedChangedComparison == Changed.None || cachedChangedComparison == Changed.CompressRot) && hasSentUnchangedPosition && onlySyncOnChange) { return; }
SyncData syncData = new SyncData(cachedChangedComparison, snapshot);
CmdClientToServerSync(syncData);
if (cachedChangedComparison == Changed.None || cachedChangedComparison == Changed.CompressRot)
{
hasSentUnchangedPosition = true;
}
else
{
hasSentUnchangedPosition = false;
UpdateLastSentSnapshot(cachedChangedComparison, snapshot);
}
}
else
{
CmdClientToServerSync(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? snapshot.rotation : default(Quaternion?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
}
cachedSnapshotComparison = CompareSnapshots(snapshot);
if (cachedSnapshotComparison && hasSentUnchangedPosition && onlySyncOnChange) { return; }
if (cachedSnapshotComparison)
{
hasSentUnchangedPosition = true;
}
else
{
hasSentUnchangedPosition = false;
lastSnapshot = snapshot;
if (compressRotation)
{
CmdClientToServerSyncCompressRotation(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? Compression.CompressQuaternion(snapshot.rotation) : default(uint?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
}
else
{
CmdClientToServerSync(
// only sync what the user wants to sync
syncPosition && positionChanged ? snapshot.position : default(Vector3?),
syncRotation && rotationChanged ? snapshot.rotation : default(Quaternion?),
syncScale && scaleChanged ? snapshot.scale : default(Vector3?)
);
}
if (cachedSnapshotComparison)
{
hasSentUnchangedPosition = true;
}
else
{
hasSentUnchangedPosition = false;
// Fixes https://github.com/MirrorNetworking/Mirror/issues/3572
// This also fixes https://github.com/MirrorNetworking/Mirror/issues/3573
// with the exception of Quaternion.Angle sensitivity has to be > 0.16.
// Unity issue, we are leaving it as is.
if (positionChanged) lastSnapshot.position = snapshot.position;
if (rotationChanged) lastSnapshot.rotation = snapshot.rotation;
if (positionChanged) lastSnapshot.scale = snapshot.scale;
}
}
}
}
@ -406,5 +475,204 @@ protected virtual void OnServerToClientSync(Vector3? position, Quaternion? rotat
AddSnapshot(clientSnapshots, NetworkClient.connection.remoteTimeStamp + timeStampAdjustment + offset, position, rotation, scale);
}
protected virtual void UpdateLastSentSnapshot(Changed change, TransformSnapshot currentSnapshot)
{
if (change == Changed.None || change == Changed.CompressRot) return;
if ((change & Changed.PosX) > 0) lastSnapshot.position.x = currentSnapshot.position.x;
if ((change & Changed.PosY) > 0) lastSnapshot.position.y = currentSnapshot.position.y;
if ((change & Changed.PosZ) > 0) lastSnapshot.position.z = currentSnapshot.position.z;
if (compressRotation)
{
if ((change & Changed.Rot) > 0) lastSnapshot.rotation = currentSnapshot.rotation;
}
else
{
Vector3 newRotation;
newRotation.x = (change & Changed.RotX) > 0 ? currentSnapshot.rotation.eulerAngles.x : lastSnapshot.rotation.eulerAngles.x;
newRotation.y = (change & Changed.RotY) > 0 ? currentSnapshot.rotation.eulerAngles.y : lastSnapshot.rotation.eulerAngles.y;
newRotation.z = (change & Changed.RotZ) > 0 ? currentSnapshot.rotation.eulerAngles.z : lastSnapshot.rotation.eulerAngles.z;
lastSnapshot.rotation = Quaternion.Euler(newRotation);
}
if ((change & Changed.Scale) > 0) lastSnapshot.scale = currentSnapshot.scale;
}
// Returns true if position, rotation AND scale are unchanged, within given sensitivity range.
// Note the sensitivity comparison are different for pos, rot and scale.
protected virtual Changed CompareChangedSnapshots(TransformSnapshot currentSnapshot)
{
Changed change = Changed.None;
if (syncPosition)
{
bool positionChanged = Vector3.SqrMagnitude(lastSnapshot.position - currentSnapshot.position) > positionSensitivity * positionSensitivity;
if (positionChanged)
{
if (Mathf.Abs(lastSnapshot.position.x - currentSnapshot.position.x) > positionSensitivity) change |= Changed.PosX;
if (Mathf.Abs(lastSnapshot.position.y - currentSnapshot.position.y) > positionSensitivity) change |= Changed.PosY;
if (Mathf.Abs(lastSnapshot.position.z - currentSnapshot.position.z) > positionSensitivity) change |= Changed.PosZ;
}
}
if (syncRotation)
{
if (compressRotation)
{
bool rotationChanged = Quaternion.Angle(lastSnapshot.rotation, currentSnapshot.rotation) > rotationSensitivity;
if (rotationChanged)
{
// Here we set all Rot enum flags, to tell us if there was a change in rotation
// when using compression. If no change, we don't write the compressed Quat.
change |= Changed.CompressRot;
change |= Changed.Rot;
}
else
{
change |= Changed.CompressRot;
}
}
else
{
if (Mathf.Abs(lastSnapshot.rotation.eulerAngles.x - currentSnapshot.rotation.eulerAngles.x) > rotationSensitivity) change |= Changed.RotX;
if (Mathf.Abs(lastSnapshot.rotation.eulerAngles.y - currentSnapshot.rotation.eulerAngles.y) > rotationSensitivity) change |= Changed.RotY;
if (Mathf.Abs(lastSnapshot.rotation.eulerAngles.z - currentSnapshot.rotation.eulerAngles.z) > rotationSensitivity) change |= Changed.RotZ;
}
}
if (syncScale)
{
if (Vector3.SqrMagnitude(lastSnapshot.scale - currentSnapshot.scale) > scaleSensitivity * scaleSensitivity) change |= Changed.Scale;
}
return change;
}
[Command(channel = Channels.Unreliable)]
void CmdClientToServerSync(SyncData syncData)
{
OnClientToServerSync(syncData);
//For client authority, immediately pass on the client snapshot to all other
//clients instead of waiting for server to send its snapshots.
if (syncDirection == SyncDirection.ClientToServer)
RpcServerToClientSync(syncData);
}
protected virtual void OnClientToServerSync(SyncData syncData)
{
// only apply if in client authority mode
if (syncDirection != SyncDirection.ClientToServer) return;
// protect against ever growing buffer size attacks
if (serverSnapshots.Count >= connectionToClient.snapshotBufferSizeLimit) return;
// only player owned objects (with a connection) can send to
// server. we can get the timestamp from the connection.
double timestamp = connectionToClient.remoteTimeStamp;
if (onlySyncOnChange)
{
double timeIntervalCheck = bufferResetMultiplier * sendIntervalMultiplier * NetworkClient.sendInterval;
if (serverSnapshots.Count > 0 && serverSnapshots.Values[serverSnapshots.Count - 1].remoteTime + timeIntervalCheck < timestamp)
ResetState();
}
UpdateSyncData(ref syncData, serverSnapshots);
AddSnapshot(serverSnapshots, connectionToClient.remoteTimeStamp + timeStampAdjustment + offset, syncData.position, syncData.quatRotation, syncData.scale);
}
[ClientRpc(channel = Channels.Unreliable)]
void RpcServerToClientSync(SyncData syncData) =>
OnServerToClientSync(syncData);
protected virtual void OnServerToClientSync(SyncData syncData)
{
// in host mode, the server sends rpcs to all clients.
// the host client itself will receive them too.
// -> host server is always the source of truth
// -> we can ignore any rpc on the host client
// => otherwise host objects would have ever growing clientBuffers
// (rpc goes to clients. if isServer is true too then we are host)
if (isServer) return;
// don't apply for local player with authority
if (IsClientWithAuthority) return;
// on the client, we receive rpcs for all entities.
// not all of them have a connectionToServer.
// but all of them go through NetworkClient.connection.
// we can get the timestamp from there.
double timestamp = NetworkClient.connection.remoteTimeStamp;
if (onlySyncOnChange)
{
double timeIntervalCheck = bufferResetMultiplier * sendIntervalMultiplier * NetworkServer.sendInterval;
if (clientSnapshots.Count > 0 && clientSnapshots.Values[clientSnapshots.Count - 1].remoteTime + timeIntervalCheck < timestamp)
ResetState();
}
UpdateSyncData(ref syncData, clientSnapshots);
AddSnapshot(clientSnapshots, NetworkClient.connection.remoteTimeStamp + timeStampAdjustment + offset, syncData.position, syncData.quatRotation, syncData.scale);
}
protected virtual void UpdateSyncData(ref SyncData syncData, SortedList<double, TransformSnapshot> snapshots)
{
if (syncData.changedDataByte == Changed.None || syncData.changedDataByte == Changed.CompressRot)
{
syncData.position = snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].position : GetPosition();
syncData.quatRotation = snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].rotation : GetRotation();
syncData.scale = snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].scale : GetScale();
}
else
{
// Just going to update these without checking if syncposition or not,
// because if not syncing position, NT will not apply any position data
// to the target during Apply().
syncData.position.x = (syncData.changedDataByte & Changed.PosX) > 0 ? syncData.position.x : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].position.x : GetPosition().x);
syncData.position.y = (syncData.changedDataByte & Changed.PosY) > 0 ? syncData.position.y : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].position.y : GetPosition().y);
syncData.position.z = (syncData.changedDataByte & Changed.PosZ) > 0 ? syncData.position.z : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].position.z : GetPosition().z);
// If compressRot is true, we already have the Quat in syncdata.
if ((syncData.changedDataByte & Changed.CompressRot) == 0)
{
syncData.vecRotation.x = (syncData.changedDataByte & Changed.RotX) > 0 ? syncData.vecRotation.x : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].rotation.eulerAngles.x : GetRotation().eulerAngles.x);
syncData.vecRotation.y = (syncData.changedDataByte & Changed.RotY) > 0 ? syncData.vecRotation.y : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].rotation.eulerAngles.y : GetRotation().eulerAngles.y); ;
syncData.vecRotation.z = (syncData.changedDataByte & Changed.RotZ) > 0 ? syncData.vecRotation.z : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].rotation.eulerAngles.z : GetRotation().eulerAngles.z);
syncData.quatRotation = Quaternion.Euler(syncData.vecRotation);
}
else
{
syncData.quatRotation = (syncData.changedDataByte & Changed.Rot) > 0 ? syncData.quatRotation : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].rotation : GetRotation());
}
syncData.scale = (syncData.changedDataByte & Changed.Scale) > 0 ? syncData.scale : (snapshots.Count > 0 ? snapshots.Values[snapshots.Count - 1].scale : GetScale());
}
}
// This is to extract position/rotation/scale data from payload. Override
// Construct and Deconstruct if you are implementing a different SyncData logic.
// Note however that snapshot interpolation still requires the basic 3 data
// position, rotation and scale, which are computed from here.
protected virtual void DeconstructSyncData(System.ArraySegment<byte> receivedPayload, out byte? changedFlagData, out Vector3? position, out Quaternion? rotation, out Vector3? scale)
{
using (NetworkReaderPooled reader = NetworkReaderPool.Get(receivedPayload))
{
SyncData syncData = reader.Read<SyncData>();
changedFlagData = (byte)syncData.changedDataByte;
position = syncData.position;
rotation = syncData.quatRotation;
scale = syncData.scale;
}
}
}
}

View File

@ -0,0 +1,156 @@
using UnityEngine;
using System;
using Mirror;
namespace Mirror
{
[Serializable]
public struct SyncData
{
public Changed changedDataByte;
public Vector3 position;
public Quaternion quatRotation;
public Vector3 vecRotation;
public Vector3 scale;
public SyncData(Changed _dataChangedByte, Vector3 _position, Quaternion _rotation, Vector3 _scale)
{
this.changedDataByte = _dataChangedByte;
this.position = _position;
this.quatRotation = _rotation;
this.vecRotation = quatRotation.eulerAngles;
this.scale = _scale;
}
public SyncData(Changed _dataChangedByte, TransformSnapshot _snapshot)
{
this.changedDataByte = _dataChangedByte;
this.position = _snapshot.position;
this.quatRotation = _snapshot.rotation;
this.vecRotation = quatRotation.eulerAngles;
this.scale = _snapshot.scale;
}
public SyncData(Changed _dataChangedByte, Vector3 _position, Vector3 _vecRotation, Vector3 _scale)
{
this.changedDataByte = _dataChangedByte;
this.position = _position;
this.vecRotation = _vecRotation;
this.quatRotation = Quaternion.Euler(vecRotation);
this.scale = _scale;
}
}
[Flags]
public enum Changed : byte
{
None = 0,
PosX = 1 << 0,
PosY = 1 << 1,
PosZ = 1 << 2,
CompressRot = 1 << 3,
RotX = 1 << 4,
RotY = 1 << 5,
RotZ = 1 << 6,
Scale = 1 << 7,
Pos = PosX | PosY | PosZ,
Rot = RotX | RotY | RotZ
}
public static class SyncDataReaderWriter
{
public static void WriteSyncData(this NetworkWriter writer, SyncData syncData)
{
writer.WriteByte((byte)syncData.changedDataByte);
// Write position
if ((syncData.changedDataByte & Changed.PosX) > 0)
{
writer.WriteFloat(syncData.position.x);
}
if ((syncData.changedDataByte & Changed.PosY) > 0)
{
writer.WriteFloat(syncData.position.y);
}
if ((syncData.changedDataByte & Changed.PosZ) > 0)
{
writer.WriteFloat(syncData.position.z);
}
// Write rotation
if ((syncData.changedDataByte & Changed.CompressRot) > 0)
{
if((syncData.changedDataByte & Changed.Rot) > 0)
{
writer.WriteUInt(Compression.CompressQuaternion(syncData.quatRotation));
}
}
else
{
if ((syncData.changedDataByte & Changed.RotX) > 0)
{
writer.WriteFloat(syncData.quatRotation.eulerAngles.x);
}
if ((syncData.changedDataByte & Changed.RotY) > 0)
{
writer.WriteFloat(syncData.quatRotation.eulerAngles.y);
}
if ((syncData.changedDataByte & Changed.RotZ) > 0)
{
writer.WriteFloat(syncData.quatRotation.eulerAngles.z);
}
}
// Write scale
if ((syncData.changedDataByte & Changed.Scale) > 0)
{
writer.WriteVector3(syncData.scale);
}
}
public static SyncData ReadSyncData(this NetworkReader reader)
{
Changed changedData = (Changed)reader.ReadByte();
// If we have nothing to read here, let's say because posX is unchanged, then we can write anything
// for now, but in the NT, we will need to check changedData again, to put the right values of the axis
// back. We don't have it here.
Vector3 position =
new Vector3(
(changedData & Changed.PosX) > 0 ? reader.ReadFloat() : 0,
(changedData & Changed.PosY) > 0 ? reader.ReadFloat() : 0,
(changedData & Changed.PosZ) > 0 ? reader.ReadFloat() : 0
);
Vector3 vecRotation = new Vector3();
Quaternion quatRotation = new Quaternion();
if ((changedData & Changed.CompressRot) > 0)
{
quatRotation = (changedData & Changed.RotX) > 0 ? Compression.DecompressQuaternion(reader.ReadUInt()) : new Quaternion();
}
else
{
vecRotation =
new Vector3(
(changedData & Changed.RotX) > 0 ? reader.ReadFloat() : 0,
(changedData & Changed.RotY) > 0 ? reader.ReadFloat() : 0,
(changedData & Changed.RotZ) > 0 ? reader.ReadFloat() : 0
);
}
Vector3 scale = (changedData & Changed.Scale) == Changed.Scale ? reader.ReadVector3() : new Vector3();
SyncData _syncData = (changedData & Changed.CompressRot) > 0 ? new SyncData(changedData, position, quatRotation, scale) : new SyncData(changedData, position, vecRotation, scale);
return _syncData;
}
}
}

View File

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

View File

@ -10,40 +10,58 @@
// instead of real physics. It's not 100% correct - but it sure is fast!
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using UnityEngine;
namespace Mirror
{
public enum CorrectionMode
{
Set, // rigidbody.position/rotation = ...
Move, // rigidbody.MovePosition/Rotation
}
public enum PredictionMode { Smooth, Fast }
// [RequireComponent(typeof(Rigidbody))] <- RB is moved out at runtime, can't require it.
public class PredictedRigidbody : NetworkBehaviour
{
Transform tf; // this component is performance critical. cache .transform getter!
Rigidbody rb; // own rigidbody on server. this is never moved to a physics copy.
// Prediction sometimes moves the Rigidbody to a ghost object.
// .predictedRigidbody is always kept up to date to wherever the RB is.
// other components should use this when accessing Rigidbody.
public Rigidbody predictedRigidbody;
Transform predictedRigidbodyTransform; // predictedRigidbody.transform for performance (Get/SetPositionAndRotation)
Vector3 lastPosition;
// [Tooltip("Broadcast changes if position changed by more than ... meters.")]
// public float positionSensitivity = 0.01f;
// motion smoothing happen on-demand, because it requires moving physics components to another GameObject.
// this only starts at a given velocity and ends when stopped moving.
// to avoid constant on/off/on effects, it also stays on for a minimum time.
[Header("Motion Smoothing")]
[Tooltip("Prediction supports two different modes: Smooth and Fast:\n\nSmooth: Physics are separated from the GameObject & applied in the background. Rendering smoothly follows the physics for perfectly smooth interpolation results. Much softer, can be even too soft where sharp collisions won't look as sharp (i.e. Billiard balls avoid the wall before even hitting it).\n\nFast: Physics remain on the GameObject and corrections are applied hard. Much faster since we don't need to update a separate GameObject, a bit harsher, more precise.")]
public PredictionMode mode = PredictionMode.Smooth;
[Tooltip("Smoothing via Ghost-following only happens on demand, while moving with a minimum velocity.")]
public float motionSmoothingVelocityThreshold = 0.1f;
float motionSmoothingVelocityThresholdSqr; // ² cached in Awake
public float motionSmoothingAngularVelocityThreshold = 5.0f; // Billiards demo: 0.1 is way too small, takes forever for IsMoving()==false
float motionSmoothingAngularVelocityThresholdSqr; // ² cached in Awake
public float motionSmoothingTimeTolerance = 0.5f;
double motionSmoothingLastMovedTime;
// client keeps state history for correction & reconciliation.
// this needs to be a SortedList because we need to be able to insert inbetween.
// RingBuffer would be faster iteration, but can't do insertions.
// => RingBuffer: see prediction_ringbuffer_2 branch, but it's slower!
[Header("State History")]
public int stateHistoryLimit = 32; // 32 x 50 ms = 1.6 seconds is definitely enough
readonly SortedList<double, RigidbodyState> stateHistory = new SortedList<double, RigidbodyState>();
public float recordInterval = 0.050f;
[Tooltip("(Optional) performance optimization where FixedUpdate.RecordState() only inserts state into history if the state actually changed.\nThis is generally a good idea.")]
public bool onlyRecordChanges = true;
[Tooltip("(Optional) performance optimization where received state is compared to the LAST recorded state first, before sampling the whole history.\n\nThis can save significant traversal overhead for idle objects with a tiny chance of missing corrections for objects which revisisted the same position in the recent history twice.")]
public bool compareLastFirst = true;
[Header("Reconciliation")]
[Tooltip("Correction threshold in meters. For example, 0.1 means that if the client is off by more than 10cm, it gets corrected.")]
public double positionCorrectionThreshold = 0.10;
double positionCorrectionThresholdSqr; // ² cached in Awake
[Tooltip("Correction threshold in degrees. For example, 5 means that if the client is off by more than 5 degrees, it gets corrected.")]
public double rotationCorrectionThreshold = 5;
@ -51,22 +69,22 @@ public class PredictedRigidbody : NetworkBehaviour
public bool oneFrameAhead = true;
[Header("Smoothing")]
[Tooltip("Configure how to apply the corrected state.")]
public CorrectionMode correctionMode = CorrectionMode.Move;
[Tooltip("Snap to the server state directly when velocity is < threshold. This is useful to reduce jitter/fighting effects before coming to rest.\nNote this applies position, rotation and velocity(!) so it's still smooth.")]
public float snapThreshold = 2; // 0.5 has too much fighting-at-rest, 2 seems ideal.
[Header("Visual Interpolation")]
[Tooltip("After creating the visual interpolation object, keep showing the original Rigidbody with a ghost (transparent) material for debugging.")]
public bool showGhost = true;
public float ghostDistanceThreshold = 0.1f;
public float ghostEnabledCheckInterval = 0.2f;
[Tooltip("Physics components are moved onto a ghost object beyond this threshold. Main object visually interpolates to it.")]
public float ghostVelocityThreshold = 0.1f;
[Tooltip("After creating the visual interpolation object, replace this object's renderer materials with the ghost (ideally transparent) material.")]
public Material localGhostMaterial;
public Material remoteGhostMaterial;
[Tooltip("Performance optimization: only create/destroy ghosts every n-th frame is enough.")]
public int checkGhostsEveryNthFrame = 4;
[Tooltip("How fast to interpolate to the target position, relative to how far we are away from it.\nHigher value will be more jitter but sharper moves, lower value will be less jitter but a little too smooth / rounded moves.")]
public float positionInterpolationSpeed = 15; // 10 is a little too low for billiards at least
public float rotationInterpolationSpeed = 10;
@ -74,25 +92,57 @@ public class PredictedRigidbody : NetworkBehaviour
[Tooltip("Teleport if we are further than 'multiplier x collider size' behind.")]
public float teleportDistanceMultiplier = 10;
[Header("Debugging")]
public float lineTime = 10;
[Header("Bandwidth")]
[Tooltip("Reduce sends while velocity==0. Client's objects may slightly move due to gravity/physics, so we still want to send corrections occasionally even if an object is idle on the server the whole time.")]
public bool reduceSendsWhileIdle = true;
// Rigidbody & Collider are moved out into a separate object.
// this way the visual object can smoothly follow.
protected GameObject physicsCopy;
Transform physicsCopyTransform; // caching to avoid GetComponent
Rigidbody physicsCopyRigidbody; // caching to avoid GetComponent
Collider physicsCopyCollider; // caching to avoid GetComponent
float smoothFollowThreshold; // caching to avoid calculation in LateUpdate
// protected Transform physicsCopyTransform; // caching to avoid GetComponent
// protected Rigidbody physicsCopyRigidbody => rb; // caching to avoid GetComponent
// protected Collider physicsCopyCollider; // caching to avoid GetComponent
float smoothFollowThreshold; // caching to avoid calculation in LateUpdate
float smoothFollowThresholdSqr; // caching to avoid calculation in LateUpdate
// we also create one extra ghost for the exact known server state.
protected GameObject remoteCopy;
void Awake()
// joints
Vector3 initialPosition;
Quaternion initialRotation;
// Vector3 initialScale; // don't change scale for now. causes issues with parenting.
Color originalColor;
protected virtual void Awake()
{
tf = transform;
rb = GetComponent<Rigidbody>();
if (rb == null) throw new InvalidOperationException($"Prediction: {name} is missing a Rigidbody component.");
predictedRigidbody = GetComponent<Rigidbody>();
if (predictedRigidbody == null) throw new InvalidOperationException($"Prediction: {name} is missing a Rigidbody component.");
predictedRigidbodyTransform = predictedRigidbody.transform;
// in fast mode, we need to force enable Rigidbody.interpolation.
// otherwise there's not going to be any smoothing whatsoever.
if (mode == PredictionMode.Fast)
{
predictedRigidbody.interpolation = RigidbodyInterpolation.Interpolate;
}
// cache some threshold to avoid calculating them in LateUpdate
float colliderSize = GetComponentInChildren<Collider>().bounds.size.magnitude;
smoothFollowThreshold = colliderSize * teleportDistanceMultiplier;
smoothFollowThresholdSqr = smoothFollowThreshold * smoothFollowThreshold;
// cache initial position/rotation/scale to be used when moving physics components (configurable joints' range of motion)
initialPosition = tf.position;
initialRotation = tf.rotation;
// initialScale = tf.localScale;
// cache ² computations
motionSmoothingVelocityThresholdSqr = motionSmoothingVelocityThreshold * motionSmoothingVelocityThreshold;
motionSmoothingAngularVelocityThresholdSqr = motionSmoothingAngularVelocityThreshold * motionSmoothingAngularVelocityThreshold;
positionCorrectionThresholdSqr = positionCorrectionThreshold * positionCorrectionThreshold;
}
protected virtual void CopyRenderersAsGhost(GameObject destination, Material material)
@ -130,10 +180,10 @@ protected virtual void CopyRenderersAsGhost(GameObject destination, Material mat
// besides, Rigidbody+Collider are two components, where as renders may be many.
protected virtual void CreateGhosts()
{
// skip if already separated
if (physicsCopy != null) return;
// skip if host mode or already separated
if (isServer || physicsCopy != null) return;
Debug.Log($"Separating Physics for {name}");
// Debug.Log($"Separating Physics for {name}"); // logging this allocates too much
// create an empty GameObject with the same name + _Physical
// it's important to copy world position/rotation/scale, not local!
@ -146,9 +196,6 @@ protected virtual void CreateGhosts()
// if we copy localScale then the copy has scale=0.5, where as the
// original would have a global scale of ~1.0.
physicsCopy = new GameObject($"{name}_Physical");
physicsCopy.transform.position = tf.position; // world position!
physicsCopy.transform.rotation = tf.rotation; // world rotation!
physicsCopy.transform.localScale = tf.lossyScale; // world scale!
// assign the same Layer for the physics copy.
// games may use a custom physics collision matrix, layer matters.
@ -157,19 +204,32 @@ protected virtual void CreateGhosts()
// add the PredictedRigidbodyPhysical component
PredictedRigidbodyPhysicsGhost physicsGhostRigidbody = physicsCopy.AddComponent<PredictedRigidbodyPhysicsGhost>();
physicsGhostRigidbody.target = tf;
physicsGhostRigidbody.ghostDistanceThreshold = ghostDistanceThreshold;
physicsGhostRigidbody.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
// move the rigidbody component & all colliders to the physics GameObject
// when moving (Configurable)Joints, their range of motion is
// relative to the initial position. if we move them after the
// GameObject rotated, the range of motion is wrong.
// the easiest solution is to move to initial position,
// then move physics components, then move back.
// => remember previous
Vector3 position = tf.position;
Quaternion rotation = tf.rotation;
// Vector3 scale = tf.localScale; // don't change scale for now. causes issues with parenting.
// => reset to initial
physicsGhostRigidbody.transform.position = tf.position = initialPosition;
physicsGhostRigidbody.transform.rotation = tf.rotation = initialRotation;
physicsGhostRigidbody.transform.localScale = tf.lossyScale;// world scale! // = initialScale; // don't change scale for now. causes issues with parenting.
// => move physics components
PredictionUtils.MovePhysicsComponents(gameObject, physicsCopy);
// => reset previous
physicsGhostRigidbody.transform.position = tf.position = position;
physicsGhostRigidbody.transform.rotation = tf.rotation = rotation;
//physicsGhostRigidbody.transform.localScale = tf.lossyScale; // world scale! //= scale; // don't change scale for now. causes issues with parenting.
// show ghost by copying all renderers / materials with ghost material applied
if (showGhost)
{
// one for the locally predicted rigidbody
CopyRenderersAsGhost(physicsCopy, localGhostMaterial);
physicsGhostRigidbody.ghostDistanceThreshold = ghostDistanceThreshold;
physicsGhostRigidbody.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
// one for the latest remote state for comparison
// it's important to copy world position/rotation/scale, not local!
@ -185,23 +245,12 @@ protected virtual void CreateGhosts()
remoteCopy.transform.position = tf.position; // world position!
remoteCopy.transform.rotation = tf.rotation; // world rotation!
remoteCopy.transform.localScale = tf.lossyScale; // world scale!
PredictedRigidbodyRemoteGhost predictedGhost = remoteCopy.AddComponent<PredictedRigidbodyRemoteGhost>();
predictedGhost.target = tf;
predictedGhost.ghostDistanceThreshold = ghostDistanceThreshold;
predictedGhost.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
CopyRenderersAsGhost(remoteCopy, remoteGhostMaterial);
}
// cache components to avoid GetComponent calls at runtime
physicsCopyTransform = physicsCopy.transform;
physicsCopyRigidbody = physicsCopy.GetComponent<Rigidbody>();
physicsCopyCollider = physicsCopy.GetComponentInChildren<Collider>();
if (physicsCopyRigidbody == null) throw new Exception("SeparatePhysics: couldn't find final Rigidbody.");
if (physicsCopyCollider == null) throw new Exception("SeparatePhysics: couldn't find final Collider.");
// cache some threshold to avoid calculating them in LateUpdate
float colliderSize = physicsCopyCollider.bounds.size.magnitude;
smoothFollowThreshold = colliderSize * teleportDistanceMultiplier;
// assign our Rigidbody reference to the ghost
predictedRigidbody = physicsCopy.GetComponent<Rigidbody>();
predictedRigidbodyTransform = predictedRigidbody.transform;
}
protected virtual void DestroyGhosts()
@ -211,8 +260,32 @@ protected virtual void DestroyGhosts()
// otherwise next time they wouldn't have a collider anymore.
if (physicsCopy != null)
{
// when moving (Configurable)Joints, their range of motion is
// relative to the initial position. if we move them after the
// GameObject rotated, the range of motion is wrong.
// the easiest solution is to move to initial position,
// then move physics components, then move back.
// => remember previous
Vector3 position = tf.position;
Quaternion rotation = tf.rotation;
Vector3 scale = tf.localScale;
// => reset to initial
physicsCopy.transform.position = tf.position = initialPosition;
physicsCopy.transform.rotation = tf.rotation = initialRotation;
physicsCopy.transform.localScale = tf.lossyScale;// = initialScale;
// => move physics components
PredictionUtils.MovePhysicsComponents(physicsCopy, gameObject);
// => reset previous
tf.position = position;
tf.rotation = rotation;
tf.localScale = scale;
// when moving components back, we need to undo the joints initial-delta rotation that we added.
Destroy(physicsCopy);
// reassign our Rigidbody reference
predictedRigidbody = GetComponent<Rigidbody>();
predictedRigidbodyTransform = predictedRigidbody.transform;
}
// simply destroy the remote copy
@ -223,8 +296,8 @@ protected virtual void DestroyGhosts()
protected virtual void SmoothFollowPhysicsCopy()
{
// hard follow:
// tf.position = physicsCopyCollider.position;
// tf.rotation = physicsCopyCollider.rotation;
// predictedRigidbodyTransform.GetPositionAndRotation(out Vector3 physicsPosition, out Quaternion physicsRotation);
// tf.SetPositionAndRotation(physicsPosition, physicsRotation);
// ORIGINAL VERSION: CLEAN AND SIMPLE
/*
@ -251,14 +324,18 @@ protected virtual void SmoothFollowPhysicsCopy()
*/
// FAST VERSION: this shows in profiler a lot, so cache EVERYTHING!
Vector3 currentPosition = tf.position;
Quaternion currentRotation = tf.rotation;
Vector3 physicsPosition = physicsCopyTransform.position; // faster than accessing physicsCopyRigidbody!
Quaternion physicsRotation = physicsCopyTransform.rotation; // faster than accessing physicsCopyRigidbody!
float deltaTime = Time.deltaTime;
tf.GetPositionAndRotation(out Vector3 currentPosition, out Quaternion currentRotation); // faster than tf.position + tf.rotation
predictedRigidbodyTransform.GetPositionAndRotation(out Vector3 physicsPosition, out Quaternion physicsRotation); // faster than Rigidbody .position and .rotation
float deltaTime = Time.deltaTime;
float distance = Vector3.Distance(currentPosition, physicsPosition);
if (distance > smoothFollowThreshold)
// slow and simple version:
// float distance = Vector3.Distance(currentPosition, physicsPosition);
// if (distance > smoothFollowThreshold)
// faster version
Vector3 delta = physicsPosition - currentPosition;
float sqrDistance = Vector3.SqrMagnitude(delta);
float distance = Mathf.Sqrt(sqrDistance);
if (sqrDistance > smoothFollowThresholdSqr)
{
tf.SetPositionAndRotation(physicsPosition, physicsRotation); // faster than .position and .rotation manually
Debug.Log($"[PredictedRigidbody] Teleported because distance to physics copy = {distance:F2} > threshold {smoothFollowThreshold:F2}");
@ -271,21 +348,42 @@ protected virtual void SmoothFollowPhysicsCopy()
// sooner we need to catch the fuck up
// float positionStep = (distance * distance) * interpolationSpeed;
float positionStep = distance * positionInterpolationSpeed;
Vector3 newPosition = Vector3.MoveTowards(currentPosition, physicsPosition, positionStep * deltaTime);
Vector3 newPosition = MoveTowardsCustom(currentPosition, physicsPosition, delta, sqrDistance, distance, positionStep * deltaTime);
// smoothly interpolate to the target rotation.
// Quaternion.RotateTowards doesn't seem to work at all, so let's use SLerp.
Quaternion newRotation = Quaternion.Slerp(currentRotation, physicsRotation, rotationInterpolationSpeed * deltaTime);
// Quaternions always need to be normalized in order to be a valid rotation after operations
Quaternion newRotation = Quaternion.Slerp(currentRotation, physicsRotation, rotationInterpolationSpeed * deltaTime).normalized;
// assign position and rotation together. faster than accessing manually.
tf.SetPositionAndRotation(newPosition, newRotation);
}
// creater visual copy only on clients, where players are watching.
public override void OnStartClient()
// simple and slow version with MoveTowards, which recalculates delta and delta.sqrMagnitude:
// Vector3 newPosition = Vector3.MoveTowards(currentPosition, physicsPosition, positionStep * deltaTime);
// faster version copied from MoveTowards:
// this increases Prediction Benchmark Client's FPS from 615 -> 640.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static Vector3 MoveTowardsCustom(
Vector3 current,
Vector3 target,
Vector3 _delta, // pass this in since we already calculated it
float _sqrDistance, // pass this in since we already calculated it
float _distance, // pass this in since we already calculated it
float maxDistanceDelta)
{
// OnDeserialize may have already created this
if (physicsCopy == null) CreateGhosts();
if (_sqrDistance == 0.0 || maxDistanceDelta >= 0.0 && _sqrDistance <= maxDistanceDelta * maxDistanceDelta)
return target;
float distFactor = maxDistanceDelta / _distance; // unlike Vector3.MoveTowards, we only calculate this once
return new Vector3(
// current.x + (_delta.x / _distance) * maxDistanceDelta,
// current.y + (_delta.y / _distance) * maxDistanceDelta,
// current.z + (_delta.z / _distance) * maxDistanceDelta);
current.x + _delta.x * distFactor,
current.y + _delta.y * distFactor,
current.z + _delta.z * distFactor);
}
// destroy visual copy only in OnStopClient().
@ -297,35 +395,158 @@ public override void OnStopClient()
void UpdateServer()
{
// to save bandwidth, we only serialize when position changed
// if (Vector3.Distance(tf.position, lastPosition) >= positionSensitivity)
// {
// lastPosition = tf.position;
// SetDirty();
// }
// bandwidth optimization while idle.
if (reduceSendsWhileIdle)
{
// while moving, always sync every frame for immediate corrections.
// while idle, only sync once per second.
//
// we still need to sync occasionally because objects on client
// may still slide or move slightly due to gravity, physics etc.
// and those still need to get corrected if not moving on server.
//
// TODO
// next round of optimizations: if client received nothing for 1s,
// force correct to last received state. then server doesn't need
// to send once per second anymore.
syncInterval = IsMoving() ? 0 : 1;
}
// always set dirty to always serialize.
// fixes issues where an object was idle and stopped serializing on server,
// even though it was still moving on client.
// hence getting totally out of sync.
// always set dirty to always serialize in next sync interval.
SetDirty();
}
// movement detection is virtual, in case projects want to use other methods.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected virtual bool IsMoving() =>
// straight forward implementation
// predictedRigidbody.velocity.magnitude >= motionSmoothingVelocityThreshold ||
// predictedRigidbody.angularVelocity.magnitude >= motionSmoothingAngularVelocityThreshold;
// faster implementation with cached ²
predictedRigidbody.velocity.sqrMagnitude >= motionSmoothingVelocityThresholdSqr ||
predictedRigidbody.angularVelocity.sqrMagnitude >= motionSmoothingAngularVelocityThresholdSqr;
// TODO maybe merge the IsMoving() checks & callbacks with UpdateState().
void UpdateGhosting()
{
// perf: enough to check ghosts every few frames.
// PredictionBenchmark: only checking every 4th frame: 585 => 600 FPS
if (Time.frameCount % checkGhostsEveryNthFrame != 0) return;
// client only uses ghosts on demand while interacting.
// this way 1000 GameObjects don't need +1000 Ghost GameObjects all the time!
// no ghost at the moment
if (physicsCopy == null)
{
// faster than velocity threshold? then create the ghosts.
// with 10% buffer zone so we don't flip flop all the time.
if (IsMoving())
{
CreateGhosts();
OnBeginPrediction();
}
}
// ghosting at the moment
else
{
// always set last moved time while moving.
// this way we can avoid on/off/oneffects when stopping.
if (IsMoving())
{
motionSmoothingLastMovedTime = NetworkTime.time;
}
// slower than velocity threshold? then destroy the ghosts.
// with a minimum time since starting to move, to avoid on/off/on effects.
else
{
if (NetworkTime.time >= motionSmoothingLastMovedTime + motionSmoothingTimeTolerance)
{
DestroyGhosts();
OnEndPrediction();
physicsCopy = null; // TESTING
}
}
}
}
// when using Fast mode, we don't create any ghosts.
// but we still want to check IsMoving() in order to support the same
// user callbacks.
bool lastMoving = false;
void UpdateState()
{
// perf: enough to check ghosts every few frames.
// PredictionBenchmark: only checking every 4th frame: 770 => 800 FPS
if (Time.frameCount % checkGhostsEveryNthFrame != 0) return;
bool moving = IsMoving();
// started moving?
if (moving && !lastMoving)
{
OnBeginPrediction();
lastMoving = true;
}
// stopped moving?
else if (!moving && lastMoving)
{
// ensure a minimum time since starting to move, to avoid on/off/on effects.
if (NetworkTime.time >= motionSmoothingLastMovedTime + motionSmoothingTimeTolerance)
{
OnEndPrediction();
lastMoving = false;
}
}
}
void Update()
{
if (isServer) UpdateServer();
if (isClientOnly)
{
if (mode == PredictionMode.Smooth)
UpdateGhosting();
else if (mode == PredictionMode.Fast)
UpdateState();
}
}
void LateUpdate()
{
if (isClient) SmoothFollowPhysicsCopy();
// only follow on client-only, not in server or host mode
if (isClientOnly && mode == PredictionMode.Smooth && physicsCopy) SmoothFollowPhysicsCopy();
}
void FixedUpdate()
{
// on clients we record the current state every FixedUpdate.
// on clients (not host) we record the current state every FixedUpdate.
// this is cheap, and allows us to keep a dense history.
if (isClient) RecordState();
if (!isClientOnly) return;
// OPTIMIZATION: RecordState() is expensive because it inserts into a SortedList.
// only record if state actually changed!
// risks not having up to date states when correcting,
// but it doesn't matter since we'll always compare with the 'newest' anyway.
//
// we check in here instead of in RecordState() because RecordState() should definitely record if we call it!
if (onlyRecordChanges)
{
// TODO maybe don't reuse the correction thresholds?
tf.GetPositionAndRotation(out Vector3 position, out Quaternion rotation);
// clean & simple:
// if (Vector3.Distance(lastRecorded.position, position) < positionCorrectionThreshold &&
// Quaternion.Angle(lastRecorded.rotation, rotation) < rotationCorrectionThreshold)
// faster:
if ((lastRecorded.position - position).sqrMagnitude < positionCorrectionThresholdSqr &&
Quaternion.Angle(lastRecorded.rotation, rotation) < rotationCorrectionThreshold)
{
// Debug.Log($"FixedUpdate for {name}: taking optimized early return instead of recording state.");
return;
}
}
RecordState();
}
// manually store last recorded so we can easily check against this
@ -334,10 +555,13 @@ void FixedUpdate()
double lastRecordTime;
void RecordState()
{
// performance optimization: only call NetworkTime.time getter once
double networkTime = NetworkTime.time;
// instead of recording every fixedupdate, let's record in an interval.
// we don't want to record every tiny move and correct too hard.
if (NetworkTime.time < lastRecordTime + recordInterval) return;
lastRecordTime = NetworkTime.time;
if (networkTime < lastRecordTime + recordInterval) return;
lastRecordTime = networkTime;
// NetworkTime.time is always behind by bufferTime.
// prediction aims to be on the exact same server time (immediately).
@ -356,27 +580,42 @@ void RecordState()
if (stateHistory.Count >= stateHistoryLimit)
stateHistory.RemoveAt(0);
// grab current position/rotation/velocity only once.
// this is performance critical, avoid calling .transform multiple times.
tf.GetPositionAndRotation(out Vector3 currentPosition, out Quaternion currentRotation); // faster than accessing .position + .rotation manually
Vector3 currentVelocity = predictedRigidbody.velocity;
Vector3 currentAngularVelocity = predictedRigidbody.angularVelocity;
// calculate delta to previous state (if any)
Vector3 positionDelta = Vector3.zero;
Vector3 velocityDelta = Vector3.zero;
Vector3 angularVelocityDelta = Vector3.zero;
Quaternion rotationDelta = Quaternion.identity;
if (stateHistory.Count > 0)
int stateHistoryCount = stateHistory.Count; // perf: only grab .Count once
if (stateHistoryCount > 0)
{
RigidbodyState last = stateHistory.Values[stateHistory.Count - 1];
positionDelta = physicsCopyRigidbody.position - last.position;
velocityDelta = physicsCopyRigidbody.velocity - last.velocity;
rotationDelta = physicsCopyRigidbody.rotation * Quaternion.Inverse(last.rotation); // this is how you calculate a quaternion delta
RigidbodyState last = stateHistory.Values[stateHistoryCount - 1];
positionDelta = currentPosition - last.position;
velocityDelta = currentVelocity - last.velocity;
// Quaternions always need to be normalized in order to be valid rotations after operations
rotationDelta = (currentRotation * Quaternion.Inverse(last.rotation)).normalized;
angularVelocityDelta = currentAngularVelocity - last.angularVelocity;
// debug draw the recorded state
Debug.DrawLine(last.position, physicsCopyRigidbody.position, Color.red, lineTime);
// Debug.DrawLine(last.position, currentPosition, Color.red, lineTime);
}
// create state to insert
RigidbodyState state = new RigidbodyState(
predictedTime,
positionDelta, physicsCopyRigidbody.position,
rotationDelta, physicsCopyRigidbody.rotation,
velocityDelta, physicsCopyRigidbody.velocity
positionDelta,
currentPosition,
rotationDelta,
currentRotation,
velocityDelta,
currentVelocity,
angularVelocityDelta,
currentAngularVelocity
);
// add state to history
@ -388,33 +627,48 @@ void RecordState()
// optional user callbacks, in case people need to know about events.
protected virtual void OnSnappedIntoPlace() {}
protected virtual void OnBeforeApplyState() {}
protected virtual void OnCorrected() {}
protected virtual void OnBeginPrediction() {} // when the Rigidbody moved above threshold and we created a ghost
protected virtual void OnEndPrediction() {} // when the Rigidbody came to rest and we destroyed the ghost
void ApplyState(double timestamp, Vector3 position, Quaternion rotation, Vector3 velocity)
void ApplyState(double timestamp, Vector3 position, Quaternion rotation, Vector3 velocity, Vector3 angularVelocity)
{
// fix rigidbodies seemingly dancing in place instead of coming to rest.
// hard snap to the position below a threshold velocity.
// this is fine because the visual object still smoothly interpolates to it.
if (physicsCopyRigidbody.velocity.magnitude <= snapThreshold)
// => consider both velocity and angular velocity (in case of Rigidbodies only rotating with joints etc.)
if (predictedRigidbody.velocity.magnitude <= snapThreshold &&
predictedRigidbody.angularVelocity.magnitude <= snapThreshold)
{
// Debug.Log($"Prediction: snapped {name} into place because velocity {physicsCopyRigidbody.velocity.magnitude:F3} <= {snapThreshold:F3}");
// Debug.Log($"Prediction: snapped {name} into place because velocity {predictedRigidbody.velocity.magnitude:F3} <= {snapThreshold:F3}");
// apply server state immediately.
// important to apply velocity as well, instead of Vector3.zero.
// in case an object is still slightly moving, we don't want it
// to stop and start moving again on client - slide as well here.
physicsCopyRigidbody.position = position;
physicsCopyRigidbody.rotation = rotation;
physicsCopyRigidbody.velocity = velocity;
predictedRigidbody.position = position;
predictedRigidbody.rotation = rotation;
// projects may keep Rigidbodies as kinematic sometimes. in that case, setting velocity would log an error
if (!predictedRigidbody.isKinematic)
{
predictedRigidbody.velocity = velocity;
predictedRigidbody.angularVelocity = angularVelocity;
}
// clear history and insert the exact state we just applied.
// this makes future corrections more accurate.
stateHistory.Clear();
stateHistory.Add(timestamp, new RigidbodyState(
timestamp,
Vector3.zero, position,
Quaternion.identity, rotation,
Vector3.zero, velocity
Vector3.zero,
position,
Quaternion.identity,
rotation,
Vector3.zero,
velocity,
Vector3.zero,
angularVelocity
));
// user callback
@ -422,35 +676,63 @@ void ApplyState(double timestamp, Vector3 position, Quaternion rotation, Vector3
return;
}
// Rigidbody .position teleports, while .MovePosition interpolates
// TODO is this a good idea? what about next capture while it's interpolating?
if (correctionMode == CorrectionMode.Move)
// we have a callback for snapping into place (above).
// we also need one for corrections without snapping into place.
// call it before applying pos/rot/vel in case we need to set kinematic etc.
OnBeforeApplyState();
// apply the state to the Rigidbody
if (mode == PredictionMode.Smooth)
{
physicsCopyRigidbody.MovePosition(position);
physicsCopyRigidbody.MoveRotation(rotation);
// Smooth mode separates Physics from Renderering.
// Rendering smoothly follows Physics in SmoothFollowPhysicsCopy().
// this allows us to be able to hard teleport to the correction.
// which gives most accurate results since the Rigidbody can't
// be stopped by another object when trying to correct.
predictedRigidbody.position = position;
predictedRigidbody.rotation = rotation;
}
else if (correctionMode == CorrectionMode.Set)
else if (mode == PredictionMode.Fast)
{
physicsCopyRigidbody.position = position;
physicsCopyRigidbody.rotation = rotation;
// Fast mode doesn't separate physics from rendering.
// The only smoothing we get is from Rigidbody.MovePosition.
predictedRigidbody.MovePosition(position);
predictedRigidbody.MoveRotation(rotation);
}
// there's only one way to set velocity
physicsCopyRigidbody.velocity = velocity;
// there's only one way to set velocity.
// (projects may keep Rigidbodies as kinematic sometimes. in that case, setting velocity would log an error)
if (!predictedRigidbody.isKinematic)
{
predictedRigidbody.velocity = velocity;
predictedRigidbody.angularVelocity = angularVelocity;
}
}
// process a received server state.
// compares it against our history and applies corrections if needed.
void OnReceivedState(double timestamp, RigidbodyState state)
void OnReceivedState(double timestamp, RigidbodyState state)//, bool sleeping)
{
// always update remote state ghost
if (remoteCopy != null)
{
remoteCopy.transform.position = state.position;
remoteCopy.transform.rotation = state.rotation;
remoteCopy.transform.localScale = tf.lossyScale; // world scale! see CreateGhosts comment.
Transform remoteCopyTransform = remoteCopy.transform;
remoteCopyTransform.SetPositionAndRotation(state.position, state.rotation); // faster than .position + .rotation setters
remoteCopyTransform.localScale = tf.lossyScale; // world scale! see CreateGhosts comment.
}
// DO NOT SYNC SLEEPING! this cuts benchmark performance in half(!!!)
// color code remote sleeping objects to debug objects coming to rest
// if (showRemoteSleeping)
// {
// rend.material.color = sleeping ? Color.gray : originalColor;
// }
// performance: get Rigidbody position & rotation only once,
// and together via its transform
predictedRigidbodyTransform.GetPositionAndRotation(out Vector3 physicsPosition, out Quaternion physicsRotation);
// OPTIONAL performance optimization when comparing idle objects.
// even idle objects will have a history of ~32 entries.
// sampling & traversing through them is unnecessarily costly.
@ -469,9 +751,11 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// this is as fast as it gets for skipping idle objects.
//
// if this ever causes issues, feel free to disable it.
float positionToStateDistanceSqr = Vector3.SqrMagnitude(state.position - physicsPosition);
if (compareLastFirst &&
Vector3.Distance(state.position, physicsCopyRigidbody.position) < positionCorrectionThreshold &&
Quaternion.Angle(state.rotation, physicsCopyRigidbody.rotation) < rotationCorrectionThreshold)
// Vector3.Distance(state.position, physicsPosition) < positionCorrectionThreshold && // slow comparison
positionToStateDistanceSqr < positionCorrectionThresholdSqr && // fast comparison
Quaternion.Angle(state.rotation, physicsRotation) < rotationCorrectionThreshold)
{
// Debug.Log($"OnReceivedState for {name}: taking optimized early return!");
return;
@ -498,8 +782,14 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// otherwise it could be out of sync as long as it's too far behind.
if (state.timestamp < oldest.timestamp)
{
Debug.LogWarning($"Hard correcting client object {name} because the client is too far behind the server. History of size={stateHistory.Count} @ t={timestamp:F3} oldest={oldest.timestamp:F3} newest={newest.timestamp:F3}. This would cause the client to be out of sync as long as it's behind.");
ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
// when starting, client may only have 2-3 states in history.
// it's expected that server states would be behind those 2-3.
// only show a warning if it's behind the full history limit!
if (stateHistory.Count >= stateHistoryLimit)
Debug.LogWarning($"Hard correcting client object {name} because the client is too far behind the server. History of size={stateHistory.Count} @ t={timestamp:F3} oldest={oldest.timestamp:F3} newest={newest.timestamp:F3}. This would cause the client to be out of sync as long as it's behind.");
// force apply the state
ApplyState(state.timestamp, state.position, state.rotation, state.velocity, state.angularVelocity);
return;
}
@ -516,11 +806,13 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// we clamp it to 'now'.
// but only correct if off by threshold.
// TODO maybe we should interpolate this back to 'now'?
if (Vector3.Distance(state.position, physicsCopyRigidbody.position) >= positionCorrectionThreshold)
// if (Vector3.Distance(state.position, physicsPosition) >= positionCorrectionThreshold) // slow comparison
if (positionToStateDistanceSqr >= positionCorrectionThresholdSqr) // fast comparison
{
double ahead = state.timestamp - newest.timestamp;
Debug.Log($"Hard correction because the client is ahead of the server by {(ahead*1000):F1}ms. History of size={stateHistory.Count} @ t={timestamp:F3} oldest={oldest.timestamp:F3} newest={newest.timestamp:F3}. This can happen when latency is near zero, and is fine unless it shows jitter.");
ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
// this can happen a lot when latency is ~0. logging all the time allocates too much and is too slow.
// double ahead = state.timestamp - newest.timestamp;
// Debug.Log($"Hard correction because the client is ahead of the server by {(ahead*1000):F1}ms. History of size={stateHistory.Count} @ t={timestamp:F3} oldest={oldest.timestamp:F3} newest={newest.timestamp:F3}. This can happen when latency is near zero, and is fine unless it shows jitter.");
ApplyState(state.timestamp, state.position, state.rotation, state.velocity, state.angularVelocity);
}
return;
}
@ -531,7 +823,7 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// something went very wrong. sampling should've worked.
// hard correct to recover the error.
Debug.LogError($"Failed to sample history of size={stateHistory.Count} @ t={timestamp:F3} oldest={oldest.timestamp:F3} newest={newest.timestamp:F3}. This should never happen because the timestamp is within history.");
ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
ApplyState(state.timestamp, state.position, state.rotation, state.velocity, state.angularVelocity);
return;
}
@ -540,19 +832,21 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// calculate the difference between where we were and where we should be
// TODO only position for now. consider rotation etc. too later
float positionDifference = Vector3.Distance(state.position, interpolated.position);
float rotationDifference = Quaternion.Angle(state.rotation, interpolated.rotation);
// float positionToInterpolatedDistance = Vector3.Distance(state.position, interpolated.position); // slow comparison
float positionToInterpolatedDistanceSqr = Vector3.SqrMagnitude(state.position - interpolated.position); // fast comparison
float rotationToInterpolatedDistance = Quaternion.Angle(state.rotation, interpolated.rotation);
// Debug.Log($"Sampled history of size={stateHistory.Count} @ {timestamp:F3}: client={interpolated.position} server={state.position} difference={difference:F3} / {correctionThreshold:F3}");
// too far off? then correct it
if (positionDifference >= positionCorrectionThreshold ||
rotationDifference >= rotationCorrectionThreshold)
if (positionToInterpolatedDistanceSqr >= positionCorrectionThresholdSqr || // fast comparison
//positionToInterpolatedDistance >= positionCorrectionThreshold || // slow comparison
rotationToInterpolatedDistance >= rotationCorrectionThreshold)
{
// Debug.Log($"CORRECTION NEEDED FOR {name} @ {timestamp:F3}: client={interpolated.position} server={state.position} difference={difference:F3}");
// show the received correction position + velocity for debugging.
// helps to compare with the interpolated/applied correction locally.
Debug.DrawLine(state.position, state.position + state.velocity * 0.1f, Color.white, lineTime);
//Debug.DrawLine(state.position, state.position + state.velocity * 0.1f, Color.white, lineTime);
// insert the correction and correct the history on top of it.
// returns the final recomputed state after rewinding.
@ -563,8 +857,8 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// for example, on same machine with near zero latency.
// int correctedAmount = stateHistory.Count - afterIndex;
// Debug.Log($"Correcting {name}: {correctedAmount} / {stateHistory.Count} states to final position from: {rb.position} to: {last.position}");
Debug.DrawLine(physicsCopyRigidbody.position, recomputed.position, Color.green, lineTime);
ApplyState(recomputed.timestamp, recomputed.position, recomputed.rotation, recomputed.velocity);
//Debug.DrawLine(physicsCopyRigidbody.position, recomputed.position, Color.green, lineTime);
ApplyState(recomputed.timestamp, recomputed.position, recomputed.rotation, recomputed.velocity, recomputed.angularVelocity);
// user callback
OnCorrected();
@ -585,27 +879,57 @@ public override void OnSerialize(NetworkWriter writer, bool initialState)
// server is technically supposed to be at a fixed frame rate, but this can vary.
// sending server's current deltaTime is the safest option.
// client then applies it on top of remoteTimestamp.
writer.WriteFloat(Time.deltaTime);
writer.WriteVector3(rb.position); // own rigidbody on server, it's never moved to physics copy
writer.WriteQuaternion(rb.rotation); // own rigidbody on server, it's never moved to physics copy
writer.WriteVector3(rb.velocity); // own rigidbody on server, it's never moved to physics copy
// FAST VERSION: this shows in profiler a lot, so cache EVERYTHING!
tf.GetPositionAndRotation(out Vector3 position, out Quaternion rotation); // faster than tf.position + tf.rotation. server's rigidbody is on the same transform.
// simple but slow write:
// writer.WriteFloat(Time.deltaTime);
// writer.WriteVector3(position);
// writer.WriteQuaternion(rotation);
// writer.WriteVector3(predictedRigidbody.velocity);
// writer.WriteVector3(predictedRigidbody.angularVelocity);
// performance optimization: write a whole struct at once via blittable:
PredictedSyncData data = new PredictedSyncData(
Time.deltaTime,
position,
rotation,
predictedRigidbody.velocity,
predictedRigidbody.angularVelocity);//,
// DO NOT SYNC SLEEPING! this cuts benchmark performance in half(!!!)
// predictedRigidbody.IsSleeping());
writer.WritePredictedSyncData(data);
}
// read the server's state, compare with client state & correct if necessary.
public override void OnDeserialize(NetworkReader reader, bool initialState)
{
// this may be called before OnStartClient.
// in that case, separate physics first before applying state.
if (physicsCopy == null) CreateGhosts();
// deserialize data
// we want to know the time on the server when this was sent, which is remoteTimestamp.
double timestamp = NetworkClient.connection.remoteTimeStamp;
// server send state at the end of the frame.
// simple but slow read:
// double serverDeltaTime = reader.ReadFloat();
// Vector3 position = reader.ReadVector3();
// Quaternion rotation = reader.ReadQuaternion();
// Vector3 velocity = reader.ReadVector3();
// Vector3 angularVelocity = reader.ReadVector3();
// performance optimization: read a whole struct at once via blittable:
PredictedSyncData data = reader.ReadPredictedSyncData();
double serverDeltaTime = data.deltaTime;
Vector3 position = data.position;
Quaternion rotation = data.rotation;
Vector3 velocity = data.velocity;
Vector3 angularVelocity = data.angularVelocity;
// DO NOT SYNC SLEEPING! this cuts benchmark performance in half(!!!)
// bool sleeping = data.sleeping != 0;
// server sends state at the end of the frame.
// parse and apply the server's delta time to our timestamp.
// otherwise we see noticeable resets that seem off by one frame.
double serverDeltaTime = reader.ReadFloat();
timestamp += serverDeltaTime;
// however, adding yet one more frame delay gives much(!) better results.
@ -614,13 +938,8 @@ public override void OnDeserialize(NetworkReader reader, bool initialState)
// with physics happening at the end of the frame?
if (oneFrameAhead) timestamp += serverDeltaTime;
// parse state
Vector3 position = reader.ReadVector3();
Quaternion rotation = reader.ReadQuaternion();
Vector3 velocity = reader.ReadVector3();
// process received state
OnReceivedState(timestamp, new RigidbodyState(timestamp, Vector3.zero, position, Quaternion.identity, rotation, Vector3.zero, velocity));
OnReceivedState(timestamp, new RigidbodyState(timestamp, Vector3.zero, position, Quaternion.identity, rotation, Vector3.zero, velocity, Vector3.zero, angularVelocity));//, sleeping);
}
protected override void OnValidate()
@ -635,5 +954,44 @@ protected override void OnValidate()
// then we can maybe relax this a bit.
syncInterval = 0;
}
// helper function for Physics tests to check if a Rigidbody belongs to
// a PredictedRigidbody component (either on it, or on its ghost).
public static bool IsPredicted(Rigidbody rb, out PredictedRigidbody predictedRigidbody)
{
// by default, Rigidbody is on the PredictedRigidbody GameObject
if (rb.TryGetComponent(out predictedRigidbody))
return true;
// it might be on a ghost while interacting
if (rb.TryGetComponent(out PredictedRigidbodyPhysicsGhost ghost))
{
predictedRigidbody = ghost.target.GetComponent<PredictedRigidbody>();
return true;
}
// otherwise the Rigidbody does not belong to any PredictedRigidbody.
predictedRigidbody = null;
return false;
}
// helper function for Physics tests to check if a Collider (which may be in children) belongs to
// a PredictedRigidbody component (either on it, or on its ghost).
public static bool IsPredicted(Collider co, out PredictedRigidbody predictedRigidbody)
{
// by default, Collider is on the PredictedRigidbody GameObject or it's children.
predictedRigidbody = co.GetComponentInParent<PredictedRigidbody>();
if (predictedRigidbody != null)
return true;
// it might be on a ghost while interacting
PredictedRigidbodyPhysicsGhost ghost = co.GetComponentInParent<PredictedRigidbodyPhysicsGhost>();
if (ghost != null && ghost.target != null && ghost.target.TryGetComponent(out predictedRigidbody))
return true;
// otherwise the Rigidbody does not belong to any PredictedRigidbody.
predictedRigidbody = null;
return false;
}
}
}

View File

@ -1,6 +1,6 @@
// Prediction moves out the Rigidbody & Collider into a separate object.
// This way the main (visual) object can smoothly follow it, instead of hard.
using System;
// this component simply points back to the owner component.
// in case Raycasts hit it and need to know the owner, etc.
using UnityEngine;
namespace Mirror
@ -11,66 +11,5 @@ public class PredictedRigidbodyPhysicsGhost : MonoBehaviour
// PredictedRigidbody, this way we don't need to call the .transform getter.
[Tooltip("The predicted rigidbody owner.")]
public Transform target;
// ghost (settings are copyed from PredictedRigidbody)
MeshRenderer ghost;
public float ghostDistanceThreshold = 0.1f;
public float ghostEnabledCheckInterval = 0.2f;
double lastGhostEnabledCheckTime = 0;
// cache components because this is performance critical!
Transform tf;
Collider co;
// we add this component manually from PredictedRigidbody.
// so assign this in Start. target isn't set in Awake yet.
void Start()
{
tf = transform;
co = GetComponent<Collider>();
ghost = GetComponent<MeshRenderer>();
}
void UpdateGhostRenderers()
{
// only if a ghost renderer was given
if (ghost == null) return;
// enough to run this in a certain interval.
// doing this every update would be overkill.
// this is only for debug purposes anyway.
if (NetworkTime.localTime < lastGhostEnabledCheckTime + ghostEnabledCheckInterval) return;
lastGhostEnabledCheckTime = NetworkTime.localTime;
// only show ghost while interpolating towards the object.
// if we are 'inside' the object then don't show ghost.
// otherwise it just looks like z-fighting the whole time.
// => iterated the renderers we found when creating the visual copy.
// we don't want to GetComponentsInChildren every time here!
bool insideTarget = Vector3.Distance(tf.position, target.position) <= ghostDistanceThreshold;
ghost.enabled = !insideTarget;
}
void Update() => UpdateGhostRenderers();
// always follow in late update, after update modified positions
void LateUpdate()
{
// if owner gets network destroyed for any reason, destroy visual
if (target == null) Destroy(gameObject);
}
// also show a yellow gizmo for the predicted & corrected physics.
// in case we can't renderer ghosts, at least we have this.
void OnDrawGizmos()
{
if (co != null)
{
// show the client's predicted & corrected physics in yellow
Bounds bounds = co.bounds;
Gizmos.color = Color.yellow;
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
}
}
}

View File

@ -1,59 +1 @@
// simply ghost object that always follows last received server state.
using UnityEngine;
namespace Mirror
{
public class PredictedRigidbodyRemoteGhost : MonoBehaviour
{
// this is performance critical, so store target's .Transform instead of
// PredictedRigidbody, this way we don't need to call the .transform getter.
[Tooltip("The predicted rigidbody owner.")]
public Transform target;
// ghost (settings are copyed from PredictedRigidbody)
MeshRenderer ghost;
public float ghostDistanceThreshold = 0.1f;
public float ghostEnabledCheckInterval = 0.2f;
double lastGhostEnabledCheckTime = 0;
// cache components because this is performance critical!
Transform tf;
// we add this component manually from PredictedRigidbody.
// so assign this in Start. target isn't set in Awake yet.
void Start()
{
tf = transform;
ghost = GetComponent<MeshRenderer>();
}
void UpdateGhostRenderers()
{
// only if a ghost renderer was given
if (ghost == null) return;
// enough to run this in a certain interval.
// doing this every update would be overkill.
// this is only for debug purposes anyway.
if (NetworkTime.localTime < lastGhostEnabledCheckTime + ghostEnabledCheckInterval) return;
lastGhostEnabledCheckTime = NetworkTime.localTime;
// only show ghost while interpolating towards the object.
// if we are 'inside' the object then don't show ghost.
// otherwise it just looks like z-fighting the whole time.
// => iterated the renderers we found when creating the visual copy.
// we don't want to GetComponentsInChildren every time here!
bool insideTarget = Vector3.Distance(tf.position, target.position) <= ghostDistanceThreshold;
ghost.enabled = !insideTarget;
}
void Update() => UpdateGhostRenderers();
// always follow in late update, after update modified positions
void LateUpdate()
{
// if owner gets network destroyed for any reason, destroy visual
if (target == null) Destroy(gameObject);
}
}
}
// removed 2024-02-09

View File

@ -0,0 +1,54 @@
// this struct exists only for OnDe/Serialize performance.
// instead of WriteVector3+Quaternion+Vector3+Vector3,
// we read & write the whole struct as blittable once.
//
// struct packing can cause odd results with blittable on different platforms,
// so this is usually not recommended!
//
// in this case however, we need to squeeze everything we can out of prediction
// to support low even devices / VR.
using System.Runtime.InteropServices;
using UnityEngine;
namespace Mirror
{
// struct packing
[StructLayout(LayoutKind.Sequential)] // explicitly force sequential
public struct PredictedSyncData
{
public float deltaTime; // 4 bytes (word aligned)
public Vector3 position; // 12 bytes (word aligned)
public Quaternion rotation; // 16 bytes (word aligned)
public Vector3 velocity; // 12 bytes (word aligned)
public Vector3 angularVelocity; // 12 bytes (word aligned)
// DO NOT SYNC SLEEPING! this cuts benchmark performance in half(!!!)
// public byte sleeping; // 1 byte: bool isn't blittable
// constructor for convenience
public PredictedSyncData(float deltaTime, Vector3 position, Quaternion rotation, Vector3 velocity, Vector3 angularVelocity)//, bool sleeping)
{
this.deltaTime = deltaTime;
this.position = position;
this.rotation = rotation;
this.velocity = velocity;
this.angularVelocity = angularVelocity;
// DO NOT SYNC SLEEPING! this cuts benchmark performance in half(!!!)
// this.sleeping = sleeping ? (byte)1 : (byte)0;
}
}
// NetworkReader/Writer extensions to write this struct
public static class PredictedSyncDataReadWrite
{
public static void WritePredictedSyncData(this NetworkWriter writer, PredictedSyncData data)
{
writer.WriteBlittable(data);
}
public static PredictedSyncData ReadPredictedSyncData(this NetworkReader reader)
{
return reader.ReadBlittable<PredictedSyncData>();
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f595f112a39e4634b670d56991b23823
timeCreated: 1710387026

View File

@ -30,9 +30,19 @@ public static void MoveRigidbody(GameObject source, GameObject destination)
rigidbodyCopy.constraints = original.constraints;
rigidbodyCopy.sleepThreshold = original.sleepThreshold;
rigidbodyCopy.freezeRotation = original.freezeRotation;
rigidbodyCopy.position = original.position;
rigidbodyCopy.rotation = original.rotation;
rigidbodyCopy.velocity = original.velocity;
// moving (Configurable)Joints messes up their range of motion unless
// we reset to initial position first (we do this in PredictedRigibody.cs).
// so here we don't set the Rigidbody's physics position at all.
// rigidbodyCopy.position = original.position;
// rigidbodyCopy.rotation = original.rotation;
// projects may keep Rigidbodies as kinematic sometimes. in that case, setting velocity would log an error
if (!original.isKinematic)
{
rigidbodyCopy.velocity = original.velocity;
rigidbodyCopy.angularVelocity = original.angularVelocity;
}
// destroy original
GameObject.Destroy(original);
@ -131,6 +141,21 @@ public static void MoveMeshColliders(GameObject source, GameObject destination)
MeshCollider[] sourceColliders = source.GetComponentsInChildren<MeshCollider>();
foreach (MeshCollider sourceCollider in sourceColliders)
{
// when Models have Mesh->Read/Write disabled, it means that Unity
// uploads the mesh directly to the GPU and erases it on the CPU.
// on some platforms this makes moving a MeshCollider in builds impossible:
//
// "CollisionMeshData couldn't be created because the mesh has been marked as non-accessible."
//
// on other platforms, this works fine.
// let's show an explicit log message so in case collisions don't
// work at runtime, it's obvious why it happens and how to fix it.
if (!sourceCollider.sharedMesh.isReadable)
{
Debug.Log($"[Prediction]: MeshCollider on {sourceCollider.name} isn't readable, which may indicate that the Mesh only exists on the GPU. If {sourceCollider.name} is missing collisions, then please select the model in the Project Area, and enable Mesh->Read/Write so it's also available on the CPU!");
// don't early return. keep trying, it may work.
}
// copy the relative transform:
// if collider is on root, it returns destination root.
// if collider is on a child, it creates and returns a child on destination.
@ -229,10 +254,10 @@ public static void MoveConfigurableJoints(GameObject source, GameObject destinat
jointCopy.connectedMassScale = sourceJoint.connectedMassScale;
jointCopy.enableCollision = sourceJoint.enableCollision;
jointCopy.enablePreprocessing = sourceJoint.enablePreprocessing;
jointCopy.highAngularXLimit = sourceJoint.highAngularXLimit;
jointCopy.highAngularXLimit = sourceJoint.highAngularXLimit; // moving this only works if the object is at initial position/rotation/scale, see PredictedRigidbody.cs
jointCopy.linearLimitSpring = sourceJoint.linearLimitSpring;
jointCopy.linearLimit = sourceJoint.linearLimit;
jointCopy.lowAngularXLimit = sourceJoint.lowAngularXLimit;
jointCopy.lowAngularXLimit = sourceJoint.lowAngularXLimit; // moving this only works if the object is at initial position/rotation/scale, see PredictedRigidbody.cs
jointCopy.massScale = sourceJoint.massScale;
jointCopy.projectionAngle = sourceJoint.projectionAngle;
jointCopy.projectionDistance = sourceJoint.projectionDistance;

View File

@ -1,28 +1,38 @@
// PredictedRigidbody stores a history of its rigidbody states.
using System.Runtime.CompilerServices;
using UnityEngine;
namespace Mirror
{
// inline everything because this is performance critical!
public struct RigidbodyState : PredictedState
{
public double timestamp { get; private set; }
public double timestamp { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] private set; }
// we want to store position delta (last + delta = current), and current.
// this way we can apply deltas on top of corrected positions to get the corrected final position.
public Vector3 positionDelta { get; set; } // delta to get from last to this position
public Vector3 position { get; set; }
public Vector3 positionDelta { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; } // delta to get from last to this position
public Vector3 position { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; }
public Quaternion rotationDelta { get; set; } // delta to get from last to this rotation
public Quaternion rotation { get; set; }
public Quaternion rotationDelta { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; } // delta to get from last to this rotation
public Quaternion rotation { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; }
public Vector3 velocityDelta { get; set; } // delta to get from last to this velocity
public Vector3 velocity { get; set; }
public Vector3 velocityDelta { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; } // delta to get from last to this velocity
public Vector3 velocity { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; }
public Vector3 angularVelocityDelta { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; } // delta to get from last to this velocity
public Vector3 angularVelocity { [MethodImpl(MethodImplOptions.AggressiveInlining)] get; [MethodImpl(MethodImplOptions.AggressiveInlining)] set; }
public RigidbodyState(
double timestamp,
Vector3 positionDelta, Vector3 position,
Quaternion rotationDelta, Quaternion rotation,
Vector3 velocityDelta, Vector3 velocity)
Vector3 positionDelta,
Vector3 position,
Quaternion rotationDelta,
Quaternion rotation,
Vector3 velocityDelta,
Vector3 velocity,
Vector3 angularVelocityDelta,
Vector3 angularVelocity)
{
this.timestamp = timestamp;
this.positionDelta = positionDelta;
@ -31,6 +41,8 @@ public RigidbodyState(
this.rotation = rotation;
this.velocityDelta = velocityDelta;
this.velocity = velocity;
this.angularVelocityDelta = angularVelocityDelta;
this.angularVelocity = angularVelocity;
}
public static RigidbodyState Interpolate(RigidbodyState a, RigidbodyState b, float t)
@ -38,8 +50,10 @@ public static RigidbodyState Interpolate(RigidbodyState a, RigidbodyState b, flo
return new RigidbodyState
{
position = Vector3.Lerp(a.position, b.position, t),
rotation = Quaternion.Slerp(a.rotation, b.rotation, t),
velocity = Vector3.Lerp(a.velocity, b.velocity, t)
// Quaternions always need to be normalized in order to be a valid rotation after operations
rotation = Quaternion.Slerp(a.rotation, b.rotation, t).normalized,
velocity = Vector3.Lerp(a.velocity, b.velocity, t),
angularVelocity = Vector3.Lerp(a.angularVelocity, b.angularVelocity, t)
};
}
}

View File

@ -91,7 +91,7 @@ public class RemoteStatistics : NetworkBehaviour
[Header("GUI")]
public bool showGui;
public KeyCode hotKey = KeyCode.F11;
public KeyCode hotKey = KeyCode.BackQuote;
Rect windowRect = new Rect(0, 0, 400, 400);
// password can't be stored in code or in Unity project.

View File

@ -10,3 +10,4 @@
[assembly: InternalsVisibleTo("Mirror.Tests.Performance.Editor")]
[assembly: InternalsVisibleTo("Mirror.Tests.Performance.Runtime")]
[assembly: InternalsVisibleTo("Mirror.Editor")]
[assembly: InternalsVisibleTo("Mirror.Components")]

View File

@ -4,8 +4,12 @@
namespace Mirror
{
/// <summary>
/// SyncVars are used to synchronize a variable from the server to all clients automatically.
/// <para>Value must be changed on server, not directly by clients. Hook parameter allows you to define a client-side method to be invoked when the client gets an update from the server.</para>
/// SyncVars are used to automatically synchronize a variable between the server and all clients. The direction of synchronization depends on the Sync Direction property, ServerToClient by default.
/// <para>
/// When Sync Direction is equal to ServerToClient, the value should be changed on the server side and synchronized to all clients.
/// Otherwise, the value should be changed on the client side and synchronized to server and other clients.
/// </para>
/// <para>Hook parameter allows you to define a method to be invoked when gets an value update. Notice that the hook method will not be called on the change side.</para>
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public class SyncVarAttribute : PropertyAttribute

View File

@ -135,8 +135,9 @@ public bool authority
// -> still supports dynamically sized types
//
// 64 bit mask, tracking up to 64 SyncVars.
protected ulong syncVarDirtyBits { get; private set; }
// 64 bit mask, tracking up to 64 sync collections (internal for tests).
// protected since NB child classes read this field in the weaver generated SerializeSyncVars method
protected ulong syncVarDirtyBits;
// 64 bit mask, tracking up to 64 sync collections.
// internal for tests, field for faster access (instead of property)
// TODO 64 SyncLists are too much. consider smaller mask later.
internal ulong syncObjectDirtyBits;

View File

@ -261,7 +261,8 @@ static void OnTransportConnected()
// the handler may want to send messages to the client
// thus we should set the connected state before calling the handler
connectState = ConnectState.Connected;
NetworkTime.UpdateClient();
// ping right away after connecting so client gets new time asap
NetworkTime.SendPing();
OnConnectedEvent?.Invoke();
}
else Debug.LogError("Skipped Connect message handling because connection is null.");

View File

@ -38,7 +38,7 @@ public class NetworkManager : MonoBehaviour
public bool editorAutoStart;
/// <summary>Server Update frequency, per second. Use around 60Hz for fast paced games like Counter-Strike to minimize latency. Use around 30Hz for games like WoW to minimize computations. Use around 1-10Hz for slow paced games like EVE.</summary>
[Tooltip("Server & Client send rate per second. Use 60-100Hz for fast paced games like Counter-Strike to minimize latency. Use around 30Hz for games like WoW to minimize computations. Use around 1-10Hz for slow paced games like EVE.")]
[Tooltip("Server / Client send rate per second.\nUse 60-100Hz for fast paced games like Counter-Strike to minimize latency.\nUse around 30Hz for games like WoW to minimize computations.\nUse around 1-10Hz for slow paced games like EVE.")]
[FormerlySerializedAs("serverTickRate")]
public int sendRate = 60;
@ -597,11 +597,9 @@ void FinishStartHost()
// client will do things before the server is even fully started.
//Debug.Log("StartHostClient called");
SetupClient();
networkAddress = "localhost";
RegisterClientMessages();
// call OnConencted needs to be called AFTER RegisterClientMessages
// InvokeOnConnected needs to be called AFTER RegisterClientMessages
// (https://github.com/vis2k/Mirror/pull/1249/)
HostMode.InvokeOnConnected();

View File

@ -266,21 +266,8 @@ static void CleanupSpawned()
{
if (identity != null)
{
// scene object
if (identity.sceneId != 0)
{
// spawned scene objects are unspawned and reset.
// afterwards we disable them again.
// (they always stay in the scene, we don't destroy them)
DestroyObject(identity, DestroyMode.Reset);
identity.gameObject.SetActive(false);
}
// spawned prefabs
else
{
// spawned prefabs are unspawned and destroyed.
DestroyObject(identity, DestroyMode.Destroy);
}
// NetworkServer.Destroy resets if scene object, destroys if prefab.
Destroy(identity.gameObject);
}
}
@ -337,7 +324,7 @@ static void OnCommandMessage(NetworkConnectionToClient conn, CommandMessage msg,
// for example, NetworkTransform.
// let's not spam the console for unreliable out of order messages.
if (channelId == Channels.Reliable)
Debug.LogWarning($"Spawned object not found when handling Command message {identity.name} netId={msg.netId}");
Debug.LogWarning($"Spawned object not found when handling Command message netId={msg.netId}");
return;
}
@ -385,7 +372,7 @@ static void OnEntityStateMessage(NetworkConnectionToClient connection, EntitySta
if (!identity.DeserializeServer(reader))
{
if (exceptionsDisconnect)
{
{
Debug.LogError($"Server failed to deserialize client state for {identity.name} with netId={identity.netId}, Disconnecting.");
connection.Disconnect();
}
@ -928,22 +915,22 @@ public static void ReplaceHandler<T>(Action<NetworkConnectionToClient, T> handle
where T : struct, NetworkMessage
{
ushort msgType = NetworkMessageId<T>.Id;
// register Id <> Type in lookup for debugging.
NetworkMessages.Lookup[msgType] = typeof(T);
handlers[msgType] = NetworkMessages.WrapHandler(handler, requireAuthentication, exceptionsDisconnect);
}
/// <summary>Replace a handler for message type T. Most should require authentication.</summary>
public static void ReplaceHandler<T>(Action<NetworkConnectionToClient, T, int> handler, bool requireAuthentication = true)
where T : struct, NetworkMessage
{
ushort msgType = NetworkMessageId<T>.Id;
// register Id <> Type in lookup for debugging.
NetworkMessages.Lookup[msgType] = typeof(T);
handlers[msgType] = NetworkMessages.WrapHandler(handler, requireAuthentication, exceptionsDisconnect);
}
@ -1519,6 +1506,10 @@ static void SpawnObject(GameObject obj, NetworkConnection ownerConnection)
if (ownerConnection is LocalConnectionToClient)
identity.isOwned = true;
// NetworkServer.Unspawn sets object as inactive.
// NetworkServer.Spawn needs to set them active again in case they were previously unspawned / inactive.
identity.gameObject.SetActive(true);
// only call OnStartServer if not spawned yet.
// check used to be in NetworkIdentity. may not be necessary anymore.
if (!identity.isServer && identity.netId == 0)
@ -1564,43 +1555,26 @@ static void SpawnObject(GameObject obj, NetworkConnection ownerConnection)
// Unlike when calling NetworkServer.Destroy(), on the server the object
// will NOT be destroyed. This allows the server to re-use the object,
// even spawn it again later.
public static void UnSpawn(GameObject obj) => DestroyObject(obj, DestroyMode.Reset);
// destroy /////////////////////////////////////////////////////////////
// sometimes we want to GameObject.Destroy it.
// sometimes we want to just unspawn on clients and .Reset() it on server.
// => 'bool destroy' isn't obvious enough. it's really destroy OR reset!
enum DestroyMode { Destroy, Reset }
/// <summary>Destroys this object and corresponding objects on all clients.</summary>
// In some cases it is useful to remove an object but not delete it on
// the server. For that, use NetworkServer.UnSpawn() instead of
// NetworkServer.Destroy().
public static void Destroy(GameObject obj) => DestroyObject(obj, DestroyMode.Destroy);
static void DestroyObject(GameObject obj, DestroyMode mode)
{
if (obj == null)
{
Debug.Log("NetworkServer DestroyObject is null");
return;
}
if (GetNetworkIdentity(obj, out NetworkIdentity identity))
{
DestroyObject(identity, mode);
}
}
static void DestroyObject(NetworkIdentity identity, DestroyMode mode)
public static void UnSpawn(GameObject obj)
{
// Debug.Log($"DestroyObject instance:{identity.netId}");
// NetworkServer.Destroy should only be called on server or host.
// NetworkServer.Unspawn should only be called on server or host.
// on client, show a warning to explain what it does.
if (!active)
{
Debug.LogWarning("NetworkServer.Destroy() called without an active server. Servers can only destroy while active, clients can only ask the server to destroy (for example, with a [Command]), after which the server may decide to destroy the object and broadcast the change to all clients.");
Debug.LogWarning("NetworkServer.Unspawn() called without an active server. Servers can only destroy while active, clients can only ask the server to destroy (for example, with a [Command]), after which the server may decide to destroy the object and broadcast the change to all clients.");
return;
}
if (obj == null)
{
Debug.Log("NetworkServer.Unspawn(): object is null");
return;
}
if (!GetNetworkIdentity(obj, out NetworkIdentity identity))
{
return;
}
@ -1654,31 +1628,59 @@ static void DestroyObject(NetworkIdentity identity, DestroyMode mode)
// we are on the server. call OnStopServer.
identity.OnStopServer();
// are we supposed to GameObject.Destroy() it completely?
if (mode == DestroyMode.Destroy)
// finally reset the state and deactivate it
identity.ResetState();
identity.gameObject.SetActive(false);
}
// destroy /////////////////////////////////////////////////////////////
/// <summary>Destroys this object and corresponding objects on all clients.</summary>
// In some cases it is useful to remove an object but not delete it on
// the server. For that, use NetworkServer.UnSpawn() instead of
// NetworkServer.Destroy().
public static void Destroy(GameObject obj)
{
// NetworkServer.Destroy should only be called on server or host.
// on client, show a warning to explain what it does.
if (!active)
{
Debug.LogWarning("NetworkServer.Destroy() called without an active server. Servers can only destroy while active, clients can only ask the server to destroy (for example, with a [Command]), after which the server may decide to destroy the object and broadcast the change to all clients.");
return;
}
if (obj == null)
{
Debug.Log("NetworkServer.Destroy(): object is null");
return;
}
// first, we unspawn it on clients and server
UnSpawn(obj);
// additionally, if it's a prefab then we destroy it completely.
// we never destroy scene objects on server or on client, since once
// they are gone, they are gone forever and can't be instantiate again.
// for example, server may Destroy() a scene object and once a match
// restarts, the scene objects would be gone from the new match.
if (GetNetworkIdentity(obj, out NetworkIdentity identity) &&
identity.sceneId == 0)
{
identity.destroyCalled = true;
// Destroy if application is running
if (Application.isPlaying)
{
UnityEngine.Object.Destroy(identity.gameObject);
UnityEngine.Object.Destroy(obj);
}
// Destroy can't be used in Editor during tests. use DestroyImmediate.
else
{
GameObject.DestroyImmediate(identity.gameObject);
GameObject.DestroyImmediate(obj);
}
}
// otherwise simply .Reset() and set inactive again
else if (mode == DestroyMode.Reset)
{
identity.ResetState();
}
}
// interest management /////////////////////////////////////////////////
// Helper function to add all server connections as observers.
// This is used if none of the components provides their own
// OnRebuildObservers function.

View File

@ -141,17 +141,21 @@ internal static void UpdateClient()
{
// localTime (double) instead of Time.time for accuracy over days
if (localTime >= lastPingTime + PingInterval)
{
// send raw predicted time without the offset applied yet.
// we then apply the offset to it after.
NetworkPingMessage pingMessage = new NetworkPingMessage
(
localTime,
predictedTime
);
NetworkClient.Send(pingMessage, Channels.Unreliable);
lastPingTime = localTime;
}
SendPing();
}
// Separate method so we can call it from NetworkClient directly.
internal static void SendPing()
{
// send raw predicted time without the offset applied yet.
// we then apply the offset to it after.
NetworkPingMessage pingMessage = new NetworkPingMessage
(
localTime,
predictedTime
);
NetworkClient.Send(pingMessage, Channels.Unreliable);
lastPingTime = localTime;
}
// client rtt calculation //////////////////////////////////////////////

View File

@ -19,12 +19,16 @@ public interface PredictedState
Vector3 velocity { get; set; }
Vector3 velocityDelta { get; set; }
Vector3 angularVelocity { get; set; }
Vector3 angularVelocityDelta { get; set; }
}
public static class Prediction
{
// get the two states closest to a given timestamp.
// those can be used to interpolate the exact state at that time.
// => RingBuffer: see prediction_ringbuffer_2 branch, but it's slower!
public static bool Sample<T>(
SortedList<double, T> history,
double timestamp, // current server time
@ -56,29 +60,36 @@ public static bool Sample<T>(
// should be O(1) most of the time, unless sampling was off.
int index = 0; // manually count when iterating. easier than for-int loop.
KeyValuePair<double, T> prev = new KeyValuePair<double, T>();
foreach (KeyValuePair<double, T> entry in history) {
// SortedList foreach iteration allocates a LOT. use for-int instead.
// foreach (KeyValuePair<double, T> entry in history) {
for (int i = 0; i < history.Count; ++i)
{
double key = history.Keys[i];
T value = history.Values[i];
// exact match?
if (timestamp == entry.Key)
if (timestamp == key)
{
before = entry.Value;
after = entry.Value;
before = value;
after = value;
afterIndex = index;
t = Mathd.InverseLerp(entry.Key, entry.Key, timestamp);
t = Mathd.InverseLerp(key, key, timestamp);
return true;
}
// did we check beyond timestamp? then return the previous two.
if (entry.Key > timestamp)
if (key > timestamp)
{
before = prev.Value;
after = entry.Value;
after = value;
afterIndex = index;
t = Mathd.InverseLerp(prev.Key, entry.Key, timestamp);
t = Mathd.InverseLerp(prev.Key, key, timestamp);
return true;
}
// remember the last
prev = entry;
prev = new KeyValuePair<double, T>(key, value);
index += 1;
}
@ -88,22 +99,33 @@ public static bool Sample<T>(
// inserts a server state into the client's history.
// readjust the deltas of the states after the inserted one.
// returns the corrected final position.
// => RingBuffer: see prediction_ringbuffer_2 branch, but it's slower!
public static T CorrectHistory<T>(
SortedList<double, T> stateHistory,
SortedList<double, T> history,
int stateHistoryLimit,
T corrected, // corrected state with timestamp
T before, // state in history before the correction
T after, // state in history after the correction
int afterIndex) // index of the 'after' value so we don't need to find it again here
int afterIndex) // index of the 'after' value so we don't need to find it again here
where T: PredictedState
{
// respect the limit
// TODO unit test to check if it respects max size
if (stateHistory.Count >= stateHistoryLimit)
stateHistory.RemoveAt(0);
if (history.Count >= stateHistoryLimit)
{
history.RemoveAt(0);
afterIndex -= 1; // we removed the first value so all indices are off by one now
}
// insert the corrected state into the history, or overwrite if already exists
stateHistory[corrected.timestamp] = corrected;
// PERFORMANCE OPTIMIZATION: avoid O(N) insertion, only readjust all values after.
// the end result is the same since after.delta and after.position are both recalculated.
// it's technically not correct if we were to reconstruct final position from 0..after..end but
// we never do, we only ever iterate from after..end!
//
// insert the corrected state into the history, or overwrite if already exists
// SortedList insertions are O(N)!
// history[corrected.timestamp] = corrected;
// afterIndex += 1; // we inserted the corrected value before the previous index
// the entry behind the inserted one still has the delta from (before, after).
// we need to correct it to (corrected, after).
@ -136,35 +158,34 @@ public static T CorrectHistory<T>(
double multiplier = previousDeltaTime != 0 ? correctedDeltaTime / previousDeltaTime : 0; // 0.5 / 2.0 = 0.25
// recalculate 'after.delta' with the multiplier
after.positionDelta = Vector3.Lerp(Vector3.zero, after.positionDelta, (float)multiplier);
after.velocityDelta = Vector3.Lerp(Vector3.zero, after.velocityDelta, (float)multiplier);
// rotation deltas aren't working yet. instead, we apply the corrected rotation to all entries after the correction below.
// this at least syncs the rotations and looks quite decent, compared to not syncing!
// after.rotationDelta = Quaternion.Slerp(Quaternion.identity, after.rotationDelta, (float)multiplier);
after.positionDelta = Vector3.Lerp(Vector3.zero, after.positionDelta, (float)multiplier);
after.velocityDelta = Vector3.Lerp(Vector3.zero, after.velocityDelta, (float)multiplier);
after.angularVelocityDelta = Vector3.Lerp(Vector3.zero, after.angularVelocityDelta, (float)multiplier);
// Quaternions always need to be normalized in order to be a valid rotation after operations
after.rotationDelta = Quaternion.Slerp(Quaternion.identity, after.rotationDelta, (float)multiplier).normalized;
// changes aren't saved until we overwrite them in the history
stateHistory[after.timestamp] = after;
history[after.timestamp] = after;
// second step: readjust all absolute values by rewinding client's delta moves on top of it.
T last = corrected;
for (int i = afterIndex; i < stateHistory.Count; ++i)
for (int i = afterIndex; i < history.Count; ++i)
{
double key = stateHistory.Keys[i];
T entry = stateHistory.Values[i];
double key = history.Keys[i];
T value = history.Values[i];
// correct absolute position based on last + delta.
entry.position = last.position + entry.positionDelta;
entry.velocity = last.velocity + entry.velocityDelta;
// rotation deltas aren't working yet. instead, we apply the corrected rotation to all entries after the correction.
// this at least syncs the rotations and looks quite decent, compared to not syncing!
// entry.rotation = entry.rotationDelta * last.rotation; // quaternions add delta by multiplying in this order
entry.rotation = corrected.rotation;
value.position = last.position + value.positionDelta;
value.velocity = last.velocity + value.velocityDelta;
value.angularVelocity = last.angularVelocity + value.angularVelocityDelta;
// Quaternions always need to be normalized in order to be a valid rotation after operations
value.rotation = (value.rotationDelta * last.rotation).normalized; // quaternions add delta by multiplying in this order
// save the corrected entry into history.
stateHistory[key] = entry;
history[key] = value;
// save last
last = entry;
last = value;
}
// third step: return the final recomputed state.

View File

@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
@ -5,13 +6,31 @@ namespace Mirror
{
public class SyncIDictionary<TKey, TValue> : SyncObject, IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue>
{
public delegate void SyncDictionaryChanged(Operation op, TKey key, TValue item);
/// <summary>This is called after the item is added with TKey</summary>
public Action<TKey> OnAdd;
/// <summary>This is called after the item is changed with TKey. TValue is the OLD item</summary>
public Action<TKey, TValue> OnSet;
/// <summary>This is called after the item is removed with TKey. TValue is the OLD item</summary>
public Action<TKey, TValue> OnRemove;
/// <summary>This is called before the data is cleared</summary>
public Action OnClear;
// Deprecated 2024-03-22
[Obsolete("Use individual Actions, which pass OLD values where appropriate, instead.")]
public Action<Operation, TKey, TValue> Callback;
protected readonly IDictionary<TKey, TValue> objects;
public SyncIDictionary(IDictionary<TKey, TValue> objects)
{
this.objects = objects;
}
public int Count => objects.Count;
public bool IsReadOnly => !IsWritable();
public event SyncDictionaryChanged Callback;
public enum Operation : byte
{
@ -30,7 +49,7 @@ struct Change
// list of changes.
// -> insert/delete/clear is only ONE change
// -> changing the same slot 10x caues 10 changes.
// -> changing the same slot 10x causes 10 changes.
// -> note that this grows until next sync(!)
// TODO Dictionary<key, change> to avoid ever growing changes / redundant changes!
readonly List<Change> changes = new List<Change>();
@ -41,13 +60,6 @@ struct Change
// so we need to skip them
int changesAhead;
public override void Reset()
{
changes.Clear();
changesAhead = 0;
objects.Clear();
}
public ICollection<TKey> Keys => objects.Keys;
public ICollection<TValue> Values => objects.Values;
@ -56,38 +68,6 @@ public override void Reset()
IEnumerable<TValue> IReadOnlyDictionary<TKey, TValue>.Values => objects.Values;
// throw away all the changes
// this should be called after a successful sync
public override void ClearChanges() => changes.Clear();
public SyncIDictionary(IDictionary<TKey, TValue> objects)
{
this.objects = objects;
}
void AddOperation(Operation op, TKey key, TValue item, bool checkAccess)
{
if (checkAccess && IsReadOnly)
{
throw new System.InvalidOperationException("SyncDictionaries can only be modified by the owner.");
}
Change change = new Change
{
operation = op,
key = key,
item = item
};
if (IsRecording())
{
changes.Add(change);
OnDirty?.Invoke();
}
Callback?.Invoke(op, key, item);
}
public override void OnSerializeAll(NetworkWriter writer)
{
// if init, write the full list content
@ -179,15 +159,15 @@ public override void OnDeserializeDelta(NetworkReader reader)
// ClientToServer needs to set dirty in server OnDeserialize.
// no access check: server OnDeserialize can always
// write, even for ClientToServer (for broadcasting).
if (ContainsKey(key))
if (objects.TryGetValue(key, out TValue oldItem))
{
objects[key] = item; // assign after ContainsKey check
AddOperation(Operation.OP_SET, key, item, false);
objects[key] = item; // assign after TryGetValue
AddOperation(Operation.OP_SET, key, item, oldItem, false);
}
else
{
objects[key] = item; // assign after ContainsKey check
AddOperation(Operation.OP_ADD, key, item, false);
objects[key] = item; // assign after TryGetValue
AddOperation(Operation.OP_ADD, key, item, default, false);
}
}
break;
@ -195,12 +175,14 @@ public override void OnDeserializeDelta(NetworkReader reader)
case Operation.OP_CLEAR:
if (apply)
{
objects.Clear();
// add dirty + changes.
// ClientToServer needs to set dirty in server OnDeserialize.
// no access check: server OnDeserialize can always
// write, even for ClientToServer (for broadcasting).
AddOperation(Operation.OP_CLEAR, default, default, false);
AddOperation(Operation.OP_CLEAR, default, default, default, false);
// clear after invoking the callback so users can iterate the dictionary
// and take appropriate action on the items before they are wiped.
objects.Clear();
}
break;
@ -208,14 +190,14 @@ public override void OnDeserializeDelta(NetworkReader reader)
key = reader.Read<TKey>();
if (apply)
{
if (objects.TryGetValue(key, out item))
if (objects.TryGetValue(key, out TValue oldItem))
{
// add dirty + changes.
// ClientToServer needs to set dirty in server OnDeserialize.
// no access check: server OnDeserialize can always
// write, even for ClientToServer (for broadcasting).
objects.Remove(key);
AddOperation(Operation.OP_REMOVE, key, item, false);
AddOperation(Operation.OP_REMOVE, key, oldItem, oldItem, false);
}
}
break;
@ -229,22 +211,15 @@ public override void OnDeserializeDelta(NetworkReader reader)
}
}
public void Clear()
// throw away all the changes
// this should be called after a successful sync
public override void ClearChanges() => changes.Clear();
public override void Reset()
{
changes.Clear();
changesAhead = 0;
objects.Clear();
AddOperation(Operation.OP_CLEAR, default, default, true);
}
public bool ContainsKey(TKey key) => objects.ContainsKey(key);
public bool Remove(TKey key)
{
if (objects.TryGetValue(key, out TValue item) && objects.Remove(key))
{
AddOperation(Operation.OP_REMOVE, key, item, true);
return true;
}
return false;
}
public TValue this[TKey i]
@ -254,42 +229,31 @@ public TValue this[TKey i]
{
if (ContainsKey(i))
{
TValue oldItem = objects[i];
objects[i] = value;
AddOperation(Operation.OP_SET, i, value, true);
AddOperation(Operation.OP_SET, i, value, oldItem, true);
}
else
{
objects[i] = value;
AddOperation(Operation.OP_ADD, i, value, true);
AddOperation(Operation.OP_ADD, i, value, default, true);
}
}
}
public bool TryGetValue(TKey key, out TValue value) => objects.TryGetValue(key, out value);
public void Add(TKey key, TValue value)
{
objects.Add(key, value);
AddOperation(Operation.OP_ADD, key, value, true);
}
public bool ContainsKey(TKey key) => objects.ContainsKey(key);
public void Add(KeyValuePair<TKey, TValue> item) => Add(item.Key, item.Value);
public bool Contains(KeyValuePair<TKey, TValue> item)
{
return TryGetValue(item.Key, out TValue val) && EqualityComparer<TValue>.Default.Equals(val, item.Value);
}
public bool Contains(KeyValuePair<TKey, TValue> item) => TryGetValue(item.Key, out TValue val) && EqualityComparer<TValue>.Default.Equals(val, item.Value);
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
{
if (arrayIndex < 0 || arrayIndex > array.Length)
{
throw new System.ArgumentOutOfRangeException(nameof(arrayIndex), "Array Index Out of Range");
}
if (array.Length - arrayIndex < Count)
{
throw new System.ArgumentException("The number of items in the SyncDictionary is greater than the available space from arrayIndex to the end of the destination array");
}
int i = arrayIndex;
foreach (KeyValuePair<TKey, TValue> item in objects)
@ -299,16 +263,80 @@ public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
}
}
public void Add(KeyValuePair<TKey, TValue> item) => Add(item.Key, item.Value);
public void Add(TKey key, TValue value)
{
objects.Add(key, value);
AddOperation(Operation.OP_ADD, key, value, default, true);
}
public bool Remove(TKey key)
{
if (objects.TryGetValue(key, out TValue oldItem) && objects.Remove(key))
{
AddOperation(Operation.OP_REMOVE, key, oldItem, oldItem, true);
return true;
}
return false;
}
public bool Remove(KeyValuePair<TKey, TValue> item)
{
bool result = objects.Remove(item.Key);
if (result)
{
AddOperation(Operation.OP_REMOVE, item.Key, item.Value, true);
}
AddOperation(Operation.OP_REMOVE, item.Key, item.Value, item.Value, true);
return result;
}
public void Clear()
{
AddOperation(Operation.OP_CLEAR, default, default, default, true);
// clear after invoking the callback so users can iterate the dictionary
// and take appropriate action on the items before they are wiped.
objects.Clear();
}
void AddOperation(Operation op, TKey key, TValue item, TValue oldItem, bool checkAccess)
{
if (checkAccess && IsReadOnly)
throw new InvalidOperationException("SyncDictionaries can only be modified by the owner.");
Change change = new Change
{
operation = op,
key = key,
item = item
};
if (IsRecording())
{
changes.Add(change);
OnDirty?.Invoke();
}
switch (op)
{
case Operation.OP_ADD:
OnAdd?.Invoke(key);
break;
case Operation.OP_SET:
OnSet?.Invoke(key, oldItem);
break;
case Operation.OP_REMOVE:
OnRemove?.Invoke(key, oldItem);
break;
case Operation.OP_CLEAR:
OnClear?.Invoke();
break;
}
#pragma warning disable CS0618 // Type or member is obsolete
Callback?.Invoke(op, key, item);
#pragma warning restore CS0618 // Type or member is obsolete
}
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => objects.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => objects.GetEnumerator();
@ -316,9 +344,9 @@ public bool Remove(KeyValuePair<TKey, TValue> item)
public class SyncDictionary<TKey, TValue> : SyncIDictionary<TKey, TValue>
{
public SyncDictionary() : base(new Dictionary<TKey, TValue>()) {}
public SyncDictionary(IEqualityComparer<TKey> eq) : base(new Dictionary<TKey, TValue>(eq)) {}
public SyncDictionary(IDictionary<TKey, TValue> d) : base(new Dictionary<TKey, TValue>(d)) {}
public SyncDictionary() : base(new Dictionary<TKey, TValue>()) { }
public SyncDictionary(IEqualityComparer<TKey> eq) : base(new Dictionary<TKey, TValue>(eq)) { }
public SyncDictionary(IDictionary<TKey, TValue> d) : base(new Dictionary<TKey, TValue>(d)) { }
public new Dictionary<TKey, TValue>.ValueCollection Values => ((Dictionary<TKey, TValue>)objects).Values;
public new Dictionary<TKey, TValue>.KeyCollection Keys => ((Dictionary<TKey, TValue>)objects).Keys;
public new Dictionary<TKey, TValue>.Enumerator GetEnumerator() => ((Dictionary<TKey, TValue>)objects).GetEnumerator();

View File

@ -6,23 +6,39 @@ namespace Mirror
{
public class SyncList<T> : SyncObject, IList<T>, IReadOnlyList<T>
{
public delegate void SyncListChanged(Operation op, int itemIndex, T oldItem, T newItem);
public enum Operation : byte
{
OP_ADD,
OP_SET,
OP_INSERT,
OP_REMOVEAT,
OP_CLEAR
}
/// <summary>This is called after the item is added with index</summary>
public Action<int> OnAdd;
/// <summary>This is called after the item is inserted with inedx</summary>
public Action<int> OnInsert;
/// <summary>This is called after the item is set with index and OLD Value</summary>
public Action<int, T> OnSet;
/// <summary>This is called after the item is removed with index and OLD Value</summary>
public Action<int, T> OnRemove;
/// <summary>This is called before the list is cleared so the list can be iterated</summary>
public Action OnClear;
// Deprecated 2024-03-23
[Obsolete("Use individual Actions, which pass OLD values where appropriate, instead.")]
public Action<Operation, int, T, T> Callback;
readonly IList<T> objects;
readonly IEqualityComparer<T> comparer;
public int Count => objects.Count;
public bool IsReadOnly => !IsWritable();
public event SyncListChanged Callback;
public enum Operation : byte
{
OP_ADD,
OP_CLEAR,
OP_INSERT,
OP_REMOVEAT,
OP_SET
}
struct Change
{
@ -43,7 +59,7 @@ struct Change
// so we need to skip them
int changesAhead;
public SyncList() : this(EqualityComparer<T>.Default) {}
public SyncList() : this(EqualityComparer<T>.Default) { }
public SyncList(IEqualityComparer<T> comparer)
{
@ -71,9 +87,7 @@ public override void Reset()
void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkAccess)
{
if (checkAccess && IsReadOnly)
{
throw new InvalidOperationException("Synclists can only be modified by the owner.");
}
Change change = new Change
{
@ -88,7 +102,28 @@ void AddOperation(Operation op, int itemIndex, T oldItem, T newItem, bool checkA
OnDirty?.Invoke();
}
switch (op)
{
case Operation.OP_ADD:
OnAdd?.Invoke(itemIndex);
break;
case Operation.OP_INSERT:
OnInsert?.Invoke(itemIndex);
break;
case Operation.OP_SET:
OnSet?.Invoke(itemIndex, oldItem);
break;
case Operation.OP_REMOVEAT:
OnRemove?.Invoke(itemIndex, oldItem);
break;
case Operation.OP_CLEAR:
OnClear?.Invoke();
break;
}
#pragma warning disable CS0618 // Type or member is obsolete
Callback?.Invoke(op, itemIndex, oldItem, newItem);
#pragma warning restore CS0618 // Type or member is obsolete
}
public override void OnSerializeAll(NetworkWriter writer)
@ -195,12 +230,14 @@ public override void OnDeserializeDelta(NetworkReader reader)
case Operation.OP_CLEAR:
if (apply)
{
objects.Clear();
// add dirty + changes.
// ClientToServer needs to set dirty in server OnDeserialize.
// no access check: server OnDeserialize can always
// write, even for ClientToServer (for broadcasting).
AddOperation(Operation.OP_CLEAR, 0, default, default, false);
// clear after invoking the callback so users can iterate the list
// and take appropriate action on the items before they are wiped.
objects.Clear();
}
break;
@ -265,15 +302,15 @@ public void Add(T item)
public void AddRange(IEnumerable<T> range)
{
foreach (T entry in range)
{
Add(entry);
}
}
public void Clear()
{
objects.Clear();
AddOperation(Operation.OP_CLEAR, 0, default, default, true);
// clear after invoking the callback so users can iterate the list
// and take appropriate action on the items before they are wiped.
objects.Clear();
}
public bool Contains(T item) => IndexOf(item) >= 0;
@ -331,9 +368,8 @@ public bool Remove(T item)
int index = IndexOf(item);
bool result = index >= 0;
if (result)
{
RemoveAt(index);
}
return result;
}
@ -352,9 +388,7 @@ public int RemoveAll(Predicate<T> match)
toRemove.Add(objects[i]);
foreach (T entry in toRemove)
{
Remove(entry);
}
return toRemove.Count;
}
@ -393,6 +427,7 @@ public struct Enumerator : IEnumerator<T>
{
readonly SyncList<T> list;
int index;
public T Current { get; private set; }
public Enumerator(SyncList<T> list)
@ -405,16 +440,15 @@ public Enumerator(SyncList<T> list)
public bool MoveNext()
{
if (++index >= list.Count)
{
return false;
}
Current = list[index];
return true;
}
public void Reset() => index = -1;
object IEnumerator.Current => Current;
public void Dispose() {}
public void Dispose() { }
}
}
}

View File

@ -6,19 +6,29 @@ namespace Mirror
{
public class SyncSet<T> : SyncObject, ISet<T>
{
public delegate void SyncSetChanged(Operation op, T item);
/// <summary>This is called after the item is added. T is the new item.</summary>
public Action<T> OnAdd;
/// <summary>This is called after the item is removed. T is the OLD item</summary>
public Action<T> OnRemove;
/// <summary>This is called BEFORE the data is cleared</summary>
public Action OnClear;
// Deprecated 2024-03-22
[Obsolete("Use individual Actions, which pass OLD value where appropriate, instead.")]
public Action<Operation, T> Callback;
protected readonly ISet<T> objects;
public int Count => objects.Count;
public bool IsReadOnly => !IsWritable();
public event SyncSetChanged Callback;
public enum Operation : byte
{
OP_ADD,
OP_CLEAR,
OP_REMOVE
OP_REMOVE,
OP_CLEAR
}
struct Change
@ -59,9 +69,7 @@ public override void Reset()
void AddOperation(Operation op, T item, bool checkAccess)
{
if (checkAccess && IsReadOnly)
{
throw new InvalidOperationException("SyncSets can only be modified by the owner.");
}
Change change = new Change
{
@ -75,7 +83,22 @@ void AddOperation(Operation op, T item, bool checkAccess)
OnDirty?.Invoke();
}
switch (op)
{
case Operation.OP_ADD:
OnAdd?.Invoke(item);
break;
case Operation.OP_REMOVE:
OnRemove?.Invoke(item);
break;
case Operation.OP_CLEAR:
OnClear?.Invoke();
break;
}
#pragma warning disable CS0618 // Type or member is obsolete
Callback?.Invoke(op, item);
#pragma warning restore CS0618 // Type or member is obsolete
}
void AddOperation(Operation op, bool checkAccess) => AddOperation(op, default, checkAccess);
@ -86,9 +109,7 @@ public override void OnSerializeAll(NetworkWriter writer)
writer.WriteUInt((uint)objects.Count);
foreach (T obj in objects)
{
writer.Write(obj);
}
// all changes have been applied already
// thus the client will need to skip all the pending changes
@ -112,13 +133,11 @@ public override void OnSerializeDelta(NetworkWriter writer)
case Operation.OP_ADD:
writer.Write(change.item);
break;
case Operation.OP_CLEAR:
break;
case Operation.OP_REMOVE:
writer.Write(change.item);
break;
case Operation.OP_CLEAR:
break;
}
}
}
@ -171,18 +190,6 @@ public override void OnDeserializeDelta(NetworkReader reader)
}
break;
case Operation.OP_CLEAR:
if (apply)
{
objects.Clear();
// add dirty + changes.
// ClientToServer needs to set dirty in server OnDeserialize.
// no access check: server OnDeserialize can always
// write, even for ClientToServer (for broadcasting).
AddOperation(Operation.OP_CLEAR, false);
}
break;
case Operation.OP_REMOVE:
item = reader.Read<T>();
if (apply)
@ -195,6 +202,20 @@ public override void OnDeserializeDelta(NetworkReader reader)
AddOperation(Operation.OP_REMOVE, item, false);
}
break;
case Operation.OP_CLEAR:
if (apply)
{
// add dirty + changes.
// ClientToServer needs to set dirty in server OnDeserialize.
// no access check: server OnDeserialize can always
// write, even for ClientToServer (for broadcasting).
AddOperation(Operation.OP_CLEAR, false);
// clear after invoking the callback so users can iterate the set
// and take appropriate action on the items before they are wiped.
objects.Clear();
}
break;
}
if (!apply)
@ -218,15 +239,15 @@ public bool Add(T item)
void ICollection<T>.Add(T item)
{
if (objects.Add(item))
{
AddOperation(Operation.OP_ADD, item, true);
}
}
public void Clear()
{
objects.Clear();
AddOperation(Operation.OP_CLEAR, true);
// clear after invoking the callback so users can iterate the set
// and take appropriate action on the items before they are wiped.
objects.Clear();
}
public bool Contains(T item) => objects.Contains(item);
@ -257,17 +278,13 @@ public void ExceptWith(IEnumerable<T> other)
// remove every element in other from this
foreach (T element in other)
{
Remove(element);
}
}
public void IntersectWith(IEnumerable<T> other)
{
if (other is ISet<T> otherSet)
{
IntersectWithSet(otherSet);
}
else
{
HashSet<T> otherAsSet = new HashSet<T>(other);
@ -280,12 +297,8 @@ void IntersectWithSet(ISet<T> otherSet)
List<T> elements = new List<T>(objects);
foreach (T element in elements)
{
if (!otherSet.Contains(element))
{
Remove(element);
}
}
}
public bool IsProperSubsetOf(IEnumerable<T> other) => objects.IsProperSubsetOf(other);
@ -304,38 +317,26 @@ void IntersectWithSet(ISet<T> otherSet)
public void SymmetricExceptWith(IEnumerable<T> other)
{
if (other == this)
{
Clear();
}
else
{
foreach (T element in other)
{
if (!Remove(element))
{
Add(element);
}
}
}
}
// custom implementation so we can do our own Clear/Add/Remove for delta
public void UnionWith(IEnumerable<T> other)
{
if (other != this)
{
foreach (T element in other)
{
Add(element);
}
}
}
}
public class SyncHashSet<T> : SyncSet<T>
{
public SyncHashSet() : this(EqualityComparer<T>.Default) {}
public SyncHashSet(IEqualityComparer<T> comparer) : base(new HashSet<T>(comparer ?? EqualityComparer<T>.Default)) {}
public SyncHashSet() : this(EqualityComparer<T>.Default) { }
public SyncHashSet(IEqualityComparer<T> comparer) : base(new HashSet<T>(comparer ?? EqualityComparer<T>.Default)) { }
// allocation free enumerator
public new HashSet<T>.Enumerator GetEnumerator() => ((HashSet<T>)objects).GetEnumerator();
@ -343,8 +344,8 @@ public SyncHashSet(IEqualityComparer<T> comparer) : base(new HashSet<T>(comparer
public class SyncSortedSet<T> : SyncSet<T>
{
public SyncSortedSet() : this(Comparer<T>.Default) {}
public SyncSortedSet(IComparer<T> comparer) : base(new SortedSet<T>(comparer ?? Comparer<T>.Default)) {}
public SyncSortedSet() : this(Comparer<T>.Default) { }
public SyncSortedSet(IComparer<T> comparer) : base(new SortedSet<T>(comparer ?? Comparer<T>.Default)) { }
// allocation free enumerator
public new SortedSet<T>.Enumerator GetEnumerator() => ((SortedSet<T>)objects).GetEnumerator();

View File

@ -87,20 +87,23 @@ static void OnLateUpdate()
{
switch (entry.type)
{
// add [Thread#] prefix to make it super obvious where this log message comes from.
// some projects may see unexpected messages that were previously hidden,
// since Unity wouldn't log them without ThreadLog.cs.
case LogType.Log:
Debug.Log($"[T{entry.threadId}] {entry.message}\n{entry.stackTrace}");
Debug.Log($"[Thread{entry.threadId}] {entry.message}\n{entry.stackTrace}");
break;
case LogType.Warning:
Debug.LogWarning($"[T{entry.threadId}] {entry.message}\n{entry.stackTrace}");
Debug.LogWarning($"[Thread{entry.threadId}] {entry.message}\n{entry.stackTrace}");
break;
case LogType.Error:
Debug.LogError($"[T{entry.threadId}] {entry.message}\n{entry.stackTrace}");
Debug.LogError($"[Thread{entry.threadId}] {entry.message}\n{entry.stackTrace}");
break;
case LogType.Exception:
Debug.LogError($"[T{entry.threadId}] {entry.message}\n{entry.stackTrace}");
Debug.LogError($"[Thread{entry.threadId}] {entry.message}\n{entry.stackTrace}");
break;
case LogType.Assert:
Debug.LogAssertion($"[T{entry.threadId}] {entry.message}\n{entry.stackTrace}");
Debug.LogAssertion($"[Thread{entry.threadId}] {entry.message}\n{entry.stackTrace}");
break;
}
}

View File

@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using UnityEngine;
namespace Mirror
{
@ -98,5 +99,14 @@ public static void Clear<T>(this ConcurrentQueue<T> source)
}
}
#endif
#if !UNITY_2021_3_OR_NEWER
// Unity 2021.2 and earlier don't have transform.GetPositionAndRotation which we use for performance in some places
public static void GetPositionAndRotation(this Transform transform, out Vector3 position, out Quaternion rotation)
{
position = transform.position;
rotation = transform.rotation;
}
#endif
}
}

View File

@ -36,6 +36,12 @@ public abstract class Transport : MonoBehaviour
/// <summary>Is this transport available in the current platform?</summary>
public abstract bool Available();
/// <summary>Is this transported encrypted for secure communication?</summary>
public virtual bool IsEncrypted => false;
/// <summary>If encrypted, which cipher is used?</summary>
public virtual string EncryptionCipher => "";
// client //////////////////////////////////////////////////////////////
/// <summary>Called by Transport when the client connected to the server.</summary>
public Action OnClientConnected;

View File

@ -0,0 +1,14 @@
using UnityEditor;
namespace Mirror
{
[CustomEditor(typeof(LagCompensator))]
public class LagCompensatorInspector : Editor
{
public override void OnInspectorGUI()
{
EditorGUILayout.HelpBox("Preview Component - Feedback appreciated on GitHub or Discord!", MessageType.Warning);
DrawDefaultInspector();
}
}
}

View File

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

View File

@ -3,6 +3,7 @@
"rootNamespace": "",
"references": [
"GUID:30817c1a0e6d646d99c048fc403f5979",
"GUID:72872094b21c16e48b631b2224833d49",
"GUID:1d0b9d21c3ff546a4aa32399dfd33474"
],
"includePlatforms": [

View File

@ -128,7 +128,9 @@ float DrawNetworkIdentityInfo(NetworkIdentity identity, float initialX, float Y)
Vector2 maxValueLabelSize = GetMaxNameLabelSize(infos);
Rect labelRect = new Rect(initialX, Y, maxNameLabelSize.x, maxNameLabelSize.y);
Rect idLabelRect = new Rect(maxNameLabelSize.x, Y, maxValueLabelSize.x, maxValueLabelSize.y);
// height needs a +1 to line up nicely
Rect idLabelRect = new Rect(maxNameLabelSize.x, Y, maxValueLabelSize.x, maxValueLabelSize.y + 1);
foreach (NetworkIdentityInfo info in infos)
{

View File

@ -98,7 +98,13 @@ public AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters p
// let's make it obvious why we returned null for easier debugging.
// NOTE: if this fails for "System.Private.CoreLib":
// ILPostProcessorReflectionImporter fixes it!
Log.Warning($"ILPostProcessorAssemblyResolver.Resolve: Failed to find file for {name}");
// the fix for #2503 started showing this warning for Bee.BeeDriver on mac,
// which is for compilation. we can ignore that one.
if (!name.Name.StartsWith("Bee.BeeDriver"))
{
Log.Warning($"ILPostProcessorAssemblyResolver.Resolve: Failed to find file for {name}");
}
return null;
}

View File

@ -504,7 +504,7 @@ void GenerateSerialization(ref bool WeavingFailed)
worker.Emit(OpCodes.Ldarg_1);
// base
worker.Emit(OpCodes.Ldarg_0);
worker.Emit(OpCodes.Call, weaverTypes.NetworkBehaviourDirtyBitsReference);
worker.Emit(OpCodes.Ldfld, weaverTypes.NetworkBehaviourDirtyBitsReference);
MethodReference writeUint64Func = writers.GetWriteFunc(weaverTypes.Import<ulong>(), ref WeavingFailed);
worker.Emit(OpCodes.Call, writeUint64Func);
@ -524,7 +524,7 @@ void GenerateSerialization(ref bool WeavingFailed)
// Generates: if ((base.get_syncVarDirtyBits() & 1uL) != 0uL)
// base
worker.Emit(OpCodes.Ldarg_0);
worker.Emit(OpCodes.Call, weaverTypes.NetworkBehaviourDirtyBitsReference);
worker.Emit(OpCodes.Ldfld, weaverTypes.NetworkBehaviourDirtyBitsReference);
// 8 bytes = long
worker.Emit(OpCodes.Ldc_I8, 1L << dirtyBit);
worker.Emit(OpCodes.And);

View File

@ -1,4 +1,5 @@
// finds all readers and writers and register them
using System.Collections.Generic;
using System.Linq;
using Mono.CecilX;
using Mono.CecilX.Cil;
@ -17,6 +18,21 @@ public static bool Process(AssemblyDefinition CurrentAssembly, IAssemblyResolver
// otherwise Unity crashes when running tests
ProcessMirrorAssemblyClasses(CurrentAssembly, resolver, Log, writers, readers, ref WeavingFailed);
// process dependencies first, this way weaver can process types of other assemblies properly.
// fixes: https://github.com/MirrorNetworking/Mirror/issues/2503
//
// find NetworkReader/Writer extensions in referenced assemblies
// save a copy of the collection enumerator since it appears to be modified at some point during iteration
IEnumerable<AssemblyNameReference> assemblyReferences = CurrentAssembly.MainModule.AssemblyReferences.ToList();
foreach (AssemblyNameReference assemblyNameReference in assemblyReferences)
{
AssemblyDefinition referencedAssembly = resolver.Resolve(assemblyNameReference);
if (referencedAssembly != null)
{
ProcessAssemblyClasses(CurrentAssembly, referencedAssembly, writers, readers, ref WeavingFailed);
}
}
// find readers/writers in the assembly we are in right now.
return ProcessAssemblyClasses(CurrentAssembly, CurrentAssembly, writers, readers, ref WeavingFailed);
}

View File

@ -42,6 +42,38 @@ public static MethodReference ResolveMethod(TypeReference t, AssemblyDefinition
return null;
}
public static FieldReference ResolveField(TypeReference tr, AssemblyDefinition assembly, Logger Log, string name, ref bool WeavingFailed)
{
if (tr == null)
{
Log.Error($"Cannot resolve Field {name} without a class");
WeavingFailed = true;
return null;
}
FieldReference field = ResolveField(tr, assembly, Log, m => m.Name == name, ref WeavingFailed);
if (field == null)
{
Log.Error($"Field not found with name {name} in type {tr.Name}", tr);
WeavingFailed = true;
}
return field;
}
public static FieldReference ResolveField(TypeReference t, AssemblyDefinition assembly, Logger Log, System.Func<FieldDefinition, bool> predicate, ref bool WeavingFailed)
{
foreach (FieldDefinition fieldRef in t.Resolve().Fields)
{
if (predicate(fieldRef))
{
return assembly.MainModule.ImportReference(fieldRef);
}
}
Log.Error($"Field not found in type {t.Name}", t);
WeavingFailed = true;
return null;
}
public static MethodReference TryResolveMethodInParents(TypeReference tr, AssemblyDefinition assembly, string name)
{
if (tr == null)

View File

@ -10,7 +10,7 @@ public class WeaverTypes
{
public MethodReference ScriptableObjectCreateInstanceMethod;
public MethodReference NetworkBehaviourDirtyBitsReference;
public FieldReference NetworkBehaviourDirtyBitsReference;
public MethodReference GetWriterReference;
public MethodReference ReturnWriterReference;
@ -90,7 +90,7 @@ public WeaverTypes(AssemblyDefinition assembly, Logger Log, ref bool WeavingFail
TypeReference NetworkBehaviourType = Import<NetworkBehaviour>();
NetworkBehaviourDirtyBitsReference = Resolvers.ResolveProperty(NetworkBehaviourType, assembly, "syncVarDirtyBits");
NetworkBehaviourDirtyBitsReference = Resolvers.ResolveField(NetworkBehaviourType, assembly, Log, "syncVarDirtyBits", ref WeavingFailed);
generatedSyncVarSetter = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter", ref WeavingFailed);
generatedSyncVarSetter_GameObject = Resolvers.ResolveMethod(NetworkBehaviourType, assembly, Log, "GeneratedSyncVarSetter_GameObject", ref WeavingFailed);

View File

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

View File

@ -0,0 +1,80 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: BallMaterial
m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0}
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _BumpScale: 1
- _Cutoff: 0.5
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _GlossMapScale: 1
- _Glossiness: 1
- _GlossyReflections: 1
- _Metallic: 1
- _Mode: 0
- _OcclusionStrength: 1
- _Parallax: 0.02
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _UVSec: 0
- _ZWrite: 1
m_Colors:
- _Color: {r: 0, g: 0, b: 0, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
m_BuildTextureStacks: []

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 09fe33013804145e8a4ba1d18f834dcf
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 26e96d86a94c2451d85dcabf4aff3551
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,49 @@
using UnityEngine;
namespace Mirror.Examples.PredictionBenchmark
{
public class NetworkManagerPredictionBenchmark : NetworkManager
{
[Header("Spawns")]
public int spawnAmount = 1000;
public GameObject spawnPrefab;
public Bounds spawnArea = new Bounds(new Vector3(0, 2.5f, 0), new Vector3(10f, 5f, 10f));
public override void Awake()
{
base.Awake();
// ensure vsync is disabled for the benchmark, otherwise results are capped
QualitySettings.vSyncCount = 0;
}
void SpawnAll()
{
// spawn randomly inside the cage
for (int i = 0; i < spawnAmount; ++i)
{
// choose a random point within the cage
float x = Random.Range(spawnArea.min.x, spawnArea.max.x);
float y = Random.Range(spawnArea.min.y, spawnArea.max.y);
float z = Random.Range(spawnArea.min.z, spawnArea.max.z);
Vector3 position = new Vector3(x, y, z);
// spawn & position
GameObject go = Instantiate(spawnPrefab);
go.transform.position = position;
NetworkServer.Spawn(go);
}
}
public override void OnStartServer()
{
base.OnStartServer();
SpawnAll();
// disable rendering on server to reduce noise in profiling.
// keep enabled in host mode though.
if (mode == NetworkManagerMode.ServerOnly)
Camera.main.enabled = false;
}
}
}

View File

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

View File

@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &6080703956733773953
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5774152995658786670}
- component: {fileID: 4958697633604052194}
m_Layer: 0
m_Name: PlayerSpectator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &5774152995658786670
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6080703956733773953}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &4958697633604052194
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6080703956733773953}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9b91ecbcc199f4492b9a91e820070131, type: 3}
m_Name:
m_EditorClassIdentifier:
sceneId: 0
_assetId: 0
serverOnly: 0
visibility: 0
hasSpawned: 0

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: feea51e51b4564f06a38482bbebac8fa
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,190 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &5646305152014201295
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5646305152014201299}
- component: {fileID: 5646305152014201298}
- component: {fileID: 5646305152014201297}
- component: {fileID: 5646305152014201296}
- component: {fileID: 1898357413811911178}
- component: {fileID: 7187875016326091757}
- component: {fileID: 1900383403885999746}
- component: {fileID: 813163234907249251}
m_Layer: 0
m_Name: PredictedBall
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &5646305152014201299
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 2.5, z: 0}
m_LocalScale: {x: 0.35, y: 0.35, z: 0.35}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &5646305152014201298
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &5646305152014201297
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 09fe33013804145e8a4ba1d18f834dcf, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!135 &5646305152014201296
SphereCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_Material: {fileID: 0}
m_IsTrigger: 0
m_Enabled: 1
serializedVersion: 2
m_Radius: 0.5
m_Center: {x: 0, y: 0, z: 0}
--- !u!54 &1898357413811911178
Rigidbody:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
serializedVersion: 2
m_Mass: 1
m_Drag: 0
m_AngularDrag: 0.05
m_UseGravity: 1
m_IsKinematic: 0
m_Interpolate: 1
m_Constraints: 0
m_CollisionDetection: 1
--- !u!114 &7187875016326091757
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9b91ecbcc199f4492b9a91e820070131, type: 3}
m_Name:
m_EditorClassIdentifier:
sceneId: 0
_assetId: 3619328764
serverOnly: 0
visibility: 0
hasSpawned: 0
--- !u!114 &1900383403885999746
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d38927cdc6024b9682b5fe9778b9ef99, type: 3}
m_Name:
m_EditorClassIdentifier:
syncDirection: 0
syncMode: 0
syncInterval: 0
predictedRigidbody: {fileID: 0}
mode: 1
motionSmoothingVelocityThreshold: 0.1
motionSmoothingAngularVelocityThreshold: 0.1
motionSmoothingTimeTolerance: 0.5
stateHistoryLimit: 32
recordInterval: 0.05
onlyRecordChanges: 1
compareLastFirst: 1
positionCorrectionThreshold: 0.1
rotationCorrectionThreshold: 5
oneFrameAhead: 1
snapThreshold: 2
showGhost: 0
ghostVelocityThreshold: 0.1
localGhostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320, type: 2}
remoteGhostMaterial: {fileID: 2100000, guid: 04f0b2088c857414393bab3b80356776, type: 2}
checkGhostsEveryNthFrame: 4
positionInterpolationSpeed: 15
rotationInterpolationSpeed: 10
teleportDistanceMultiplier: 10
reduceSendsWhileIdle: 1
--- !u!114 &813163234907249251
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5646305152014201295}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 87a6103a0a29544ba9f303c8a3b7407c, type: 3}
m_Name:
m_EditorClassIdentifier:
syncDirection: 0
syncMode: 0
syncInterval: 0
force: 10
interval: 3

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 881505c283e224c4fbe4e03127f08b4a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,52 @@
using UnityEngine;
namespace Mirror.Examples.PredictionBenchmark
{
public class RandomForce : NetworkBehaviour
{
public float force = 10;
public float interval = 3;
PredictedRigidbody prediction;
Rigidbody rb => prediction.predictedRigidbody;
void Awake()
{
prediction = GetComponent<PredictedRigidbody>();
}
// every(!) connected client adds force to all objects(!)
// the more clients, the more crazier it gets.
// this is intentional for benchmarks.
public override void OnStartClient()
{
// start at a random time, but repeat at a fixed time
float randomStart = Random.Range(0, interval);
InvokeRepeating(nameof(ApplyForce), randomStart, interval);
}
[ClientCallback]
void ApplyForce()
{
// calculate force in random direction but always upwards
Vector2 direction2D = Random.insideUnitCircle;
Vector3 direction3D = new Vector3(direction2D.x, 1.0f, direction2D.y);
Vector3 impulse = direction3D * force;
// grab the current Rigidbody from PredictedRigidbody.
// sometimes this is on a ghost object, so always grab it live:
// predicted locally and sync to server for others to see.
// PredictedRigidbody will take care of corrections automatically.
rb.AddForce(impulse, ForceMode.Impulse);
CmdApplyForce(impulse);
}
[Command(requiresAuthority = false)] // everyone can call this
void CmdApplyForce(Vector3 impulse)
{
rb.AddForce(impulse, ForceMode.Impulse);
}
}
}

View File

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

View File

@ -0,0 +1,24 @@
Mirror's PredictedRigidbody is optimized for low end devices / VR.
While not interacting with the object, there's zero overhead!
While interacting, overhead comes from sync & corrections.
This benchmark has predicted objects which are constantly synced & corrected.
=> This is not a real world scenario, it's worst case that we can use for profiling!
=> As a Mirror user you don't need to worry about this demo.
# Benchmark Setup
- Unity 2021.3 LTS
- IL2CPP Builds
- M1 Macbook Pro
- vsync disabled in NetworkManagerPredictionBenchmark.cs
# Benchmark Results History for 1000 objects without ghosts:
Not Predicted: 1000 FPS Client, 2500 FPS Server
Predicted:
2024-03-13: 500 FPS Client, 1700 FPS Server
2024-03-13: 580 FPS Client, 1700 FPS Server // micro optimizations
2024-03-14: 590 FPS Client, 1700 FPS Server // UpdateGhosting() every 4th frame
2024-03-14: 615 FPS Client, 1700 FPS Server // predictedRigidbodyTransform.GetPositionAndRotation()
2024-03-15: 625 FPS Client, 1700 FPS Server // Vector3.MoveTowardsCustom()
2024-03-18: 628 FPS Client, 1700 FPS Server // removed O(N) insertion from CorrectHistory()
2024-03-28: 800 FPS Client, 1700 FPS Server // FAST mode prediction

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ef1cc472cf2141baa667b35be391340a
timeCreated: 1710305999

View File

@ -0,0 +1,82 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: WallMaterial
m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0}
m_ValidKeywords:
- _ALPHAPREMULTIPLY_ON
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: 3000
stringTagMap:
RenderType: Transparent
disabledShaderPasses: []
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _BumpScale: 1
- _Cutoff: 0.5
- _DetailNormalMapScale: 1
- _DstBlend: 10
- _GlossMapScale: 1
- _Glossiness: 0.5
- _GlossyReflections: 1
- _Metallic: 0
- _Mode: 3
- _OcclusionStrength: 1
- _Parallax: 0.02
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _UVSec: 0
- _ZWrite: 0
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 0}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
m_BuildTextureStacks: []

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5afe569b0e1434398b94cf6c73e90c89
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -213,7 +213,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
height: 150
offsetY: 40
maxLogCount: 50
showInEditor: 0
hotKey: 293
--- !u!1001 &250045978
PrefabInstance:
@ -746,6 +748,7 @@ GameObject:
- component: {fileID: 1282001519}
- component: {fileID: 1282001521}
- component: {fileID: 1282001522}
- component: {fileID: 1282001523}
m_Layer: 0
m_Name: NetworkManager
m_TagString: Untagged
@ -796,12 +799,14 @@ MonoBehaviour:
m_EditorClassIdentifier:
dontDestroyOnLoad: 1
runInBackground: 1
autoStartServerBuild: 1
autoConnectClientBuild: 0
headlessStartMode: 1
editorAutoStart: 0
sendRate: 120
autoStartServerBuild: 0
autoConnectClientBuild: 0
offlineScene:
onlineScene:
transport: {fileID: 1282001521}
transport: {fileID: 1282001523}
networkAddress: localhost
maxConnections: 2
disconnectInactiveConnections: 0
@ -814,6 +819,7 @@ MonoBehaviour:
spawnPrefabs:
- {fileID: 3429911415116987808, guid: d07e00a439ecd46e79554ec89f65317b, type: 3}
- {fileID: 3429911415116987808, guid: 0100f0c90700741b496ccbc2fe54c196, type: 3}
exceptionsDisconnect: 1
snapshotSettings:
bufferTimeMultiplier: 2
bufferLimit: 32
@ -825,7 +831,8 @@ MonoBehaviour:
dynamicAdjustment: 1
dynamicAdjustmentTolerance: 1
deliveryTimeEmaDuration: 2
connectionQualityInterval: 3
evaluationMethod: 0
evaluationInterval: 3
timeInterpolationGui: 1
--- !u!114 &1282001521
MonoBehaviour:
@ -852,7 +859,7 @@ MonoBehaviour:
MaxRetransmit: 40
MaximizeSocketBuffers: 1
ReliableMaxMessageSize: 297433
UnreliableMaxMessageSize: 1195
UnreliableMaxMessageSize: 1194
debugLog: 0
statisticsGUI: 0
statisticsLog: 0
@ -872,6 +879,24 @@ MonoBehaviour:
padding: 2
width: 180
height: 25
--- !u!114 &1282001523
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1282001517}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 96b149f511061407fb54895c057b7736, type: 3}
m_Name:
m_EditorClassIdentifier:
wrap: {fileID: 1282001521}
latency: 50
jitter: 0.02
jitterSpeed: 1
unreliableLoss: 2
unreliableScramble: 2
--- !u!1001 &1633978772
PrefabInstance:
m_ObjectHideFlags: 0

View File

@ -18,6 +18,7 @@ GameObject:
- component: {fileID: 3539222710066621734}
- component: {fileID: 3539222710066621732}
- component: {fileID: 3539222710066621733}
- component: {fileID: 4431155707644151673}
m_Layer: 0
m_Name: PocketsCollider
m_TagString: Untagged
@ -188,6 +189,18 @@ CapsuleCollider:
m_Height: 1
m_Direction: 2
m_Center: {x: -0.47, y: 0, z: 0}
--- !u!114 &4431155707644151673
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3539222710066621743}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f17c923d118b941fb90a834d87e9ff27, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &3539222711229660005
GameObject:
m_ObjectHideFlags: 0

View File

@ -4,11 +4,15 @@ Mouse drag the white ball to apply force.
Billiards is surprisingly easy to implement, which makes this a great demo for beginners!
Hits are sent to the server with a [Command].
There will always be some latency for the results to show.
Server simulates physics and sends results back to the client.
To solve this, there's another BilliardsPredicted demo which uses prediction & reconciliation.
This demo however is meant for complete beginners to learn Mirror!
While simple, this approach has a major flaw: latency.
The NetworkManager has a LatencySimulation component to see this on your own computer.
Client actions will always feel a bit delayed while waiting for the server.
The solution to this is called Prediction:
https://mirror-networking.gitbook.io/docs/manual/general/client-side-prediction
Notes:
- Red/White ball Rigidbody CollisionMode needs to be ContinousDynamic to avoid white flying through red sometimes.
even 'Continous' is not enough, we need ContinousDynamic.
even 'Continuous' is not enough, we need ContinuousDynamic.

View File

@ -0,0 +1,38 @@
// script to handle the table's pocket collisions for resets / destruction.
// predicted objects sometimes have their rigidbodies moved out of them.
// which is why we handle collisions in the table itself, not per-object.
// because here we can check who the rigidbody belongs to more easily.
// ... that's just the best practice at the moment, maybe we can make this
// easier in the future ...
using UnityEngine;
namespace Mirror.Examples.BilliardsPredicted
{
public class Pockets : MonoBehaviour
{
void OnTriggerEnter(Collider other)
{
if (!NetworkServer.active) return;
// the collider may be on a predicted object or on its ghost object.
// find the source first.
if (PredictedRigidbody.IsPredicted(other, out PredictedRigidbody predicted))
{
// is it a white ball?
if (predicted.TryGetComponent(out WhiteBallPredicted white))
{
Rigidbody rigidBody = predicted.predictedRigidbody;
rigidBody.position = white.startPosition;
rigidBody.velocity = Vector3.zero;
}
// is it a read ball?
if (predicted.GetComponent<RedBallPredicted>())
{
// destroy when entering a pocket.
NetworkServer.Destroy(predicted.gameObject);
}
}
}
}
}

View File

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

View File

@ -1,9 +1,15 @@
using UnityEngine;
namespace Mirror.Examples.BilliardsPredicted
{
// keep the empty script so we can find out what type of ball we collided with.
public class RedBallPredicted : NetworkBehaviour
{
/* ball<->pocket collisions are handled by Pockets.cs for now.
because predicted object's rigidbodies are sometimes moved out of them.
which means this script here wouldn't get the collision info while predicting.
which means it's easier to check collisions from the table perspective.
// destroy when entering a pocket.
// there's only one trigger in the scene (the pocket).
[ServerCallback]
@ -11,5 +17,6 @@ void OnTriggerEnter(Collider other)
{
NetworkServer.Destroy(gameObject);
}
*/
}
}

View File

@ -100,11 +100,10 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 9b91ecbcc199f4492b9a91e820070131, type: 3}
m_Name:
m_EditorClassIdentifier:
clientStarted: 0
sceneId: 0
_assetId: 776221176
serverOnly: 0
visible: 0
visibility: 0
hasSpawned: 0
--- !u!135 &3429911415116987811
SphereCollider:
@ -164,10 +163,26 @@ MonoBehaviour:
m_EditorClassIdentifier:
syncDirection: 0
syncMode: 0
syncInterval: 0.1
syncInterval: 0
predictedRigidbody: {fileID: -177125271246800426}
mode: 1
motionSmoothingVelocityThreshold: 0.1
motionSmoothingAngularVelocityThreshold: 5
motionSmoothingTimeTolerance: 0.5
stateHistoryLimit: 32
correctionThreshold: 0.1
recordInterval: 0.05
onlyRecordChanges: 1
compareLastFirst: 1
positionCorrectionThreshold: 0.1
rotationCorrectionThreshold: 5
oneFrameAhead: 1
correctionMode: 1
ghostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320, type: 2}
lineTime: 10
snapThreshold: 2
showGhost: 1
ghostVelocityThreshold: 0.1
localGhostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320, type: 2}
remoteGhostMaterial: {fileID: 2100000, guid: 04f0b2088c857414393bab3b80356776, type: 2}
checkGhostsEveryNthFrame: 4
positionInterpolationSpeed: 15
rotationInterpolationSpeed: 10
teleportDistanceMultiplier: 10
reduceSendsWhileIdle: 1

View File

@ -1,3 +1,4 @@
using System;
using UnityEngine;
namespace Mirror.Examples.BilliardsPredicted
@ -5,12 +6,15 @@ namespace Mirror.Examples.BilliardsPredicted
public class WhiteBallPredicted : NetworkBehaviour
{
public LineRenderer dragIndicator;
public float dragTolerance = 1.0f;
public Rigidbody rigidBody;
public float forceMultiplier = 2;
public float maxForce = 40;
// remember start position to reset to after entering a pocket
Vector3 startPosition;
internal Vector3 startPosition;
bool draggingStartedOverObject;
// cast mouse position on screen to world position
bool MouseToWorld(out Vector3 position)
@ -31,6 +35,77 @@ void Awake()
startPosition = transform.position;
}
[ClientCallback]
void Update()
{
// mouse down on the white ball?
if (Input.GetMouseButtonDown(0))
{
if (MouseToWorld(out Vector3 position))
{
// allow dragging if mouse is 'close enough'.
// balls are moving so we don't need to be exactly on it.
float distance = Vector3.Distance(position, transform.position);
if (distance <= dragTolerance)
{
// enable drag indicator
dragIndicator.SetPosition(0, transform.position);
dragIndicator.SetPosition(1, transform.position);
dragIndicator.gameObject.SetActive(true);
draggingStartedOverObject = true;
}
}
}
// mouse button dragging?
else if (Input.GetMouseButton(0))
{
// cast mouse position to world
if (draggingStartedOverObject && MouseToWorld(out Vector3 current))
{
// drag indicator
dragIndicator.SetPosition(0, transform.position);
dragIndicator.SetPosition(1, current);
}
}
// mouse button up?
else if (Input.GetMouseButtonUp(0))
{
// cast mouse position to world
if (draggingStartedOverObject && MouseToWorld(out Vector3 current))
{
// calculate delta from ball to mouse
// ball may have moved since we started dragging,
// so always use current ball position here.
Vector3 from = transform.position;
// debug drawing: only works if Gizmos are enabled!
Debug.DrawLine(from, current, Color.white, 2);
// calculate pending force delta
Vector3 delta = from - current;
Vector3 force = delta * forceMultiplier;
// there should be a maximum allowed force
force = Vector3.ClampMagnitude(force, maxForce);
// forward the event to the local player's object.
// the ball isn't part of the local player.
NetworkClient.localPlayer.GetComponent<PlayerPredicted>().OnDraggedBall(force);
// disable drag indicator
dragIndicator.gameObject.SetActive(false);
}
draggingStartedOverObject = false;
}
}
// OnMouse callbacks don't work for predicted objects because we need to
// move the collider out of the main object ocassionally.
// besides, having a drag tolerance and not having to click exactly on
// the white ball is nice.
/*
[ClientCallback]
void OnMouseDown()
{
@ -79,7 +154,12 @@ void OnMouseUp()
// disable drag indicator
dragIndicator.gameObject.SetActive(false);
}
*/
/* ball<->pocket collisions are handled by Pockets.cs for now.
because predicted object's rigidbodies are sometimes moved out of them.
which means this script here wouldn't get the collision info while predicting.
which means it's easier to check collisions from the table perspective.
// reset position when entering a pocket.
// there's only one trigger in the scene (the pocket).
[ServerCallback]
@ -89,6 +169,7 @@ void OnTriggerEnter(Collider other)
rigidBody.Sleep(); // reset forces
// GetComponent<NetworkRigidbodyUnreliable>().RpcTeleport(startPosition);
}
*/
[ClientCallback]
void OnGUI()

View File

@ -232,11 +232,10 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 9b91ecbcc199f4492b9a91e820070131, type: 3}
m_Name:
m_EditorClassIdentifier:
clientStarted: 0
sceneId: 0
_assetId: 2140274599
serverOnly: 0
visible: 0
visibility: 0
hasSpawned: 0
--- !u!135 &3429911415116987811
SphereCollider:
@ -283,6 +282,7 @@ MonoBehaviour:
syncMode: 0
syncInterval: 0
dragIndicator: {fileID: 982362982}
dragTolerance: 1
rigidBody: {fileID: 1848203816128897140}
forceMultiplier: 2
maxForce: 40
@ -300,10 +300,26 @@ MonoBehaviour:
m_EditorClassIdentifier:
syncDirection: 0
syncMode: 0
syncInterval: 0.1
syncInterval: 0
predictedRigidbody: {fileID: 1848203816128897140}
mode: 1
motionSmoothingVelocityThreshold: 0.1
motionSmoothingAngularVelocityThreshold: 5
motionSmoothingTimeTolerance: 0.5
stateHistoryLimit: 32
correctionThreshold: 0.1
recordInterval: 0.05
onlyRecordChanges: 1
compareLastFirst: 1
positionCorrectionThreshold: 0.1
rotationCorrectionThreshold: 5
oneFrameAhead: 1
correctionMode: 1
ghostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320, type: 2}
lineTime: 10
snapThreshold: 2
showGhost: 1
ghostVelocityThreshold: 0.1
localGhostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320, type: 2}
remoteGhostMaterial: {fileID: 2100000, guid: 04f0b2088c857414393bab3b80356776, type: 2}
checkGhostsEveryNthFrame: 4
positionInterpolationSpeed: 15
rotationInterpolationSpeed: 10
teleportDistanceMultiplier: 10
reduceSendsWhileIdle: 1

View File

@ -23,14 +23,10 @@ public class PlayerPredicted : NetworkBehaviour
// white ball component
WhiteBallPredicted whiteBall;
// keep a history of inputs with timestamp
public int inputHistorySize = 64;
readonly SortedList<double, PlayerInput> inputs = new SortedList<double, PlayerInput>();
void Awake()
{
// find the white ball once
#if UNITY_2021_3_OR_NEWER
#if UNITY_2022_2_OR_NEWER
whiteBall = FindAnyObjectByType<WhiteBallPredicted>();
#else
// Deprecated in Unity 2023.1
@ -45,29 +41,29 @@ void ApplyForceToWhite(Vector3 force)
// https://docs.unity3d.com/2021.3/Documentation/ScriptReference/Rigidbody.AddForce.html
// this is buffered until the next FixedUpdate.
// get the white ball's Rigidbody.
// prediction sometimes moves this out of the object for a while,
// so we need to grab it this way:
Rigidbody rb = whiteBall.GetComponent<PredictedRigidbody>().predictedRigidbody;
// AddForce has different force modes, see this excellent diagram:
// https://www.reddit.com/r/Unity3D/comments/psukm1/know_the_difference_between_forcemodes_a_little/
// for prediction it's extremely important(!) to apply the correct mode:
// 'Force' makes server & client drift significantly here
// 'Impulse' is correct usage with significantly less drift
whiteBall.GetComponent<Rigidbody>().AddForce(force, ForceMode.Impulse);
rb.AddForce(force, ForceMode.Impulse);
}
// called when the local player dragged the white ball.
// we reuse the white ball's OnMouseDrag and forward the event to here.
public void OnDraggedBall(Vector3 force)
{
// record the input for reconciliation if needed
if (inputs.Count >= inputHistorySize) inputs.RemoveAt(0);
inputs.Add(NetworkTime.time, new PlayerInput(NetworkTime.time, force));
Debug.Log($"Inputs.Count={inputs.Count}");
// apply force locally immediately
ApplyForceToWhite(force);
// apply on server as well.
// not necessary in host mode, otherwise we would apply it twice.
if (!isServer) CmdApplyForce(force, NetworkTime.predictedTime);
if (!isServer) CmdApplyForce(force);
}
// while prediction is applied on clients immediately,
@ -80,7 +76,7 @@ public void OnDraggedBall(Vector3 force)
// TODO send over unreliable with ack, notify, etc. later
[Command]
void CmdApplyForce(Vector3 force, double predictedTime)
void CmdApplyForce(Vector3 force)
{
if (!IsValidMove(force))
{
@ -88,30 +84,6 @@ void CmdApplyForce(Vector3 force, double predictedTime)
return;
}
// client is on a predicted timeline.
// double check the prediction - it should arrive at server time.
//
// there are multiple reasons why this may be off:
// - time prediction may still be adjusting itself
// - time prediction may have an issue
// - server or client may be lagging or under heavy load temporarily
// - unreliable vs. reliable channel latencies are signifcantly different
// for example, if latency simulation is only applied to one channel!
double delta = NetworkTime.time - predictedTime;
if (delta < -0.010)
{
Debug.LogWarning($"Cmd predictedTime was {(delta*1000):F0}ms behind the server time. This could occasionally happen if the time prediction is off. If it happens consistently, check that unreliable NetworkTime and reliable [Command]s have the same latency. If they are off, this will cause heavy jitter.");
}
else if (delta > 0.010)
{
// TODO consider buffering inputs which are ahead, apply next frame
Debug.LogWarning($"Cmd predictedTime was {(delta*1000):F0}ms ahead of the server time. This could occasionally happen if the time prediction is off. If it happens consistently, check that unreliable NetworkTime and reliable [Command]s have the same latency. If they are off, this will cause heavy jitter. If reliable & unreliable latency are similar and this still happens a lot, consider buffering inputs for the next frame.");
}
else
{
Debug.Log($"Cmd predictedTime was {(delta*1000):F0}ms close to the server time.");
}
// apply force
ApplyForceToWhite(force);
}

View File

@ -1,12 +1,18 @@
Advanced multiplayer Billiards demo with Prediction.
Please read this first:
https://mirror-networking.gitbook.io/docs/manual/general/client-side-prediction
Mouse drag the white ball to apply force.
PredictedRigidbody syncInterval is intentionally set pretty high so we can see when it corrects.
If you are a beginner, start with the basic Billiards demo instead.
If you are advanced, this demo shows how to use Mirror's prediction features for physics / FPS games.
The demo is work in progress.
At the moment, this is only for the Mirror team to test individual prediction features!
Billiards is a great example to try our Prediction algorithm, it works extremely well here!
=> We use 'Fast' Prediction mode for Billiards because we want to see exact collisions with balls/walls.
=> 'Smooth' mode would look too soft, with balls changing direction even before touching other balls/walls.
Notes:
- Red/White ball Rigidbody CollisionMode needs to be ContinousDynamic to avoid white flying through red sometimes.

View File

@ -83,7 +83,7 @@ public override void OnStartAuthority()
characterController.enabled = true;
this.enabled = true;
#if UNITY_2021_3_OR_NEWER
#if UNITY_2022_2_OR_NEWER
sceneReferencer = GameObject.FindAnyObjectByType<SceneReferencer>();
#else
// Deprecated in Unity 2023.1

View File

@ -10,7 +10,7 @@ public class PlayerEmpty : NetworkBehaviour
public override void OnStartAuthority()
{
// enable UI located in the scene, after empty player spawns in.
#if UNITY_2021_3_OR_NEWER
#if UNITY_2022_2_OR_NEWER
sceneReferencer = GameObject.FindAnyObjectByType<SceneReferencer>();
#else
// Deprecated in Unity 2023.1

View File

@ -26,7 +26,7 @@ public override void OnStartAuthority()
if (isOwned)
{
#if UNITY_2021_3_OR_NEWER
#if UNITY_2022_2_OR_NEWER
couchPlayerManager = GameObject.FindAnyObjectByType<CouchPlayerManager>();
#else
// Deprecated in Unity 2023.1

View File

@ -23,7 +23,7 @@ public class CouchPlayerManager : NetworkBehaviour
public override void OnStartAuthority()
{
// hook up UI to local player, for cmd communication
#if UNITY_2021_3_OR_NEWER
#if UNITY_2022_2_OR_NEWER
canvasScript = GameObject.FindAnyObjectByType<CanvasScript>();
#else
// Deprecated in Unity 2023.1

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5dbbfee253d4c6e4d915cb88674ec680
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ebc1436948da70b4abbf74f58106c318
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,301 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &1861598604008055398
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1861598604008055397}
- component: {fileID: 1861598604008055395}
- component: {fileID: 1861598604008055396}
m_Layer: 5
m_Name: PlayerCount
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1861598604008055397
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604008055398}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1861598604614510503}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.6, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0.000030517578}
m_SizeDelta: {x: -30, y: 0}
m_Pivot: {x: 0, y: 0.5}
--- !u!222 &1861598604008055395
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604008055398}
m_CullTransparentMesh: 1
--- !u!114 &1861598604008055396
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604008055398}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
m_FontSize: 46
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 4
m_MaxSize: 46
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: 1/5
--- !u!1 &1861598604367147692
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1861598604367147691}
- component: {fileID: 1861598604367147689}
- component: {fileID: 1861598604367147690}
m_Layer: 5
m_Name: Name
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1861598604367147691
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604367147692}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1861598604614510503}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0.6, y: 1}
m_AnchoredPosition: {x: 30, y: 0.000030517578}
m_SizeDelta: {x: -30, y: 0}
m_Pivot: {x: 0, y: 0.5}
--- !u!222 &1861598604367147689
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604367147692}
m_CullTransparentMesh: 1
--- !u!114 &1861598604367147690
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604367147692}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
m_FontSize: 46
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 4
m_MaxSize: 46
m_Alignment: 3
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: A long lobby name
--- !u!1 &1861598604614510488
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1861598604614510503}
- component: {fileID: 1861598604614510500}
- component: {fileID: 1861598604614510501}
- component: {fileID: 1861598604614510502}
- component: {fileID: 827505188}
m_Layer: 5
m_Name: LobbyUIEntry
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1861598604614510503
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604614510488}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1861598604367147691}
- {fileID: 1861598604008055397}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1861598604614510500
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604614510488}
m_CullTransparentMesh: 1
--- !u!114 &1861598604614510501
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604614510488}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0.392}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10907, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &1861598604614510502
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604614510488}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1861598604614510501}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &827505188
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1861598604614510488}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9bd3228b44c0a7e478964d95c512cebf, type: 3}
m_Name:
m_EditorClassIdentifier:
JoinButton: {fileID: 1861598604614510502}
Name: {fileID: 1861598604367147690}
PlayerCount: {fileID: 1861598604008055396}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9ad36d24bb59d094dbf84bf5bbbdd1c6
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,54 @@
using Edgegap;
using UnityEngine;
using UnityEngine.UI;
namespace Mirror.Examples.EdgegapLobby
{
public class UILobbyCreate : MonoBehaviour
{
public UILobbyList List;
public Button CancelButton;
public InputField LobbyName;
public Text SlotCount;
public Slider SlotSlider;
public Button HostButton;
public Button ServerButton;
private EdgegapLobbyKcpTransport _transport => (EdgegapLobbyKcpTransport)NetworkManager.singleton.transport;
private void Awake()
{
ValidateName();
LobbyName.onValueChanged.AddListener(_ =>
{
ValidateName();
});
CancelButton.onClick.AddListener(() =>
{
List.gameObject.SetActive(true);
gameObject.SetActive(false);
});
SlotSlider.onValueChanged.AddListener(arg0 =>
{
SlotCount.text = ((int)arg0).ToString();
});
HostButton.onClick.AddListener(() =>
{
gameObject.SetActive(false);
_transport.SetServerLobbyParams(LobbyName.text, (int)SlotSlider.value);
NetworkManager.singleton.StartHost();
});
ServerButton.onClick.AddListener(() =>
{
gameObject.SetActive(false);
_transport.SetServerLobbyParams(LobbyName.text, (int)SlotSlider.value);
NetworkManager.singleton.StartServer();
});
}
void ValidateName()
{
bool valid = !string.IsNullOrWhiteSpace(LobbyName.text);
HostButton.interactable = valid;
ServerButton.interactable = valid;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6d48c41753254160ac6a02c9585880f0
timeCreated: 1709967491

View File

@ -0,0 +1,37 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Edgegap;
using UnityEngine;
using UnityEngine.UI;
namespace Mirror.Examples.EdgegapLobby
{
public class UILobbyEntry : MonoBehaviour
{
public Button JoinButton;
public Text Name;
public Text PlayerCount;
private LobbyBrief _lobby;
private UILobbyList _list;
private void Awake()
{
JoinButton.onClick.AddListener(() =>
{
_list.Join(_lobby);
});
}
public void Init(UILobbyList list, LobbyBrief lobby, bool active = true)
{
gameObject.SetActive(active && lobby.is_joinable);
JoinButton.interactable = lobby.available_slots > 0;
_list = list;
_lobby = lobby;
Name.text = lobby.name;
PlayerCount.text = $"{lobby.player_count}/{lobby.capacity}";
}
}
}

View File

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

View File

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Edgegap;
using UnityEngine;
using UnityEngine.UI;
namespace Mirror.Examples.EdgegapLobby
{
public class UILobbyList : MonoBehaviour
{
public UILobbyCreate Create;
public GameObject EntryPrefab;
public Transform LobbyContent;
public GameObject Loading;
public Button RefreshButton;
public InputField SearchInput;
public Button CreateButton;
public Text Error;
private List<UILobbyEntry> _entries = new List<UILobbyEntry>();
private EdgegapLobbyKcpTransport _transport => (EdgegapLobbyKcpTransport)NetworkManager.singleton.transport;
private void Awake()
{
SearchInput.onValueChanged.AddListener(arg0 =>
{
SetLobbies(_transport.Api.Lobbies);
});
RefreshButton.onClick.AddListener(Refresh);
CreateButton.onClick.AddListener(() =>
{
Create.gameObject.SetActive(true);
gameObject.SetActive(false);
});
}
public void Start()
{
Refresh();
}
private void Refresh()
{
Loading.SetActive(true);
_transport.Api.RefreshLobbies(SetLobbies, s =>
{
Error.text = s;
Loading.SetActive(false);
});
}
public void Join(LobbyBrief lobby)
{
NetworkManager.singleton.networkAddress = lobby.lobby_id;
NetworkManager.singleton.StartClient();
}
public void SetLobbies(LobbyBrief[] lobbies)
{
Loading.SetActive(false);
Error.text = "";
// Create enough entries
for (int i = _entries.Count; i < lobbies.Length; i++)
{
var go = Instantiate(EntryPrefab, LobbyContent);
_entries.Add(go.GetComponent<UILobbyEntry>());
}
// Update entries
var searchText = SearchInput.text;
for (int i = 0; i < lobbies.Length; i++)
{
_entries[i].Init(
this,
lobbies[i],
// search filter
searchText.Length == 0 ||
#if UNITY_2021_3_OR_NEWER
lobbies[i].name.Contains(searchText, StringComparison.InvariantCultureIgnoreCase)
#else
lobbies[i].name.IndexOf(searchText, StringComparison.InvariantCultureIgnoreCase) >= 0
#endif
);
}
// hide entries that are too many
for (int i = lobbies.Length; i < _entries.Count; i++)
{
_entries[i].gameObject.SetActive(false);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c5e5ad322f314077a66f889b58485188
timeCreated: 1709962378

View File

@ -0,0 +1,121 @@
using System;
using Edgegap;
using UnityEngine;
using UnityEngine.UI;
namespace Mirror.Examples.EdgegapLobby
{
public class UILobbyStatus : MonoBehaviour
{
public GameObject[] ShowDisconnected;
public GameObject[] ShowServer;
public GameObject[] ShowHost;
public GameObject[] ShowClient;
public Button StopServer;
public Button StopHost;
public Button StopClient;
public Text StatusText;
private Status _status;
private EdgegapLobbyKcpTransport _transport;
enum Status
{
Offline,
Server,
Host,
Client
}
void Awake()
{
Refresh();
StopServer.onClick.AddListener(() =>
{
NetworkManager.singleton.StopServer();
});
StopHost.onClick.AddListener(() =>
{
NetworkManager.singleton.StopHost();
});
StopClient.onClick.AddListener(() =>
{
NetworkManager.singleton.StopClient();
});
}
private void Start()
{
_transport = (EdgegapLobbyKcpTransport)NetworkManager.singleton.transport;
}
private void Update()
{
var status = GetStatus();
if (_status != status)
{
_status = status;
Refresh();
}
if (_transport)
{
StatusText.text = _transport.Status.ToString();
}
else
{
StatusText.text = "";
}
}
private void Refresh()
{
switch (_status)
{
case Status.Offline:
SetUI(ShowServer, false);
SetUI(ShowHost, false);
SetUI(ShowClient, false);
SetUI(ShowDisconnected, true);
break;
case Status.Server:
SetUI(ShowDisconnected, false);
SetUI(ShowHost, false);
SetUI(ShowClient, false);
SetUI(ShowServer, true);
break;
case Status.Host:
SetUI(ShowDisconnected, false);
SetUI(ShowServer, false);
SetUI(ShowClient, false);
SetUI(ShowHost, true);
break;
case Status.Client:
SetUI(ShowDisconnected, false);
SetUI(ShowServer, false);
SetUI(ShowHost, false);
SetUI(ShowClient, true);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
private void SetUI(GameObject[] gos, bool active)
{
foreach (GameObject go in gos)
{
go.SetActive(active);
}
}
private Status GetStatus()
{
if (NetworkServer.active && NetworkClient.active)
{
return Status.Host;
}
if (NetworkServer.active)
{
return Status.Server;
}
if (NetworkClient.active)
{
return Status.Client;
}
return Status.Offline;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 44d2f1170bbe4432bf6f388bcfabefee
timeCreated: 1710138272

View File

@ -0,0 +1,11 @@
Docs: https://mirror-networking.gitbook.io/docs/manual/examples/edgegap-lobby
This is a copy of the Tanks example (basic scene with player controlled tanks),
but with a lobby ui for using Edgegap's Lobby and Relay service.
It showcases how one might interact with the EdgegapLobbyKcpTransport to list, join and create lobbies.
Providing a good starting point for anyone wanting to use Edgegap lobbies.
# Setup
As this example uses external services from Edgegap you will need to set up the transport
on the NetworkManager gameobject before you can use it.
Please see the EdgegapLobbyKcpTransport Setup instructions on how to do that:
https://mirror-networking.gitbook.io/docs/manual/transports/edgegap-relay-transport#setup

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a6c3a72e7e659a7459a3ba3adb15b2e0
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -33,7 +33,7 @@ public class MatchController : NetworkBehaviour
void Awake()
{
#if UNITY_2021_3_OR_NEWER
#if UNITY_2022_2_OR_NEWER
canvasController = GameObject.FindAnyObjectByType<CanvasController>();
#else
// Deprecated in Unity 2023.1
@ -58,7 +58,9 @@ IEnumerator AddPlayersToMatchController()
public override void OnStartClient()
{
matchPlayerData.Callback += UpdateWins;
#pragma warning disable CS0618 // Type or member is obsolete
matchPlayerData.Callback = UpdateWins;
#pragma warning restore CS0618 // Type or member is obsolete
canvasGroup.alpha = 1f;
canvasGroup.interactable = true;

Some files were not shown because too many files have changed in this diff Show More