Ben Traje
← Back to particle

Houdini VEX: Creating an Attraction Force with Smooth Arrival & Deceleration

09 Sep 26 (1d ago)

Whether designing cyclical push/pull shockwaves (where particles burst outward and get reeled back into the origin) or pulling instanced geometry into a tight formation, attraction setups are a staple of procedural animation in SideFX Houdini.

However, a standard distance-based pull introduces two classic simulation bugs:

  1. Orbital Overshoot: Points accelerate toward the target with so much kinetic momentum that they slingshot past the origin, oscillating back and forth indefinitely like an unstable planetary orbit.
  2. Instant Snapping / Micro-Jitter: Abruptly zeroing velocity (@v = 0) when crossing an arrival radius causes points to freeze unnaturally or jitter violently across the target threshold.

Implementing a classic "Arrival" steering behavior—where desired velocity ramps down proportionally to target distance within a designated braking radius—allows points to glide smoothly to a target and settle cleanly.

1. SOPs vs. POPs: Architectural Distinction

Before writing code, consider where your points live in Houdini's evaluation pipeline:

  • SOPs / SOP Solver (Kinematic Animation): If you are updating positions deterministically frame-over-frame (e.g., inside a SOP Solver or manually trailing @P += @v * @TimeInc), setting @v directly gives you predictable, frame-rate-independent control.
  • POP Networks (Dynamic Simulation): In dynamic DOP simulations, directly overriding @v every frame fights the POP solver’s internal sub-step integrator, bypasses particle mass, cancels gravity/drag, and breaks physical collision responses. For POP networks, calculate a Steering Force (@force) instead.

2. The SOP Approach: Direct Velocity Steering

When animating points procedurally in SOPs or writing a cyclical rewind step, calculate a normalized trajectory vector, scale it by an arrival deceleration ramp, and assign it directly to @v:

// Run over: Points (Point Wrangle inside SOPs or SOP Solver)

// 1. Define attraction target position
vector target = chv("target_pos"); // Default: {0, 0, 0}

// 2. Compute relative trajectory and distance
vector dir = target - @P;
float dist = length(dir);

// Guard against division-by-zero if a point is already at the target
vector dir_norm = (dist > 0.0001) ? normalize(dir) : set(0, 0, 0);

// 3. User Parameters
float max_speed   = chf("max_speed");   // Cruising velocity (e.g., 6.0)
float slow_radius = chf("slow_radius"); // Braking zone radius (e.g., 2.0)
float stop_dist   = chf("stop_dist");   // Dead-zone radius (e.g., 0.05)

// 4. Evaluate Arrival Ramp
if (dist > stop_dist) {
    // Ramp speed down linearly from max_speed to 0 within the slow_radius
    float ramp = clamp(dist / slow_radius, 0.0, 1.0);
    float desired_speed = max_speed * ramp;

    @v = dir_norm * desired_speed;
} else {
    // Lock the point down completely inside the dead zone
    @v = set(0, 0, 0);
}

Speed
  ▲
Max ─────┐ (Cruising speed)
  │       \
  │        \  Braking zone (ramp = dist / slow_radius)
  │         \
0 └──────────┴───────► Distance to Target
         stop_dist   slow_radius

Note on SOP Trailing: Assigning @v in a standard Point Wrangle sets the velocity attribute, but it does not move the geometry unless you feed it into a SOP Solver, append a Point Replicate / Trail SOP, or manually advance position in the wrangle:

@P += @v * @TimeInc;


3. The POP Approach: Dynamic Steering Force (@force)

Inside a POP Network, steering behavior follows Craig Reynolds' classic formula:

$$\text{Steering Force} = \text{Desired Velocity} - \text{Current Velocity}$$

Applying this delta to @force allows particles to slow down smoothly while preserving physical properties like inertia, mass, and obstacle bounce responses:

// Run over: Points (POP Wrangle)

vector target    = chv("target_pos");
vector to_target = target - @P;
float dist       = length(to_target);

float max_speed   = chf("max_speed");   // Maximum approach speed (e.g., 6.0)
float slow_radius = chf("slow_radius"); // Distance to start braking (e.g., 2.5)
float stop_dist   = chf("stop_dist");   // Dead zone threshold (e.g., 0.08)
float max_force   = chf("max_force");   // Clamps max acceleration (e.g., 20.0)

if (dist > stop_dist) {
    // 1. Calculate desired velocity vector with arrival ramp
    float ramp = clamp(dist / slow_radius, 0.0, 1.0);
    vector desired_v = normalize(to_target) * (max_speed * ramp);

    // 2. Compute steering delta
    vector steer = desired_v - @v;

    // 3. Clamp steering acceleration to prevent unnatural instant turns
    if (length(steer) > max_force) {
        steer = normalize(steer) * max_force;
    }

    // 4. Apply force scaled by particle mass (F = m * a)
    @force += steer * f@mass;
} else {
    // Damp residual momentum inside the arrival zone rather than hard-locking
    @v *= 0.8;
}


Parameter Reference

ParameterRecommended RangePurpose
max_speed4.0 – 8.0Top speed points reach while cruising outside the braking zone.
slow_radius1.5 – 3.5The distance from the target where deceleration begins.
stop_dist0.05 – 0.1The dead-zone radius that prevents micro-jittering around the exact origin.
max_force (POPs)10.0 – 25.0Limits steering authority so particles don't snap unnaturally on a dime.

By separating the braking radius (slow_radius) from the dead zone (stop_dist), points decelerate naturally into formation instead of jerking to a sudden halt.