mirror of
https://github.com/MirrorNetworking/Mirror.git
synced 2024-11-17 18:40:33 +00:00
Merged master
This commit is contained in:
commit
83fd6cbc20
6
.github/workflows/RunUnityTests.yml
vendored
6
.github/workflows/RunUnityTests.yml
vendored
@ -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
|
||||
|
2
.github/workflows/Semantic.yml
vendored
2
.github/workflows/Semantic.yml
vendored
@ -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'
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
196
Assets/Mirror/Components/LagCompensation/LagCompensator.cs
Normal file
196
Assets/Mirror/Components/LagCompensation/LagCompensator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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:
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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?
|
||||
|
@ -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,6 +115,29 @@ void UpdateServerBroadcast()
|
||||
// send snapshot without timestamp.
|
||||
// receiver gets it from batch timestamp to save bandwidth.
|
||||
TransformSnapshot snapshot = Construct();
|
||||
|
||||
if (changedDetection)
|
||||
{
|
||||
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; }
|
||||
|
||||
@ -138,7 +167,16 @@ void UpdateServerBroadcast()
|
||||
else
|
||||
{
|
||||
hasSentUnchangedPosition = false;
|
||||
lastSnapshot = snapshot;
|
||||
|
||||
// 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,6 +243,29 @@ void UpdateClientBroadcast()
|
||||
// send snapshot without timestamp.
|
||||
// receiver gets it from batch timestamp to save bandwidth.
|
||||
TransformSnapshot snapshot = Construct();
|
||||
|
||||
if (changedDetection)
|
||||
{
|
||||
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
|
||||
{
|
||||
cachedSnapshotComparison = CompareSnapshots(snapshot);
|
||||
if (cachedSnapshotComparison && hasSentUnchangedPosition && onlySyncOnChange) { return; }
|
||||
|
||||
@ -234,7 +295,15 @@ void UpdateClientBroadcast()
|
||||
else
|
||||
{
|
||||
hasSentUnchangedPosition = false;
|
||||
lastSnapshot = snapshot;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
156
Assets/Mirror/Components/NetworkTransform/TransformSyncData.cs
Normal file
156
Assets/Mirror/Components/NetworkTransform/TransformSyncData.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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:
|
@ -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
|
||||
// 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!
|
||||
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)
|
||||
{
|
||||
// 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.");
|
||||
ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f595f112a39e4634b670d56991b23823
|
||||
timeCreated: 1710387026
|
@ -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;
|
||||
|
||||
// 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;
|
||||
|
@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -10,3 +10,4 @@
|
||||
[assembly: InternalsVisibleTo("Mirror.Tests.Performance.Editor")]
|
||||
[assembly: InternalsVisibleTo("Mirror.Tests.Performance.Runtime")]
|
||||
[assembly: InternalsVisibleTo("Mirror.Editor")]
|
||||
[assembly: InternalsVisibleTo("Mirror.Components")]
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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.");
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
@ -141,6 +141,11 @@ internal static void UpdateClient()
|
||||
{
|
||||
// localTime (double) instead of Time.time for accuracy over days
|
||||
if (localTime >= lastPingTime + PingInterval)
|
||||
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.
|
||||
@ -152,7 +157,6 @@ internal static void UpdateClient()
|
||||
NetworkClient.Send(pingMessage, Channels.Unreliable);
|
||||
lastPingTime = localTime;
|
||||
}
|
||||
}
|
||||
|
||||
// client rtt calculation //////////////////////////////////////////////
|
||||
// executed at the server when we receive a ping message
|
||||
|
@ -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) {
|
||||
// exact match?
|
||||
if (timestamp == entry.Key)
|
||||
|
||||
// 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)
|
||||
{
|
||||
before = entry.Value;
|
||||
after = entry.Value;
|
||||
double key = history.Keys[i];
|
||||
T value = history.Values[i];
|
||||
|
||||
// exact match?
|
||||
if (timestamp == key)
|
||||
{
|
||||
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,8 +99,9 @@ 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
|
||||
@ -99,11 +111,21 @@ public static T CorrectHistory<T>(
|
||||
{
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
stateHistory[corrected.timestamp] = corrected;
|
||||
// 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).
|
||||
@ -138,33 +160,32 @@ public static T CorrectHistory<T>(
|
||||
// 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.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.
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
{
|
||||
@ -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,9 +440,8 @@ public Enumerator(SyncList<T> list)
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (++index >= list.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Current = list[index];
|
||||
return true;
|
||||
}
|
||||
|
@ -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,13 +297,9 @@ 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,33 +317,21 @@ 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>
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
14
Assets/Mirror/Editor/LagCompensatorInspector.cs
Normal file
14
Assets/Mirror/Editor/LagCompensatorInspector.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
11
Assets/Mirror/Editor/LagCompensatorInspector.cs.meta
Normal file
11
Assets/Mirror/Editor/LagCompensatorInspector.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 703e39b5385ae2e479987ff4ec0707a1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -3,6 +3,7 @@
|
||||
"rootNamespace": "",
|
||||
"references": [
|
||||
"GUID:30817c1a0e6d646d99c048fc403f5979",
|
||||
"GUID:72872094b21c16e48b631b2224833d49",
|
||||
"GUID:1d0b9d21c3ff546a4aa32399dfd33474"
|
||||
],
|
||||
"includePlatforms": [
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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!
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
|
8
Assets/Mirror/Examples/BenchmarkPrediction.meta
Normal file
8
Assets/Mirror/Examples/BenchmarkPrediction.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e90270b475f740d69548d4ed4ef5f7a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
80
Assets/Mirror/Examples/BenchmarkPrediction/BallMaterial.mat
Normal file
80
Assets/Mirror/Examples/BenchmarkPrediction/BallMaterial.mat
Normal 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: []
|
@ -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
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26e96d86a94c2451d85dcabf4aff3551
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f96c236d30fd94a75a172a7642242637
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -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
|
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: feea51e51b4564f06a38482bbebac8fa
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
190
Assets/Mirror/Examples/BenchmarkPrediction/PredictedBall.prefab
Normal file
190
Assets/Mirror/Examples/BenchmarkPrediction/PredictedBall.prefab
Normal 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
|
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 881505c283e224c4fbe4e03127f08b4a
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
52
Assets/Mirror/Examples/BenchmarkPrediction/RandomForce.cs
Normal file
52
Assets/Mirror/Examples/BenchmarkPrediction/RandomForce.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87a6103a0a29544ba9f303c8a3b7407c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
24
Assets/Mirror/Examples/BenchmarkPrediction/Readme.md
Normal file
24
Assets/Mirror/Examples/BenchmarkPrediction/Readme.md
Normal 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
|
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef1cc472cf2141baa667b35be391340a
|
||||
timeCreated: 1710305999
|
82
Assets/Mirror/Examples/BenchmarkPrediction/WallMaterial.mat
Normal file
82
Assets/Mirror/Examples/BenchmarkPrediction/WallMaterial.mat
Normal 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: []
|
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5afe569b0e1434398b94cf6c73e90c89
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
38
Assets/Mirror/Examples/BilliardsPredicted/Ball/Pockets.cs
Normal file
38
Assets/Mirror/Examples/BilliardsPredicted/Ball/Pockets.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f17c923d118b941fb90a834d87e9ff27
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -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);
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
8
Assets/Mirror/Examples/EdgegapLobby.meta
Normal file
8
Assets/Mirror/Examples/EdgegapLobby.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8befc60066f3f148ab1ab4120064045
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
1104
Assets/Mirror/Examples/EdgegapLobby/EdgegapLobbyTanks.unity
Normal file
1104
Assets/Mirror/Examples/EdgegapLobby/EdgegapLobbyTanks.unity
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5dbbfee253d4c6e4d915cb88674ec680
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
8
Assets/Mirror/Examples/EdgegapLobby/Prefabs.meta
Normal file
8
Assets/Mirror/Examples/EdgegapLobby/Prefabs.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 776ba2248d912ad4b839e39448ad4a9c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
4738
Assets/Mirror/Examples/EdgegapLobby/Prefabs/LobbyUI.prefab
Normal file
4738
Assets/Mirror/Examples/EdgegapLobby/Prefabs/LobbyUI.prefab
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebc1436948da70b4abbf74f58106c318
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
301
Assets/Mirror/Examples/EdgegapLobby/Prefabs/LobbyUIEntry.prefab
Normal file
301
Assets/Mirror/Examples/EdgegapLobby/Prefabs/LobbyUIEntry.prefab
Normal 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}
|
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ad36d24bb59d094dbf84bf5bbbdd1c6
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
8
Assets/Mirror/Examples/EdgegapLobby/Scripts.meta
Normal file
8
Assets/Mirror/Examples/EdgegapLobby/Scripts.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db78997560ea9e94fafeeca27eb3e4f0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
54
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyCreate.cs
Normal file
54
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyCreate.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d48c41753254160ac6a02c9585880f0
|
||||
timeCreated: 1709967491
|
37
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyEntry.cs
Normal file
37
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyEntry.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9bd3228b44c0a7e478964d95c512cebf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
91
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyList.cs
Normal file
91
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyList.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5e5ad322f314077a66f889b58485188
|
||||
timeCreated: 1709962378
|
121
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyStatus.cs
Normal file
121
Assets/Mirror/Examples/EdgegapLobby/Scripts/UILobbyStatus.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44d2f1170bbe4432bf6f388bcfabefee
|
||||
timeCreated: 1710138272
|
11
Assets/Mirror/Examples/EdgegapLobby/_ReadMe.txt
Normal file
11
Assets/Mirror/Examples/EdgegapLobby/_ReadMe.txt
Normal 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
|
7
Assets/Mirror/Examples/EdgegapLobby/_ReadMe.txt.meta
Normal file
7
Assets/Mirror/Examples/EdgegapLobby/_ReadMe.txt.meta
Normal file
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6c3a72e7e659a7459a3ba3adb15b2e0
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
@ -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
Loading…
Reference in New Issue
Block a user