Im trying to sync particle system over the network using FishNet. Im having issues with particles that i remove by setting them to default (no remaining life time i.e. dead), but the particle is still being shown. There is nothing to indicate the particle is alive nor that any other particles are alive.
the code below is server authoritative so i dont believe that to be causing the issue. basically, you want to look at the SyncParticles_OnChange
callback. i have the particles emitting at a rate that wouldnt trigger a list set operation, so add/remove is well enough.
the Add callback works just fine, the particles spawn once it syncs from server, spawn at the correct position, and travel at the correct velocity
i think the issue is in the Remove callback. as you can see by the logs in the video below, no particles seem to exist after they are removed, but sometimes that one particle goes “back in time” a bit and doesnt get destroyed. this happens even though the sync list is empty of particles, and so is the particle system itself as evidenced by the logged remainingLifetimes being less than or equal to 0.
ive been stuck on this for a week and am ready to put it on the back burner for a very long time, so any help now would be greatly appreciated!
My Code
using FishNet;
using FishNet.Connection;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using static UnityEngine.ParticleSystem;
[RequireComponent(typeof(ParticleSystem))]
public class NetworkParticleSystem : NetworkBehaviour
{
[System.Serializable]
private struct ParticleSyncData
{
public uint Seed;
public float Time;
public uint Tick;
public override string ToString() => $"Seed: {Seed}, PS Time: {Time}, Server tick: {Tick}";
}
[SerializeField] private ParticleSystem _particleSystem = null;
public ParticleSystem ParticleSystem => _particleSystem;
[SerializeField] private bool _syncAllParticles = false;
[SyncVar(OnChange = nameof(OnSeedChanged))]
private ParticleSyncData _psSyncData;
private void OnSeedChanged(ParticleSyncData old, ParticleSyncData syncData, bool asServer)
{
if (asServer || IsHost) return;
_particleSystem.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
_particleSystem.randomSeed = syncData.Seed;
_particleSystem.Simulate(syncData.Time - (float)TimeManager.TicksToTime(TimeManager.Tick - syncData.Tick), true);
_particleSystem.Play(true);
}
[Header("Events")]
/// <summary>
/// Invokes the GameObject that collided with the particle.
/// </summary>
public UnityEvent<GameObject> OnParticleCollisionWithGameObject;
/// <summary>
/// Invokes the index of the particle that was born. Used to get info by indexing CustomData or Particles.
/// </summary>
public UnityEvent<int> OnParticleBirth;
/// <summary>
/// Invokes the index of the particle that died. Used to get info by indexing CustomData or Particles.
/// </summary>
public UnityEvent<int> OnParticleDeath;
private void OnParticleCollision(GameObject other)
{
InvokeParticleCollision(other);
}
void InvokeParticleCollision(GameObject other)
{
OnParticleCollisionWithGameObject?.Invoke(other);
}
public override void OnSpawnServer(NetworkConnection connection)
{
base.OnSpawnServer(connection);
_psSyncData = new ParticleSyncData()
{
Seed = _particleSystem.randomSeed,
Time = _particleSystem.time,
Tick = TimeManager.Tick,
};
}
protected override void OnValidate()
{
base.OnValidate();
if (_particleSystem == null)
_particleSystem = GetComponent<ParticleSystem>();
}
/// <summary>
/// <para> x = unique particle ID </para>
/// <para> y = </para>
/// <para> z = </para>
/// <para> w = </para>
/// </summary>
private List<Vector4> _customData = new List<Vector4>();
public List<Vector4> CustomData => _customData;
private int _customParticleDataCount;
private int _particleCount;
private Particle[] _particles;
public Particle[] Particles => _particles;
[SyncObject]
private readonly SyncList<ParticleData> _syncParticles = new SyncList<ParticleData>();
private void Awake()
{
_particles = new Particle[_particleSystem.main.maxParticles];
// custom data must be OFF in order to sync custom particle IDs across server
var customData = _particleSystem.customData;
customData.enabled = false;
}
public override void OnStartServer()
{
base.OnStartServer();
TimeManager.OnLateUpdate += TimeManager_OnLateUpdate;
}
public override void OnStopServer()
{
base.OnStopServer();
TimeManager.OnLateUpdate -= TimeManager_OnLateUpdate;
}
private void TimeManager_OnLateUpdate()
{
if (!IsServer) return;
if (!_syncAllParticles) return;
_customParticleDataCount = _particleSystem.GetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
_particleCount = _particleSystem.GetParticles(_particles);
if (_customParticleDataCount != _particleCount)
Debug.LogWarning("Custom data and particle counts out of sync!");
for (int i = 0; i < _particleCount; i++)
{
// particle death if has ID (was born) and would be killed this tick
if (_customData[i].x != 0 && _particles[i].remainingLifetime <= 0)
InvokeParticleDeath(i);
// assign unique ID to particle on birth
else if (_customData[i].x == 0)
InvokeParticleBirth(i);
// else particle still alive and moving
}
_particleSystem.SetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
}
int GetUniqueId()
{
return Animator.StringToHash(System.Guid.NewGuid().ToString());
}
void InvokeParticleDeath(int index)
{
OnParticleDeath?.Invoke(index);
Debug.Log($"Particle died. ID: {_customData[index]}");
_customData[index] = Vector4.zero;
//_syncParticles[index] = default;
_syncParticles.RemoveAt(index);
}
void InvokeParticleBirth(int index)
{
_customData[index] = new Vector4(GetUniqueId(), 0);
Debug.Log("Particle born! ID: " + _customData[index].x);
ParticleData newParticleData = new ParticleData(this, index);
if (index >= _syncParticles.Count)
_syncParticles.Add(newParticleData);
else
_syncParticles[index] = newParticleData;
OnParticleBirth?.Invoke(index);
}
private void OnEnable()
{
_syncParticles.OnChange += SyncParticles_OnChange;
}
private void OnDisable()
{
_syncParticles.OnChange -= SyncParticles_OnChange;
}
int FindIndexOfParticleWithID(int id)
{
if (id == 0)
{
Debug.LogWarning("Particle id was 0, invalid");
return 0;
}
_customParticleDataCount = _particleSystem.GetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
for (int i = 0; i < _customParticleDataCount; i++)
if (_customData[i].x == id)
return i;
return -1;
}
int FindEmptyParticleIndex()
{
_customParticleDataCount = _particleSystem.GetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
if (_customParticleDataCount > 0)
{
for (int i = 0; i < _customParticleDataCount; i++)
if (_customData[i].x == 0f)
return i;
}
_particleCount = _particleSystem.GetParticles(_particles);
for (int i = 0; i < _particleCount; i++)
if (_particles[i].remainingLifetime == 0)
return i;
return -1;
}
private void SyncParticles_OnChange(SyncListOperation op, int index, ParticleData oldItem, ParticleData newItem, bool asServer)
{
if (op == SyncListOperation.Complete) return;
Debug.Log($"Op: {op}, old: {oldItem}, newItem: {newItem}, as server? {asServer}, list size: {_syncParticles.Count}");
if (IsServer) return;
Particle particle;
// get current state of particles
_particleCount = _particleSystem.GetParticles(_particles);
switch (op)
{
case SyncListOperation.Add:
particle = newItem.ToParticle();
particle.remainingLifetime -= (float)TimeManager.TicksToTime(TimeManager.Tick - newItem.Tick);
_particles[newItem.Index] = particle;
_particleSystem.SetParticles(_particles);
_customParticleDataCount = _particleSystem.GetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
_customData[newItem.Index] = newItem.CustomData;
_particleSystem.SetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
break;
case SyncListOperation.RemoveAt:
int oldIndex = FindIndexOfParticleWithID(oldItem.ID);
// old particle doesnt exist on client
if (oldIndex == -1) break;
_particles[oldIndex] = default;
_particles[oldIndex].remainingLifetime = -1;
_particleSystem.SetParticles(_particles);
for (int i = 0; i < _particleCount; i++)
{
if (_particles[i].remainingLifetime > 0 || _customData[i].x != 0)
{
Debug.Log($"Particle {i} with ID {_customData[i].x} rem life: {_particles[i].remainingLifetime}");
}
}
_customParticleDataCount = _particleSystem.GetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
_customData[oldIndex] = default;
_particleSystem.SetCustomParticleData(_customData, ParticleSystemCustomData.Custom1);
break;
}
}
public override void OnStartClient()
{
base.OnStartClient();
// host check
if (IsServer) return;
var collision = _particleSystem.collision;
collision.enabled = false;
}
}
[System.Serializable]
public struct ParticleData : IEquatable<ParticleData>
{
public int ID => (int)CustomData.x;
public int Index;
public Vector4 CustomData;
public uint Tick;
public uint RandomSeed;
//public int MeshIndex;
public Vector3 AngularVelocity3D;
public float AngularVelocityZ;
public Vector3 StartSize3D;
public Vector3 Position;
public Vector3 Velocity;
public Vector3 Rotation3D;
public float RemainingLifetime;
public float StartLifetime;
public float RotationZ;
public Color32 StartColor;
public Vector3 AxisOfRotation;
public float StartSizeX;
public override string ToString()
{
return $"ID: {CustomData}, Tick: {Tick}, Index: {Index}, Pos: {Position}, Vel: {Velocity}, Remaining: {RemainingLifetime}, Seed: {RandomSeed}";
}
public ParticleData(NetworkParticleSystem parent, int index)
{
var particle = parent.Particles[index];
AngularVelocityZ = particle.angularVelocity;
AngularVelocity3D = particle.angularVelocity3D;
AxisOfRotation = particle.axisOfRotation;
// TODO this line causes unity to crash
//MeshIndex = particle.GetMeshIndex(parent.ParticleSystem);
Position = particle.position;
RandomSeed = particle.randomSeed;
RemainingLifetime = particle.remainingLifetime;
RotationZ = particle.rotation;
Rotation3D = particle.rotation3D;
StartColor = particle.startColor;
StartLifetime = particle.startLifetime;
StartSizeX = particle.startSize;
StartSize3D = particle.startSize3D;
Velocity = particle.velocity;
Index = index;
CustomData = parent.CustomData[index];
Tick = parent.TimeManager.Tick;
}
public Particle ToParticle()
{
Particle particle = new Particle();
particle.angularVelocity = AngularVelocityZ;
particle.angularVelocity3D = AngularVelocity3D;
particle.axisOfRotation = AxisOfRotation;
particle.startColor = StartColor;
//particle.SetMeshIndex(MeshIndex);
particle.position = Position;
particle.randomSeed = RandomSeed;
particle.remainingLifetime = RemainingLifetime;
particle.rotation = RotationZ;
particle.rotation3D = Rotation3D;
particle.startLifetime = StartLifetime;
particle.startSize = StartSizeX;
particle.startSize3D = StartSize3D;
particle.velocity = Velocity;
return particle;
}
public bool Equals(ParticleData other)=>this.ID == other.ID;
}
(You can go frame by frame in youtube by pressing < and > btw)