diff --git a/Assets/Mirror/Components/InterestManagement/Distance/DistanceInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/Distance/DistanceInterestManagement.cs
index 044155877..654996191 100644
--- a/Assets/Mirror/Components/InterestManagement/Distance/DistanceInterestManagement.cs
+++ b/Assets/Mirror/Components/InterestManagement/Distance/DistanceInterestManagement.cs
@@ -25,7 +25,7 @@ int GetVisRange(NetworkIdentity identity)
}
[ServerCallback]
- public override void Reset()
+ public override void ResetState()
{
lastRebuildTime = 0D;
CustomRanges.Clear();
diff --git a/Assets/Mirror/Components/InterestManagement/Match/NetworkMatch.cs b/Assets/Mirror/Components/InterestManagement/Match/NetworkMatch.cs
index 970c9a1cc..0d55fd015 100644
--- a/Assets/Mirror/Components/InterestManagement/Match/NetworkMatch.cs
+++ b/Assets/Mirror/Components/InterestManagement/Match/NetworkMatch.cs
@@ -11,6 +11,12 @@ public class NetworkMatch : NetworkBehaviour
{
Guid _matchId;
+#pragma warning disable IDE0052 // Suppress warning for unused field...this is for debugging purposes
+ [SerializeField, ReadOnly]
+ [Tooltip("Match ID is shown here on server for debugging purposes.")]
+ string MatchID = string.Empty;
+#pragma warning restore IDE0052
+
///Set this to the same value on all networked objects that belong to a given match
public Guid matchId
{
@@ -25,6 +31,7 @@ public Guid matchId
Guid oldMatch = _matchId;
_matchId = value;
+ MatchID = value.ToString();
// Only inform the AOI if this netIdentity has been spawned (isServer) and only if using a MatchInterestManagement
if (isServer && NetworkServer.aoi is MatchInterestManagement matchInterestManagement)
diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs
index 298603497..066a1c5ee 100644
--- a/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs
+++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs
@@ -72,7 +72,7 @@ public override void OnRebuildObservers(NetworkIdentity identity, HashSetserver and server->client
@@ -367,7 +367,7 @@ protected virtual void OnTeleport(Vector3 destination, Quaternion rotation)
// -> maybe add destination as first entry?
}
- public virtual void Reset()
+ public virtual void ResetState()
{
// disabled objects aren't updated anymore.
// so let's clear the buffers.
@@ -375,9 +375,16 @@ public virtual void Reset()
clientSnapshots.Clear();
}
+ public virtual void Reset()
+ {
+ ResetState();
+ // default to ClientToServer so this works immediately for users
+ syncDirection = SyncDirection.ClientToServer;
+ }
+
protected virtual void OnEnable()
{
- Reset();
+ ResetState();
if (NetworkServer.active)
NetworkIdentity.clientAuthorityCallback += OnClientAuthorityChanged;
@@ -385,7 +392,7 @@ protected virtual void OnEnable()
protected virtual void OnDisable()
{
- Reset();
+ ResetState();
if (NetworkServer.active)
NetworkIdentity.clientAuthorityCallback -= OnClientAuthorityChanged;
@@ -403,8 +410,8 @@ void OnClientAuthorityChanged(NetworkConnectionToClient conn, NetworkIdentity id
if (syncDirection == SyncDirection.ClientToServer)
{
- Reset();
- RpcReset();
+ ResetState();
+ RpcResetState();
}
}
diff --git a/Assets/Mirror/Components/NetworkTransform/NetworkTransformReliable.cs b/Assets/Mirror/Components/NetworkTransform/NetworkTransformReliable.cs
index e24bb9d0c..f6e851bf0 100644
--- a/Assets/Mirror/Components/NetworkTransform/NetworkTransformReliable.cs
+++ b/Assets/Mirror/Components/NetworkTransform/NetworkTransformReliable.cs
@@ -402,9 +402,9 @@ static void RewriteHistory(
// reset state for next session.
// do not ever call this during a session (i.e. after teleport).
// calling this will break delta compression.
- public override void Reset()
+ public override void ResetState()
{
- base.Reset();
+ base.ResetState();
// reset delta
lastSerializedPosition = Vector3Long.zero;
diff --git a/Assets/Mirror/Components/NetworkTransform/NetworkTransformUnreliable.cs b/Assets/Mirror/Components/NetworkTransform/NetworkTransformUnreliable.cs
index 1c23b0e0a..677904d4b 100644
--- a/Assets/Mirror/Components/NetworkTransform/NetworkTransformUnreliable.cs
+++ b/Assets/Mirror/Components/NetworkTransform/NetworkTransformUnreliable.cs
@@ -346,7 +346,7 @@ protected virtual void OnClientToServerSync(Vector3? position, Quaternion? rotat
double timeIntervalCheck = bufferResetMultiplier * sendIntervalMultiplier * NetworkClient.sendInterval;
if (serverSnapshots.Count > 0 && serverSnapshots.Values[serverSnapshots.Count - 1].remoteTime + timeIntervalCheck < timestamp)
- Reset();
+ ResetState();
}
AddSnapshot(serverSnapshots, connectionToClient.remoteTimeStamp + timeStampAdjustment + offset, position, rotation, scale);
@@ -401,7 +401,7 @@ protected virtual void OnServerToClientSync(Vector3? position, Quaternion? rotat
double timeIntervalCheck = bufferResetMultiplier * sendIntervalMultiplier * NetworkServer.sendInterval;
if (clientSnapshots.Count > 0 && clientSnapshots.Values[clientSnapshots.Count - 1].remoteTime + timeIntervalCheck < timestamp)
- Reset();
+ ResetState();
}
AddSnapshot(clientSnapshots, NetworkClient.connection.remoteTimeStamp + timeStampAdjustment + offset, position, rotation, scale);
diff --git a/Assets/Mirror/Components/PredictedRigidbody/GhostMaterial.mat b/Assets/Mirror/Components/PredictedRigidbody/LocalGhostMaterial.mat
similarity index 92%
rename from Assets/Mirror/Components/PredictedRigidbody/GhostMaterial.mat
rename to Assets/Mirror/Components/PredictedRigidbody/LocalGhostMaterial.mat
index 49d28b1fa..ff29a7316 100644
--- a/Assets/Mirror/Components/PredictedRigidbody/GhostMaterial.mat
+++ b/Assets/Mirror/Components/PredictedRigidbody/LocalGhostMaterial.mat
@@ -7,8 +7,10 @@ Material:
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
- m_Name: GhostMaterial
+ m_Name: LocalGhostMaterial
m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0}
+ m_Parent: {fileID: 0}
+ m_ModifiedSerializedProperties: 0
m_ValidKeywords:
- _ALPHAPREMULTIPLY_ON
m_InvalidKeywords: []
@@ -19,6 +21,7 @@ Material:
stringTagMap:
RenderType: Transparent
disabledShaderPasses: []
+ m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
@@ -77,6 +80,6 @@ Material:
- _UVSec: 0
- _ZWrite: 0
m_Colors:
- - _Color: {r: 1, g: 1, b: 1, a: 0.11764706}
+ - _Color: {r: 1, g: 0, b: 0.067070484, a: 0.15686275}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
m_BuildTextureStacks: []
diff --git a/Assets/Mirror/Components/PredictedRigidbody/GhostMaterial.mat.meta b/Assets/Mirror/Components/PredictedRigidbody/LocalGhostMaterial.mat.meta
similarity index 100%
rename from Assets/Mirror/Components/PredictedRigidbody/GhostMaterial.mat.meta
rename to Assets/Mirror/Components/PredictedRigidbody/LocalGhostMaterial.mat.meta
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs
index 7a4e197c4..cb47262c6 100644
--- a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs
+++ b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs
@@ -20,10 +20,10 @@ public enum CorrectionMode
Move, // rigidbody.MovePosition/Rotation
}
- [RequireComponent(typeof(Rigidbody))]
+ // [RequireComponent(typeof(Rigidbody))] <- RB is moved out at runtime, can't require it.
public class PredictedRigidbody : NetworkBehaviour
{
- Rigidbody rb;
+ Rigidbody rb; // own rigidbody on server. this is never moved to a physics copy.
Vector3 lastPosition;
// [Tooltip("Broadcast changes if position changed by more than ... meters.")]
@@ -35,6 +35,7 @@ public class PredictedRigidbody : NetworkBehaviour
[Header("State History")]
public int stateHistoryLimit = 32; // 32 x 50 ms = 1.6 seconds is definitely enough
readonly SortedList stateHistory = new SortedList();
+ public float recordInterval = 0.050f;
[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;
@@ -52,18 +53,18 @@ public class PredictedRigidbody : NetworkBehaviour
[Tooltip("Configure how to apply the corrected state.")]
public CorrectionMode correctionMode = CorrectionMode.Move;
- [Tooltip("Server & Client would sometimes fight over the final position at rest. Instead, hard snap into black below a certain velocity threshold.")]
- public float snapThreshold = 0.5f; // adjust with log messages ('snap'). '2' works, but '0.5' is fine too.
+ [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;
- double lastGhostEnabledCheckTime = 0;
[Tooltip("After creating the visual interpolation object, replace this object's renderer materials with the ghost (ideally transparent) material.")]
- public Material ghostMaterial;
+ public Material localGhostMaterial;
+ public Material remoteGhostMaterial;
[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
@@ -75,41 +76,103 @@ public class PredictedRigidbody : NetworkBehaviour
[Header("Debugging")]
public float lineTime = 10;
- // visually interpolated GameObject copy for smoothing
- protected GameObject visualCopy;
- protected MeshRenderer[] originalRenderers;
+ // Rigidbody & Collider are moved out into a separate object.
+ // this way the visual object can smoothly follow.
+ protected GameObject physicsCopy;
+ Rigidbody physicsCopyRigidbody; // caching to avoid GetComponent
+ Collider physicsCopyCollider; // caching to avoid GetComponent
+
+ // we also create one extra ghost for the exact known server state.
+ protected GameObject remoteCopy;
void Awake()
{
rb = GetComponent();
}
- // instantiate a visually-only copy of the gameobject to apply smoothing.
- // on clients, where players are watching.
- // create & destroy methods are virtual so games with a different
- // rendering setup / hierarchy can inject their own copying code here.
- protected virtual void CreateVisualCopy()
+ protected virtual Rigidbody MoveRigidbody(GameObject destination)
{
- // create an empty GameObject with the same name + _Visual
- visualCopy = new GameObject($"{name}_Visual");
- visualCopy.transform.position = transform.position;
- visualCopy.transform.rotation = transform.rotation;
- visualCopy.transform.localScale = transform.localScale;
+ Rigidbody source = GetComponent();
+ Rigidbody rigidbodyCopy = destination.AddComponent();
+ rigidbodyCopy.mass = source.mass;
+ rigidbodyCopy.drag = source.drag;
+ rigidbodyCopy.angularDrag = source.angularDrag;
+ rigidbodyCopy.useGravity = source.useGravity;
+ rigidbodyCopy.isKinematic = source.isKinematic;
+ rigidbodyCopy.interpolation = source.interpolation;
+ rigidbodyCopy.collisionDetectionMode = source.collisionDetectionMode;
+ rigidbodyCopy.constraints = source.constraints;
+ rigidbodyCopy.sleepThreshold = source.sleepThreshold;
+ rigidbodyCopy.freezeRotation = source.freezeRotation;
+ rigidbodyCopy.position = source.position;
+ rigidbodyCopy.rotation = source.rotation;
+ rigidbodyCopy.velocity = source.velocity;
+ Destroy(source);
+ return rigidbodyCopy;
+ }
- // add the PredictedRigidbodyVisual component
- PredictedRigidbodyVisual visualRigidbody = visualCopy.AddComponent();
- visualRigidbody.target = this;
- visualRigidbody.positionInterpolationSpeed = positionInterpolationSpeed;
- visualRigidbody.rotationInterpolationSpeed = rotationInterpolationSpeed;
- visualRigidbody.teleportDistanceMultiplier = teleportDistanceMultiplier;
-
- // copy the rendering components
- if (TryGetComponent(out MeshRenderer originalMeshRenderer))
+ protected virtual void MoveBoxColliders(GameObject destination)
+ {
+ BoxCollider[] sourceColliders = GetComponents();
+ foreach (BoxCollider sourceCollider in sourceColliders)
{
- MeshFilter meshFilter = visualCopy.AddComponent();
- meshFilter.mesh = GetComponent().mesh;
+ BoxCollider colliderCopy = destination.AddComponent();
+ colliderCopy.center = sourceCollider.center;
+ colliderCopy.size = sourceCollider.size;
+ Destroy(sourceCollider);
+ }
+ }
- MeshRenderer meshRenderer = visualCopy.AddComponent();
+ protected virtual void MoveSphereColliders(GameObject destination)
+ {
+ SphereCollider[] sourceColliders = GetComponents();
+ foreach (SphereCollider sourceCollider in sourceColliders)
+ {
+ SphereCollider colliderCopy = destination.AddComponent();
+ colliderCopy.center = sourceCollider.center;
+ colliderCopy.radius = sourceCollider.radius;
+ Destroy(sourceCollider);
+ }
+ }
+
+ protected virtual void MoveCapsuleColliders(GameObject destination)
+ {
+ CapsuleCollider[] sourceColliders = GetComponents();
+ foreach (CapsuleCollider sourceCollider in sourceColliders)
+ {
+ CapsuleCollider colliderCopy = destination.AddComponent();
+ colliderCopy.center = sourceCollider.center;
+ colliderCopy.radius = sourceCollider.radius;
+ colliderCopy.height = sourceCollider.height;
+ colliderCopy.direction = sourceCollider.direction;
+ Destroy(sourceCollider);
+ }
+ }
+
+ protected virtual void MoveMeshColliders(GameObject destination)
+ {
+ MeshCollider[] sourceColliders = GetComponents();
+ foreach (MeshCollider sourceCollider in sourceColliders)
+ {
+ MeshCollider colliderCopy = destination.AddComponent();
+ colliderCopy.sharedMesh = sourceCollider.sharedMesh;
+ colliderCopy.convex = sourceCollider.convex;
+ colliderCopy.isTrigger = sourceCollider.isTrigger;
+ Destroy(sourceCollider);
+ }
+ }
+
+ protected virtual void CopyRenderersAsGhost(GameObject destination, Material material)
+ {
+ // find the MeshRenderer component, which sometimes is on a child.
+ MeshRenderer originalMeshRenderer = GetComponentInChildren(true);
+ MeshFilter originalMeshFilter = GetComponentInChildren(true);
+ if (originalMeshRenderer != null && originalMeshFilter != null)
+ {
+ MeshFilter meshFilter = destination.AddComponent();
+ meshFilter.mesh = originalMeshFilter.mesh;
+
+ MeshRenderer meshRenderer = destination.AddComponent();
meshRenderer.material = originalMeshRenderer.material;
// renderers often have multiple materials. copy all.
@@ -118,74 +181,120 @@ protected virtual void CreateVisualCopy()
Material[] materials = new Material[originalMeshRenderer.materials.Length];
for (int i = 0; i < materials.Length; ++i)
{
- materials[i] = originalMeshRenderer.materials[i];
+ materials[i] = material;
}
meshRenderer.materials = materials; // need to reassign to see it in effect
}
}
// if we didn't find a renderer, show a warning
else Debug.LogWarning($"PredictedRigidbody: {name} found no renderer to copy onto the visual object. If you are using a custom setup, please overwrite PredictedRigidbody.CreateVisualCopy().");
+ }
- // find renderers in children.
- // this will be used a lot later, so only find them once when
- // creating the visual copy here.
- originalRenderers = GetComponentsInChildren();
+ // instantiate a physics-only copy of the gameobject to apply corrections.
+ // this way the main visual object can smoothly follow.
+ // it's best to separate the physics instead of separating the renderers.
+ // some projects have complex rendering / animation setups which we can't touch.
+ // besides, Rigidbody+Collider are two components, where as renders may be many.
+ protected virtual void CreateGhosts()
+ {
+ // skip if already separated
+ if (physicsCopy != null) return;
- // replace this renderer's materials with the ghost (if enabled)
- foreach (MeshRenderer rend in originalRenderers)
+ Debug.Log($"Separating Physics for {name}");
+
+ // create an empty GameObject with the same name + _Physical
+ physicsCopy = new GameObject($"{name}_Physical");
+ physicsCopy.transform.position = transform.position;
+ physicsCopy.transform.rotation = transform.rotation;
+ physicsCopy.transform.localScale = transform.localScale;
+
+ // add the PredictedRigidbodyPhysical component
+ PredictedRigidbodyPhysicsGhost physicsGhostRigidbody = physicsCopy.AddComponent();
+ physicsGhostRigidbody.target = this;
+ physicsGhostRigidbody.ghostDistanceThreshold = ghostDistanceThreshold;
+ physicsGhostRigidbody.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
+
+ // move the rigidbody component to the physics GameObject
+ MoveRigidbody(physicsCopy);
+
+ // move the collider components to the physics GameObject
+ MoveBoxColliders(physicsCopy);
+ MoveSphereColliders(physicsCopy);
+ MoveCapsuleColliders(physicsCopy);
+ MoveMeshColliders(physicsCopy);
+
+ // show ghost by copying all renderers / materials with ghost material applied
+ if (showGhost)
{
- if (showGhost)
- {
- // renderers often have multiple materials. replace all.
- if (rend.materials != null)
- {
- Material[] materials = rend.materials;
- for (int i = 0; i < materials.Length; ++i)
- {
- materials[i] = ghostMaterial;
- }
- rend.materials = materials; // need to reassign to see it in effect
- }
- }
- else
- {
- rend.enabled = false;
- }
+ // one for the locally predicted rigidbody
+ CopyRenderersAsGhost(physicsCopy, localGhostMaterial);
+ physicsGhostRigidbody.ghostDistanceThreshold = ghostDistanceThreshold;
+ physicsGhostRigidbody.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
+
+ // one for the latest remote state for comparison
+ remoteCopy = new GameObject($"{name}_Remote");
+ PredictedRigidbodyRemoteGhost predictedGhost = remoteCopy.AddComponent();
+ predictedGhost.target = this;
+ predictedGhost.ghostDistanceThreshold = ghostDistanceThreshold;
+ predictedGhost.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
+ CopyRenderersAsGhost(remoteCopy, remoteGhostMaterial);
}
+
+ // cache components to avoid GetComponent calls at runtime
+ physicsCopyRigidbody = physicsCopy.GetComponent();
+ physicsCopyCollider = physicsCopy.GetComponent();
+ if (physicsGhostRigidbody == null) throw new Exception("SeparatePhysics: couldn't find final Rigidbody.");
+ if (physicsCopyCollider == null) throw new Exception("SeparatePhysics: couldn't find final Collider.");
}
- protected virtual void DestroyVisualCopy()
+ protected virtual void DestroyCopies()
{
- if (visualCopy != null) Destroy(visualCopy);
+ if (physicsCopy != null) Destroy(physicsCopy);
+ if (remoteCopy != null) Destroy(remoteCopy);
}
- protected virtual void UpdateVisualCopy()
+ protected virtual void SmoothFollowPhysicsCopy()
{
- // only if visual copy was already created
- if (visualCopy == null || originalRenderers == null) return;
+ // hard follow:
+ // transform.position = physicsCopyCollider.position;
+ // transform.rotation = physicsCopyCollider.rotation;
- // 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;
+ // if we are further than N colliders sizes behind, then teleport
+ float colliderSize = physicsCopyCollider.bounds.size.magnitude;
+ float threshold = colliderSize * teleportDistanceMultiplier;
+ float distance = Vector3.Distance(transform.position, physicsCopyRigidbody.position);
+ if (distance > threshold)
+ {
+ transform.position = physicsCopyRigidbody.position;
+ transform.rotation = physicsCopyRigidbody.rotation;
+ Debug.Log($"[PredictedRigidbody] Teleported because distance to physics copy = {distance:F2} > threshold {threshold:F2}");
+ return;
+ }
- // 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(transform.position, visualCopy.transform.position) <= ghostDistanceThreshold;
- foreach (MeshRenderer rend in originalRenderers)
- rend.enabled = !insideTarget;
+ // smoothly interpolate to the target position.
+ // speed relative to how far away we are
+ float positionStep = distance * positionInterpolationSpeed;
+ // speed relative to how far away we are.
+ // => speed increases by distance² because the further away, the
+ // sooner we need to catch the fuck up
+ // float positionStep = (distance * distance) * interpolationSpeed;
+ transform.position = Vector3.MoveTowards(transform.position, physicsCopyRigidbody.position, positionStep * Time.deltaTime);
+
+ // smoothly interpolate to the target rotation.
+ // Quaternion.RotateTowards doesn't seem to work at all, so let's use SLerp.
+ transform.rotation = Quaternion.Slerp(transform.rotation, physicsCopyRigidbody.rotation, rotationInterpolationSpeed * Time.deltaTime);
}
// creater visual copy only on clients, where players are watching.
- public override void OnStartClient() => CreateVisualCopy();
+ public override void OnStartClient()
+ {
+ // OnDeserialize may have already created this
+ if (physicsCopy == null) CreateGhosts();
+ }
// destroy visual copy only in OnStopClient().
// OnDestroy() wouldn't be called for scene objects that are only disabled instead of destroyed.
- public override void OnStopClient() => DestroyVisualCopy();
+ public override void OnStopClient() => DestroyCopies();
void UpdateServer()
{
@@ -203,15 +312,14 @@ void UpdateServer()
SetDirty();
}
- void UpdateClient()
- {
- UpdateVisualCopy();
- }
-
void Update()
{
if (isServer) UpdateServer();
- if (isClient) UpdateClient();
+ }
+
+ void LateUpdate()
+ {
+ if (isClient) SmoothFollowPhysicsCopy();
}
void FixedUpdate()
@@ -224,8 +332,14 @@ void FixedUpdate()
// manually store last recorded so we can easily check against this
// without traversing the SortedList.
RigidbodyState lastRecorded;
+ double lastRecordTime;
void RecordState()
{
+ // 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;
+
// NetworkTime.time is always behind by bufferTime.
// prediction aims to be on the exact same server time (immediately).
// use predictedTime to record state, otherwise we would record in the past.
@@ -250,20 +364,20 @@ void RecordState()
if (stateHistory.Count > 0)
{
RigidbodyState last = stateHistory.Values[stateHistory.Count - 1];
- positionDelta = rb.position - last.position;
- velocityDelta = rb.velocity - last.velocity;
- rotationDelta = rb.rotation * Quaternion.Inverse(last.rotation); // this is how you calculate a quaternion delta
+ 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
// debug draw the recorded state
- Debug.DrawLine(last.position, rb.position, Color.red, lineTime);
+ Debug.DrawLine(last.position, physicsCopyRigidbody.position, Color.red, lineTime);
}
// create state to insert
RigidbodyState state = new RigidbodyState(
predictedTime,
- positionDelta, rb.position,
- rotationDelta, rb.rotation,
- velocityDelta, rb.velocity
+ positionDelta, physicsCopyRigidbody.position,
+ rotationDelta, physicsCopyRigidbody.rotation,
+ velocityDelta, physicsCopyRigidbody.velocity
);
// add state to history
@@ -277,18 +391,31 @@ void RecordState()
protected virtual void OnSnappedIntoPlace() {}
protected virtual void OnCorrected() {}
- void ApplyState(Vector3 position, Quaternion rotation, Vector3 velocity)
+ void ApplyState(double timestamp, Vector3 position, Quaternion rotation, Vector3 velocity)
{
// 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 (rb.velocity.magnitude <= snapThreshold)
+ if (physicsCopyRigidbody.velocity.magnitude <= snapThreshold)
{
- Debug.Log($"Prediction: snapped {name} into place because velocity {rb.velocity.magnitude:F3} <= {snapThreshold:F3}");
+ Debug.Log($"Prediction: snapped {name} into place because velocity {physicsCopyRigidbody.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;
+
+ // clear history and insert the exact state we just applied.
+ // this makes future corrections more accurate.
stateHistory.Clear();
- rb.position = position;
- rb.rotation = rotation;
- rb.velocity = Vector3.zero;
+ stateHistory.Add(timestamp, new RigidbodyState(
+ timestamp,
+ Vector3.zero, position,
+ Quaternion.identity, rotation,
+ Vector3.zero, velocity
+ ));
// user callback
OnSnappedIntoPlace();
@@ -299,23 +426,31 @@ void ApplyState(Vector3 position, Quaternion rotation, Vector3 velocity)
// TODO is this a good idea? what about next capture while it's interpolating?
if (correctionMode == CorrectionMode.Move)
{
- rb.MovePosition(position);
- rb.MoveRotation(rotation);
+ physicsCopyRigidbody.MovePosition(position);
+ physicsCopyRigidbody.MoveRotation(rotation);
}
else if (correctionMode == CorrectionMode.Set)
{
- rb.position = position;
- rb.rotation = rotation;
+ physicsCopyRigidbody.position = position;
+ physicsCopyRigidbody.rotation = rotation;
}
// there's only one way to set velocity
- rb.velocity = velocity;
+ physicsCopyRigidbody.velocity = velocity;
}
// process a received server state.
// compares it against our history and applies corrections if needed.
void OnReceivedState(double timestamp, RigidbodyState state)
{
+ // always update remote state ghost
+ if (remoteCopy != null)
+ {
+ remoteCopy.transform.position = state.position;
+ remoteCopy.transform.rotation = state.rotation;
+ remoteCopy.transform.localScale = transform.localScale;
+ }
+
// OPTIONAL performance optimization when comparing idle objects.
// even idle objects will have a history of ~32 entries.
// sampling & traversing through them is unnecessarily costly.
@@ -335,8 +470,8 @@ void OnReceivedState(double timestamp, RigidbodyState state)
//
// if this ever causes issues, feel free to disable it.
if (compareLastFirst &&
- Vector3.Distance(state.position, rb.position) < positionCorrectionThreshold &&
- Quaternion.Angle(state.rotation, rb.rotation) < rotationCorrectionThreshold)
+ Vector3.Distance(state.position, physicsCopyRigidbody.position) < positionCorrectionThreshold &&
+ Quaternion.Angle(state.rotation, physicsCopyRigidbody.rotation) < rotationCorrectionThreshold)
{
// Debug.Log($"OnReceivedState for {name}: taking optimized early return!");
return;
@@ -364,7 +499,7 @@ void OnReceivedState(double timestamp, RigidbodyState state)
if (state.timestamp < oldest.timestamp)
{
Debug.LogWarning($"Hard correcting client 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.position, state.rotation, state.velocity);
+ ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
return;
}
@@ -381,11 +516,11 @@ 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, rb.position) >= positionCorrectionThreshold)
+ if (Vector3.Distance(state.position, physicsCopyRigidbody.position) >= positionCorrectionThreshold)
{
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.position, state.rotation, state.velocity);
+ ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
}
return;
}
@@ -396,7 +531,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.position, state.rotation, state.velocity);
+ ApplyState(state.timestamp, state.position, state.rotation, state.velocity);
return;
}
@@ -428,8 +563,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(rb.position, recomputed.position, Color.green, lineTime);
- ApplyState(recomputed.position, recomputed.rotation, recomputed.velocity);
+ Debug.DrawLine(physicsCopyRigidbody.position, recomputed.position, Color.green, lineTime);
+ ApplyState(recomputed.timestamp, recomputed.position, recomputed.rotation, recomputed.velocity);
// user callback
OnCorrected();
@@ -451,14 +586,18 @@ public override void OnSerialize(NetworkWriter writer, bool initialState)
// 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);
- writer.WriteQuaternion(rb.rotation);
- writer.WriteVector3(rb.velocity);
+ 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
}
// 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;
@@ -490,6 +629,11 @@ protected override void OnValidate()
// force syncDirection to be ServerToClient
syncDirection = SyncDirection.ServerToClient;
+
+ // state should be synced immediately for now.
+ // later when we have prediction fully dialed in,
+ // then we can maybe relax this a bit.
+ syncInterval = 0;
}
}
}
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs.meta b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs.meta
index b128e6ab9..645605858 100644
--- a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs.meta
+++ b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbody.cs.meta
@@ -4,7 +4,10 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences:
- - ghostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320, type: 2}
+ - localGhostMaterial: {fileID: 2100000, guid: 411a48b4a197d4924bec3e3809bc9320,
+ type: 2}
+ - remoteGhostMaterial: {fileID: 2100000, guid: 04f0b2088c857414393bab3b80356776,
+ type: 2}
executionOrder: 0
icon: {fileID: 2800000, guid: 7453abfe9e8b2c04a8a47eb536fe21eb, type: 3}
userData:
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyPhysicsGhost.cs b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyPhysicsGhost.cs
new file mode 100644
index 000000000..5e4674987
--- /dev/null
+++ b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyPhysicsGhost.cs
@@ -0,0 +1,72 @@
+// 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;
+using UnityEngine;
+
+namespace Mirror
+{
+ public class PredictedRigidbodyPhysicsGhost : MonoBehaviour
+ {
+ [Tooltip("The predicted rigidbody owner.")]
+ public PredictedRigidbody target;
+
+ // ghost (settings are copyed from PredictedRigidbody)
+ MeshRenderer ghost;
+ public float ghostDistanceThreshold = 0.1f;
+ public float ghostEnabledCheckInterval = 0.2f;
+ double lastGhostEnabledCheckTime = 0;
+
+ // cache
+ Collider co;
+
+ // we add this component manually from PredictedRigidbody.
+ // so assign this in Start. target isn't set in Awake yet.
+ void Start()
+ {
+ co = GetComponent();
+ ghost = GetComponent();
+ }
+
+ 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(transform.position, target.transform.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 || target.gameObject == 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);
+ }
+ }
+ }
+}
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyVisual.cs.meta b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyPhysicsGhost.cs.meta
similarity index 100%
rename from Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyVisual.cs.meta
rename to Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyPhysicsGhost.cs.meta
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyRemoteGhost.cs b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyRemoteGhost.cs
new file mode 100644
index 000000000..24b37b624
--- /dev/null
+++ b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyRemoteGhost.cs
@@ -0,0 +1,53 @@
+// simply ghost object that always follows last received server state.
+using UnityEngine;
+
+namespace Mirror
+{
+ public class PredictedRigidbodyRemoteGhost : MonoBehaviour
+ {
+ [Tooltip("The predicted rigidbody owner.")]
+ public PredictedRigidbody target;
+
+ // ghost (settings are copyed from PredictedRigidbody)
+ MeshRenderer ghost;
+ public float ghostDistanceThreshold = 0.1f;
+ public float ghostEnabledCheckInterval = 0.2f;
+ double lastGhostEnabledCheckTime = 0;
+
+ // we add this component manually from PredictedRigidbody.
+ // so assign this in Start. target isn't set in Awake yet.
+ void Start()
+ {
+ ghost = GetComponent();
+ }
+
+ 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(transform.position, target.transform.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 || target.gameObject == null) Destroy(gameObject);
+ }
+ }
+}
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyRemoteGhost.cs.meta b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyRemoteGhost.cs.meta
new file mode 100644
index 000000000..43d3900bd
--- /dev/null
+++ b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyRemoteGhost.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 62e7e9424c7e48d69b6a3517796142a1
+timeCreated: 1705235542
\ No newline at end of file
diff --git a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyVisual.cs b/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyVisual.cs
deleted file mode 100644
index ef875cba1..000000000
--- a/Assets/Mirror/Components/PredictedRigidbody/PredictedRigidbodyVisual.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System;
-using UnityEngine;
-
-namespace Mirror
-{
- public class PredictedRigidbodyVisual : MonoBehaviour
- {
- [Tooltip("The predicted rigidbody to follow.")]
- public PredictedRigidbody target;
- Rigidbody targetRigidbody;
-
- // settings are applied in the other PredictedRigidbody component and then copied here.
- [HideInInspector] public float positionInterpolationSpeed = 15; // 10 is a little too low for billiards at least
- [HideInInspector] public float rotationInterpolationSpeed = 10;
- [HideInInspector] public float teleportDistanceMultiplier = 10;
-
- // we add this component manually from PredictedRigidbody.
- // so assign this in Start. target isn't set in Awake yet.
- void Start()
- {
- targetRigidbody = target.GetComponent();
- }
-
- // always follow in late update, after update modified positions
- void LateUpdate()
- {
- // if target gets network destroyed for any reason, destroy visual
- if (targetRigidbody == null || target.gameObject == null)
- {
- Destroy(gameObject);
- return;
- }
-
- // hard follow:
- // transform.position = targetRigidbody.position;
- // transform.rotation = targetRigidbody.rotation;
-
- // if we are further than N colliders sizes behind, then teleport
- float colliderSize = target.GetComponent().bounds.size.magnitude;
- float threshold = colliderSize * teleportDistanceMultiplier;
- float distance = Vector3.Distance(transform.position, targetRigidbody.position);
- if (distance > threshold)
- {
- transform.position = targetRigidbody.position;
- transform.rotation = targetRigidbody.rotation;
- Debug.Log($"[PredictedRigidbodyVisual] Teleported because distance {distance:F2} > threshold {threshold:F2}");
- return;
- }
-
- // smoothly interpolate to the target position.
- // speed relative to how far away we are
- float positionStep = distance * positionInterpolationSpeed;
- // speed relative to how far away we are.
- // => speed increases by distance² because the further away, the
- // sooner we need to catch the fuck up
- // float positionStep = (distance * distance) * interpolationSpeed;
- transform.position = Vector3.MoveTowards(transform.position, targetRigidbody.position, positionStep * Time.deltaTime);
-
- // smoothly interpolate to the target rotation.
- // Quaternion.RotateTowards doesn't seem to work at all, so let's use SLerp.
- transform.rotation = Quaternion.Slerp(transform.rotation, targetRigidbody.rotation, rotationInterpolationSpeed * Time.deltaTime);
- }
- }
-}
diff --git a/Assets/Mirror/Components/PredictedRigidbody/RemoteGhostMaterial.mat b/Assets/Mirror/Components/PredictedRigidbody/RemoteGhostMaterial.mat
new file mode 100644
index 000000000..d652a5016
--- /dev/null
+++ b/Assets/Mirror/Components/PredictedRigidbody/RemoteGhostMaterial.mat
@@ -0,0 +1,85 @@
+%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: RemoteGhostMaterial
+ m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0}
+ m_Parent: {fileID: 0}
+ m_ModifiedSerializedProperties: 0
+ m_ValidKeywords:
+ - _ALPHAPREMULTIPLY_ON
+ m_InvalidKeywords: []
+ m_LightmapFlags: 4
+ m_EnableInstancingVariants: 0
+ m_DoubleSidedGI: 0
+ m_CustomRenderQueue: 3000
+ stringTagMap:
+ RenderType: Transparent
+ disabledShaderPasses: []
+ m_LockedProperties:
+ 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.92
+ - _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: 0.09849727, g: 1, b: 0, a: 0.15686275}
+ - _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
+ m_BuildTextureStacks: []
diff --git a/Assets/Mirror/Components/PredictedRigidbody/RemoteGhostMaterial.mat.meta b/Assets/Mirror/Components/PredictedRigidbody/RemoteGhostMaterial.mat.meta
new file mode 100644
index 000000000..50854eb07
--- /dev/null
+++ b/Assets/Mirror/Components/PredictedRigidbody/RemoteGhostMaterial.mat.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 04f0b2088c857414393bab3b80356776
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 2100000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Mirror/Core/Attributes.cs b/Assets/Mirror/Core/Attributes.cs
index 55fa36bb4..df8236a8f 100644
--- a/Assets/Mirror/Core/Attributes.cs
+++ b/Assets/Mirror/Core/Attributes.cs
@@ -87,5 +87,5 @@ public class ShowInInspectorAttribute : Attribute {}
/// Used to make a field readonly in the inspector
///
[AttributeUsage(AttributeTargets.Field)]
- public class ReadOnlyAttribute : PropertyAttribute { }
+ public class ReadOnlyAttribute : PropertyAttribute {}
}
diff --git a/Assets/Mirror/Core/InterestManagementBase.cs b/Assets/Mirror/Core/InterestManagementBase.cs
index 8fed86860..ec60d640e 100644
--- a/Assets/Mirror/Core/InterestManagementBase.cs
+++ b/Assets/Mirror/Core/InterestManagementBase.cs
@@ -23,7 +23,7 @@ protected virtual void OnEnable()
}
[ServerCallback]
- public virtual void Reset() {}
+ public virtual void ResetState() {}
// Callback used by the visibility system to determine if an observer
// (player) can see the NetworkIdentity. If this function returns true,
diff --git a/Assets/Mirror/Core/NetworkClient.cs b/Assets/Mirror/Core/NetworkClient.cs
index d5d8ddaae..e1362c1a5 100644
--- a/Assets/Mirror/Core/NetworkClient.cs
+++ b/Assets/Mirror/Core/NetworkClient.cs
@@ -318,8 +318,14 @@ internal static void OnTransportData(ArraySegment data, int channelId)
// always process all messages in the batch.
if (!unbatcher.AddBatch(data))
{
- Debug.LogWarning($"NetworkClient: failed to add batch, disconnecting.");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"NetworkClient: failed to add batch, disconnecting.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning($"NetworkClient: failed to add batch.");
+
return;
}
@@ -355,17 +361,27 @@ internal static void OnTransportData(ArraySegment data, int channelId)
// so we need to disconnect.
// -> return to avoid the below unbatches.count error.
// we already disconnected and handled it.
- Debug.LogWarning($"NetworkClient: failed to unpack and invoke message. Disconnecting.");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"NetworkClient: failed to unpack and invoke message. Disconnecting.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning($"NetworkClient: failed to unpack and invoke message.");
+
return;
}
}
// otherwise disconnect
else
{
- // WARNING, not error. can happen if attacker sends random data.
- Debug.LogWarning($"NetworkClient: received Message was too short (messages should start with message id)");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"NetworkClient: received Message was too short (messages should start with message id). Disconnecting.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning("NetworkClient: received Message was too short (messages should start with message id)");
return;
}
}
@@ -536,14 +552,17 @@ public static void RegisterHandler(Action handler, bool requireAuthen
handlers[msgType] = NetworkMessages.WrapHandler((Action)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
}
- /// Replace a handler for a particular message type. Should require authentication by default.
- // RegisterHandler throws a warning (as it should) if a handler is assigned twice
- // Use of ReplaceHandler makes it clear the user intended to replace the handler
+ // Deprecated 2024-01-21
+ [Obsolete("Use ReplaceHandler without the NetworkConnection parameter instead. This version is obsolete and will be removed soon.")]
public static void ReplaceHandler(Action handler, bool requireAuthentication = true)
where T : struct, NetworkMessage
{
+ // we use the same WrapHandler function for server and client.
+ // so let's wrap it to ignore the NetworkConnection parameter.
+ // it's not needed on client. it's always NetworkClient.connection.
ushort msgType = NetworkMessageId.Id;
- handlers[msgType] = NetworkMessages.WrapHandler(handler, requireAuthentication, exceptionsDisconnect);
+ void HandlerWrapped(NetworkConnection _, T value) => handler(_, value);
+ handlers[msgType] = NetworkMessages.WrapHandler((Action)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
}
/// Replace a handler for a particular message type. Should require authentication by default.
@@ -552,7 +571,26 @@ public static void ReplaceHandler(Action handler, bool
public static void ReplaceHandler(Action handler, bool requireAuthentication = true)
where T : struct, NetworkMessage
{
- ReplaceHandler((NetworkConnection _, T value) => { handler(value); }, requireAuthentication);
+ // we use the same WrapHandler function for server and client.
+ // so let's wrap it to ignore the NetworkConnection parameter.
+ // it's not needed on client. it's always NetworkClient.connection.
+ ushort msgType = NetworkMessageId.Id;
+ void HandlerWrapped(NetworkConnection _, T value) => handler(value);
+ handlers[msgType] = NetworkMessages.WrapHandler((Action)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
+ }
+
+ /// Replace a handler for a particular message type. Should require authentication by default. This version passes channelId to the handler.
+ // RegisterHandler throws a warning (as it should) if a handler is assigned twice
+ // Use of ReplaceHandler makes it clear the user intended to replace the handler
+ public static void ReplaceHandler(Action handler, bool requireAuthentication = true)
+ where T : struct, NetworkMessage
+ {
+ // we use the same WrapHandler function for server and client.
+ // so let's wrap it to ignore the NetworkConnection parameter.
+ // it's not needed on client. it's always NetworkClient.connection.
+ ushort msgType = NetworkMessageId.Id;
+ void HandlerWrapped(NetworkConnection _, T value, int channelId) => handler(value, channelId);
+ handlers[msgType] = NetworkMessages.WrapHandler((Action)HandlerWrapped, requireAuthentication, exceptionsDisconnect);
}
/// Unregister a message handler of type T.
@@ -1634,7 +1672,7 @@ public static void DestroyAllClientObjects()
// unspawned objects should be reset for reuse later.
if (wasUnspawned)
{
- identity.Reset();
+ identity.ResetState();
}
// without unspawn handler, we need to disable/destroy.
else
@@ -1643,7 +1681,7 @@ public static void DestroyAllClientObjects()
// they always stay in the scene, we don't destroy them.
if (identity.sceneId != 0)
{
- identity.Reset();
+ identity.ResetState();
identity.gameObject.SetActive(false);
}
// spawned objects are destroyed
@@ -1679,7 +1717,7 @@ static void DestroyObject(uint netId)
if (InvokeUnSpawnHandler(identity.assetId, identity.gameObject))
{
// reset object after user's handler
- identity.Reset();
+ identity.ResetState();
}
// otherwise fall back to default Destroy
else if (identity.sceneId == 0)
@@ -1693,7 +1731,7 @@ static void DestroyObject(uint netId)
identity.gameObject.SetActive(false);
spawnableObjects[identity.sceneId] = identity;
// reset for scene objects
- identity.Reset();
+ identity.ResetState();
}
// remove from dictionary no matter how it is unspawned
diff --git a/Assets/Mirror/Core/NetworkIdentity.cs b/Assets/Mirror/Core/NetworkIdentity.cs
index 7e51976b9..331ab3e44 100644
--- a/Assets/Mirror/Core/NetworkIdentity.cs
+++ b/Assets/Mirror/Core/NetworkIdentity.cs
@@ -1324,7 +1324,7 @@ public void RemoveClientAuthority()
// the identity during destroy as people might want to be able to read
// the members inside OnDestroy(), and we have no way of invoking reset
// after OnDestroy is called.
- internal void Reset()
+ internal void ResetState()
{
hasSpawned = false;
clientStarted = false;
diff --git a/Assets/Mirror/Core/NetworkServer.cs b/Assets/Mirror/Core/NetworkServer.cs
index 7ae96d821..8cb713604 100644
--- a/Assets/Mirror/Core/NetworkServer.cs
+++ b/Assets/Mirror/Core/NetworkServer.cs
@@ -161,7 +161,7 @@ static void Initialize()
// reset Interest Management so that rebuild intervals
// start at 0 when starting again.
- if (aoi != null) aoi.Reset();
+ if (aoi != null) aoi.ResetState();
// reset NetworkTime
NetworkTime.ResetStatics();
@@ -244,7 +244,7 @@ public static void Shutdown()
OnDisconnectedEvent = null;
OnErrorEvent = null;
- if (aoi != null) aoi.Reset();
+ if (aoi != null) aoi.ResetState();
}
static void RemoveTransportHandlers()
@@ -384,8 +384,13 @@ static void OnEntityStateMessage(NetworkConnectionToClient connection, EntitySta
// failure to deserialize disconnects to prevent exploits.
if (!identity.DeserializeServer(reader))
{
- Debug.LogWarning($"Server failed to deserialize client state for {identity.name} with netId={identity.netId}, Disconnecting.");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"Server failed to deserialize client state for {identity.name} with netId={identity.netId}, Disconnecting.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning($"Server failed to deserialize client state for {identity.name} with netId={identity.netId}.");
}
}
}
@@ -725,8 +730,14 @@ internal static void OnTransportData(int connectionId, ArraySegment data,
// always process all messages in the batch.
if (!connection.unbatcher.AddBatch(data))
{
- Debug.LogWarning($"NetworkServer: received Message was too short (messages should start with message id)");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"NetworkServer: received message from connectionId:{connectionId} was too short (messages should start with message id). Disconnecting.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning($"NetworkServer: received message from connectionId:{connectionId} was too short (messages should start with message id).");
+
return;
}
@@ -762,17 +773,28 @@ internal static void OnTransportData(int connectionId, ArraySegment data,
// so we need to disconnect.
// -> return to avoid the below unbatches.count error.
// we already disconnected and handled it.
- Debug.LogWarning($"NetworkServer: failed to unpack and invoke message. Disconnecting {connectionId}.");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"NetworkServer: failed to unpack and invoke message. Disconnecting {connectionId}.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning($"NetworkServer: failed to unpack and invoke message from connectionId:{connectionId}.");
+
return;
}
}
// otherwise disconnect
else
{
- // WARNING, not error. can happen if attacker sends random data.
- Debug.LogWarning($"NetworkServer: received Message was too short (messages should start with message id). Disconnecting {connectionId}");
- connection.Disconnect();
+ if (exceptionsDisconnect)
+ {
+ Debug.LogError($"NetworkServer: received message from connectionId:{connectionId} was too short (messages should start with message id). Disconnecting.");
+ connection.Disconnect();
+ }
+ else
+ Debug.LogWarning($"NetworkServer: received message from connectionId:{connectionId} was too short (messages should start with message id).");
+
return;
}
}
@@ -908,6 +930,14 @@ public static void ReplaceHandler(Action handle
ushort msgType = NetworkMessageId.Id;
handlers[msgType] = NetworkMessages.WrapHandler(handler, requireAuthentication, exceptionsDisconnect);
}
+
+ /// Replace a handler for message type T. Most should require authentication.
+ public static void ReplaceHandler(Action handler, bool requireAuthentication = true)
+ where T : struct, NetworkMessage
+ {
+ ushort msgType = NetworkMessageId.Id;
+ handlers[msgType] = NetworkMessages.WrapHandler(handler, requireAuthentication, exceptionsDisconnect);
+ }
/// Unregister a handler for a message type T.
public static void UnregisterHandler()
@@ -1635,7 +1665,7 @@ static void DestroyObject(NetworkIdentity identity, DestroyMode mode)
// otherwise simply .Reset() and set inactive again
else if (mode == DestroyMode.Reset)
{
- identity.Reset();
+ identity.ResetState();
}
}
diff --git a/Assets/Mirror/Examples/BenchmarkIdle/MirrorBenchmarkIdle.unity b/Assets/Mirror/Examples/BenchmarkIdle/MirrorBenchmarkIdle.unity
index 147bb8048..bae7496f7 100644
--- a/Assets/Mirror/Examples/BenchmarkIdle/MirrorBenchmarkIdle.unity
+++ b/Assets/Mirror/Examples/BenchmarkIdle/MirrorBenchmarkIdle.unity
@@ -367,6 +367,7 @@ MonoBehaviour:
connectionQualityInterval: 3
timeInterpolationGui: 0
spawnAmount: 50000
+ spawnAmount: 10000
interleave: 2
spawnPrefab: {fileID: 449802645721213856, guid: 0ea79775d59804682a8cdd46b3811344,
type: 3}
diff --git a/Assets/Mirror/Examples/Room/Scripts/Reward.cs b/Assets/Mirror/Examples/Room/Scripts/Reward.cs
index 989b6e6c1..ac2ced195 100644
--- a/Assets/Mirror/Examples/Room/Scripts/Reward.cs
+++ b/Assets/Mirror/Examples/Room/Scripts/Reward.cs
@@ -5,9 +5,13 @@ namespace Mirror.Examples.NetworkRoom
[RequireComponent(typeof(Common.RandomColor))]
public class Reward : NetworkBehaviour
{
- public bool available = true;
+ [Header("Components")]
public Common.RandomColor randomColor;
+ [Header("Diagnostics")]
+ [ReadOnly, SerializeField]
+ bool available = true;
+
protected override void OnValidate()
{
base.OnValidate();
diff --git a/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentitySerializationTests.cs b/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentitySerializationTests.cs
index 9d466f026..25e8c8dc9 100644
--- a/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentitySerializationTests.cs
+++ b/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentitySerializationTests.cs
@@ -140,7 +140,7 @@ public void TooManyComponents()
// let's reset and initialize again with the added ones.
// this should show the 'too many components' error
LogAssert.Expect(LogType.Error, new Regex(".*too many NetworkBehaviour.*"));
- serverIdentity.Reset();
+ serverIdentity.ResetState();
// clientIdentity.Reset();
serverIdentity.Awake();
// clientIdentity.Awake();
diff --git a/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentityTests.cs b/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentityTests.cs
index e627c6f49..e6632a5c5 100644
--- a/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentityTests.cs
+++ b/Assets/Mirror/Tests/Editor/NetworkIdentity/NetworkIdentityTests.cs
@@ -624,7 +624,7 @@ public void ClearAllComponentsDirtyBits()
}
[Test]
- public void Reset()
+ public void ResetState()
{
CreateNetworked(out GameObject _, out NetworkIdentity identity);
@@ -637,7 +637,7 @@ public void Reset()
identity.observers[43] = new NetworkConnectionToClient(2);
// mark for reset and reset
- identity.Reset();
+ identity.ResetState();
Assert.That(identity.isServer, Is.False);
Assert.That(identity.isClient, Is.False);
Assert.That(identity.isLocalPlayer, Is.False);
diff --git a/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs b/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs
index 164c57bdd..6031dafc5 100644
--- a/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs
+++ b/Assets/Mirror/Tests/Editor/NetworkServer/NetworkServerTest.cs
@@ -632,6 +632,7 @@ public void Send_ClientToServerMessage_UnknownMessageIdDisconnects()
// send a message without a registered handler
NetworkClient.Send(new TestMessage1());
+ LogAssert.Expect(LogType.Error, $"NetworkServer: failed to unpack and invoke message. Disconnecting 1.");
ProcessMessages();
// should have been disconnected
@@ -684,6 +685,7 @@ public void Send_ServerToClientMessage_UnknownMessageIdDisconnects()
// send a message without a registered handler
connectionToClient.Send(new TestMessage1());
+ LogAssert.Expect(LogType.Error, $"NetworkClient: failed to unpack and invoke message. Disconnecting.");
ProcessMessages();
// should have been disconnected
@@ -866,6 +868,7 @@ public void UnregisterHandler()
// unregister, send again, should not be called again
NetworkServer.UnregisterHandler();
NetworkClient.Send(new TestMessage1());
+ LogAssert.Expect(LogType.Error, $"NetworkServer: failed to unpack and invoke message. Disconnecting 1.");
ProcessMessages();
Assert.That(variant1Called, Is.EqualTo(1));
}
@@ -889,6 +892,7 @@ public void ClearHandler()
// clear handlers, send again, should not be called again
NetworkServer.ClearHandlers();
NetworkClient.Send(new TestMessage1());
+ LogAssert.Expect(LogType.Error, $"NetworkServer: failed to unpack and invoke message. Disconnecting 1.");
ProcessMessages();
Assert.That(variant1Called, Is.EqualTo(1));
}
diff --git a/Assets/Mirror/Tests/Editor/NetworkTransform/NetworkTransform2kTests.cs b/Assets/Mirror/Tests/Editor/NetworkTransform/NetworkTransform2kTests.cs
index dcc43825b..8ea890997 100644
--- a/Assets/Mirror/Tests/Editor/NetworkTransform/NetworkTransform2kTests.cs
+++ b/Assets/Mirror/Tests/Editor/NetworkTransform/NetworkTransform2kTests.cs
@@ -314,6 +314,9 @@ public void OnServerToClientSync_WithClientAuthority_Nullables_Uses_Last()
component.netIdentity.isClient = true;
component.netIdentity.isLocalPlayer = true;
+ // client authority has to be disabled
+ component.syncDirection = SyncDirection.ServerToClient;
+
// call OnClientToServerSync with authority and nullable types
// to make sure it uses the last valid position then.
component.OnServerToClientSync(new Vector3?(), new Quaternion?(), new Vector3?());
diff --git a/Assets/ScriptTemplates/54-Mirror__Custom Interest Management-CustomInterestManagement.cs.txt b/Assets/ScriptTemplates/54-Mirror__Custom Interest Management-CustomInterestManagement.cs.txt
index a47caf1bc..5523ac9ae 100644
--- a/Assets/ScriptTemplates/54-Mirror__Custom Interest Management-CustomInterestManagement.cs.txt
+++ b/Assets/ScriptTemplates/54-Mirror__Custom Interest Management-CustomInterestManagement.cs.txt
@@ -75,7 +75,7 @@ public class #SCRIPTNAME# : InterestManagement
/// Called by NetworkServer in Initialize and Shutdown
///
[ServerCallback]
- public override void Reset() { }
+ public override void ResetState() { }
[ServerCallback]
void Update()
diff --git a/Assets/ScriptTemplates/57-Mirror__Network Transform-NewNetworkTransform.cs.txt b/Assets/ScriptTemplates/57-Mirror__Network Transform-NewNetworkTransform.cs.txt
index 5b0cb269e..3f06e76b0 100644
--- a/Assets/ScriptTemplates/57-Mirror__Network Transform-NewNetworkTransform.cs.txt
+++ b/Assets/ScriptTemplates/57-Mirror__Network Transform-NewNetworkTransform.cs.txt
@@ -10,20 +10,10 @@ using Mirror;
public class #SCRIPTNAME# : NetworkTransformBase
{
- protected override Transform targetComponent => transform;
-
- // If you need this template to reference a child target,
- // replace the line above with the code below.
-
- /*
- [Header("Target")]
- public Transform target;
-
- protected override Transform targetComponent => target;
- */
-
#region Unity Callbacks
+ protected override void Awake() { }
+
protected override void OnValidate()
{
base.OnValidate();
@@ -45,44 +35,56 @@ public class #SCRIPTNAME# : NetworkTransformBase
base.OnDisable();
}
- ///
- /// Buffers are cleared and interpolation times are reset to zero here.
- /// This may be called when you are implementing some system of not sending
- /// if nothing changed, or just plain resetting if you have not received data
- /// for some time, as this will prevent a long interpolation period between old
- /// and just received data, as it will look like a lag. Reset() should also be
- /// called when authority is changed to another client or server, to prevent
- /// old buffers bugging out the interpolation if authority is changed back.
- ///
- public override void Reset()
- {
- base.Reset();
- }
-
#endregion
#region NT Base Callbacks
///
- /// NTSnapshot struct is created from incoming data from server
- /// and added to SnapshotInterpolation sorted list.
- /// You may want to skip calling the base method for the local player
- /// if doing client-side prediction, or perhaps pass altered values,
- /// or compare the server data to local values and correct large differences.
+ /// NTSnapshot struct is created here
///
- protected override void OnServerToClientSync(Vector3? position, Quaternion? rotation, Vector3? scale)
+ protected override TransformSnapshot Construct()
{
- base.OnServerToClientSync(position, rotation, scale);
+ return base.Construct();
+ }
+
+ protected override Vector3 GetPosition()
+ {
+ return base.GetPosition();
+ }
+
+ protected override Quaternion GetRotation()
+ {
+ return base.GetRotation();
+ }
+
+ protected override Vector3 GetScale()
+ {
+ return base.GetScale();
+ }
+
+ protected override void SetPosition(Vector3 position)
+ {
+ base.SetPosition(position);
+ }
+
+ protected override void SetRotation(Quaternion rotation)
+ {
+ base.SetRotation(rotation);
+ }
+
+ protected override void SetScale(Vector3 scale)
+ {
+ base.SetScale(scale);
}
///
- /// NTSnapshot struct is created from incoming data from client
- /// and added to SnapshotInterpolation sorted list.
- /// You may want to implement anti-cheat checks here in client authority mode.
+ /// localPosition, localRotation, and localScale are set here:
+ /// interpolated values are used if interpolation is enabled.
+ /// goal values are used if interpolation is disabled.
///
- protected override void OnClientToServerSync(Vector3? position, Quaternion? rotation, Vector3? scale)
+ protected override void Apply(TransformSnapshot interpolated, TransformSnapshot endGoal)
{
- base.OnClientToServerSync(position, rotation, scale);
+ base.Apply(interpolated, endGoal);
}
///
@@ -106,48 +108,32 @@ public class #SCRIPTNAME# : NetworkTransformBase
}
///
- /// NTSnapshot struct is created here
+ /// Buffers are cleared and interpolation times are reset to zero here.
+ /// This may be called when you are implementing some system of not sending
+ /// if nothing changed, or just plain resetting if you have not received data
+ /// for some time, as this will prevent a long interpolation period between old
+ /// and just received data, as it will look like a lag. Reset() should also be
+ /// called when authority is changed to another client or server, to prevent
+ /// old buffers bugging out the interpolation if authority is changed back.
///
- protected override NTSnapshot ConstructSnapshot()
+ public override void ResetState()
{
- return base.ConstructSnapshot();
+ base.ResetState();
}
- ///
- /// localPosition, localRotation, and localScale are set here:
- /// interpolated values are used if interpolation is enabled.
- /// goal values are used if interpolation is disabled.
- ///
- protected override void Apply(TransformSnapshot interpolated, TransformSnapshot endGoal)
- {
- base.Apply(interpolated, endGoal);
- }
-
-#if onlySyncOnChange_BANDWIDTH_SAVING
-
- ///
- /// Returns true if position, rotation AND scale are unchanged, within given sensitivity range.
- ///
- protected override bool CompareSnapshots(NTSnapshot currentSnapshot)
- {
- return base.CompareSnapshots(currentSnapshot);
- }
-
-#endif
-
#endregion
#region GUI
-#if UNITY_EDITOR || DEVELOPMENT_BUILD
// OnGUI allocates even if it does nothing. avoid in release.
+#if UNITY_EDITOR || DEVELOPMENT_BUILD
protected override void OnGUI()
{
base.OnGUI();
}
- protected override void DrawGizmos(SortedList buffer)
+ protected override void DrawGizmos(SortedList buffer)
{
base.DrawGizmos(buffer);
}