Particle Systems in TouchDesigner
Build CPU-based particle systems with the Particle SOP and scale up to 500k GPU particles using GLSL feedback shaders for audio-reactive real-time visuals.
TouchDesigner offers two fundamentally different approaches to particle systems: the Particle SOP, a CPU-based system ideal for physics-rich simulations with a few thousand particles, and a GLSL feedback loop that runs entirely on the GPU and can handle hundreds of thousands of particles at 60fps. Knowing when to use each — and how to push the GPU approach to its limits — is what separates functional experiments from polished, scalable works.
The Particle SOP
The Particle SOP is a modifier SOP: it takes an input geometry (the emitter) and outputs a point cloud of active particles. Add a Particle SOP after a Point SOP or Grid SOP that defines the birth positions.
Key parameters:
- Life — maximum particle lifespan in seconds
- Birth Rate — new particles per second (drive this from audio for reactivity)
- Forces — multiple sub-parameters control individual forces:
- Gravity — constant downward acceleration (Y axis, negative value)
- Drag — velocity damping, 0 = no drag, 1 = instant stop
- Turbulence — random per-particle force applied each frame;
turbparameter - Wind — constant directional force;
windx,windy,windz
Birth, Life, Death Attributes
The Particle SOP automatically maintains life, age, and dead point attributes. Access them in a downstream Attribute SOP or GLSL SOP to drive per-particle appearance based on age:
life— total lifespan in seconds (set at birth)age— seconds lived so far (0 →life)- Normalized age:
age / lifegives 0 at birth, 1 at death
Use an Attribute SOP to compute Cd (colour) based on normalized age:
Particle SOP → Attribute SOP
In the Attribute SOP, set the VEX expression for the Colour attribute:
// VEX — in Attribute SOP colour field
float t = @age / @life;
@Cd = set(t, 1.0 - t, 0.5);
Scaling with Instancing
The Particle SOP outputs a point cloud. To render it as thousands of visible objects, combine it with GPU instancing:
- SOP to CHOP: reads the particle point positions and converts each point’s
Pattribute to CHOP channelstx ty tzwith one sample per particle. - Geo COMP with a small Sphere SOP inside, Instancing enabled, pointing to the SOP to CHOP.
This gives you the physics of the Particle SOP with GPU-accelerated rendering for each point.
GLSL GPU Particles: Architecture
For 500k+ particles, you need to move the state entirely to the GPU. The architecture uses textures as data buffers:
- Position texture: a floating-point TOP where each pixel stores one particle’s position (
R=x, G=y, B=z, A=life) - Velocity texture: same layout, stores current velocity per particle
- A GLSL Multi TOP (or two GLSL TOPs) updates both textures each frame
- Feedback TOPs return each texture back to the updater as the previous frame
Position Update Shader
// Pixel shader for position update
// Input 0: previous position texture
// Input 1: previous velocity texture
uniform float uDt; // time step, e.g. 1.0/60.0
uniform float uGravity; // e.g. -9.8
out vec4 fragColor;
void main()
{
vec2 uv = vUV.st;
vec4 pos = texture(sTD2DInputs[0], uv); // xyz=position, w=life
vec4 vel = texture(sTD2DInputs[1], uv); // xyz=velocity, w=unused
float life = pos.w;
if (life <= 0.0) {
// Dead particle — output zero (respawn handled in birth shader)
fragColor = vec4(0.0);
return;
}
// Integrate position
vec3 newPos = pos.xyz + vel.xyz * uDt;
float newLife = life - uDt;
fragColor = vec4(newPos, newLife);
}
Velocity Update Shader
// Pixel shader for velocity update
// Input 0: previous position texture
// Input 1: previous velocity texture
uniform float uDt;
uniform float uGravity;
uniform vec3 uAttractor; // mouse position in world space
uniform float uAttractStrength;
uniform float uDrag;
out vec4 fragColor;
void main()
{
vec2 uv = vUV.st;
vec4 pos = texture(sTD2DInputs[0], uv);
vec4 vel = texture(sTD2DInputs[1], uv);
if (pos.w <= 0.0) {
fragColor = vec4(0.0);
return;
}
vec3 v = vel.xyz;
// Gravity
v.y += uGravity * uDt;
// Attractor at mouse position
vec3 toAttractor = uAttractor - pos.xyz;
float dist = length(toAttractor) + 0.001;
v += normalize(toAttractor) * uAttractStrength / (dist * dist) * uDt;
// Drag
v *= (1.0 - uDrag * uDt);
fragColor = vec4(v, 0.0);
}
Particle Respawn / Birth Shader
A separate GLSL TOP reads the position texture and overwrites dead particles with a new birth position and lifetime:
// Birth shader — blended on top of position output
uniform float uBirthRate; // 0–1 fraction of dead particles to respawn
uniform float uMaxLife; // maximum lifetime in seconds
float rand(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
out vec4 fragColor;
void main()
{
vec2 uv = vUV.st;
vec4 pos = texture(sTD2DInputs[0], uv); // existing position
if (pos.w > 0.0) {
// Alive — keep as is
fragColor = pos;
return;
}
// Dead — maybe respawn
float r = rand(uv + vec2(uTime, uTime * 0.7));
if (r > uBirthRate) {
fragColor = vec4(0.0);
return;
}
// Spawn at origin with random position offset
float rx = rand(uv + vec2(1.3, 0.7)) * 2.0 - 1.0;
float ry = rand(uv + vec2(0.2, 2.1)) * 2.0 - 1.0;
float rz = rand(uv + vec2(3.1, 0.5)) * 2.0 - 1.0;
float life = rand(uv + vec2(5.7, 1.9)) * uMaxLife;
fragColor = vec4(rx * 10.0, ry * 10.0, rz * 10.0, life);
}
Rendering GPU Particles
The position texture encodes one particle per pixel. To render them:
- Point Cloud SOP reads the position TOP and creates a SOP point per active pixel.
- Wire into a Geo COMP with a Sprite SOP inside for billboarded quads.
- Use a GLSL MAT in the Geo COMP — sample the position texture at
TDInstanceID()to get per-instance colour from the age channel.
Alternatively, use the Instance TOP approach: the position texture becomes the Instance TOP, and tx ty tz are encoded in RGB. A custom GLSL MAT decodes the position.
Audio-Reactive Birth Rate
Connect an Audio Device In CHOP → Audio Spectrum CHOP → Math CHOP (to get a single peak level) → Null CHOP. Export this null’s value to the birth shader’s uBirthRate uniform, and to the Particle SOP’s Birth Rate parameter if using the CPU path.
# Execute DAT — update birth rate from audio level each frame
def onFrameStart(frame):
level = op('audio_level')['chan1'][0]
# Exponential response — more reactive to quiet sounds
birth = level ** 0.5
op('birth_glsl').par.value0 = birth # Uniform passed to birth shader
return
Mouse Attractor via Python
# In a DAT Execute, onFrameStart
def onFrameStart(frame):
mouse = ui.mouse
# Convert screen pixel to normalized -1..1 range
w, h = op('render1').width, op('render1').height
mx = (mouse.x / w) * 2.0 - 1.0
my = 1.0 - (mouse.y / h) * 2.0
# Scale to world space
world_x = mx * 200.0
world_y = my * 200.0
op('glsl_velocity').par.value0 = world_x # uAttractor.x
op('glsl_velocity').par.value1 = world_y # uAttractor.y
op('glsl_velocity').par.value2 = 0.0 # uAttractor.z
return
At 500k particles (707×707 float32 texture), the full update cycle (position + velocity + birth + render) typically runs in 4–8ms on a mid-range GPU, leaving ample budget for the rest of the visual network.