新增FishNet文档

This commit is contained in:
2026-09-20 15:53:11 +08:00
parent c403145db9
commit 179c282405
431 changed files with 15476 additions and 0 deletions
@@ -0,0 +1,3 @@
# Lag Compensation
Lag compensation, also known as collider rollback, is the act of placing colliders back in time on the server to provide accurate raycast hit detection regardless of client latency. 
@@ -0,0 +1,227 @@
# Projectiles
Projectiles which move over time can have lag hidden by accelerating the projectiles for the server and spectators, while allowing the firing client to show the projectile immediately.
Another bonus to this approach is you are not networking the projectile movement each tick, which saves tremendously on bandwidth and performance.
Here is an example of what this guide will cover. Notice how even though the client has a 220ms ping the projectile is still aligned on the server. Other clients would align the projectile as well with the same guide code.
<figure><img src="../../../.gitbook/assets/predicted-projectile-example.gif" alt=""><figcaption></figcaption></figure>
First the local client, or owning client, fires the projectile. The projectile is spawned locally, then the client tells the server to also fire the projectile. The MAX\_PASSED\_TIME constant is covered in the next code snippet.
```csharp
/// <summary>
/// Projectile to spawn.
/// </summary>
[Tooltip("Projectile to spawn.")]
[SerializeField]
private PredictedProjectile _projectile;
/// <summary>
/// Maximum amount of passed time a projectile may have.
/// This ensures really laggy players won't be able to disrupt
/// other players by having the projectile speed up beyond
/// reason on their screens.
/// </summary>
private const float MAX_PASSED_TIME = 0.3f;
/// <summary>
/// Local client fires weapon.
/// </summary>
private void ClientFire()
{
Vector3 position = transform.position;
Vector3 direction = transform.forward;
/* Spawn locally with 0f passed time.
* Since this is the firing client
* they do not need to accelerate/catch up
* the projectile. */
SpawnProjectile(position, direction, 0f);
// Ask server to also fire passing in current Tick.
ServerFire(position, direction, base.TimeManager.Tick);
}
/// <summary>
/// Spawns a projectile locally.
/// </summary>
private void SpawnProjectile(Vector3 position, Vector3 direction, float passedTime)
{
PredictedProjectile pp = Instantiate(_projectile, position, Quaternion.identity);
pp.Initialize(direction, passedTime);
}#
```
When the server receives the fire request it will calculate how long it took the client to send the fire message using the provided tick. Later on the projectile will be accelerated based on this time passed.
{% hint style="info" %}
You will notice we aren't calling `Spawn` for the projectiles, that is because they aren't NetworkObjects, just regular game objects. Do not make the PredictedProjectile script a NetworkBehaviour, or give it any network components.
{% endhint %}
After the _passedTime_ is calculated, spawn the projectile on the server, then tell spectators to also spawn the projectile; spectators being other clients.
```csharp
/// <summary>
/// Fires on the server.
/// </summary>
/// <param name="position">Position to spawn projectile.</param>
/// <param name="direction">Direction to move projectile.</param>
/// <param name="tick">Tick when projectile was spawned on client.</param>
[ServerRpc]
private void ServerFire(Vector3 position, Vector3 direction, uint tick)
{
/* You may want to validate position and direction here.
* How this is done depends largely upon your game so it
* won't be covered in this guide. */
// Get passed time. Note the false for allow negative values.
float passedTime = (float)base.TimeManager.TimePassed(tick, false);
/* Cap passed time at half of constant value for the server.
* In our example max passed time is 300ms, so server value
* would be max 150ms. This means if it took a client longer
* than 150ms to send the rpc to the server, the time would
* be capped to 150ms. This might sound restrictive, but that would
* mean the client would have roughly a 300ms ping; we do not want
* to punish other players because a laggy client is firing. */
passedTime = Mathf.Min(MAX_PASSED_TIME / 2f, passedTime);
// Spawn on the server.
SpawnProjectile(position, direction, passedTime);
// Tell other clients to spawn the projectile.
ObserversFire(position, direction, tick);
}
```
Observers use the same technique to calculate the passed time and spawn the projectile. There are a few small things to note.
First, this RPC is sent to everyone but the owner. The owner does not need to receive the RPC because it already spawned the projectile locally.
Second, the passed time calculation is not limited by half. This is to support the maximum possible passed time. This is not a requirement but is recommended.
```csharp
/// <summary>
/// Fires on all clients but owner.
/// </summary>
[ObserversRpc(ExcludeOwner = true)]
private void ObserversFire(Vector3 position, Vector3 direction, uint tick)
{
// Like on server get the time passed and cap it. Note the false for allow negative values.
float passedTime = (float)base.TimeManager.TimePassed(tick, false);
passedTime = Mathf.Min(MAX_PASSED_TIME, passedTime);
// Spawn the projectile locally.
SpawnProjectile(position, direction, passedTime);
}
```
With the projectile spawned all that's left is showing how to use the calculated passed time. You likely noticed the `SpawnProjectile` method was initializing the projectile with some values. Here's what that looks like:
```csharp
/// <summary>
/// Direction to travel.
/// </summary>
private Vector3 _direction;
/// <summary>
/// Distance remaining to catch up. This is calculated from a passed time and move rate.
/// </summary>
private float _passedTime = 0f;
/// <summary>
/// In this example the projectile moves at a flat rate of 5f.
/// </summary>
private const float MOVE_RATE = 5f;
/// <summary>
/// Initializes this projectile.
/// </summary>
/// <param name="direction">Direction to travel.</param>
/// <param name="passedTime">How far in time this projectile is behind te prediction.</param>
public void Initialize(Vector3 direction, float passedTime)
{
_direction = direction;
_passedTime = passedTime;
}
```
After initializing with the specified passed time and direction all that's left to accelerate the projectile is a move method. The provided example is a very basic implementation of a move method, while also applying the acceleration.
If there is passed time to apply, then additional delta is added using the `passedTimeDelta` variable.
The percentage applied when assigning the _step_ variable decides how fast your projectile will catch up to the predicted value. In this code I am using 8% of the passed time per Move call. Higher values will result in the projectile catching up faster.
```csharp
/// <summary>
/// Move the projectile each frame. This would be called from Update.
/// </summary>
private void Move()
{
// Frame delta, nothing unusual here.
float delta = Time.deltaTime;
// See if to add on additional delta to consume passed time.
float passedTimeDelta = 0f;
if (_passedTime > 0f)
{
/* Rather than use a flat catch up rate the
* extra delta will be based on how much passed time
* remains. This means the projectile will accelerate
* faster at the beginning and slower at the end.
* If a flat rate was used then the projectile
* would accelerate at a constant rate, then abruptly
* change to normal move rate. This is similar to using
* a smooth damp. */
/* Apply 8% of the step per frame. You can adjust
* this number to whatever feels good. */
float step = (_passedTime * 0.08f);
_passedTime -= step;
/* If the remaining time is less than half a delta then
* just append it onto the step. The change won't be noticeable. */
if (_passedTime <= (delta / 2f))
{
step += _passedTime;
_passedTime = 0f;
}
passedTimeDelta = step;
}
// Move the projectile using moverate, delta, and passed time delta.
transform.position += _direction * (MOVE_RATE * (delta + passedTimeDelta));
}
```
Very last, keep in mind these projectiles are fired locally and are not networked. Because of this you may want to perform different actions based on if client or server. The code below demonstrates what a collision event might look like.
```csharp
/// <summary>
/// Handles collision events.
/// </summary>
private void OnCollisionEnter(Collision collision)
{
/* These projectiles are instantiated locally, as in,
* they are not networked. Because of this there is a very
* small chance the occasional projectile may not align with
* 100% accuracy. But, the differences are generally
* insignifcant and will not affect gameplay. */
// If client show visual effects, play impact audio.
if (InstanceFinder.IsClient)
{
// Show VFX.
// Play Audio.
}
// If server check to damage hit objects.
if (InstanceFinder.IsServer)
{
PlayerShip ps = collision.gameObject.GetComponent<PlayerShip>();
/* If a player ship was hit then remove 50 health.
* The health value can be synchronized however you like,
* such as a syncvar. */
if (ps != null)
ps.Health -= 50f;
}
// Destroy projectile (probably pool it instead).
Destroy(gameObject);
}
```
@@ -0,0 +1,70 @@
# Raycast
{% hint style="info" %}
Fish-Networking [Pro](../../../overview/readme/pro-projects-and-support.md) is required for raycast lag compensation. Over-time projectiles may use another approach and do not require Pro. See [Projectiles ](projectiles.md)for more information.
{% endhint %}
This feature can be setup easily in a few steps.
To begin I am entering the ColliderRollbackDemo scene included with Fish-Networking Pro. You do not need to open the scene, but if you wish to follow along visually you may.
## Hitboxes
In my scene is an object named EnemySetup. The screen capture below shows two boxes on the 'enemy'. One for the body, and one for the head.
<figure><img src="../../../.gitbook/assets/collider-rollback-hitboxes.png" alt=""><figcaption></figcaption></figure>
Each hitbox must be on a new object. Generally speaking, the hitbox would be a child of whichever component moves. With an actual humanoid rig, you would probably place the head collider on a child beneath the neck bone.
Notice the 'hitbox' objects are children of the moving component, and only have a collider on them. You may use any collider type.
<figure><img src="../../../.gitbook/assets/collider-rollback-children-objects.png" alt=""><figcaption></figcaption></figure>
{% hint style="info" %}
When using a [Tick Smoother](../../../fishnet-building-blocks/components/tick-smoothers/), ensure that the hitboxes are placed on the "target" objects rather than smoothed objects (the object with the smoother component or one of its children). When a rollback occurs, the server automatically interpolates (lerps) hitboxes between positions, making local smoothing unnecessary. To maintain consistency, set the smoother’s **Flat Interpolation** value to match the **Additional Ticks** configured in your **Rollback Manager**.
{% endhint %}
Next the ColliderRollback script must be added somewhere within your prefab or object. For the sake of simplicity I have mine on the root, with the NetworkObject.
Once added, specify each object you added as a collider within the **Collider Parents** collection.
{% hint style="info" %}
To make things easier, place the suffix 'Hitbox' on all your hitbox objects. You can then lock the inspector on your ColliderRollback object, search Hitbox, and quickly drag all entries in at once.
{% endhint %}
<figure><img src="../../../.gitbook/assets/collider-rollback-setup.png" alt=""><figcaption></figcaption></figure>
You've completed all the steps required for setting up an object.
## Rollback Manager
To utilize the lag compensation you must also add the RollbackManager script to your Networkmanager object. We have a section on the [RollbackManager ](../../../fishnet-building-blocks/components/managers/rollbackmanager-pro-feature.md)component to understand what each field does. Once you review the component settings we can move onto the code.
## Performing a rollback
The RollbackManager must know how far back in time to place colliders to obtain accurate hit results. When your client is to fire their weapon you will want to gather the current PreciseTick and include it with your Fire RPC.
```csharp
[Client]
private void Fire()
{
// Use LastPacketTick to get the best tick alignment.
PreciseTick pt = base.TimeManager.GetPreciseTick(TickType.LastPacketTick);
// Call fire on the server.
ServerFire(pt);
}
[ServerRpc]
private void ServerFire(PreciseTick pt)
{
// Rollback using the precise tick sent in.
// Using Physics for 3d rollback, Physics3D for 2d rollback.
// Both physics types can be used at once.
base.RollbackManager.Rollback(pt, RollbackManager.PhysicsType.Physics, base.IsOwner);
// Perform your raycast normally.
RaycastHit hit;
if (Physics.Raycast(transform.position, transform.forward, out hit)) { }
// Return the colliders to their proper positions.
base.RollbackManager.Return();
}
```
@@ -0,0 +1,84 @@
# States
States sometimes need to be synchronized between clients and server to prevent desynchronization. This is generally only true when using client-side prediction. However, it is possible this guide could be used outside prediction so please keep that in mind when reading.
Ideally when using prediction the client should run all states locally as they would normally occur, just like the server. For example, if you were to drive a ship into the wall and want to apply a slow, the client and server would apply the state immediately when hitting the wall. In such a scenario the states would most likely exist in the replicate and reconcile data. However, there are cases when only a server is aware of a state, and if the server applies the state before the client does a desynchronization will occur. If this is the design you are intending, consider reworking your logic so clients and server run the same simulation instead; otherwise, continue reading!
A common state that requires synchronizing as example given is something that would impact movement. In this guide I'll be demonstrating on how to apply an EMP.
Since only the server is aware when the state is applied we will perform a check on the server, using the _base.IsServer_ check. When the condition is satisfied as by our pretend _ApplyEmp()_ method a RPC is sent.
```csharp
private void TimeManager_OnTick()
{
/* We're going to pretend there is
* some logic here which happens only on the
* server resulting in applying a EMP.
*
* The EMP is set to apply 50ms in the future.
* However many ticks used are up to you. The goal is
* to give clients a chance to get the emp tick
* before that tick actually runs on their end.
* This will have clients all run the EMP at roughly
* the same time, keeping in mind there is sometimes a
* small margin of error of 1 tick. */
if (base.IsServer && ApplyEMP())
{
uint futureTicks = base.TimeManager.TimeToTicks(0.05f, TickRounding.RoundUp);
ObserversSetEMPTick(base.TimeManager.LocalTick + futureTicks);
}
}
// RunLocally is used to the server also sets the emp tick.
[ObserversRpc(RunLocally = true)]
private void ObserversSetEMPTick(uint serverTick)
{
// Converts the server EMP tick to local tick for this client.
_empStartTick = base.TimeManager.TickToLocalTick(serverTick);
// Set end tick. In our example the EMP will last 1 second.
_empEndTick = _empStartTick + base.TimeManager.TimeToTicks(1f, TickRounding.RoundUp);
}
```
When the RPC is received two fields are set, a tick when to start the EMP and when to end it.
Next in our replicate method if the clients or servers LocalTick is within that EMP duration we prevent any further motor movement. As mentioned this example uses prediction but you could apply this to an Update loop as well if not using prediction.
```csharp
// Tick when to start emp.
private uint _empStartTick = uint.MaxValue;
// Tick when to end emp.
private uint _empEndTick = uint.MaxValue;
/* This is an example of a replicate data excluding the extra
* parameters, given they have no context in this example. */
[Replicate]
private void Replicate(MotorData md)
{
uint localTick = base.TimeManager.LocalTick;
/* If localTick is between EMP range then return.
* It's important to not reset the emp values because
* we want these to be the same during replays. If you reset
* values soon as the condition of exiting emp was satisfied
* then emp would not be properly set during a replay.
*
* Note that this logic runs on the server and owner,
* and if using prediction v2 can run on other clients
* as well. */
if (localTick >= _empStartTick && localTick <= _empEndTick)
{
/* Since under emp exit method early
* to not process MotorData. In this example
* we are using an EMP state, therefor the motor
* will not work. You'll adjust this to whatever
* your game needs. */
return;
}
// Normal motor logic here.
}
```
Just like that the client is set to apply the EMP marginally in the future, and as will the server. Yes, this does create a very small delay on when the EMP or state begins but this is often considered acceptable to maintain a reliable simulation. In addition, also as said before, ideally the client will be running the same simulation as the server at all times which would let you avoid this sort of setup.