Code/Controllers/TornadoForce.Cs

A particle controller that simulates a tornado force field. It applies tangential swirl, radial confinement toward a funnel radius, and optional axial lift to particle velocities, with options for local/world space and per-particle parameter curves.

Native Interop
using Sandbox;

/// <summary>
/// Tornado motion around an axis (default world up).
/// Combines a tangential swirl (rotation around the axis) with a radial force
/// that keeps particles confined to a funnel radius, so they form a rotating
/// column instead of spiralling outward like a plain tangential vortex.
/// Lift (upward motion) is optional - you can also drive it from initial velocity.
/// </summary>
public class TornadoForce() : ParticleController
{
    [Property]
    public Vector3 VortexCenterOffset { get; set; }

    [Property]
    public Vector3 Axis { get; set; } = Vector3.Up;

    /// <summary>
    /// When true the Axis and center offset follow the controller's rotation/position,
    /// so the tornado tilts and moves with the emitter. When false they are world-space.
    /// </summary>
    [Property]
    public bool LocalSpace { get; set; } = false;

    /// <summary>
    /// Target tangential speed around the axis. This is the rotation.
    /// </summary>
    [Property]
    public ParticleFloat SwirlSpeed { get; set; } = 100f;

    /// <summary>
    /// Radius of the funnel the particles are pulled toward. Use a curve over
    /// particle life to widen/narrow the tornado into a cone shape.
    /// </summary>
    [Property]
    public ParticleFloat FunnelRadius { get; set; } = 40f;

    /// <summary>
    /// How hard particles are pulled back toward FunnelRadius. Higher = tighter column.
    /// </summary>
    [Property]
    public ParticleFloat RadialPull { get; set; } = 2f;

    /// <summary>
    /// Optional target upward speed along the axis. Leave 0 if you drive lift from velocity.
    /// </summary>
    [Property]
    public ParticleFloat LiftSpeed { get; set; } = 0f;

    /// <summary>
    /// How quickly velocity converges to the targets (swirl / radial / lift). Higher = snappier.
    /// </summary>
    [Property]
    public ParticleFloat Responsiveness { get; set; } = 4f;

    protected override void OnParticleStep(Particle particle, float delta)
    {
        var vortexCenter = LocalSpace
            ? WorldTransform.PointToWorld(VortexCenterOffset)
            : WorldPosition + VortexCenterOffset;
        var axis = (LocalSpace ? WorldTransform.NormalToWorld(Axis) : Axis).Normal;

        var r = particle.Position - vortexCenter;

        // Radial vector in the plane perpendicular to the axis (ignore height along the axis).
        var rPlane = r - Vector3.Dot(r, axis) * axis;
        var radius = rPlane.Length;
        if (radius < 0.0001f)
            return;

        var radialDir = rPlane / radius;                 // outward, unit
        var tangent = Vector3.Cross(axis, radialDir);    // rotation direction, unit

        var responsiveness = Responsiveness.Evaluate(particle, 71011);

        // --- Swirl: drive tangential velocity toward SwirlSpeed ---
        var swirlSpeed = SwirlSpeed.Evaluate(particle, 71012);
        var curTangential = Vector3.Dot(particle.Velocity, tangent);
        particle.Velocity += tangent * (swirlSpeed - curTangential) * responsiveness * delta;

        // --- Confinement: drive radial velocity toward closing the gap to FunnelRadius ---
        var funnelRadius = FunnelRadius.Evaluate(particle, 71013);
        var radialPull = RadialPull.Evaluate(particle, 71014);
        // Negative when outside the funnel (pull in), positive when inside (push out).
        var targetRadial = (funnelRadius - radius) * radialPull;
        var curRadial = Vector3.Dot(particle.Velocity, radialDir);
        particle.Velocity += radialDir * (targetRadial - curRadial) * responsiveness * delta;

        // --- Lift: drive velocity along the axis toward LiftSpeed (optional) ---
        var liftSpeed = LiftSpeed.Evaluate(particle, 71015);
        if (liftSpeed != 0f)
        {
            var curAxial = Vector3.Dot(particle.Velocity, axis);
            particle.Velocity += axis * (liftSpeed - curAxial) * responsiveness * delta;
        }
    }
}