FootballBallReadabilityMotion.cs

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