perf(Prediction): ghosts-on-demand(!)

This commit is contained in:
mischa 2024-02-11 17:07:54 +01:00
parent 946716b29a
commit 2f9d60be48
3 changed files with 128 additions and 182 deletions

View File

@ -24,11 +24,18 @@ public enum CorrectionMode
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.
protected Rigidbody predictedRigidbody; // always valid, even while moved onto the ghost.
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("Smoothing via Ghost-following only happens on demand, while moving with a minimum velocity.")]
public float motionSmoothingVelocityThreshold = 0.1f;
public float motionSmoothingAngularVelocityThreshold = 0.1f;
public float motionSmoothingMinimumTime = 0.5f;
double motionSmoothingStartTime;
// client keeps state history for correction & reconciliation.
// this needs to be a SortedList because we need to be able to insert inbetween.
@ -63,8 +70,8 @@ public class PredictedRigidbody : NetworkBehaviour
[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;
@ -87,9 +94,9 @@ public class PredictedRigidbody : NetworkBehaviour
// Rigidbody & Collider are moved out into a separate object.
// this way the visual object can smoothly follow.
protected GameObject physicsCopy;
protected Transform physicsCopyTransform; // caching to avoid GetComponent
protected Rigidbody physicsCopyRigidbody; // caching to avoid GetComponent
protected 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
// we also create one extra ghost for the exact known server state.
@ -98,8 +105,12 @@ public class PredictedRigidbody : NetworkBehaviour
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.");
// cache some threshold to avoid calculating them in LateUpdate
float colliderSize = GetComponentInChildren<Collider>().bounds.size.magnitude;
smoothFollowThreshold = colliderSize * teleportDistanceMultiplier;
}
protected virtual void CopyRenderersAsGhost(GameObject destination, Material material)
@ -164,9 +175,6 @@ 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
PredictionUtils.MovePhysicsComponents(gameObject, physicsCopy);
@ -175,8 +183,6 @@ protected virtual void CreateGhosts()
{
// 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!
@ -192,23 +198,11 @@ 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>();
}
protected virtual void DestroyGhosts()
@ -220,6 +214,9 @@ protected virtual void DestroyGhosts()
{
PredictionUtils.MovePhysicsComponents(physicsCopy, gameObject);
Destroy(physicsCopy);
// reassign our Rigidbody reference
predictedRigidbody = GetComponent<Rigidbody>();
}
// simply destroy the remote copy
@ -259,7 +256,8 @@ protected virtual void SmoothFollowPhysicsCopy()
// FAST VERSION: this shows in profiler a lot, so cache EVERYTHING!
tf.GetPositionAndRotation(out Vector3 currentPosition, out Quaternion currentRotation); // faster than tf.position + tf.rotation
physicsCopyTransform.GetPositionAndRotation(out Vector3 physicsPosition, out Quaternion physicsRotation); // faster than physicsCopyRigidbody.position + physicsCopyRigidbody.rotation
Vector3 physicsPosition = predictedRigidbody.position;
Quaternion physicsRotation = predictedRigidbody.rotation;
float deltaTime = Time.deltaTime;
float distance = Vector3.Distance(currentPosition, physicsPosition);
@ -286,13 +284,6 @@ protected virtual void SmoothFollowPhysicsCopy()
tf.SetPositionAndRotation(newPosition, newRotation);
}
// creater visual copy only on clients, where players are watching.
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()
@ -316,7 +307,7 @@ void UpdateServer()
// 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.
bool moving = rb.velocity != Vector3.zero; // on server, always use .rb. it has no physicsRigidbody.
bool moving = predictedRigidbody.velocity != Vector3.zero;
syncInterval = moving ? 0 : 1;
}
@ -324,15 +315,52 @@ void UpdateServer()
SetDirty();
}
// movement detection is virtual, in case projects want to use other methods.
protected virtual bool IsMoving() =>
predictedRigidbody.velocity.magnitude >= motionSmoothingVelocityThreshold ||
predictedRigidbody.angularVelocity.magnitude >= motionSmoothingAngularVelocityThreshold;
void UpdateGhosting()
{
// 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())
{
motionSmoothingStartTime = NetworkTime.time;
CreateGhosts();
OnBeginPrediction();
}
}
// ghosting at the moment
else
{
// slower than velocity threshold? then destroy the ghosts.
// with a minimum time since starting to move, to avoid on/off/on effects.
if (!IsMoving() && NetworkTime.time >= motionSmoothingStartTime + motionSmoothingMinimumTime)
{
DestroyGhosts();
OnEndPrediction();
physicsCopy = null; // TESTING
}
}
}
void Update()
{
if (isServer) UpdateServer();
if (isClientOnly) UpdateGhosting();
}
void LateUpdate()
{
// only follow on client-only, not in server or host mode
if (isClientOnly) SmoothFollowPhysicsCopy();
if (isClientOnly && physicsCopy) SmoothFollowPhysicsCopy();
}
void FixedUpdate()
@ -394,7 +422,7 @@ void RecordState()
// 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 = physicsCopyRigidbody.velocity;
Vector3 currentVelocity = predictedRigidbody.velocity;
// calculate delta to previous state (if any)
Vector3 positionDelta = Vector3.zero;
@ -433,23 +461,25 @@ void RecordState()
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)
{
// 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)
if (predictedRigidbody.velocity.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;
predictedRigidbody.velocity = velocity;
// clear history and insert the exact state we just applied.
// this makes future corrections more accurate.
@ -478,17 +508,17 @@ void ApplyState(double timestamp, Vector3 position, Quaternion rotation, Vector3
// TODO is this a good idea? what about next capture while it's interpolating?
if (correctionMode == CorrectionMode.Move)
{
physicsCopyRigidbody.MovePosition(position);
physicsCopyRigidbody.MoveRotation(rotation);
predictedRigidbody.MovePosition(position);
predictedRigidbody.MoveRotation(rotation);
}
else if (correctionMode == CorrectionMode.Set)
{
physicsCopyRigidbody.position = position;
physicsCopyRigidbody.rotation = rotation;
predictedRigidbody.position = position;
predictedRigidbody.rotation = rotation;
}
// there's only one way to set velocity
physicsCopyRigidbody.velocity = velocity;
predictedRigidbody.velocity = velocity;
}
// process a received server state.
@ -522,8 +552,8 @@ void OnReceivedState(double timestamp, RigidbodyState state)
//
// if this ever causes issues, feel free to disable it.
if (compareLastFirst &&
Vector3.Distance(state.position, physicsCopyRigidbody.position) < positionCorrectionThreshold &&
Quaternion.Angle(state.rotation, physicsCopyRigidbody.rotation) < rotationCorrectionThreshold)
Vector3.Distance(state.position, predictedRigidbody.position) < positionCorrectionThreshold &&
Quaternion.Angle(state.rotation, predictedRigidbody.rotation) < rotationCorrectionThreshold)
{
// Debug.Log($"OnReceivedState for {name}: taking optimized early return!");
return;
@ -574,7 +604,7 @@ 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, predictedRigidbody.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.");
@ -650,16 +680,12 @@ public override void OnSerialize(NetworkWriter writer, bool initialState)
writer.WriteFloat(Time.deltaTime);
writer.WriteVector3(position);
writer.WriteQuaternion(rotation);
writer.WriteVector3(rb.velocity); // own rigidbody on server, it's never moved to physics copy
writer.WriteVector3(predictedRigidbody.velocity);
}
// 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;
@ -697,5 +723,44 @@ protected override void OnValidate()
// then we can maybe relax this a bit.
syncInterval = 0;
}
// helper function for Physics tests to check if a Rigidbody belongs to
// a PredictedRigidbody component (either on it, or on its ghost).
public static bool IsPredicted(Rigidbody rb, out PredictedRigidbody predictedRigidbody)
{
// by default, Rigidbody is on the PredictedRigidbody GameObject
if (rb.TryGetComponent(out predictedRigidbody))
return true;
// it might be on a ghost while interacting
if (rb.TryGetComponent(out PredictedRigidbodyPhysicsGhost ghost))
{
predictedRigidbody = ghost.target.GetComponent<PredictedRigidbody>();
return true;
}
// otherwise the Rigidbody does not belong to any PredictedRigidbody.
predictedRigidbody = null;
return false;
}
// helper function for Physics tests to check if a Collider (which may be in children) belongs to
// a PredictedRigidbody component (either on it, or on its ghost).
public static bool IsPredicted(Collider co, out PredictedRigidbody predictedRigidbody)
{
// by default, Collider is on the PredictedRigidbody GameObject or it's children.
predictedRigidbody = co.GetComponentInParent<PredictedRigidbody>();
if (predictedRigidbody != null)
return true;
// it might be on a ghost while interacting
PredictedRigidbodyPhysicsGhost ghost = co.GetComponentInParent<PredictedRigidbodyPhysicsGhost>();
if (ghost != null && ghost.target != null && ghost.target.TryGetComponent(out predictedRigidbody))
return true;
// otherwise the Rigidbody does not belong to any PredictedRigidbody.
predictedRigidbody = null;
return false;
}
}
}

View File

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

View File

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