# 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.