Look at any game with fire, smoke, a magic spell, or sparks flying off a sword hit. None of that is one big animated picture. It is hundreds or thousands of tiny, simple images called particles, each one following a few basic rules. This lesson walks through exactly how those pieces work: the loop that spawns and moves them, the properties each one carries, and the shaders that make them glow, dissolve, and fade.
A particle is a tiny piece of a visual effect: usually a flat rectangle made of two triangles (called a quad) with a small picture drawn on it (a sprite), drawn so it always faces something — usually the camera (more on that in section 4). A particle system is the code that creates many particles, moves them over time, and draws them. Smoke, fire, rain, sparks, a magic aura — all of it is thousands of these little quads, not one hand-drawn animation.
Every particle system, no matter how fancy the engine behind it, runs the same three-step loop every frame:
To make this concrete, here is that exact loop written as a tiny, dependency-free C# program — no Unity needed. It is a stripped-down version of what a real particle system does internally.
using System;
using System.Collections.Generic;
class Particle
{
public float x, y; // position
public float vx, vy; // velocity
public float life; // seconds left before it dies
}
class MiniParticleSystem
{
List<Particle> particles = new List<Particle>();
float spawnTimer = 0f;
const float spawnInterval = 0.5f; // one new particle every 0.5s
const float lifeTime = 1.5f; // each particle lives 1.5s
public void Update(float dt)
{
// 1. EMIT: spawn new particles on a timer
spawnTimer += dt;
if (spawnTimer >= spawnInterval)
{
spawnTimer = 0f;
particles.Add(new Particle { x = 0, y = 0, vx = 1f, vy = 2f, life = lifeTime });
}
// 2. SIMULATE: move every particle, age it, remove the dead ones
for (int i = particles.Count - 1; i >= 0; i--)
{
Particle p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0f)
particles.RemoveAt(i); // dead, remove it
}
// 3. RENDER: a real engine draws a quad here, we just print
Console.WriteLine($"particles alive = {particles.Count}");
}
}
class Program
{
static void Main()
{
var system = new MiniParticleSystem();
for (int frame = 0; frame < 6; frame++)
system.Update(0.5f); // pretend each frame takes 0.5 seconds
}
}
Output:
particles alive = 1
particles alive = 2
particles alive = 2
particles alive = 2
particles alive = 2
particles alive = 2
One particle spawns every frame (since spawnInterval equals our frame time), and each one lives for 1.5 seconds — three simulate steps. A particle is counted as "alive" in the frame it spawns and the next one, then gets removed on the third step, right before the count would include it again. So after the first couple of frames, the system settles into a steady rhythm: roughly lifetime ÷ spawnInterval particles alive at any moment. Keep that number in mind — it comes back in the exercises and in the performance section later.
Every particle carries a small bundle of data. The core ones you will use constantly are:
The trick that makes size and color "change over life" is always the same: take how far along the particle is in its life, as a fraction from 0 (just born) to 1 (about to die), and use that fraction to blend (interpolate) between a start value and an end value. That fraction is usually called t, and the blend is a lerp (linear interpolation, a fancy name for "slide smoothly between two numbers").
struct Particle
{
public float life; // seconds left
public float maxLife; // total lifetime, for normalizing
}
static float Lerp(float a, float b, float t) => a + (b - a) * t;
static void PrintTrace()
{
Particle p = new Particle { life = 1.0f, maxLife = 1.0f };
float startSize = 0.2f, endSize = 1.0f;
for (int step = 0; step <= 4; step++)
{
float t = 1f - (p.life / p.maxLife); // 0 = just born, 1 = about to die
float size = Lerp(startSize, endSize, t);
float alpha = 1f - t; // fade out linearly
Console.WriteLine($"life={p.life:F2} t={t:F2} size={size:F2} alpha={alpha:F2}");
p.life -= 0.25f;
}
}
Output:
life=1.00 t=0.00 size=0.20 alpha=1.00
life=0.75 t=0.25 size=0.40 alpha=0.75
life=0.50 t=0.50 size=0.60 alpha=0.50
life=0.25 t=0.75 size=0.80 alpha=0.25
life=0.00 t=1.00 size=1.00 alpha=0.00
Watch t climb from 0 to 1 as life counts down. Size grows from 0.2 to 1.0 right alongside it, and alpha fades from fully opaque (1.0) to fully invisible (0.0). This is exactly what Unity's "Size over Lifetime" and "Color over Lifetime" modules do internally — they store a curve or gradient, and sample it using this same t value every frame.
The mini particle system in section 1 ran entirely on the CPU: a plain for loop touched one particle after another, one at a time. That is simple to write and debug, but it does not scale — a few thousand particles is already a heavy CPU workload, because the CPU is built for a handful of complex, branching tasks, not millions of tiny identical ones.
A GPU (graphics processing unit) is built the opposite way: instead of a few powerful cores, it has thousands of small, simple cores designed to do the exact same operation on many pieces of data at once. That is precisely what particle simulation needs — the same "move, age, maybe die" logic, applied independently to millions of particles. A compute shader (a shader that runs general-purpose calculations on the GPU instead of drawing a triangle) can update every particle in parallel using a structured buffer (a block of GPU memory holding an array of custom structs, readable and writable by shaders).
Here is a compute shader kernel that does the same "simulate" step as our C# loop, but written to run once per particle across thousands of parallel GPU threads:
// A GPU compute shader kernel: this ONE function body
// runs once per particle, but thousands of copies run at the same time.
struct Particle
{
float3 position;
float3 velocity;
float life;
};
RWStructuredBuffer<Particle> particles;
float deltaTime;
[numthreads(64, 1, 1)]
void UpdateParticles(uint3 id : SV_DispatchThreadID)
{
Particle p = particles[id.x];
p.position += p.velocity * deltaTime;
p.life -= deltaTime;
particles[id.x] = p;
// note: no adding/removing from an array here -- GPU particle
// systems usually just mark life <= 0 as "dead" and skip drawing
// it, instead of resizing an array the way our C# List did.
}
Read this as a worked trace, not console output: if you dispatch this kernel with 1,000,000 particles and a thread group size of 64, the GPU launches roughly 1,000,000 / 64 ≈ 15,625 thread groups, and every particle's position and life get updated in the same GPU pass — not one after another like the CPU version, but effectively all together. That is the whole reason GPU particle systems can handle millions of particles while CPU ones start struggling in the low tens of thousands.
Billboarding means rotating a flat quad, every single frame, so it always faces the camera — no matter which way the particle's own transform is rotated, and no matter where the camera moves. Without it, a flat sprite would look correct from one angle and turn into an invisible sliver of nothing when viewed edge-on, which is exactly what a plain flat rectangle looks like from the side.
The math: instead of using the particle's own rotation, the shader takes each local quad corner (for example, the corner at local x = -0.5, y = 0.5) and pushes it away from the particle's center along the camera's right vector and camera's up vector — two directions taken straight from the camera's view matrix. That guarantees the quad's plane is always perpendicular to the camera's view direction.
Shader "Custom/BillboardParticle"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION; // local quad corner, e.g. (-0.5,-0.5,0)..(0.5,0.5,0)
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
sampler2D _MainTex;
v2f vert (appdata v)
{
v2f o;
// particle center in world space (the particle's own position)
float3 center = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz;
// camera's right and up axes, straight from the view matrix
float3 camRight = UNITY_MATRIX_V[0].xyz;
float3 camUp = UNITY_MATRIX_V[1].xyz;
// push the quad's local corner out along those axes instead
// of the particle's own rotation -- this is billboarding
float3 worldPos = center
+ camRight * v.vertex.x
+ camUp * v.vertex.y;
o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return tex2D(_MainTex, i.uv);
}
ENDCG
}
}
}
There is no console output here — the worked trace is visual: attach this shader to a quad, spin the quad's transform on any axis you like in the editor, and its sprite never changes orientation on screen. Walk the camera around it in the Scene view, and the quad silently rotates to keep facing you. The local rotation of the GameObject stops mattering at all.
Cull Off in the SubShader. A billboard's local geometry can end up facing "backward" relative to the camera depending on how it was authored, and the default Cull Back setting will silently not draw the quad at all. If a particle is invisible from certain angles, this is the first thing to check.Unity's built-in ParticleSystem component is nicknamed Shuriken (its original internal codename, and the name most Unity developers still use for it). It simulates particles on the CPU, and all of its behavior is organized into modules — Emission, Shape, Color over Lifetime, Size over Lifetime, and more — each one editable as a foldout in the Inspector, and each one also reachable from C# as a struct on the component.
using UnityEngine;
public class HitSparkEmitter : MonoBehaviour
{
public ParticleSystem sparks;
public void PlayHitSpark(Vector3 worldPos, Color tint)
{
// Read-and-modify a module's settings from script
var main = sparks.main;
main.startColor = tint;
// Move the emitter to the hit point, then fire a burst of
// 12 particles right now, overriding a few properties per-particle
sparks.transform.position = worldPos;
var emitParams = new ParticleSystem.EmitParams();
emitParams.startSize = Random.Range(0.1f, 0.3f);
emitParams.velocity = Random.insideUnitSphere * 2f;
sparks.Emit(emitParams, 12);
}
}
Worked trace: call PlayHitSpark(hitPoint, Color.yellow) when a sword lands a hit. The emitter jumps to hitPoint, its default particle color is set to yellow, and 12 particles burst out immediately, each with its own random small size and random outward velocity (because EmitParams lets you override individual properties per burst, on top of whatever the module defaults say). Each of those 12 particles then ages exactly like section 1's loop, and fades out according to whatever curve is set in the "Color over Lifetime" module in the Inspector.
main.startColor only changes the default color for particles emitted from now on. EmitParams is for overriding one specific burst without touching the module defaults at all — reach for it whenever different calls to the same particle system need different starting values (a red hit spark vs. a blue one, say).Every particle shader needs one detail that is easy to miss: Shuriken writes each particle's current color (including whatever the "Color over Lifetime" module computed) into the mesh's per-vertex COLOR channel. If your shader never reads that channel, all of the fading and tinting configured in the Inspector will visibly do nothing, no matter how carefully it was set up.
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR; // Shuriken writes each particle's current
// color (from Color over Lifetime) here
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
v2f vert (appdata v)
{
v2f o;
// ... billboard position code from section 4 goes here ...
o.uv = v.uv;
o.color = v.color; // pass the particle's color through to the pixel shader
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
return tex * i.color; // texture tinted and faded by the particle system
}
return tex; instead of return tex * i.color; in the pixel shader. The particle system will look like it is doing nothing — no fade-out, no tint changes — because the shader is simply throwing away the very data Shuriken computed. If a particle effect "won't fade" even though the Inspector curve looks correct, this is the first line to check.So far every shader has used normal alpha blending: dst = src * a + dst * (1 - a), meaning the new pixel is mixed with whatever is already drawn, weighted by alpha. That is right for smoke, cloth, or anything that should look like it partly covers what is behind it.
Fire, magic auras, and glowing energy usually want something different: additive blending, where the new pixel's color is simply added on top of whatever is already there. Two overlapping glow sprites do not "cover" each other — they add up and get brighter, which is exactly how real light behaves when two glowing things overlap.
In Unity's shader language this is one line: Blend One One instead of Blend SrcAlpha OneMinusSrcAlpha. It tells the GPU "add the source color, times one, to the destination color, times one" — no mixing, just addition.
Shader "Custom/AdditiveGlow"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
_Intensity ("Glow Intensity", Range(1, 4)) = 1.5
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend One One // ADDITIVE: add this pixel's color onto whatever
// is already in the frame buffer
ZWrite Off
Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; };
struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; };
sampler2D _MainTex;
float _Intensity;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex); // (billboarding from section 4 applies too)
o.uv = v.uv;
o.color = v.color;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
// IMPORTANT: with Blend One One, the GPU does NOT use alpha
// to fade the color -- it just adds whatever RGB we return.
// So the fade (alpha) has to be baked into RGB ourselves.
fixed3 glow = tex.rgb * i.color.rgb * i.color.a * _Intensity;
return fixed4(glow, 1.0);
}
ENDCG
}
}
}
Worked trace: with _Intensity at 1.5, a fully-bright white texture pixel (1,1,1) tinted by a fully-opaque yellow particle color (1, 0.9, 0.2, 1) produces glow = (1,1,1) * (1,0.9,0.2) * 1 * 1.5 = (1.5, 1.35, 0.3). Values above 1.0 are exactly the point — this is HDR (high dynamic range) color, "brighter than white," which is what makes a glow punch through bloom post-processing instead of looking like a flat white blob.
Blend One One but still relying on the returned alpha channel to fade a particle out over its lifetime. Additive blending never reads alpha to mix colors — the alpha channel is simply ignored by this blend mode. If you do not multiply the fade value into RGB yourself (as i.color.a is multiplied in above), the particle will pop to invisible instead of smoothly dimming.A dissolve effect makes a sprite look like it is burning away or materializing into existence, by comparing a noise texture (a texture filled with pseudo-random-looking gray values) against a threshold that slides from 0 to 1 over time. Wherever the noise value is below the threshold, the pixel is thrown away entirely using clip() (a shader instruction meaning "discard this pixel, do not draw it").
Shader "Custom/Dissolve"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
_NoiseTex ("Dissolve Noise", 2D) = "white" {}
_DissolveAmount ("Dissolve Amount", Range(0, 1)) = 0
_EdgeWidth ("Edge Width", Range(0.01, 0.3)) = 0.08
_EdgeColor ("Edge Color", Color) = (1, 0.5, 0.1, 1)
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; };
struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; };
sampler2D _MainTex;
sampler2D _NoiseTex;
float _DissolveAmount;
float _EdgeWidth;
fixed4 _EdgeColor;
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
float noise = tex2D(_NoiseTex, i.uv).r;
// clip() discards this pixel entirely if the value passed
// in is negative -- so pixels where noise is below the
// threshold vanish
clip(noise - _DissolveAmount);
// pixels just above the threshold get an edge color, like
// a glowing line eating its way through the sprite
float edge = 1 - smoothstep(0, _EdgeWidth, noise - _DissolveAmount);
fixed3 finalColor = lerp(tex.rgb, _EdgeColor.rgb, edge);
return fixed4(finalColor, tex.a);
}
ENDCG
}
}
}
public class Dissolver : MonoBehaviour
{
public Material dissolveMat;
public float duration = 2f;
float t = 0f;
void Update()
{
t += Time.deltaTime / duration;
dissolveMat.SetFloat("_DissolveAmount", Mathf.Clamp01(t));
}
}
Worked trace: at _DissolveAmount = 0, noise - 0 is never negative (noise is always 0 or above), so nothing is clipped and the full sprite shows. At _DissolveAmount = 0.5, every pixel whose noise value is below 0.5 gets thrown away — using the sample values in the diagram above, that is exactly the pixels marked [X]. At _DissolveAmount = 1, even a pixel with the maximum noise value of 1.0 produces noise - 1 = 0, which clip() also discards (it discards on zero or negative), so the sprite has fully disappeared.
A single static sprite makes a flat blob, not convincing fire or an explosion. A flipbook (also called a sprite sheet) packs many pre-drawn animation frames into one texture, arranged in a grid, and the shader picks out just the current frame's little rectangle of UV space each time it draws — like a tiny hand-drawn cartoon playing once per particle.
// _Tiles = 4 (a 4x4 sheet, 16 frames total)
// _FramesPerSecond = how fast the flipbook plays
float _Tiles;
float _FramesPerSecond;
float2 FlipbookUV(float2 uv, float age)
{
float totalFrames = _Tiles * _Tiles;
float frame = floor(age * _FramesPerSecond);
frame = fmod(frame, totalFrames); // loop back to frame 0 after the last one
float col = fmod(frame, _Tiles);
float row = floor(frame / _Tiles);
float2 tileSize = 1.0 / _Tiles;
float2 tileOffset = float2(col, (_Tiles - 1) - row) * tileSize; // flip row: UV (0,0) is bottom-left
return tileOffset + uv * tileSize;
}
Worked trace: with _Tiles = 4 and _FramesPerSecond = 10, at age = 0.65 seconds: frame = floor(0.65 * 10) = floor(6.5) = 6. totalFrames = 16, and 6 % 16 = 6, so we stay on frame 6. col = 6 % 4 = 2, row = floor(6 / 4) = 1. tileSize = 0.25, so tileOffset = (2 * 0.25, (4 - 1 - 1) * 0.25) = (0.5, 0.5). That samples the rectangle from UV (0.5, 0.5) to (0.75, 0.75) — exactly cell row 1, column 2 in the grid above.
A particle quad is a flat, infinitely thin rectangle. When it crosses through solid geometry — smoke drifting through a floor, for example — the quad simply stops being drawn exactly where the geometry's surface is, leaving a hard, ugly, perfectly straight line. Soft particles fix this by fading the particle's alpha out as its own depth gets close to the depth of whatever is already drawn behind it, using the camera's depth texture (a texture holding, for every screen pixel, how far away the nearest already-drawn surface is).
sampler2D _CameraDepthTexture; // depth of everything already drawn this frame
float _FadeDistance; // world units over which the fade happens
fixed4 frag (v2f i) : SV_Target
{
// depth already in the frame buffer at this screen pixel (0..1, non-linear)
float sceneDepthRaw = tex2D(_CameraDepthTexture, i.screenUV).r;
float sceneEyeDepth = LinearEyeDepth(sceneDepthRaw);
// this particle pixel's own depth, passed through from the vertex shader
float particleEyeDepth = i.viewDepth;
float diff = sceneEyeDepth - particleEyeDepth;
float fade = saturate(diff / _FadeDistance);
fixed4 tex = tex2D(_MainTex, i.uv);
return fixed4(tex.rgb, tex.a * fade * i.color.a);
}
Worked trace: say _FadeDistance = 0.5. A smoke pixel sitting 0.02 world units in front of the floor gives diff = 0.02, so fade = saturate(0.02 / 0.5) = 0.04 — almost fully invisible right at the intersection, exactly what removes the hard line. A pixel of the same particle that is 0.5 units or more away from any surface gives fade = saturate(1.0) = 1.0 — fully visible, since it is nowhere near intersecting anything.
_CameraDepthTexture requires the camera's depth texture mode to actually be enabled (in Unity's Camera settings, or requested by the render pipeline). If a soft-particle shader compiles fine but shows no fading at all, this is usually why.Unity ships two different particle systems, and picking the right one is mostly the CPU-vs-GPU tradeoff from section 3, wrapped in two different authoring tools.
Shuriken is the default, always-available choice: it is simple, it works on every platform Unity supports, and it is plenty for gameplay-driven effects that only need dozens or hundreds of particles at a time — hit sparks, pickup glints, footstep dust. VFX Graph is a separate package where you build the effect as a node graph instead of stacking Inspector modules, and the simulation itself runs on the GPU as compute shaders, which is what lets it push particle counts into the millions. The tradeoff is that it needs a Scriptable Render Pipeline (URP or HDRP) and reasonably modern GPU hardware, so it is not a drop-in replacement everywhere Shuriken works.
Overdraw means the GPU runs the fragment (pixel) shader for the same screen pixel more than once in a single frame. For solid, opaque objects the GPU has tricks to skip pixels that are hidden behind something closer. Transparent particles break that trick completely: every particle covering a pixel has to be shaded and blended, in order, because each one contributes to the final blended color. A screen area covered by 20 overlapping smoke puffs runs the pixel shader roughly 20 times for every pixel in that area.
Everything in this lesson is really one idea applied in different places: a particle is cheap data (position, velocity, lifetime, size, color) moved by simple math, and a shader turns that data into light on screen. Whether that shader clips pixels for a dissolve, adds colors for a glow, or samples a moving rectangle of a sprite sheet for a flipbook, it is always reading the same handful of per-particle values you already understand from sections 1 and 2.
dst = src*a + dst*(1-a); the usual mode for normal transparency.Blend One One); used for glow, fire, and magic.vy += gravity * dt, with gravity = -9.8), then move the particle (x += vx * dt, y += vy * dt). Using dt = 0.1, write down (by hand or in code) the value of y printed after each of the first 4 frames.
class Particle
{
public float x, y;
public float vx, vy;
}
class Program
{
static void Main()
{
Particle p = new Particle { x = 0, y = 0, vx = 0, vy = 0 };
float dt = 0.1f;
float gravity = -9.8f;
for (int frame = 1; frame <= 4; frame++)
{
p.vy += gravity * dt; // gravity changes velocity first
p.x += p.vx * dt;
p.y += p.vy * dt; // then velocity changes position
Console.WriteLine($"frame {frame}: y = {p.y:F3}");
}
}
}
frame 1: y = -0.098
frame 2: y = -0.294
frame 3: y = -0.588
frame 4: y = -0.980
Each frame, gravity first shrinks vy by 0.98 (that is 9.8 * 0.1), then the new vy is applied to y. Updating velocity before position like this is called semi-implicit (or symplectic) Euler integration — the same basic idea Unity's own physics engine uses for Rigidbody motion — and it is the standard, stable way to add simple gravity to a particle.
_GlowWidth, larger than _EdgeWidth) and a second color property (_GlowColor). Rewrite the fragment shader's color logic.
fixed4 frag (v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
float noise = tex2D(_NoiseTex, i.uv).r;
float d = noise - _DissolveAmount;
clip(d);
// tight bright line right at the edge
float edge = 1 - smoothstep(0, _EdgeWidth, d);
// wider, dimmer glow just outside the edge
float glow = 1 - smoothstep(0, _GlowWidth, d);
fixed3 withGlow = lerp(tex.rgb, _GlowColor.rgb, glow * 0.5);
fixed3 finalColor = lerp(withGlow, _EdgeColor.rgb, edge);
return fixed4(finalColor, tex.a);
}
Since _GlowWidth is set larger than _EdgeWidth, smoothstep(0, _GlowWidth, d) only reaches 1 (meaning glow drops to 0) much farther from the true edge than smoothstep(0, _EdgeWidth, d) does. That makes the dim glow color visible across a wide band, while the bright edge color from the second lerp only shows up very close to d = 0, layered on top. The order matters: glow is blended in first, then the sharper edge is blended on top of that result.
Part A: roughly spawnRate * lifetime = 30 * 0.4 = 12 particles alive at once. Each particle survives for 0.4 seconds while new ones keep arriving every 1/30th of a second, so at any instant there are about 12 particles "in flight" together — the same lifetime ÷ spawnInterval idea from section 1, just written the other way around as lifetime × spawnRate.
Part B: Design B causes far more overdraw. Overdraw is driven by how many overlapping transparent quads sit on top of the same screen pixels, not by the total screen area they cover. 400 tiny quads packed into the same area as 40 medium ones means far more layers stacked on top of each other at any given pixel, so the GPU ends up running the fragment shader many more times per pixel even though the final image covers the same amount of screen. The fix from section 12 applies directly here: prefer fewer, larger particles over many tiny overlapping ones whenever the visual result is similar.