Dust and turf particle system for the soccer match presentation. Reads MatchSimulation player and ball state (impacts, falls, kicks, slides, footsteps, saves/posts) and spawns short-lived puffs that a renderer samples into flat billboard poses, with no effect on the simulation.
using System;
using System.Collections.Generic;
namespace Socer;
// Presentation only. Spawn from actual contacts; store initial conditions so the
// replay can sample the same short ballistic motion without advancing the match.
public sealed class SprintDust
{
public enum ParticleKind { Grass, Soil, Glove, Frame }
public readonly record struct Puff(Point Position,float Born,Point Velocity=default,
float Height=.035f,float Lift=.7f,float Size=.055f,ParticleKind Kind=ParticleKind.Grass,int Variation=0);
public readonly record struct ParticlePose(bool Visible,Point A,float ZA,Point B,float ZB,float Width,string Color,float Opacity);
public const int MaximumParticles=48;
public const float Lifetime=.60f;
readonly List<Puff> particles=new(MaximumParticles);
readonly Dictionary<int,FootState> feet=new();
sealed class FootState
{
public int Foot=-1,SlideSample=-1;
public float Impact=-10,Landing=-10,Kick=-10,Control=-10,Slide=-10;
}
MatchSimulation match;
float lastTime=-1,lastBallHeight;
Point lastBall;
int lastEffect;
public static float Duration(Puff p)=>p.Kind is ParticleKind.Glove or ParticleKind.Frame?.18f:Lifetime;
public static ParticlePose Sample(Puff p,float seconds)
{
float age=seconds-p.Born,duration=Duration(p);
if(age<0 || age>=duration)return default;
float t=age/duration;
bool accent=p.Kind is ParticleKind.Glove or ParticleKind.Frame;
// Clumps slow in the grass instead of drifting like smoke. The small
// contact accent lasts 5 frames at 30 fps; its silhouette never covers the ball.
float travel=accent?age:(1-MathF.Exp(-age*4.6f))/4.6f;
var at=p.Position+p.Velocity*travel;
float z=Math.Max(.018f,p.Height+p.Lift*age-(accent?0:4.905f*age*age));
var axis=p.Velocity.Length>.01f?p.Velocity.Unit:new Point(1,0);
float fade=1-Smooth(Math.Clamp((t-.40f)/.60f,0,1));
float width=p.Size*(accent?(1-t)*.55f:MathF.Sqrt(fade));
float length=accent?.045f+.10f*MathF.Sin(t*MathF.PI):p.Size*(1.15f+.5f*Noise(p.Variation,2));
string color=p.Kind switch
{
ParticleKind.Glove=>p.Variation%2==0?"#bce0d6":"#f4e8ba",
ParticleKind.Frame=>"#efe3b6",
ParticleKind.Soil=>p.Variation%2==0?"#77654b":"#92835c",
_=>p.Variation%3==0?"#b7ba78":p.Variation%3==1?"#829653":"#566d3e"
};
return new(width>.001f,at-axis*(length*.5f),z,at+axis*(length*.5f),
z+(accent?p.Lift*.015f:width*.28f),width,color,accent?fade*(1-t)*.85f:1);
}
public IReadOnlyList<Puff> Update(MatchSimulation current)
{
if(match!=current || current.Seconds<lastTime)
{
particles.Clear();feet.Clear();match=current;lastTime=-1;lastEffect=0;
lastBall=current.Ball;lastBallHeight=current.BallHeight;
}
if(current.Seconds==lastTime)return particles;
float now=current.Seconds;
particles.RemoveAll(p=>now-p.Born>=Duration(p));
foreach(var p in current.Players)
{
if(p.InjuryRemovedFromField || p.SentOff && now>=p.ImpactUntil)continue;
if(!feet.TryGetValue(p.Id,out var state))feet[p.Id]=state=new();
var forward=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
var travel=p.VisualVelocity.Length>.1f?p.VisualVelocity.Unit:forward;
var side=new Point(-travel.Y,travel.X);
int seed=p.Id*1009;
if(Recent(p.ImpactAt,state.Impact,now))
{
state.Impact=p.ImpactAt;
Burst(p.ImpactPoint,p.ImpactAt,p.ImpactDirection*-1,5,.8f,seed+13);
}
float landed=p.FallAt+(p.FallVariant==4?.58f:.44f);
if(now<p.FallUntil && Recent(landed,state.Landing,now))
{
state.Landing=landed;
Burst(p.Position+p.FallDirection*.22f,landed,p.FallDirection,6,
.75f+p.FallSeverity*.55f,seed+37);
}
if(Recent(p.LastKickAt,state.Kick,now))
{
state.Kick=p.LastKickAt;
if(p.LastKickKind!=StrikeKind.Header && now-p.LastPuntAt>.16f)
{
var strike=new Point(MathF.Cos(p.LastKickFacing),MathF.Sin(p.LastKickFacing));
var across=new Point(-strike.Y,strike.X);
var planted=p.Position-across*(p.KickFoot<0?-.17f:.17f);
Burst(planted,p.LastKickAt,strike*-1,p.LastKickWasShot?5:2,
p.LastKickWasShot?.9f:.38f,seed+71);
}
}
if(Recent(p.LastControlAt,state.Control,now))
{
state.Control=p.LastControlAt;
if(!p.Keeper && p.ChestUntil<=now && (p.ControlPoint-p.Position).Length<1.3f)
Burst(p.ControlPoint,p.LastControlAt,travel*-1,2,.30f,seed+89);
}
// A low, directional ribbon of dislodged turf follows the leading
// hip/boot during the slide. Emission stops before the get-up begins.
float slideAge=now-p.LastSlideAt;
if(state.Slide!=p.LastSlideAt){state.Slide=p.LastSlideAt;state.SlideSample=-1;}
if(slideAge>=.06f && slideAge<.66f)
{
int sample=(int)((slideAge-.06f)/.075f);
if(sample>state.SlideSample)
{
state.SlideSample=sample;
var direction=p.SlideDirection.Length>.1f?p.SlideDirection:forward;
var lateral=new Point(-direction.Y,direction.X);
var origin=p.Position-direction*.15f+lateral*(p.SlideVariant==1?-.20f:.20f);
Burst(origin,now,direction*-1,3,Math.Clamp(.55f+p.SlideSpeed*.07f,.55f,1.1f),seed+sample*23);
}
}
int foot=(int)(p.GaitPhase*2);
bool stepped=state.Foot>=0 && state.Foot!=foot;
state.Foot=foot;
bool down=now<p.FallUntil || p.DiveRemaining>0 || slideAge>=0 && slideAge<2.1f;
if(!stepped || down || p.VisualSpeed<3.2f)continue;
float braking=-Point.Dot(p.VisualAcceleration,travel);
float cutting=Math.Abs(Point.Dot(p.VisualAcceleration,side));
bool hardPlant=braking>4 || cutting>5;
if(!p.Sprinting && !hardPlant)continue;
var boot=p.Position-travel*.16f+side*(foot==0?.16f:-.16f);
Burst(boot,now,travel*-1,hardPlant?3:2,hardPlant?.65f:.42f,seed+foot*31+(int)(p.GaitPhase*100));
}
// The event IDs are monotonic and no per-event dictionary is retained.
// Match/replay reset therefore has a fixed memory budget as well.
foreach(var effect in current.Effects)
{
if(effect.Id<=lastEffect)continue;
lastEffect=effect.Id;
if(effect.Age<0 || effect.Age>=.16f)continue;
if(effect.Kind is not ("post" or "save" or "block"))continue;
bool frame=effect.Kind=="post";
var origin=(current.Ball-effect.Position).Length<1.8f?current.Ball:effect.Position;
float height=frame?Math.Clamp(current.BallHeight,.14f,2.44f):Math.Max(.12f,current.BallHeight);
Contact(origin,height,now-effect.Age,current.BallVelocity.Unit,
frame?ParticleKind.Frame:ParticleKind.Glove,effect.Id);
}
if(lastTime>=0 && now-lastTime<.12f && current.Owner<0 &&
lastBallHeight>.11f && current.BallHeight<.10f && current.VerticalSpeed>0 &&
(current.Ball-lastBall).Length<4)
Burst(current.Ball,now,current.BallVelocity.Unit,3,.42f,337+(int)(now*100));
if(particles.Count>MaximumParticles)particles.RemoveRange(0,particles.Count-MaximumParticles);
lastTime=now;lastBall=current.Ball;lastBallHeight=current.BallHeight;
return particles;
}
static bool Recent(float at,float previous,float now)=>at>=0 && at!=previous && now>=at && now-at<Lifetime;
void Burst(Point origin,float born,Point direction,int count,float strength,int seed)
{
direction=direction.Length>.01f?direction.Unit:new Point(1,0);
var side=new Point(-direction.Y,direction.X);
for(int n=0;n<count;n++)
{
int variation=seed+n*41+(int)(born*97);
float scatter=Noise(variation,0)*2-1;
var velocity=direction*((.35f+Noise(variation,1)*.8f)*strength)+side*(scatter*.8f*strength);
var at=origin+side*(scatter*.07f);
particles.Add(new(at,born,velocity,.025f,(.65f+Noise(variation,2)*1.1f)*strength,
(.035f+Noise(variation,3)*.036f)*Math.Clamp(strength,.5f,1.1f),
n%3==2?ParticleKind.Soil:ParticleKind.Grass,Math.Abs(variation)));
}
}
void Contact(Point at,float height,float born,Point direction,ParticleKind kind,int seed)
{
float facing=MathF.Atan2(direction.Y,direction.X);
for(int n=0;n<5;n++)
{
float angle=facing+(n-2)*.75f;
var axis=new Point(MathF.Cos(angle),MathF.Sin(angle));
particles.Add(new(at+axis*.05f,born,axis*(.75f+Noise(seed,n)*.7f),height,
(n-2)*.6f,.028f,kind,n));
}
}
static float Smooth(float t)=>t*t*(3-2*t);
static float Noise(int seed,int lane)
{
uint n=unchecked((uint)seed*747796405u+(uint)lane*2891336453u+277803737u);
n=(n^(n>>16))*2246822519u;n^=n>>13;
return (n&65535)/65535f;
}
}