# 4. Controlling Particles from Script Part of [[Particle Systems and Layered Effects]]. Requires Unity 6.3 LTS+ and URP. Previous: [[3.Particles Spawning Particles on Contact]]. Next: [[5.VFX Graph Comparison and Performance]]. ## 4.1 Play, Stop, Clear, Pause ```csharp ps.Play(); // includes children by default (withChildren = true) ps.Play(false); // this system only ps.Stop(); // StopEmitting: live particles finish naturally ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); // vanish now ps.Clear(); // remove live particles, keep playing state ps.Pause(); ps.Simulate(1.5f, true, true); // fast-forward (withChildren, restart) bool alive = ps.IsAlive(true); // any particles left, including children? ``` `isPlaying`, `isEmitting`, `isStopped`, and `isPaused` tell you the state. A system with `Stop()` called is `isEmitting == false` but can still be `IsAlive()` while particles fade out. ## 4.2 Emit and EmitParams `Emit(int count)` spawns particles immediately using the module settings, even if the system isn't playing. `EmitParams` overrides chosen start values for that call only; anything you don't set comes from the modules. ```csharp using UnityEngine; /// Emits a burst of sparks at a hit point, pointed along the surface normal. public class SparkShooter : MonoBehaviour { [SerializeField] ParticleSystem sparks; // World space, Emission rate 0, Play On Awake off public void SparkAt(Vector3 point, Vector3 normal, Color tint, int count = 12) { var emit = new ParticleSystem.EmitParams { position = point, // overrides the Shape position applyShapeToPosition = true, // ...but still adds Shape offsets around it startColor = tint, startSize = 0.03f, startLifetime = 0.5f, }; for (int i = 0; i < count; i++) { // Random cone around the normal Vector3 dir = Vector3.Slerp(normal, Random.onUnitSphere, 0.4f).normalized; emit.velocity = dir * Random.Range(2f, 5f); sparks.Emit(emit, 1); } } } ``` > [!tip] > Setting `position` on `EmitParams` ignores the system's Transform for that particle, so one World-space spark system can serve the whole level. That's often cheaper than many pooled spark prefabs. ## 4.3 Modifying modules: struct references Module properties look like structs (`ps.main`, `ps.emission`, ...), but each is a thin **handle** to the native system. Assigning to a property on the handle writes straight through. You do **not** assign the struct back: ```csharp var main = ps.main; // handle, not a copy of the data main.startColor = Color.cyan; // takes effect immediately main.simulationSpeed = 0.5f; var emission = ps.emission; emission.rateOverTime = 50f; // implicit MinMaxCurve from float emission.rateOverTimeMultiplier = 2f; // *Multiplier properties are cheap, no allocation var noise = ps.noise; noise.enabled = true; noise.strength = new ParticleSystem.MinMaxCurve(0.2f, 0.6f); // random between two constants var col = ps.colorOverLifetime; var g = new Gradient(); g.SetKeys( new[] { new GradientColorKey(Color.yellow, 0f), new GradientColorKey(Color.red, 1f) }, new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) }); col.color = g; // MinMaxGradient from Gradient ``` What you **can't** do is `ps.main.startColor = ...` on one line. C# rejects modifying a member of a returned struct, so you need the local variable. > [!warning] Scale curves with the Multiplier > `startSize = 2f` replaces a curve or random range with a constant. To scale a designed curve without losing it, change `startSizeMultiplier` instead. The same goes for `rateOverTimeMultiplier`, `startSpeedMultiplier`, and so on. ### Example: fire intensity slider ```csharp using UnityEngine; public class FireIntensity : MonoBehaviour { [SerializeField] ParticleSystem flames, embers, smoke; [SerializeField, Range(0f, 2f)] float intensity = 1f; float flameRate, emberRate, smokeRate, flameSize; void Awake() { flameRate = flames.emission.rateOverTimeMultiplier; emberRate = embers.emission.rateOverTimeMultiplier; smokeRate = smoke.emission.rateOverTimeMultiplier; flameSize = flames.main.startSizeMultiplier; } void OnValidate() { if (Application.isPlaying && flames) Apply(); } public void SetIntensity(float value) { intensity = value; Apply(); } void Apply() { var fe = flames.emission; fe.rateOverTimeMultiplier = flameRate * intensity; var ee = embers.emission; ee.rateOverTimeMultiplier = emberRate * intensity * intensity; var se = smoke.emission; se.rateOverTimeMultiplier = smokeRate * Mathf.Lerp(1.5f, 1f, intensity / 2f); var fm = flames.main; fm.startSizeMultiplier = flameSize * Mathf.Lerp(0.6f, 1.2f, intensity / 2f); } } ``` ## 4.4 Pooling one-shot effects Instantiating and destroying effects (impacts, muzzle flashes, explosions) allocates and spikes the GC. Pool them with `UnityEngine.Pool.ObjectPool<T>`, and let the particle system tell you when it's finished. **Prefab setup** for each one-shot effect: 1. Root has a Particle System (children are its layers). 2. Root **Main > Looping off**, **Play On Awake off** (the pool plays it), **Stop Action: Callback**. 3. Add `PooledEffect` (below) to the root. When a system with **Stop Action = Callback** finishes (all particles, including those of child systems, have died after it stopped), Unity calls `OnParticleSystemStopped()` on scripts on that GameObject. ```csharp using UnityEngine; using UnityEngine.Pool; /// Lives on the root of a pooled effect. Returns itself to its pool when the system stops. [RequireComponent(typeof(ParticleSystem))] public class PooledEffect : MonoBehaviour { public ParticleSystem System { get; private set; } IObjectPool<PooledEffect> pool; void Awake() { System = GetComponent<ParticleSystem>(); var main = System.main; main.stopAction = ParticleSystemStopAction.Callback; // safety net if the prefab wasn't set main.playOnAwake = false; } public void SetPool(IObjectPool<PooledEffect> owner) => pool = owner; public void PlayAt(Vector3 position, Quaternion rotation) { transform.SetPositionAndRotation(position, rotation); System.Clear(true); System.Play(true); } // Called by Unity when Stop Action is Callback and the system (with children) has finished. void OnParticleSystemStopped() { if (pool != null) pool.Release(this); else Destroy(gameObject); } } ``` ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.Pool; /// One pool per effect prefab. Call EffectPools.Instance.Play(prefab, pos, rot). public class EffectPools : MonoBehaviour { public static EffectPools Instance { get; private set; } [SerializeField] int defaultCapacity = 8; [SerializeField] int maxSize = 32; readonly Dictionary<PooledEffect, ObjectPool<PooledEffect>> pools = new(); void Awake() { if (Instance && Instance != this) { Destroy(gameObject); return; } Instance = this; } public PooledEffect Play(PooledEffect prefab, Vector3 position, Quaternion rotation) { if (!pools.TryGetValue(prefab, out var pool)) { ObjectPool<PooledEffect> created = null; created = new ObjectPool<PooledEffect>( createFunc: () => { var fx = Instantiate(prefab, transform); fx.SetPool(created); return fx; }, actionOnGet: fx => fx.gameObject.SetActive(true), actionOnRelease: fx => fx.gameObject.SetActive(false), actionOnDestroy: fx => { if (fx) Destroy(fx.gameObject); }, collectionCheck: true, // catches double-release in the editor defaultCapacity: defaultCapacity, maxSize: maxSize); pool = created; pools.Add(prefab, pool); } var effect = pool.Get(); effect.PlayAt(position, rotation); return effect; } /// Optional: create instances up front to avoid a hitch on first use. public void Prewarm(PooledEffect prefab, int count) { var temp = new List<PooledEffect>(count); for (int i = 0; i < count; i++) temp.Add(Play(prefab, Vector3.down * 1000f, Quaternion.identity)); foreach (var fx in temp) { fx.System.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } // StopEmittingAndClear fires OnParticleSystemStopped, which releases them. } } ``` Usage: ```csharp [SerializeField] PooledEffect impactFx; void OnHit(RaycastHit hit) => EffectPools.Instance.Play(impactFx, hit.point, Quaternion.LookRotation(hit.normal)); ``` > [!warning] Looping effects never stop by themselves > A looping system only fires `OnParticleSystemStopped` after you call `Stop()` and the last particle dies. For pooled looping effects (a torch carried by an enemy), call `Stop()` when you're done with it. > [!note] Callback timing *(partly unverified)* > Unity docs say the callback fires when the system has stopped and all particles have died. Whether `Prewarm`'s `StopEmittingAndClear` triggers it immediately in the same frame wasn't tested; if it doesn't, release manually after a frame. ### Alternative without a pool For a quick prototype, set **Stop Action: Destroy** on a non-looping prefab and just `Instantiate` it. The object destroys itself when done. Switch to the pool when you profile.