TouchDesigner Intermediate Article 7

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.

⏱ 26 min particles glsl gpu audio-reactive

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; turb parameter
    • 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 / life gives 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:

  1. SOP to CHOP: reads the particle point positions and converts each point’s P attribute to CHOP channels tx ty tz with one sample per particle.
  2. 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:

  1. Point Cloud SOP reads the position TOP and creates a SOP point per active pixel.
  2. Wire into a Geo COMP with a Sprite SOP inside for billboarded quads.
  3. 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 CHOPAudio Spectrum CHOPMath 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.