FootballBallReadability.cs

Partial class of FootballWorldPanel that draws the ball's motion trail. It feeds ball position and speed into FootballBallReadabilityMotion, builds filament stroke and cloud puff geometry into a reusable Vertex buffer, and uploads it to a SceneDynamicObject using the panel's dynamic geometry material.

Rough Code
using Sandbox;
using Socer;
using System;
using NVector=System.Numerics.Vector3;

public sealed partial class FootballWorldPanel
{
    FootballBallReadabilityMotion ballReadabilityMotion;
    const int BallCloudSides=12,BallCloudVerticesPerPuff=BallCloudSides*15;
    const int BallReadabilityMaximumVertices=FootballBallReadabilityMotion.MaximumStrokes*18+
        FootballBallReadabilityMotion.MaximumPuffs*BallCloudVerticesPerPuff;
    FootballBallReadabilityMotion.Stroke[] ballReadabilityStrokes=new FootballBallReadabilityMotion.Stroke[FootballBallReadabilityMotion.MaximumStrokes];
    FootballBallReadabilityMotion.Puff[] ballReadabilityPuffs=new FootballBallReadabilityMotion.Puff[FootballBallReadabilityMotion.MaximumPuffs];
    Vertex[] ballReadabilityVertices=new Vertex[BallReadabilityMaximumVertices];
    SceneDynamicObject ballReadabilityObject;
    // Keep an explicit managed owner as well as the shared panel owner. The
    // native SceneDynamicObject property retains only a material handle.
    Material ballReadabilityMaterial;
    MatchSimulation ballReadabilityMatch;
    bool ballReadabilityReplay;
    int ballReadabilitySeek=-1,ballReadabilityVertexCount,ballReadabilityStrokeCount,ballReadabilityPuffCount;

    void EnsureBallReadabilityBuffers()
    {
        bool resized=false;
        if(ballReadabilityStrokes==null || ballReadabilityStrokes.Length!=FootballBallReadabilityMotion.MaximumStrokes)
        {ballReadabilityStrokes=new FootballBallReadabilityMotion.Stroke[FootballBallReadabilityMotion.MaximumStrokes];resized=true;}
        if(ballReadabilityPuffs==null || ballReadabilityPuffs.Length!=FootballBallReadabilityMotion.MaximumPuffs)
        {ballReadabilityPuffs=new FootballBallReadabilityMotion.Puff[FootballBallReadabilityMotion.MaximumPuffs];resized=true;}
        if(ballReadabilityVertices==null || ballReadabilityVertices.Length!=BallReadabilityMaximumVertices)
        {ballReadabilityVertices=new Vertex[BallReadabilityMaximumVertices];resized=true;}
        if(resized)
        {
            ballReadabilityMotion?.Dispose();ballReadabilityMotion=new();ResetBallReadability("buffer-resize");
        }
        ballReadabilityMotion??=new();
    }

    public void ResetBallReadability(string reason="external")
    {
        ballReadabilityMotion?.Reset(reason);ballReadabilityVertexCount=ballReadabilityStrokeCount=ballReadabilityPuffCount=0;
        if(ballReadabilityObject!=null)ballReadabilityObject.RenderingEnabled=false;
    }
    void DrawBallReadability(MatchSimulation match,Point position,float height,float seconds,float speed,
        bool free,bool replay,bool introduction,int replaySeekRevision,bool authoredFlight)
    {
        EnsureBallReadabilityBuffers();
        if(ballReadabilityMatch!=match || ballReadabilityReplay!=replay || ballReadabilitySeek!=replaySeekRevision)
        {
            ResetBallReadability();ballReadabilityMatch=match;ballReadabilityReplay=replay;ballReadabilitySeek=replaySeekRevision;
        }
        ballReadabilityMotion.Observe(seconds,new NVector(position.X,position.Y,Math.Max(.11f,height)),speed,
            free,!introduction && !authoredFlight && match!=null && sceneCamera!=null &&
                (replay || (!match.Finished && match.RestartRemaining<=0 && !match.ManualRestartWaiting)));
        ballReadabilityStrokeCount=ballReadabilityMotion.Build(ballReadabilityStrokes.AsSpan());
        ballReadabilityPuffCount=ballReadabilityMotion.BuildPuffs(ballReadabilityPuffs.AsSpan());
        ballReadabilityVertexCount=0;
        if(ballReadabilityStrokeCount==0 && ballReadabilityPuffCount==0)
        {if(ballReadabilityObject!=null)ballReadabilityObject.RenderingEnabled=false;return;}
        if(ballReadabilityObject==null)
        {
            ballReadabilityMaterial=DynamicGeometryMaterial;
            ballReadabilityObject=new SceneDynamicObject(RenderScene.SceneWorld){Material=ballReadabilityMaterial};
            ballReadabilityObject.Attributes.SetCombo("D_NO_CULLING",1);
            ballReadabilityObject.Flags.CastShadows=false;
        }
        // Soft alpha puffs are behind the fine filaments in this same batch.
        for(int i=0;i<ballReadabilityPuffCount;i++)AddBallReadabilityPuff(ballReadabilityPuffs[i]);
        for(int i=0;i<ballReadabilityStrokeCount;i++)AddBallReadabilityStroke(ballReadabilityStrokes[i]);
        ballReadabilityObject.RenderingEnabled=ballReadabilityVertexCount>0;
        if(ballReadabilityVertexCount==0)return;
        ballReadabilityObject.Init(Graphics.PrimitiveType.Triangles);
        ballReadabilityObject.AddVertex(ballReadabilityVertices.AsSpan(0,ballReadabilityVertexCount));
    }
    void AddBallReadabilityPuff(FootballBallReadabilityMotion.Puff puff)
    {
        var center=new Vector3(puff.Position.X,-puff.Position.Y,puff.Position.Z)*Units;
        var tangent=new Vector3(puff.Tangent.X,-puff.Tangent.Y,puff.Tangent.Z);
        var view=(center-sceneCamera.WorldPosition).Normal;
        var along=tangent-view*Vector3.Dot(tangent,view);
        if(along.Length<.001f)along=Vector3.Cross(view,Vector3.Up);
        if(along.Length<.001f)along=Vector3.Forward;
        along=along.Normal;
        var across=Vector3.Cross(view,along).Normal;
        float major=puff.Radius*puff.Aspect*Units,minor=puff.Radius*Units;
        byte red=puff.Variant%2==0?(byte)229:(byte)211,green=puff.Variant%2==0?(byte)235:(byte)226,
            blue=puff.Variant%2==0?(byte)228:(byte)236;
        Vertex V(int sector,int ring)
        {
            float radius=ring switch{0=>0,1=>.38f,2=>.74f,_=>1};
            float fade=ring switch{0=>1,1=>.62f,2=>.15f,_=>0};
            float angle=sector*MathF.Tau/BallCloudSides;
            var at=center+(along*(MathF.Cos(angle)*major)+across*(MathF.Sin(angle)*minor))*radius;
            return new Vertex(at,new Color32(red,green,blue,(byte)Math.Clamp((int)(puff.Opacity*fade*255),0,255)));
        }
        void Triangle(Vertex a,Vertex b,Vertex c)
        {
            ballReadabilityVertices[ballReadabilityVertexCount++]=a;ballReadabilityVertices[ballReadabilityVertexCount++]=b;
            ballReadabilityVertices[ballReadabilityVertexCount++]=c;
        }
        for(int sector=0;sector<BallCloudSides;sector++)
        {
            Triangle(V(0,0),V(sector,1),V(sector+1,1));
            for(int ring=1;ring<3;ring++)
            {
                var a=V(sector,ring);var b=V(sector+1,ring);var c=V(sector+1,ring+1);var d=V(sector,ring+1);
                Triangle(a,b,c);Triangle(a,c,d);
            }
        }
    }
    void AddBallReadabilityStroke(FootballBallReadabilityMotion.Stroke stroke)
    {
        Vector3 World(NVector v)=>new Vector3(v.X,-v.Y,v.Z)*Units;
        var a=World(stroke.A);var b=World(stroke.B);var delta=b-a;
        if(delta.Length<.0001f)return;
        var view=((a+b)*.5f-sceneCamera.WorldPosition).Normal;
        var across=Vector3.Cross(delta.Normal,view);
        if(across.Length<.001f)return;
        across=across.Normal;
        float depth=Math.Max(1,Vector3.Dot((a+b)*.5f-sceneCamera.WorldPosition,sceneCamera.WorldRotation.Forward));
        float pixel=depth*2*MathF.Tan(sceneCamera.FieldOfView*MathF.PI/360)/Math.Max(1,Box.Rect.Width);
        // Subtle subpixel coverage support, not a bloom/glow shell. Both
        // filaments together remain narrower than the original 0.22m ball.
        float half=Math.Max(stroke.Width*Units*.5f,pixel*.32f),feather=pixel*.38f;
        byte red=stroke.Filament==0?(byte)235:(byte)207,green=stroke.Filament==0?(byte)238:(byte)224,
            blue=stroke.Filament==0?(byte)229:(byte)236;
        Vertex V(Vector3 at,int band,float opacity)
        {
            float offset=band switch {0=>-half-feather,1=>-half,2=>half,_=>half+feather};
            byte alpha=band is 0 or 3?(byte)0:(byte)Math.Clamp((int)(opacity*255),0,255);
            return new Vertex(at+across*offset,new Color32(red,green,blue,alpha));
        }
        for(int band=0;band<3;band++)
        {
            var p=V(a,band,stroke.AlphaA);var q=V(b,band,stroke.AlphaB);
            var r=V(b,band+1,stroke.AlphaB);var s=V(a,band+1,stroke.AlphaA);
            ballReadabilityVertices[ballReadabilityVertexCount++]=p;ballReadabilityVertices[ballReadabilityVertexCount++]=q;ballReadabilityVertices[ballReadabilityVertexCount++]=r;
            ballReadabilityVertices[ballReadabilityVertexCount++]=p;ballReadabilityVertices[ballReadabilityVertexCount++]=r;ballReadabilityVertices[ballReadabilityVertexCount++]=s;
        }
    }
    public object BallReadabilityReport()=>new
    {
        Filaments=2,Lifetime=FootballBallReadabilityMotion.Lifetime,MaximumLength=FootballBallReadabilityMotion.MaximumLength,
        CloudLifetime=FootballBallReadabilityMotion.CloudLifetime,CloudMaximumLength=FootballBallReadabilityMotion.CloudMaximumLength,
        Puffs=ballReadabilityPuffCount,MaximumPuffs=FootballBallReadabilityMotion.MaximumPuffs,
        CloudVisibleLength=ballReadabilityMotion?.CloudVisibleLength??0,CloudPeakOpacity=ballReadabilityMotion?.CloudPeakOpacity??0,
        MinimumSpeed=FootballBallReadabilityMotion.MinimumSpeed,BallDiameter=.22f,
        Samples=ballReadabilityMotion?.SampleCount??0,SampleCapacity=FootballBallReadabilityMotion.Capacity,
        ActualHistoryCapacity=ballReadabilityMotion?.HistoryCapacity??0,
        Strokes=ballReadabilityStrokeCount,Vertices=ballReadabilityVertexCount,MaximumVertices=ballReadabilityVertices.Length,
        DynamicObjects=ballReadabilityObject==null?0:1,MaterialHeld=ballReadabilityMaterial!=null,
        SharedMaterialHeld=dynamicGeometryMaterial!=null,Resets=ballReadabilityMotion?.ResetCount??0,
        LastReset=ballReadabilityMotion?.LastResetReason,VisibleLength=ballReadabilityMotion?.VisibleLength??0,
        Replay=ballReadabilityReplay,SeekRevision=ballReadabilitySeek
    };
    void DisposeBallReadability()
    {
        ballReadabilityObject?.Delete();ballReadabilityObject=null;
        ballReadabilityMotion?.Dispose();ballReadabilityMotion=null;ballReadabilityMaterial=null;
        ballReadabilityMatch=null;ballReadabilityVertexCount=ballReadabilityStrokeCount=ballReadabilityPuffCount=0;
    }
    public override void OnHotloaded()
    {
        // Explicit replacement is required: hotload may preserve old readonly
        // arrays and a 64-slot history from the former short effect.
        ballReadabilityMotion?.Dispose();ballReadabilityMotion=new();EnsureBallReadabilityBuffers();
        ResetBallReadability("hotload");base.OnHotloaded();
    }
}