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 public class PredictedRigidbody : NetworkBehaviour
{ {
Transform tf; // this component is performance critical. cache .transform getter! 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; Vector3 lastPosition;
// [Tooltip("Broadcast changes if position changed by more than ... meters.")] // motion smoothing happen on-demand, because it requires moving physics components to another GameObject.
// public float positionSensitivity = 0.01f; // 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. // client keeps state history for correction & reconciliation.
// this needs to be a SortedList because we need to be able to insert inbetween. // 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")] [Header("Visual Interpolation")]
[Tooltip("After creating the visual interpolation object, keep showing the original Rigidbody with a ghost (transparent) material for debugging.")] [Tooltip("After creating the visual interpolation object, keep showing the original Rigidbody with a ghost (transparent) material for debugging.")]
public bool showGhost = true; public bool showGhost = true;
public float ghostDistanceThreshold = 0.1f; [Tooltip("Physics components are moved onto a ghost object beyond this threshold. Main object visually interpolates to it.")]
public float ghostEnabledCheckInterval = 0.2f; public float ghostVelocityThreshold = 0.1f;
[Tooltip("After creating the visual interpolation object, replace this object's renderer materials with the ghost (ideally transparent) material.")] [Tooltip("After creating the visual interpolation object, replace this object's renderer materials with the ghost (ideally transparent) material.")]
public Material localGhostMaterial; public Material localGhostMaterial;
@ -87,10 +94,10 @@ public class PredictedRigidbody : NetworkBehaviour
// Rigidbody & Collider are moved out into a separate object. // Rigidbody & Collider are moved out into a separate object.
// this way the visual object can smoothly follow. // this way the visual object can smoothly follow.
protected GameObject physicsCopy; protected GameObject physicsCopy;
protected Transform physicsCopyTransform; // caching to avoid GetComponent // protected Transform physicsCopyTransform; // caching to avoid GetComponent
protected Rigidbody physicsCopyRigidbody; // caching to avoid GetComponent // protected Rigidbody physicsCopyRigidbody => rb; // caching to avoid GetComponent
protected Collider physicsCopyCollider; // caching to avoid GetComponent // protected Collider physicsCopyCollider; // caching to avoid GetComponent
float smoothFollowThreshold; // caching to avoid calculation in LateUpdate float smoothFollowThreshold; // caching to avoid calculation in LateUpdate
// we also create one extra ghost for the exact known server state. // we also create one extra ghost for the exact known server state.
protected GameObject remoteCopy; protected GameObject remoteCopy;
@ -98,8 +105,12 @@ public class PredictedRigidbody : NetworkBehaviour
void Awake() void Awake()
{ {
tf = transform; tf = transform;
rb = GetComponent<Rigidbody>(); predictedRigidbody = GetComponent<Rigidbody>();
if (rb == null) throw new InvalidOperationException($"Prediction: {name} is missing a Rigidbody component."); 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) protected virtual void CopyRenderersAsGhost(GameObject destination, Material material)
@ -164,9 +175,6 @@ protected virtual void CreateGhosts()
// add the PredictedRigidbodyPhysical component // add the PredictedRigidbodyPhysical component
PredictedRigidbodyPhysicsGhost physicsGhostRigidbody = physicsCopy.AddComponent<PredictedRigidbodyPhysicsGhost>(); PredictedRigidbodyPhysicsGhost physicsGhostRigidbody = physicsCopy.AddComponent<PredictedRigidbodyPhysicsGhost>();
physicsGhostRigidbody.target = tf; physicsGhostRigidbody.target = tf;
physicsGhostRigidbody.ghostDistanceThreshold = ghostDistanceThreshold;
physicsGhostRigidbody.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
// move the rigidbody component & all colliders to the physics GameObject // move the rigidbody component & all colliders to the physics GameObject
PredictionUtils.MovePhysicsComponents(gameObject, physicsCopy); PredictionUtils.MovePhysicsComponents(gameObject, physicsCopy);
@ -175,8 +183,6 @@ protected virtual void CreateGhosts()
{ {
// one for the locally predicted rigidbody // one for the locally predicted rigidbody
CopyRenderersAsGhost(physicsCopy, localGhostMaterial); CopyRenderersAsGhost(physicsCopy, localGhostMaterial);
physicsGhostRigidbody.ghostDistanceThreshold = ghostDistanceThreshold;
physicsGhostRigidbody.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
// one for the latest remote state for comparison // one for the latest remote state for comparison
// it's important to copy world position/rotation/scale, not local! // 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.position = tf.position; // world position!
remoteCopy.transform.rotation = tf.rotation; // world rotation! remoteCopy.transform.rotation = tf.rotation; // world rotation!
remoteCopy.transform.localScale = tf.lossyScale; // world scale! remoteCopy.transform.localScale = tf.lossyScale; // world scale!
PredictedRigidbodyRemoteGhost predictedGhost = remoteCopy.AddComponent<PredictedRigidbodyRemoteGhost>();
predictedGhost.target = tf;
predictedGhost.ghostDistanceThreshold = ghostDistanceThreshold;
predictedGhost.ghostEnabledCheckInterval = ghostEnabledCheckInterval;
CopyRenderersAsGhost(remoteCopy, remoteGhostMaterial); CopyRenderersAsGhost(remoteCopy, remoteGhostMaterial);
} }
// cache components to avoid GetComponent calls at runtime // assign our Rigidbody reference to the ghost
physicsCopyTransform = physicsCopy.transform; predictedRigidbody = physicsCopy.GetComponent<Rigidbody>();
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;
} }
protected virtual void DestroyGhosts() protected virtual void DestroyGhosts()
@ -220,6 +214,9 @@ protected virtual void DestroyGhosts()
{ {
PredictionUtils.MovePhysicsComponents(physicsCopy, gameObject); PredictionUtils.MovePhysicsComponents(physicsCopy, gameObject);
Destroy(physicsCopy); Destroy(physicsCopy);
// reassign our Rigidbody reference
predictedRigidbody = GetComponent<Rigidbody>();
} }
// simply destroy the remote copy // simply destroy the remote copy
@ -258,8 +255,9 @@ protected virtual void SmoothFollowPhysicsCopy()
*/ */
// FAST VERSION: this shows in profiler a lot, so cache EVERYTHING! // 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 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 deltaTime = Time.deltaTime;
float distance = Vector3.Distance(currentPosition, physicsPosition); float distance = Vector3.Distance(currentPosition, physicsPosition);
@ -286,13 +284,6 @@ protected virtual void SmoothFollowPhysicsCopy()
tf.SetPositionAndRotation(newPosition, newRotation); 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(). // destroy visual copy only in OnStopClient().
// OnDestroy() wouldn't be called for scene objects that are only disabled instead of destroyed. // OnDestroy() wouldn't be called for scene objects that are only disabled instead of destroyed.
public override void OnStopClient() public override void OnStopClient()
@ -316,7 +307,7 @@ void UpdateServer()
// next round of optimizations: if client received nothing for 1s, // next round of optimizations: if client received nothing for 1s,
// force correct to last received state. then server doesn't need // force correct to last received state. then server doesn't need
// to send once per second anymore. // 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; syncInterval = moving ? 0 : 1;
} }
@ -324,15 +315,52 @@ void UpdateServer()
SetDirty(); 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() void Update()
{ {
if (isServer) UpdateServer(); if (isServer) UpdateServer();
if (isClientOnly) UpdateGhosting();
} }
void LateUpdate() void LateUpdate()
{ {
// only follow on client-only, not in server or host mode // only follow on client-only, not in server or host mode
if (isClientOnly) SmoothFollowPhysicsCopy(); if (isClientOnly && physicsCopy) SmoothFollowPhysicsCopy();
} }
void FixedUpdate() void FixedUpdate()
@ -394,7 +422,7 @@ void RecordState()
// grab current position/rotation/velocity only once. // grab current position/rotation/velocity only once.
// this is performance critical, avoid calling .transform multiple times. // this is performance critical, avoid calling .transform multiple times.
tf.GetPositionAndRotation(out Vector3 currentPosition, out Quaternion currentRotation); // faster than accessing .position + .rotation manually 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) // calculate delta to previous state (if any)
Vector3 positionDelta = Vector3.zero; Vector3 positionDelta = Vector3.zero;
@ -433,23 +461,25 @@ void RecordState()
protected virtual void OnSnappedIntoPlace() {} protected virtual void OnSnappedIntoPlace() {}
protected virtual void OnBeforeApplyState() {} protected virtual void OnBeforeApplyState() {}
protected virtual void OnCorrected() {} 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)
{ {
// fix rigidbodies seemingly dancing in place instead of coming to rest. // fix rigidbodies seemingly dancing in place instead of coming to rest.
// hard snap to the position below a threshold velocity. // hard snap to the position below a threshold velocity.
// this is fine because the visual object still smoothly interpolates to it. // 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. // apply server state immediately.
// important to apply velocity as well, instead of Vector3.zero. // important to apply velocity as well, instead of Vector3.zero.
// in case an object is still slightly moving, we don't want it // 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. // to stop and start moving again on client - slide as well here.
physicsCopyRigidbody.position = position; predictedRigidbody.position = position;
physicsCopyRigidbody.rotation = rotation; predictedRigidbody.rotation = rotation;
physicsCopyRigidbody.velocity = velocity; predictedRigidbody.velocity = velocity;
// clear history and insert the exact state we just applied. // clear history and insert the exact state we just applied.
// this makes future corrections more accurate. // 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? // TODO is this a good idea? what about next capture while it's interpolating?
if (correctionMode == CorrectionMode.Move) if (correctionMode == CorrectionMode.Move)
{ {
physicsCopyRigidbody.MovePosition(position); predictedRigidbody.MovePosition(position);
physicsCopyRigidbody.MoveRotation(rotation); predictedRigidbody.MoveRotation(rotation);
} }
else if (correctionMode == CorrectionMode.Set) else if (correctionMode == CorrectionMode.Set)
{ {
physicsCopyRigidbody.position = position; predictedRigidbody.position = position;
physicsCopyRigidbody.rotation = rotation; predictedRigidbody.rotation = rotation;
} }
// there's only one way to set velocity // there's only one way to set velocity
physicsCopyRigidbody.velocity = velocity; predictedRigidbody.velocity = velocity;
} }
// process a received server state. // 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 this ever causes issues, feel free to disable it.
if (compareLastFirst && if (compareLastFirst &&
Vector3.Distance(state.position, physicsCopyRigidbody.position) < positionCorrectionThreshold && Vector3.Distance(state.position, predictedRigidbody.position) < positionCorrectionThreshold &&
Quaternion.Angle(state.rotation, physicsCopyRigidbody.rotation) < rotationCorrectionThreshold) Quaternion.Angle(state.rotation, predictedRigidbody.rotation) < rotationCorrectionThreshold)
{ {
// Debug.Log($"OnReceivedState for {name}: taking optimized early return!"); // Debug.Log($"OnReceivedState for {name}: taking optimized early return!");
return; return;
@ -574,7 +604,7 @@ void OnReceivedState(double timestamp, RigidbodyState state)
// we clamp it to 'now'. // we clamp it to 'now'.
// but only correct if off by threshold. // but only correct if off by threshold.
// TODO maybe we should interpolate this back to 'now'? // 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; 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."); 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.WriteFloat(Time.deltaTime);
writer.WriteVector3(position); writer.WriteVector3(position);
writer.WriteQuaternion(rotation); 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. // read the server's state, compare with client state & correct if necessary.
public override void OnDeserialize(NetworkReader reader, bool initialState) 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 // deserialize data
// we want to know the time on the server when this was sent, which is remoteTimestamp. // we want to know the time on the server when this was sent, which is remoteTimestamp.
double timestamp = NetworkClient.connection.remoteTimeStamp; double timestamp = NetworkClient.connection.remoteTimeStamp;
@ -697,5 +723,44 @@ protected override void OnValidate()
// then we can maybe relax this a bit. // then we can maybe relax this a bit.
syncInterval = 0; 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. // Prediction moves out the Rigidbody & Collider into a separate object.
// This way the main (visual) object can smoothly follow it, instead of hard. // this component simply points back to the owner component.
using System; // in case Raycasts hit it and need to know the owner, etc.
using UnityEngine; using UnityEngine;
namespace Mirror namespace Mirror
@ -11,66 +11,5 @@ public class PredictedRigidbodyPhysicsGhost : MonoBehaviour
// PredictedRigidbody, this way we don't need to call the .transform getter. // PredictedRigidbody, this way we don't need to call the .transform getter.
[Tooltip("The predicted rigidbody owner.")] [Tooltip("The predicted rigidbody owner.")]
public Transform target; 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. // removed 2024-02-09
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);
}
}
}