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.
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;
}
}
}