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