Presentation-only motion history for a football ball. Records rendered ball samples and rebuilds tapered filament trail strokes and deterministic cloud puffs from the passed path, with resets on teleports, rewinds, hidden or non-free states.
using System;
using NVector3=System.Numerics.Vector3;
namespace Socer;
// Presentation history only. It receives rendered ball samples and never reads
// or writes simulation state, consumes randomness, or integrates a future path.
public sealed class FootballBallReadabilityMotion : IDisposable
{
public const float Lifetime=.26f,MaximumLength=3.2f,HeadGap=.125f,MinimumSpeed=12f;
public const float CloudLifetime=.45f,CloudMaximumLength=6.8f,CloudHeadGap=.28f,CloudSpacing=.42f;
public const int Capacity=128,MaximumSegments=48,MaximumStrokes=MaximumSegments*2,MaximumPuffs=16;
public readonly record struct Stroke(NVector3 A,NVector3 B,float AlphaA,float AlphaB,float Width,
int Filament,float DistanceA,float DistanceB,float AgeA,float AgeB);
public readonly record struct Puff(NVector3 Position,NVector3 Tangent,float Radius,float Aspect,
float Opacity,float Age,float Distance,int Variant);
readonly record struct Sample(NVector3 Position,float Time,float Arc);
readonly record struct PathPoint(NVector3 Position,NVector3 Tangent,float Time,float Arc);
Sample[] samples=new Sample[Capacity];
int first,count;
float previousTime,latestTime,previousSpeed,travel,speedWeight;
NVector3 previousPosition;
bool initialized,disposed;
public int SampleCount=>count;
public int ResetCount {get;private set;}
public string LastResetReason {get;private set;}="new";
public float VisibleLength {get;private set;}
public float CloudVisibleLength {get;private set;}
public float CloudPeakOpacity {get;private set;}
public int HistoryCapacity=>samples?.Length??0;
public bool Disposed=>disposed;
static float Smooth(float t){t=Math.Clamp(t,0,1);return t*t*(3-2*t);}
static bool Finite(NVector3 p)=>float.IsFinite(p.X+p.Y+p.Z);
Sample At(int index)=>samples[(first+index)%Capacity];
void EnsureHistory()
{
// Hotload can retain an old 64-slot array while replacing the constants.
// Resize once, before any indexing, and retire its old path completely.
if(samples!=null && samples.Length==Capacity)return;
samples=new Sample[Capacity];Reset("history-resize");
}
void Add(NVector3 position,float time)
{
if(count==Capacity){first=(first+1)%Capacity;count--;}
samples[(first+count++)%Capacity]=new(position,time,travel);
}
public void Reset(string reason="external")
{
if(initialized || count>0)ResetCount++;
first=count=0;initialized=false;VisibleLength=CloudVisibleLength=CloudPeakOpacity=0;travel=speedWeight=0;
LastResetReason=reason;
}
public void Observe(float time,NVector3 position,float speed,bool free,bool visible)
{
if(disposed)return;
EnsureHistory();
if(!float.IsFinite(time+speed) || !Finite(position)){Reset("nonfinite");return;}
if(!visible){Reset("hidden");return;}
if(!free){Reset("ownership");return;}
if(speed<=MinimumSpeed){Reset("speed");return;}
if(initialized)
{
float dt=time-previousTime,distance=NVector3.Distance(position,previousPosition);
if(dt<-.00001f){Reset("rewind");}
else if(dt>.12f){Reset("time-gap");}
else if(dt<=.00001f && distance>.004f){Reset("frozen-teleport");}
else if(dt>.00001f && distance>Math.Max(1.4f,Math.Max(speed,previousSpeed)*dt*1.6f+.15f))
Reset("teleport");
}
latestTime=time;speedWeight=Smooth((speed-MinimumSpeed)/6);
if(!initialized)
{initialized=true;previousTime=time;previousPosition=position;previousSpeed=speed;Add(position,time);return;}
float moved=NVector3.Distance(position,previousPosition);
if(time>previousTime+.00001f && moved>.00001f)
{
travel+=moved;
// A very high render rate must not consume all 128 slots before
// the .45-second cloud can age out. Store at most 240 samples/s.
if(count==0 || time-At(count-1).Time>=1f/240f)Add(position,time);
}
previousTime=time;previousPosition=position;previousSpeed=speed;
while(count>0 && time-At(0).Time>=CloudLifetime){first=(first+1)%Capacity;count--;}
}
bool PointAt(float distance,out PathPoint point)
{
point=default;float accumulated=0;var near=new Sample(previousPosition,latestTime,travel);
for(int i=count-1;i>=0;i--)
{
var far=At(i);var delta=near.Position-far.Position;
float length=delta.Length();if(length<.00001f){near=far;continue;}
if(distance<=accumulated+length+.00001f)
{
float t=Math.Clamp((distance-accumulated)/length,0,1);
point=new(NVector3.Lerp(near.Position,far.Position,t),delta/length,
near.Time+(far.Time-near.Time)*t,near.Arc+(far.Arc-near.Arc)*t);return true;
}
accumulated+=length;near=far;
}
return false;
}
static NVector3 Filament(PathPoint point,int strand,float distance)
{
var side=NVector3.Cross(point.Tangent,NVector3.UnitZ);
if(side.LengthSquared()<.0025f)side=NVector3.Cross(point.Tangent,NVector3.UnitY);
side=NVector3.Normalize(side);var up=NVector3.Normalize(NVector3.Cross(side,point.Tangent));
float phase=point.Arc*(MathF.Tau/.85f),taper=FilamentTaper(distance);
float sign=strand==0?1:-1;
return point.Position+(side*(MathF.Cos(phase)*.036f)+up*(MathF.Sin(phase)*.024f))*(sign*taper);
}
static float FilamentTaper(float distance)=>1-Smooth((distance-MaximumLength*.5f)/(MaximumLength*.5f));
float AvailableLength()
{
if(count==0)return 0;
float available=NVector3.Distance(previousPosition,At(count-1).Position);
for(int i=count-1;i>0;i--)available+=NVector3.Distance(At(i).Position,At(i-1).Position);
return available;
}
float Alpha(PathPoint point,float distance,int strand)
{
float age=Math.Max(0,latestTime-point.Time);
float fade=MathF.Pow(Math.Clamp(1-age/Lifetime,0,1),1.25f);
return (strand==0?.46f:.38f)*fade*speedWeight*Smooth((distance-HeadGap)/.055f)*
FilamentTaper(distance);
}
public int Build(Span<Stroke> output)
{
VisibleLength=0;
EnsureHistory();
if(disposed || count<1 || speedWeight<=0)return 0;
float available=Math.Min(MaximumLength,AvailableLength());
if(available<=HeadGap+.025f)return 0;
int segments=Math.Clamp((int)MathF.Ceiling((available-HeadGap)/.065f),1,MaximumSegments),written=0;
for(int i=0;i<segments;i++)
{
float a=HeadGap+(available-HeadGap)*i/segments,b=HeadGap+(available-HeadGap)*(i+1)/segments;
if(!PointAt(a,out var pa) || !PointAt(b,out var pb))break;
for(int strand=0;strand<2;strand++)
{
if(written>=output.Length)return written;
float aa=Alpha(pa,a,strand),ab=Alpha(pb,b,strand);
if(Math.Max(aa,ab)<.001f)continue;
output[written++]=new(Filament(pa,strand,a),Filament(pb,strand,b),aa,ab,.018f,
strand,a,b,Math.Max(0,latestTime-pa.Time),Math.Max(0,latestTime-pb.Time));
}
}
if(written>0)VisibleLength=available;
return written;
}
public int BuildPuffs(Span<Puff> output)
{
CloudVisibleLength=CloudPeakOpacity=0;
EnsureHistory();
if(disposed || count<1 || speedWeight<=0)return 0;
float available=Math.Min(CloudMaximumLength,AvailableLength());
if(available<=CloudHeadGap)return 0;
// Arc cohorts stay at actual passed world points as the ball advances.
// Deterministic integer variation replaces per-frame particle randomness.
int cohort=(int)MathF.Floor((travel-CloudHeadGap)/CloudSpacing),written=0;
for(int i=0;i<MaximumPuffs && written<output.Length;i++)
{
int id=cohort-i;float distance=travel-id*CloudSpacing;
if(distance>available+.0001f)break;
if(!PointAt(distance,out var point))break;
float age=Math.Max(0,latestTime-point.Time);
if(age>=CloudLifetime)continue;
uint variation=unchecked((uint)id*2654435761u);
int variant=(int)((variation>>27)&3);
float life=age/CloudLifetime;
float opacity=.13f*speedWeight*Smooth(age/.025f)*MathF.Pow(Math.Clamp(1-life,0,1),.8f)*
(1-Smooth((distance-CloudMaximumLength*.65f)/(CloudMaximumLength*.35f)))*(.96f+variant*.02f);
if(opacity<.002f)continue;
float radius=(.075f+.095f*Smooth(life))*(.94f+variant*.04f);
output[written++]=new(point.Position,point.Tangent,radius,1.35f+variant*.06f,opacity,age,distance,variant);
CloudVisibleLength=Math.Max(CloudVisibleLength,distance);CloudPeakOpacity=Math.Max(CloudPeakOpacity,opacity);
}
return written;
}
public void Dispose(){Reset("dispose");disposed=true;}
}