using System.Collections.Concurrent; using System.Text.Json.Serialization; using System.Threading; namespace Sandbox; /// /// Defines and holds particles. This is the core of the particle system. /// [Expose] [Title( "Particle Effect" )] [Category( "Particles" )] [Icon( "shower" )] [EditorHandle( "materials/gizmo/particles.png" )] public sealed partial class ParticleEffect : Component, Component.ExecuteInEditor, Component.ITemporaryEffect, Component.ITintable { /// /// The maximum number of particles that can exist in this effect at once. /// [Property, Header( "Limits" )] public int MaxParticles { get; set; } = 1000; /// /// The lifetime of each particle, in seconds. /// [Property] public ParticleFloat Lifetime { get; set; } = 1.0f; /// /// Scales the simulation time for this effect. /// [Property, Header( "Time" ), Range( 0, 1 )] public float TimeScale { get; set; } = 1.0f; /// /// How many seconds to pre-warm this effect by when creating. /// [Property, Range( 0, 1 )] public float PreWarm { get; set; } = 0.0f; /// /// The delay before a particle starts after being emitted, in seconds. /// [Property] public ParticleFloat StartDelay { get; set; } = 0.0f; /// /// Per-particle time scale multiplier. Allows each particle to have a unique simulation speed. /// [Property] public ParticleFloat PerParticleTimeScale { get; set; } = 1.0f; public enum TimingMode { /// /// Use game simulation time (affected by game time scale). /// GameTime, /// /// Use real-world time (ignores game time scale). /// RealTime, } /// /// How time is updated for this effect. /// [Property] public TimingMode Timing { get; set; } = TimingMode.GameTime; /// /// The initial velocity of the particle when it is created. This is applied before any forces are applied. /// [Property, Feature( "Move" )] public ParticleVector3 InitialVelocity { get; set; } /// /// Apply an element of random velocity to the particle when it is created, in a random direction. /// [Title( "Random Velocity" )] [Property, Feature( "Move", Icon = "animation", Description = "The spatial properties of each particle" )] public ParticleFloat StartVelocity { get; set; } = 0.0f; /// /// The damping factor applied to particle velocity over time. /// This reduces the velocity of particles, simulating resistance or drag. /// [Property, Feature( "Move" )] public ParticleFloat Damping { get; set; } = 0.0f; /// /// Move this delta constantly. Ignores velocity, collisions and drag. /// [Property, Feature( "Move" )] public ParticleVector3 ConstantMovement { get; set; } [Hide, JsonIgnore] [Obsolete( "Use LocalSpace instead" )] [Property, Feature( "Move" )] public SimulationSpace Space { get; set; } /// /// When 1 particles will be moved in local space relative to the emitter GameObject's transform. /// This allows particles to be emitted in a local space, like a fire effect that moves with the player, but the particles can slowly move to world space. /// [Property, Feature( "Move" )] public ParticleFloat LocalSpace { get; set; } = 0.0f; /// /// Enables or disables rotation for particles. /// [Property, FeatureEnabled( "Rotation", Icon = "flip_camera_android", Description = "The rotation of the particles (not the emitter)" )] public bool ApplyRotation { get; set; } = false; /// /// The pitch rotation of the particles. /// [Property, Feature( "Rotation" )] public ParticleFloat Pitch { get; set; } = 0.0f; /// /// The yaw rotation of the particles. /// [Property, Feature( "Rotation" )] public ParticleFloat Yaw { get; set; } = 0.0f; /// /// The roll rotation of the particles. /// [Property, Feature( "Rotation" )] public ParticleFloat Roll { get; set; } = 0.0f; /// /// Enables or disables color application for particles. /// [Property, FeatureEnabled( "Color", Icon = "color_lens", Description = "The visual properties of each particle" )] public bool ApplyColor { get; set; } = false; /// /// Enables or disables alpha application for particles. /// [Property, Feature( "Color" )] public bool ApplyAlpha { get; set; } = false; /// /// The tint color applied to particles. /// [Property, Feature( "Color" )] public Color Tint { get; set; } = Color.White; /// /// The gradient used to color particles over their lifetime. /// [Property, Feature( "Color" )] public ParticleGradient Gradient { get; set; } = Color.White; /// /// The brightness multiplier applied to particles. /// [Property, Feature( "Color" )] public ParticleFloat Brightness { get; set; } = 1.0f; /// /// The alpha transparency of particles. /// [Property, Feature( "Color" )] public ParticleFloat Alpha { get; set; } = 1.0f; /// /// Enables or disables shape application for particles. /// [Property, FeatureEnabled( "Shape", Icon = "crop", Description = "The scale/size of each particle" )] public bool ApplyShape { get; set; } = false; /// /// The scale of particles. /// [Property, Feature( "Shape" )] public ParticleFloat Scale { get; set; } = 1.0f; /// /// The stretch factor of particles, affecting their aspect ratio. /// [Property, Feature( "Shape" )] public ParticleFloat Stretch { get; set; } = 0.0f; /// /// Enables or disables the application of forces to particles. /// [Property, FeatureEnabled( "Force", Icon = "cloud_sync", Description = "A force that is applied to each particle in the effect" )] public bool Force { get; set; } /// /// The direction of the force applied to particles. /// [Property, Feature( "Force" )] public Vector3 ForceDirection { get; set; } /// /// The scale of the force applied to each particle. /// This multiplier determines the intensity of the force applied to particles. /// [Property, Feature( "Force" )] public ParticleFloat ForceScale { get; set; } = 1.0f; /// /// The orbital force applied to particles, causing them to rotate around a point. /// [Property, Feature( "Force" )] public ParticleVector3 OrbitalForce { get; set; } /// /// The pull strength of the orbital force, drawing particles closer to the center. /// [Property, Feature( "Force" )] public ParticleFloat OrbitalPull { get; set; } /// /// The simulation space in which forces are applied. /// Forces can be applied in either local space (relative to the emitter) or world space. /// [Title( "Space" )] [Property, Feature( "Force" )] public SimulationSpace ForceSpace { get; set; } /// /// Enables or disables collision behavior for particles. /// [Property, FeatureEnabled( "Collision", Icon = "file_download", Description = "What should happen when particles collide with the world" )] public bool Collision { get; set; } /// /// The chance that a particle will die upon collision. /// [Property, Feature( "Collision" )] public ParticleFloat DieOnCollisionChance { get; set; } = 0.0f; /// /// The radius used for collision detection. /// [Property, Feature( "Collision" )] public float CollisionRadius { get; set; } = 1.0f; /// /// The set of tags to ignore during collision detection. /// [Property, Feature( "Collision" )] public TagSet CollisionIgnore { get; set; } /// /// The bounce factor applied to particles upon collision. /// [Property, Feature( "Collision" )] public ParticleFloat Bounce { get; set; } = 1.0f; /// /// The friction factor applied to particles upon collision. /// [Property, Feature( "Collision" )] public ParticleFloat Friction { get; set; } = 1.0f; /// /// The bumpiness factor applied to particles upon collision. /// [Property, Feature( "Collision" )] public ParticleFloat Bumpiness { get; set; } = 0.0f; /// /// The strength of the push force applied to particles upon collision. /// [Property, Feature( "Collision" )] public ParticleFloat PushStrength { get; set; } = 0.0f; /// /// Enables or disables the use of a sheet sequence for particles. /// [Title( "Sheet" )] [Property, FeatureEnabled( "SheetSequence", Icon = "apps" )] public bool SheetSequence { get; set; } /// /// Which sequence to use. /// [Property, Feature( "SheetSequence" )] public ParticleFloat SequenceId { get; set; } = 0.0f; /// /// Allows control of the sequence time, which spans from 0 to 1 for one loop. /// [Property, Feature( "SheetSequence" ), Range( 0, 1 )] public ParticleFloat SequenceTime { get; set; } = 1.0f; /// /// Increment the sequence time by this much. /// [Property, Feature( "SheetSequence" )] public ParticleFloat SequenceSpeed { get; set; } = 1.0f; /// /// Enables or disables the use of prefabs for particles. /// [Property, FeatureEnabled( "Prefab", Icon = "widgets", Description = "Attach a prefab to a particle" )] public bool UsePrefabFeature { get; set; } = false; /// /// Will choose a random prefab to spawn from this list. /// [Header( "Follower" )] [Property, Feature( "Prefab" )] public List FollowerPrefab { get; set; } /// /// If 1 then we'll always spawn a prefab. If 0.5 then we'll spawn a prefab 50% of the time. /// [Property, Feature( "Prefab" ), Title( "Spawn Chance" )] public ParticleFloat FollowerPrefabChance { get; set; } = 1; /// /// When true the prefab will be destroyed at the end of the particle's life. /// [Property, Feature( "Prefab" ), Title( "Kill on death" )] public bool FollowerPrefabKill { get; set; } = true; /// /// Will choose a random prefab to spawn from this list. /// [Header( "On Collision" )] [Property, Feature( "Prefab" )] public List CollisionPrefab { get; set; } /// /// Will choose a random prefab to spawn from this list. /// [Property, Feature( "Prefab" ), Title( "Align With Surface" )] public bool CollisionPrefabAlign { get; set; } /// /// We will by default align to the particle's angle, but we can also randomize that. /// [Property, Feature( "Prefab" ), Title( "Rotation" ), ShowIf( "CollisionPrefabAlign", true )] public ParticleFloat CollisionPrefabRotation { get; set; } /// /// If 1 then we'll always spawn a prefab. If 0.5 then we'll spawn a prefab 50% of the time. /// [Property, Feature( "Prefab" ), Title( "Spawn Chance" )] public ParticleFloat CollisionPrefabChance { get; set; } = 1; /// /// Called any time a particle is destroyed. /// [Property, Header( "Actions" )] public Action OnParticleDestroyed { get; set; } /// /// Called any time a particle is created. /// [Property] public Action OnParticleCreated { get; set; } /// /// Active particles in the effect. /// Active particles are those currently being simulated and rendered. /// public List Particles { get; } = new List(); /// /// Delayed particles in the effect. /// Delayed particles are those that have been emitted but are waiting to be activated based on their start delay. /// public List DelayedParticles { get; } = new List(); /// /// The total number of particles in the effect, including both active and delayed particles. /// public int ParticleCount => Particles.Count + DelayedParticles.Count; /// /// Whether the particle effect has reached its maximum capacity. /// This is determined by comparing the total particle count to the property. /// public bool IsFull => ParticleCount >= MaxParticles; /// /// Whether the particle simulation is currently paused. /// When paused, particles will not update their positions, velocities, or other properties. /// [JsonIgnore] public bool Paused { get; set; } Transform lastTransform; ConcurrentQueue deleteList = new ConcurrentQueue(); /// /// Called before the particles are stepped. /// This allows custom logic to be executed before the simulation advances. /// public Action OnPreStep { get; set; } /// /// Called after the particles are stepped. /// This allows custom logic to be executed after the simulation advances. /// public Action OnPostStep { get; set; } /// /// Called after each particle is stepped. /// This provides an opportunity to modify individual particles during the simulation. /// public Action OnStep { get; set; } /// /// The bounding box that encompasses all active particles. /// This is useful for determining the spatial extent of the particle effect. /// public BBox ParticleBounds { get; internal set; } /// /// The size of the largest particle in the effect. /// This is determined by the maximum scale of any particle along its x, y, or z axis. /// public float MaxParticleSize { get; internal set; } Color ITintable.Color { get => Tint; set => Tint = value; } List _spawnedGameObjects = new(); public enum SimulationSpace { /// /// Forces are applied in world space, independent of the emitter's position or rotation. /// World, /// /// Forces are applied in local space, relative to the emitter's position and rotation. /// Local } bool isWarmed; protected override void OnEnabled() { base.OnEnabled(); isWarmed = false; lastTransform = WorldTransform; } protected override void OnDisabled() { Clear(); } public void Clear() { foreach ( var p in Particles.ToArray() ) { Terminate( p ); } Particles.Clear(); DelayedParticles.Clear(); foreach ( var go in _spawnedGameObjects ) { if ( !go.IsValid() ) continue; go.Destroy(); } _spawnedGameObjects.Clear(); } public void ResetEmitters() { Components.ExecuteEnabledInSelfAndDescendants( e => e.ResetEmitter() ); } long _maxDistance; long _maxSize; float _timeDelta; SceneTrace _trace; bool _parentMoved; Transform _worldTx; Vector3 _worldForce; internal void UpdateParticle( int index ) { var p = Particles[index]; // keep updating deathtime, incase we're in the editor and they're changing shit p.DeathTime = p.BornTime + Lifetime.Evaluate( p.Rand( 155, 100 ), p.Rand( 145, 100 ) ); float delta = MathX.Remap( p.BornTime + p.Age, p.BornTime, p.DeathTime ); p.LifeDelta = delta; var timeScale = PerParticleTimeScale.Evaluate( p, 3355 ) * _timeDelta * p.TimeScale; var frame = p.Frame; p.Age += timeScale; p.Frame++; // delay - not spawned yet (BornTime is in the future) if ( p.LifeDelta < 0 ) return; var damping = Damping.Evaluate( p, 8234 ); var forceScale = ForceScale.Evaluate( p, 7723 ); var localSpace = LocalSpace.Evaluate( p, 254 ).Clamp( 0, 1 ); if ( _parentMoved && frame > 0 && localSpace > 0.001f ) { var localPos = lastTransform.PointToLocal( p.Position ); var worldPos = _worldTx.PointToWorld( localPos ); p.Position = p.Position.LerpTo( worldPos, localSpace ); } p.ApplyDamping( damping * timeScale ); OnStep?.Invoke( p, p.LifeDelta ); if ( Force && forceScale != 0.0f ) { if ( !ForceDirection.IsNearlyZero() ) { p.Velocity += forceScale * (ForceSpace == SimulationSpace.Local ? _worldForce : ForceDirection) * timeScale; } if ( !OrbitalForce.IsNearlyZero() ) { var force = OrbitalForce.Evaluate( delta, p.Rand( 8363 ), p.Rand( 5216 ), p.Rand( 2323 ) ); var localOffset = (_worldTx.Position - p.Position).Normal; var rotatedOffset = localOffset.RotateAround( 0, new Angles( force ) ); var rotDelta = localOffset - rotatedOffset; p.Velocity += forceScale * rotDelta * timeScale; } if ( !OrbitalPull.IsNearlyZero() ) { var localOffset = (_worldTx.Position - p.Position) / 100.0f; p.Velocity += forceScale * localOffset * timeScale * OrbitalPull.Evaluate( delta, p.Rand( 4333 ) ); } } // Apply constant movement if ( !ConstantMovement.IsNearlyZero() ) { p.Position += ConstantMovement.Evaluate( p, 4395 ) * _timeDelta; } if ( Collision ) { var bounce = Bounce.Evaluate( p, 3478 ); var friction = Friction.Evaluate( p, 7579 ); var bumpiness = Bumpiness.Evaluate( p, 2380 ); var push = PushStrength.Evaluate( p, 5281 ); var die = DieOnCollisionChance.Evaluate( p, 4582 ) > 0.5f; var radius = MathF.Max( 0.01f, CollisionRadius ); if ( Scene.IsEditor ) push = 0; var hitTime = Time.Now - p.HitTime; var collided = p.MoveWithCollision( bounce, friction, bumpiness, push, die, timeScale, radius, _trace ); if ( collided && hitTime > 0.3f && UsePrefabFeature && CollisionPrefabChance.Evaluate( delta, Random.Shared.Float( 0, 1 ) ) > Random.Shared.Float( 0, 1 ) ) { var prefabSource = Random.Shared.FromList( CollisionPrefab ); if ( prefabSource is not null ) { Rotation angle = p.Angles; Vector3 position = p.Position; if ( CollisionPrefabAlign ) { angle = Rotation.LookAt( p.HitNormal, p.Angles.Forward ); var rot = CollisionPrefabRotation.Evaluate( delta, Random.Shared.Float( 0, 1 ) ); angle = Rotation.FromYaw( rot ) * angle; position = p.HitPos; } // Queue the collision prefabs to spawn on the main thread ParticleCollisionPrefabs.Add( new( prefabSource, position, angle ) ); } } } else { p.Position += p.Velocity * timeScale; } if ( ApplyColor ) { var brightness = Brightness.Evaluate( p, 4626 ); p.Color = Tint * Gradient.Evaluate( p, 8752 ); // TODO, gradient, between two gradients etc p.Color *= new Color( brightness, 1.0f ); } if ( ApplyAlpha ) { p.Alpha = Alpha.Evaluate( p, 8525 ); } if ( ApplyShape ) { p.Size = Scale.Evaluate( p, 6211 ); var aspect = Stretch.Evaluate( p, 62415 ); if ( aspect < 0 ) { p.Size.x *= aspect.Remap( 0, -1, 1, 2, false ); } else if ( aspect > 0 ) { p.Size.y *= aspect.Remap( 0, 1, 1, 2, false ); } } if ( ApplyRotation ) { p.Angles.pitch = Pitch.Evaluate( p, 2363 ); p.Angles.yaw = Yaw.Evaluate( p, 8762 ); p.Angles.roll = Roll.Evaluate( p, 3675 ); } if ( SheetSequence ) { p.SequenceTime.x = SequenceTime.Evaluate( p, 7234 ); p.SequenceTime.y += SequenceSpeed.Evaluate( p, 1351 ) * timeScale; p.Sequence = (int)SequenceId.Evaluate( p, 1051 ); } if ( delta >= 1.0f ) { deleteList.Enqueue( p ); if ( p.hasUpdated ) { p.hasUpdated = false; p.OnDisabled(); } return; } if ( !p.hasUpdated ) { p.hasUpdated = true; p.OnEnabled(); } p.OnUpdate( delta ); if ( p.Follower.IsValid() ) { p.Follower.WorldTransform = new Transform( p.Position, p.Angles, p.Follower.WorldScale ); } var distanceFromOrigin = (long)Vector3.DistanceBetween( _worldTx.Position, p.Position ); if ( Interlocked.Read( ref _maxDistance ) <= distanceFromOrigin ) { Interlocked.Exchange( ref _maxDistance, distanceFromOrigin ); } var size = (long)float.Max( p.Size.x, p.Size.y ); if ( Interlocked.Read( ref _maxSize ) <= size ) { Interlocked.Exchange( ref _maxSize, size ); } } public void Step( float timeDelta ) { PreStep( timeDelta ); System.Threading.Tasks.Parallel.For( 0, Particles.Count, UpdateParticle ); PostStep(); } internal readonly record struct ParticleWork( ParticleEffect effect, int startIndex, int endIndex ); internal void CollectWork( List work ) { int count = Particles.Count; int chunkSize = 16; for ( int i = 0; i < count; i += chunkSize ) { work.Add( new ParticleWork( this, i, Math.Min( i + chunkSize, count ) ) ); } } internal void TryPreWarm() { if ( isWarmed ) return; isWarmed = true; float timeStep = 0.2f; if ( PreWarm < timeStep ) timeStep = PreWarm; for ( float i = 0; i < PreWarm; i += timeStep ) { Step( timeStep ); } } internal void PreStep( float timeDelta ) { _maxDistance = 15; _maxSize = 1; _timeDelta = Paused ? 0.0f : timeDelta * TimeScale; _worldTx = WorldTransform; if ( ForceSpace == SimulationSpace.Local ) _worldForce = _worldTx.Rotation * ForceDirection; Vector3 lastPos = lastTransform.Position; Transform deltaTransform = _worldTx.ToLocal( lastTransform ); _parentMoved = deltaTransform != global::Transform.Zero; OnPreStep?.Invoke( _timeDelta ); _trace = Scene.Trace.WithoutTags( CollisionIgnore ); RunDelayedParticles(); } internal void PostStep() { while ( deleteList.TryDequeue( out var delete ) ) { Terminate( delete ); } ParticleBounds = BBox.FromPositionAndSize( _worldTx.Position, _maxDistance * 2.0f ); MaxParticleSize = _maxSize; OnPostStep?.Invoke( _timeDelta ); lastTransform = WorldTransform; } [Obsolete( "Pass in a delta" )] public Particle Emit( Vector3 position ) { return Emit( position, Random.Shared.Float( 0, 1 ) ); } void RunDelayedParticles() { for ( int i = DelayedParticles.Count - 1; i >= 0; i-- ) { if ( DelayedParticles[i].BornTime > Time.Now ) continue; var p = DelayedParticles[i]; Particles.Add( p ); DelayedParticles.RemoveAt( i ); SceneMetrics.ParticlesCreated++; try { OnParticleCreated?.Invoke( p ); } catch ( System.Exception e ) { Log.Warning( e ); } } } readonly record struct ParticleCollisionPrefab( GameObject prefabSource, Vector3 position, Rotation rotation ); ConcurrentBag ParticleCollisionPrefabs = []; internal void SpawnDeferredParticleCollisionPrefabs() { foreach ( var collision in ParticleCollisionPrefabs ) { if ( !collision.prefabSource.IsValid() ) continue; var go = collision.prefabSource.Clone( collision.position, collision.rotation ); go.Flags |= GameObjectFlags.Absolute | GameObjectFlags.Hidden | GameObjectFlags.NotSaved; if ( Scene.IsEditor ) { _spawnedGameObjects.Add( go ); } } ParticleCollisionPrefabs.Clear(); } /// /// Emit a particle at the given position. /// /// The position in which to spawn the particle /// The time delta of the spawn. The first spawned particle is 0, the last spawned particle is 1. This is used to evaluate the spawn particles like lifetime and delay. /// A particle, will never be null. It's up to you to obey max particles. public Particle Emit( Vector3 position, float delta ) { var delay = StartDelay.Evaluate( delta, Random.Shared.Float() ); var p = Particle.Create(); p.Position = position; p.StartPosition = position; p.Radius = 1.0f; p.Velocity = Vector3.Random.Normal * StartVelocity.Evaluate( delta, Random.Shared.Float() ); p.Velocity += InitialVelocity.Evaluate( delta, Random.Shared.Float(), Random.Shared.Float(), Random.Shared.Float() ); p.BornTime += delay; p.DeathTime = p.BornTime + Lifetime.Evaluate( delta, p.Rand( 145, 100 ) ); if ( UsePrefabFeature && FollowerPrefabChance.Evaluate( delta, Random.Shared.Float( 0, 1 ) ) > Random.Shared.Float() ) { var prefabSource = Random.Shared.FromList( FollowerPrefab ); if ( prefabSource.IsValid() ) { p.Follower = prefabSource.Clone( position, p.Angles ); p.Follower.Flags |= GameObjectFlags.Absolute | GameObjectFlags.Hidden | GameObjectFlags.NotSaved; if ( Scene.IsEditor ) { _spawnedGameObjects.Add( p.Follower ); } } } if ( delay > 0 ) { DelayedParticles.Add( p ); } else { Particles.Add( p ); SceneMetrics.ParticlesCreated++; try { OnParticleCreated?.Invoke( p ); } catch ( System.Exception e ) { Log.Warning( e ); } } return p; } public void Terminate( Particle p ) { if ( p.hasUpdated ) { p.hasUpdated = false; p.OnDisabled(); } if ( p.Follower.IsValid() ) { // If they have a TemporaryEffect then we will trust that to destroy if ( p.Follower.GetComponent() is { } te ) { // but disable the looping effects so it doesn't live forever! ITemporaryEffect.DisableLoopingEffects( p.Follower ); } else { p.Follower.Destroy(); } p.Follower = null; } try { OnParticleDestroyed?.Invoke( p ); } catch ( System.Exception e ) { Log.Warning( e ); } Particles.Remove( p ); if ( Particle.Pool.Count < 512 ) Particle.Pool.Enqueue( p ); SceneMetrics.ParticlesDestroyed++; } internal void OnControllerDisabled( Component c ) { foreach ( var p in Particles ) { p.DisableListenersForComponent( c ); } } /// /// Should return true if we have active particles /// bool Component.ITemporaryEffect.IsActive => ParticleCount > 0; }