# 3. Particles Spawning Particles on Contact Part of [[Particle Systems and Layered Effects]]. Requires Unity 6.3 LTS+ and URP. Previous: [[2.Worked Effect - Trash Can Fire]]. Next: [[4.Controlling Particles from Script]]. There are three ways to make something happen where a particle hits a surface, from least to most code: | Approach | Code | Best for | |---|---|---| | **Collision sub-emitter** | None | Splashes, tiny sparks, puffs. Particle → particle | | **`OnParticleCollision` + `GetCollisionEvents`** | Small script | Decals, sounds, damage, spawning *other* prefabs at the exact contact point and normal | | **`TriggerSubEmitter`** | Small script | Firing a sub emitter on your own conditions (manual) | ## 3.1 Rain that splashes (Collision sub-emitter) ### The rain 1. Create `FX_Rain`. Position it 10 m above the ground. Rotation `(90, 0, 0)` so it points **down**. 2. **Main**: Looping, Prewarm, Start Lifetime `2`, Start Speed `12`, Start Size `0.02`, Simulation Space World, Gravity `0.5`, Max Particles `1500`. 3. **Emission**: Rate `600`. 4. **Shape**: **Box**, Scale `20 x 20 x 0.1`. 5. **Renderer**: **Stretched Billboard**, Speed Scale `0.02`, Length Scale `1`. Material Particles/Unlit, Alpha, pale blue with alpha ~80. 6. **Collision**: Type **World**, Mode 3D, **Lifetime Loss `1`** (a drop dies when it hits), Bounce `0`, Collides With `Ground`, **Quality Medium** (or Low). 1,500 drops with High quality is 1,500 raycasts per frame. ### The splash 7. Create a child `FX_Splash` under `FX_Rain`. - **Main**: Looping **off**, Duration `0.3`, Start Lifetime `0.2–0.35`, Start Speed `1–2`, Start Size `0.02–0.04`, Gravity `1`, Simulation Space World, **Play On Awake off**. - **Emission**: Rate over Time `0`, **Burst** at time 0: count `3–5`. - **Shape**: **Hemisphere**, Radius `0.02` (sprays upward from the hit point). - **Renderer**: Billboard (or Stretched), same material. 8. Optionally add a second child `FX_Ripple`: one particle, **Horizontal Billboard**, Size over Lifetime `0.1 → 0.5`, alpha fading out, a ring texture. 9. On `FX_Rain`, enable **Sub Emitters**, click **+**, choose **Collision**, and assign `FX_Splash`. Add `FX_Ripple` the same way. Set **Emit Probability** to `0.3` on each if 600 splashes per second is too many. > [!info] Where do sub-emitter particles spawn? > At the parent particle's position at the moment of the event. The sub emitter's **Shape** is evaluated **relative to that point**, aligned with the parent's direction of travel (not the surface normal). For a ripple that lies flat regardless, use **Horizontal Billboard**. If you need the surface normal, use 3.3. ## 3.2 Sparks that bounce and split The embers from part 2 can spawn smaller sparks each time they bounce: 1. On `FX_Embers` make sure **Collision** is on with Bounce `0.3` and **Lifetime Loss `0.3`**, so each ember bounces a few times before dying. 2. Add a child `FX_EmberSplit`: Burst `2–4`, Start Lifetime `0.2–0.4`, Start Speed `0.5–1.5`, Size `0.01–0.02`, Gravity `1`, Shape **Sphere** radius 0.01, Stretched Billboard, `M_Ember`. Give it its own Collision module too (Planes or World, Lifetime Loss 1, Quality Low) so the tiny sparks don't fall through the floor. 3. On `FX_Embers` > **Sub Emitters**: **Collision** → `FX_EmberSplit`, **Inherit: Color** (the split sparks match the ember's current colour), Emit Probability `0.5`. > [!warning] Don't chain forever > If `FX_EmberSplit` also had a Collision sub-emitter, every bounce would multiply the particle count. Keep spawn chains one or two levels deep and give the last level `Lifetime Loss 1`. ## 3.3 OnParticleCollision with GetCollisionEvents To spawn **prefabs** (decals, other effects, sounds) at the exact contact point and normal: 1. On the Particle System's Collision module, tick **Send Collision Messages**. Type must be **World**. 2. Add the script below to the **same GameObject as the Particle System**. Unity calls `OnParticleCollision(GameObject other)` once per collider hit this frame on this script. (It's also called on scripts attached to the *collider's* GameObject, with `other` being the Particle System, which is handy for "this object takes damage from fire".) ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.Pool; /// Spawns a pooled prefab (e.g. a scorch decal or splash effect) at each particle contact. /// Requires Collision module: Type World + Send Collision Messages. [RequireComponent(typeof(ParticleSystem))] public class ParticleImpactSpawner : MonoBehaviour { [SerializeField] GameObject impactPrefab; [SerializeField, Tooltip("Ignore hits closer than this to the last spawn (metres).")] float minSpacing = 0.15f; [SerializeField] int maxSpawnsPerFrame = 4; [SerializeField] float impactLifetime = 4f; [SerializeField, Tooltip("Offset along the normal to avoid z-fighting.")] float surfaceOffset = 0.01f; ParticleSystem ps; readonly List<ParticleCollisionEvent> events = new(); ObjectPool<GameObject> pool; Vector3 lastSpawn = new(float.MaxValue, 0, 0); int spawnedThisFrame; int lastFrame; void Awake() { ps = GetComponent<ParticleSystem>(); pool = new ObjectPool<GameObject>( createFunc: () => Instantiate(impactPrefab), actionOnGet: go => go.SetActive(true), actionOnRelease: go => go.SetActive(false), actionOnDestroy: Destroy, collectionCheck: false, defaultCapacity: 16, maxSize: 64); } void OnParticleCollision(GameObject other) { if (Time.frameCount != lastFrame) { lastFrame = Time.frameCount; spawnedThisFrame = 0; } int count = ps.GetCollisionEvents(other, events); // ParticlePhysicsExtensions for (int i = 0; i < count && spawnedThisFrame < maxSpawnsPerFrame; i++) { Vector3 point = events[i].intersection; Vector3 normal = events[i].normal; if ((point - lastSpawn).sqrMagnitude < minSpacing * minSpacing) continue; var go = pool.Get(); go.transform.SetPositionAndRotation( point + normal * surfaceOffset, Quaternion.LookRotation(-normal) * Quaternion.Euler(0, 0, Random.Range(0f, 360f))); // If the impact is itself a particle effect, restart it. if (go.TryGetComponent(out ParticleSystem impactPs)) impactPs.Play(true); StartCoroutine(ReleaseLater(go, impactLifetime)); lastSpawn = point; spawnedThisFrame++; } } System.Collections.IEnumerator ReleaseLater(GameObject go, float seconds) { yield return new WaitForSeconds(seconds); if (go) pool.Release(go); } } ``` `ParticleCollisionEvent` also gives you `velocity` (use its magnitude to scale a sound's volume) and `colliderComponent` (the collider that was hit). ### Scorch decals in URP For the decal prefab, use a **URP Decal Projector**: 1. In your URP Renderer asset, **Add Renderer Feature > Decal**. 2. Create a material with the **Shader Graphs/Decal** shader and a scorch texture. 3. Make a prefab with a **Decal Projector** component (size `0.3 x 0.3 x 0.2`), assign the material. The Projector projects along its **forward (+Z)** axis, which is why the script uses `LookRotation(-normal)`. 4. Assign it as `impactPrefab`. On Quest, prefer a simple transparent quad with a scorch texture instead (the decal feature adds cost). ### A receiver: things that react to being hit ```csharp using UnityEngine; /// Put on any object with a collider. Takes "fire damage" from particles that hit it. public class ParticleDamageReceiver : MonoBehaviour { [SerializeField] float damagePerHit = 1f; public float Health { get; private set; } = 100f; // 'other' is the GameObject of the Particle System that hit us. void OnParticleCollision(GameObject other) { if (!other.TryGetComponent(out ParticleSystem ps)) return; int hits = ps.GetSafeCollisionEventSize(); // upper bound on events this frame Health -= damagePerHit * Mathf.Max(1, hits) * 0.1f; } } ``` *(`GetSafeCollisionEventSize` gives the event count for the whole system, not just this collider, so the damage scale is approximate.)* ## 3.4 TriggerSubEmitter: spawn on your own conditions `ParticleSystem.TriggerSubEmitter(int subEmitterIndex)` fires the sub emitter at that index on **all** live particles. Overloads take `ref ParticleSystem.Particle` or a `List<ParticleSystem.Particle>` to fire it on chosen particles only. The sub emitter's type should be **Manual**. Example: when the player kicks the trash can, every ember that is currently high in the air pops into a little burst: ```csharp using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(ParticleSystem))] public class EmberPopper : MonoBehaviour { [SerializeField, Tooltip("Index in the Sub Emitters list; set that entry's type to Manual.")] int popSubEmitterIndex = 1; [SerializeField] float minHeight = 1.5f; ParticleSystem ps; ParticleSystem.Particle[] buffer; readonly List<ParticleSystem.Particle> chosen = new(); void Awake() { ps = GetComponent<ParticleSystem>(); buffer = new ParticleSystem.Particle[ps.main.maxParticles]; } /// Call from gameplay (e.g. when the can is kicked). public void PopHighEmbers() { int n = ps.GetParticles(buffer); chosen.Clear(); for (int i = 0; i < n; i++) if (buffer[i].position.y > minHeight) chosen.Add(buffer[i]); // World space sim if (chosen.Count > 0) ps.TriggerSubEmitter(popSubEmitterIndex, chosen); } /// Pop everything at once. public void PopAll() => ps.TriggerSubEmitter(popSubEmitterIndex); } ``` > [!note] > `buffer[i].position` is in the system's simulation space. The ember system uses **World** space, so comparing to a world height works. With Local space, transform it with `transform.TransformPoint` first.