FootballWorldPanel.cs

A ScenePanel that renders the 3D broadcast view of a football match. It builds its own Scene and camera, creates mesh geometry for the pitch, players, ball and overlays, and every Tick poses the actors, drives camera framing for broadcast, set pieces, goal celebrations and replays, and draws field rings, pings, trails and skill effects.

Rough Code
using Sandbox;
using Sandbox.UI;
using Socer;
using System;
using System.Collections.Generic;
using System.Linq;

// The broadcast and replay share real geometry, lighting and depth testing.
// This view owns only presentation objects; it never advances the match.
public sealed partial class FootballWorldPanel : ScenePanel
{
    public MatchController Game { get; set; }
    const float Units=39.37f;
    readonly List<ModelRenderer[]> actors=new();
    List<PlayerDetailRenderer> actorDetails=new();
    readonly List<ReplayFigure.Part> pose=new(24);
    readonly Dictionary<string,Color> colors=new();
    KitDesign currentKit;
    Model sphere,box,torso,limb;
    ModelRenderer ball;
    ModelRenderer ballPanels,ballTrim;
    Material matchBallMaterial;
    string shownBallId;
    readonly SceneDynamicObject[] vision=new SceneDynamicObject[22];
    readonly Vertex[] visionVertices=new Vertex[6*14*6];
    ModelRenderer[] dustObjects=new ModelRenderer[48];
    readonly ModelRenderer[] flightObjects=new ModelRenderer[12];
    readonly ModelRenderer[,] uniqueTrailObjects=new ModelRenderer[22,LegendaryVisuals.MaximumStrokes];
    readonly Point[,] uniqueTrailPositions=new Point[22,5];
    readonly float[,] uniqueTrailTimes=new float[22,5];
      readonly int[] uniqueTrailCounts=new int[22];
      readonly float[] nextUniqueTrailSample=new float[22];
      MatchSimulation uniqueTrailMatch;
    ModelRenderer offside,card;
    readonly ModelRenderer[] sprayLine=new ModelRenderer[12];
    readonly ModelRenderer[] selectionRing=new ModelRenderer[32];
    readonly ModelRenderer[] cursorRing=new ModelRenderer[24];
    readonly ModelRenderer[] destinationRing=new ModelRenderer[24];
    readonly ModelRenderer[,] pingRings=new ModelRenderer[5,24];
    readonly ModelRenderer[,] orderCross=new ModelRenderer[11,2];
    ModelRenderer dragPath;
    public FieldCallInput CallInput {get;set;}
    readonly SprintDust dust=new();
    CameraComponent sceneCamera;
    Scene ownedRenderScene;
    AudioListener stadiumListener;
    bool ready,wasManual;
    Point standardSpot;
    float standardHoldUntil,standardCameraBlend;
    MatchSimulation standardMatch;

    public static Vector3 WorldPoint(Point p,float z=0)=>new Vector3(p.X,-p.Y,z)*Units;
    public Vector2 Project(Point p,float z=0)
    {
        if(sceneCamera==null)return new Vector2(-10,-10);
        var q=sceneCamera.PointToScreenNormal(WorldPoint(p,z));
        // ScenePanel renders into its own texture, while the camera's screen
        // rectangle can retain the full game height. Keep their centres aligned
        // when mapping projected positions to pixels inside the panel.
        float panelHeight=Math.Max(1,Box.Rect.Height);
        float cameraHeight=Math.Max(1,sceneCamera.ScreenRect.Height);
        return new Vector2(q.x,.5f+(q.y-.5f)*cameraHeight/panelHeight);
    }
    bool TryPickGround(Vector2 screenFraction,out Point point)
    {
        point=default;
        // Intersect the actual camera ray with the turf. PointToScreenNormal
        // clamps positions outside the viewport, so numerically reversing it
        // loses precision toward the edges and can jump to the wrong sideline.
        if(sceneCamera==null)return false;
        float panelHeight=Math.Max(1,Box.Rect.Height);
        float cameraHeight=Math.Max(1,sceneCamera.ScreenRect.Height);
        float cameraY=.5f+(screenFraction.y-.5f)*panelHeight/cameraHeight;
        var ray=sceneCamera.ScreenNormalToRay(new Vector3(screenFraction.x,cameraY,0));
        if(ray.Forward.z>=-.01f)return false;
        float travel=-ray.Position.z/ray.Forward.z;
        if(travel<=0)return false;
        var hit=ray.Position+ray.Forward*travel;
        point=new Point(hit.x/Units,-hit.y/Units);
        return float.IsFinite(point.X) && float.IsFinite(point.Y);
    }
    public Point PickField(Vector2 screenFraction)
    {
        if(!TryPickGround(screenFraction,out var point))return Game.Broadcast.Center;
        return new Point(Math.Clamp(point.X,0,105),Math.Clamp(point.Y,0,68));
    }
    public Point CameraPanDelta(Vector2 from,Vector2 to)
    {
        // Camera drags also start over the stands. Clamping these intersections
        // to the pitch would discard motion outside the touchlines.
        if(TryPickGround(from,out var before) && TryPickGround(to,out var after))return before-after;
        // Above the horizon, use the same screen displacement over the centre.
        var center=new Vector2(.5f,.5f);
        return TryPickGround(center,out before) && TryPickGround(center+to-from,out after)?before-after:default;
    }
    public void PanCamera(Vector2 from,Vector2 to)
    {
        if(Game==null || sceneCamera==null)return;
        var before=Game.Broadcast.Center;
        Game.PanBroadcastCamera(CameraPanDelta(from,to));
        // Pointer events can arrive before the next world Tick. Move the actual
        // camera now as well, so picking at the release position uses the same
        // projection as the field that was just dragged there.
        sceneCamera.WorldPosition+=WorldPoint(Game.Broadcast.Center-before);
    }
    public object DebugView()=>new {Rect=Box.Rect.ToString(),CameraRect=sceneCamera?.ScreenRect.ToString(),
        Projection=Project(Game.Match.Players[8].Position).ToString(),
        Texture=RenderTexture?.Size.ToString(),stadiumSpectators,stadiumBatches,AnimatedFanCount,CrowdFrames,
        CursorInput=CallInput!=null,InputEnabled=CallInput?.Enabled,CursorVisible=CallInput?.CursorVisible,
        CursorSegments=visibleCursorSegments,HoverGoal=CallInput?.HoverGoal,AttackAvailable=CallInput?.AttackAvailable,
        TexturedPitch=pitchSurface!=null,GroundStrokeObjects=groundRings.Count+groundPings.Count(p=>p!=null)};
    // Palette hex values are sRGB, while the lit 3D renderer expects linear tint.
    // Passing UI colours straight through lifts dark channels and washes out the kit and turf.
    static float LinearChannel(float v)=>v<=.04045f?v/12.92f:MathF.Pow((v+.055f)/1.055f,2.4f);
    Color Ink(string s)
    {
        if(!colors.TryGetValue(s,out var c))
        {
            var srgb=Color.Parse(s)??Color.White;
            colors[s]=c=new Color(LinearChannel(srgb.r),LinearChannel(srgb.g),LinearChannel(srgb.b),srgb.a);
        }
        return c;
    }
    string TeamKitColor(int playerId,string source)
    {
        if(playerId>=11 || Game?.Career==null)return source;
        if(currentKit==null || currentKit.Id!=Game.Career.EquippedKitId)
            currentKit=ClubCosmetics.Kit(Game.Career.EquippedKitId);
        var kit=currentKit;
        return source switch {"#46c7ed"=>kit.Shirt,"#193d60"=>kit.Shorts,"#8fe5f2"=>kit.Socks,_=>source};
    }
    void Initialize()
    {
        if(ownedRenderScene?.IsValid==true)ownedRenderScene.Destroy();
        ownedRenderScene=new Scene();RenderScene=ownedRenderScene;
        sceneCamera=new GameObject(RenderScene,true,"Match camera").Components.Create<CameraComponent>();
        sceneCamera.IsMainCamera=true;
        stadiumListener=new GameObject(RenderScene,true,"Broadcast sound listener").Components.Create<AudioListener>();
        stadiumListener.UseCameraDirection=false;
        sceneCamera.FieldOfView=50;
        sceneCamera.ZNear=39.37f;sceneCamera.ZFar=14000;
        sphere=MakeSphere();box=MakeBox();torso=MakeBody(true);limb=MakeBody(false);
        bool authored=InitializeAuthoredEnvironment();
        EnsureBroadcastLighting();
        if(!authored)
        {
        BuildStadium();
        AddBox(new Point(52.5f,34),-.4f,new Vector3(123,84,.5f),"#1a4033");
        }
        InitializePitchSurface();
        InitializeGoalStrokes();
        if(!authored)foreach(var p in new[]{new Point(0,0),new Point(0,68),new Point(105,0),new Point(105,68)})
        {Beam(p,0,p,1.5f,.04f,"#e9e8d4");AddBox(p+new Point(.18f,0),1.4f,new Vector3(.38f,.025f,.24f),"#efbc4b");}
        for(int i=0;i<23;i++)
        {var slots=new ModelRenderer[24];for(int j=0;j<slots.Length;j++)slots[j]=Create(j==6?torso:j is 0 or 1 or 3 or 4 or 7 or 8 or 10 or 11?limb:sphere,"Player limb");actors.Add(slots);}
        ball=Create(MakeSmoothSphere(),"Ball");ball.Tint=Color.White;
        ballTrim=Create(MakeBallPanels(.51f,.503f),"Ball panel trim");
        ballPanels=Create(MakeBallPanels(.43f,.506f),"Ball panels");
        var visionMaterial=DynamicGeometryMaterial;
        for(int i=0;i<vision.Length;i++)
        {
            var sector=new SceneDynamicObject(RenderScene.SceneWorld){Material=visionMaterial};
            sector.Attributes.SetCombo("D_DEPTH_BIAS",1);
            sector.Attributes.SetCombo("D_NO_CULLING",1);
            sector.Flags.CastShadows=false;
            vision[i]=sector;
        }
        for(int i=0;i<dustObjects.Length;i++)dustObjects[i]=Create(sphere,"Grass dust");
        for(int i=0;i<flightObjects.Length;i++)flightObjects[i]=Create(sphere,"Ball flight wake");
        for(int player=0;player<22;player++)for(int segment=0;segment<4;segment++)
            uniqueTrailObjects[player,segment]=Create(sphere,"Unique skill player wake");
        card=Create(box,"Referee card");
        for(int i=0;i<sprayLine.Length;i++)sprayLine[i]=Create(box,"Free kick spray on turf");
        for(int p=0;p<11;p++)for(int i=0;i<2;i++)orderCross[p,i]=Create(box,"Tactical order on turf");
        dragPath=Create(box,"Order path on turf");
        ready=true;
    }
    ModelRenderer Create(Model model,string name){var r=new GameObject(RenderScene,true,name).Components.Create<ModelRenderer>();r.Model=model;return r;}
    void ApplyPlayerDetails(int index,string catalogId,string edition,int number,bool visible)
    {
        actorDetails??=new();
        while(actorDetails.Count<actors.Count)actorDetails.Add(new PlayerDetailRenderer(RenderScene));
        actorDetails[index].Update(catalogId,edition,number,visible,actors[index],trim:index<11?ClubCosmetics.Kit(Game.Career.EquippedKitId).Trim:"#ffffff",
            lowDetail:sceneCamera.WorldPosition.Distance(actors[index][13].WorldPosition)>260);
    }
    void AddBox(Point p,float z,Vector3 size,string color){var r=Create(box,"Pitch");r.WorldTransform=new Transform(WorldPoint(p,z),Rotation.Identity,size*Units);r.Tint=Ink(color);}
    void Beam(Point a,float za,Point b,float zb,float width,string color)
    {var o=Create(box,"Geometry");SetPart(o,a,za,b,zb,width,color);}
    void SetPart(ModelRenderer obj,Point a,float za,Point b,float zb,float width,string color)
    {
        var start=WorldPoint(a,za);var end=WorldPoint(b,zb);var delta=end-start;
        var direction=delta.Length>.001f?delta.Normal:Vector3.Forward;
        var rotation=delta.Length>.001f?Rotation.LookAt(direction,Math.Abs(direction.z)>.98f?Vector3.Forward:Vector3.Up):Rotation.Identity;
        obj.WorldTransform=new Transform((start+end)*.5f,rotation,
            new Vector3(Math.Max(width*Units,delta.Length+width*Units*.45f),width*Units,width*Units));
        obj.Tint=Ink(color);obj.Enabled=true;
    }
    void DrawRing(ModelRenderer[] ring,Point center,float radius,float width,string color,bool visible)
    {
        if(!groundRings.TryGetValue(ring,out var stroke))
        {
            foreach(var old in ring)old?.GameObject.Destroy();
            groundRings[ring]=stroke=GroundObject();
        }
        if(ReferenceEquals(ring,cursorRing))visibleCursorSegments=visible?ring.Length:0;
        GroundRing(stroke,center,radius,width,color,ring.Length,visible);
    }
    public (float Radius,float Stroke) CursorGeometry(Point center)
    {
        // Keep the turf ring legible as the broadcast camera zooms and the
        // perspective compresses the far side of the pitch.
        float w=Math.Max(1,Box.Rect.Width),h=Math.Max(1,Box.Rect.Height);
        const float step=.25f;
        var xp=Project(center+new Point(step,0));
        var xm=Project(center-new Point(step,0));
        var yp=Project(center+new Point(0,step));
        var ym=Project(center-new Point(0,step));
        var x=new Vector2((xp.x-xm.x)*w,(xp.y-xm.y)*h)/(2*step);
        var y=new Vector2((yp.x-ym.x)*w,(yp.y-ym.y)*h)/(2*step);
        float a=x.x*x.x+x.y*x.y,b=y.x*y.x+y.y*y.y;
        float cross=x.x*y.y-x.y*y.x;
        float minor=MathF.Sqrt(MathF.Max(0,(a+b-MathF.Sqrt(MathF.Max(0,(a+b)*(a+b)-4*cross*cross)))*.5f));
        if(minor<.1f)return (.42f,.06f);
        return (Math.Clamp(7.5f/minor,.20f,1.0f),Math.Clamp(1.3f/minor,.035f,.18f));
    }
    void DrawPingRing(int slot,Point center,float radius,float width,string color,bool visible)
    {
        if(groundPings[slot]==null)
        {
            for(int i=0;i<24;i++)pingRings[slot,i]?.GameObject.Destroy();
            groundPings[slot]=GroundObject();
        }
        GroundRing(groundPings[slot],center,radius,width,color,24,visible,Math.Clamp((width-.003f)/.026f,0,1));
    }
    public static Vector3 PoseFront(IReadOnlyList<ReplayFigure.Part> parts)
    {
        if(parts==null || parts.Count<=10)return Vector3.Forward;
        var torso=WorldPoint(parts[6].B,parts[6].ZB)-WorldPoint(parts[6].A,parts[6].ZA);
        var across=WorldPoint(parts[10].A,parts[10].ZA)-WorldPoint(parts[7].A,parts[7].ZA);
        var front=Vector3.Cross(torso,across);
        return front.Length>.001f?front.Normal:Vector3.Forward;
    }
    public static Rotation GloveRotation(Vector3 fingerAxis,Vector3 palmNormal)
    {
        if(fingerAxis.Length<.001f)return Rotation.Identity;
        var fingers=fingerAxis.Normal;
        var palm=palmNormal-fingers*Vector3.Dot(palmNormal,fingers);
        if(palm.Length<.001f)
        {
            var reference=Math.Abs(fingers.x)<.8f?Vector3.Forward:Vector3.Up;
            palm=reference-fingers*Vector3.Dot(reference,fingers);
        }
        var raised=Rotation.LookAt(fingers,palm.Normal);
        // Preserve the horizontal resting hand, then turn its palm toward the
        // shot as the fingers rise. At full extension never use world-up as
        // the LookAt reference: it is parallel to the finger axis.
        float weight=Math.Clamp((Math.Abs(fingers.z)-.35f)/.45f,0,1);
        weight=weight*weight*(3-2*weight);
        if(weight>=.999f)return raised;
        var resting=Rotation.LookAt(fingers,Vector3.Up);
        return Rotation.Slerp(resting,raised,weight);
    }
    static Vector3 PalmNormal(Vector3 front,Vector3 up,Vector3 across,Vector3 fingers,float roll)
    {
        if(fingers.Length<.001f)return front;
        fingers=fingers.Normal;
        var normal=front-fingers*Vector3.Dot(front,fingers);
        if(normal.LengthSquared<.00001f)normal=up-fingers*Vector3.Dot(up,fingers);
        if(normal.LengthSquared<.00001f)normal=across-fingers*Vector3.Dot(across,fingers);
        if(normal.Length<.001f)return Vector3.Up;
        return Rotation.FromAxis(fingers,roll)*normal.Normal;
    }
    public static Vector3 PosePalm(IReadOnlyList<ReplayFigure.Part> parts,int arm)
    {
        var normal=ReplayFigure.AnatomicalPalmNormal(parts,arm);
        return new Vector3(normal.direction.X,-normal.direction.Y,normal.z);
    }
    public static void FitPoseBody(ModelRenderer obj,int slot,IReadOnlyList<ReplayFigure.Part> parts,bool keeper=false)
    {
        var part=parts[slot];
        bool articulated=!keeper || part.ArticulatedHand;
        var palm=slot is 9 or 12?(articulated?PosePalm(parts,slot-2):PoseFront(parts)):default;
        FitBody(obj,slot,part.A,part.ZA,part.B,part.ZB,parts[10].A-parts[7].A,parts[10].ZA-parts[7].ZA,palm,articulated);
    }
    public static void FitBody(ModelRenderer obj,int slot,Point a,float za,Point b,float zb,Point shoulderAxis,float shoulderHeight=0,Vector3 glovePalmNormal=default,bool articulatedHand=false)
    {
        var model=FootballFigureAssets.Part(slot);
        if(model==null)return;
        if(obj.Model!=model)obj.Model=model;
        var start=WorldPoint(a,za);var end=WorldPoint(b,zb);var delta=end-start;
        var transform=obj.WorldTransform;var scale=transform.Scale;
        scale.x=Math.Max(1,delta.Length);
        if(slot is 2 or 5)scale=new Vector3(Math.Max(delta.Length,.26f*Units),.13f*Units,.12f*Units);
        if(slot is 9 or 12)scale=new Vector3(.105f*Units,.09f*Units,.055f*Units);
        var rotation=transform.Rotation;
        if(slot==6 && delta.Length>.01f)
        {
            // A diving rig has one shoulder above the other. Flattening this
            // axis twists diagonal dives even when every pose joint is correct.
            var across=new Vector3(shoulderAxis.X,-shoulderAxis.Y,shoulderHeight);
            var front=Vector3.Cross(delta,across);
            if(front.Length>.001f)rotation=Rotation.LookAt(delta.Normal,front.Normal);
        }
        if(slot is 9 or 12 && glovePalmNormal.Length>.001f && delta.Length>.001f)
            rotation=articulatedHand?Rotation.LookAt(delta.Normal,glovePalmNormal):GloveRotation(delta,glovePalmNormal);
        obj.WorldTransform=new Transform(transform.Position,rotation,scale);
    }
    public override void Tick()
    {
        RestoreTutorialPlayerHighlight();
        long performanceStart=PerformanceDiagnostics.Begin();
        base.Tick();ownedRenderScene??=RenderScene;
        if(Game==null || Game.ClubOpen){ResetBallReadability();return;}
        if(!ready)Initialize();
        EnsureBroadcastLighting();
        InitializePitchSurface();
        sceneCamera.ZNear=39.37f;sceneCamera.ZFar=14000;
        groundLineCount=0;
        UpdateAnimatedCrowd();
        UpdateTechnicalArea();
        var m=Game.Match;bool replay=Game.Replay.Playing;
        if(!ReferenceEquals(uniqueTrailMatch,m))
        {
            Array.Clear(uniqueTrailCounts);
            Array.Clear(nextUniqueTrailSample);
            uniqueTrailMatch=m;
        }
        UpdateGoalNets(m);
        bool introduction=Game.MatchIntroActive;
        if(sprayLine[0]==null)
            for(int i=0;i<sprayLine.Length;i++)sprayLine[i]=Create(box,"Free kick spray on turf");
        for(int i=0;i<sprayLine.Length;i++)
        {
            var mark=sprayLine[i];mark.Enabled=!introduction && !replay && m.SprayMarkVisible;
            if(!mark.Enabled)continue;
            SetPart(mark,m.SprayArcPoint(i/(float)sprayLine.Length),.032f,
                m.SprayArcPoint((i+1)/(float)sprayLine.Length),.032f,.045f,"#f4f3e8");
            mark.Tint=mark.Tint.WithAlpha(m.SprayOpacity);
            GroundLine(mark);
        }
        sceneCamera.FieldOfView=replay?45:50;
        int owner=m.Owner;
        bool ownerVisible=!introduction && !replay && owner>=0 && owner<m.Players.Count && m.Players[owner].InPlay;
        DrawRing(selectionRing,ownerVisible?m.Players[owner].Position:default,.36f,.021f,"#d9ef9b",ownerVisible);
        var input=CallInput;
        bool markers=!introduction && !replay && input?.Enabled==true;
        int hovered=markers?input.HoverPlayer:-1;
        bool hoverVisible=hovered>=0 && hovered<m.Players.Count && m.Players[hovered].InPlay;
        bool goalHover=markers && input.HoverGoal;
        bool cursorVisible=markers && (input.CursorVisible || goalHover);
        string hoverColor=hovered>=11?"#ffb879":"#8de5f4";
        // The attack cursor also anchors a warm ring at the goal, including
        // hovering the crossbar where a pointer ray cannot land on the turf.
        var cursorPoint=goalHover?m.HomeAttackGoal:hoverVisible?m.Players[hovered].Position:input?.CursorField??default;
        var (cursorRadius,cursorStroke)=cursorVisible?CursorGeometry(cursorPoint):(.30f,.04f);
        if(goalHover){cursorRadius=Math.Max(.65f,cursorRadius*1.5f);cursorStroke*=1.5f;}
        DrawRing(cursorRing,cursorPoint,cursorRadius,cursorStroke,
            goalHover?(input.AttackAvailable?"#ff9767":"#b9aa9b"):hoverVisible?hoverColor:"#ebf8e9",cursorVisible);
        bool dragging=markers && input.Dragging && input.DragPlayer>=0 && input.DragPlayer<m.Players.Count;
        DrawRing(destinationRing,dragging?input.Destination:default,.32f,.027f,"#d8f2b5",dragging);
        dragPath.Enabled=dragging;
        if(dragging){SetPart(dragPath,m.Players[input.DragPlayer].Position,.05f,input.Destination,.05f,.024f,"#dbf5c5");GroundLine(dragPath);}
        for(int i=0;i<5;i++)
        {
            bool visible=markers && i<input.Pings.Count;
            var ping=visible?input.Pings[i]:default;
            float age=visible?Math.Clamp((Time.Now-ping.StartedAt)/1.55f,0,1):1;
            var point=visible && ping.PlayerId>=0 && ping.PlayerId<m.Players.Count && m.Players[ping.PlayerId].InPlay
                ?m.Players[ping.PlayerId].Position:ping.Point;
            string color=ping.Kind==FieldPingKind.Pass?"#bde8d6":ping.Kind==FieldPingKind.Clear?"#aab9bb":ping.Kind is FieldPingKind.Mark or FieldPingKind.Press?"#ffb06b":ping.Kind==FieldPingKind.Attack?"#ff8875":ping.Kind is FieldPingKind.Run or FieldPingKind.Zone?"#d9ef9b":"#84dff3";
            DrawPingRing(i,point,.30f+age*.56f,.026f*(1-age)+.003f,color,visible);
        }
        for(int i=0;i<11;i++)
        {
            var player=m.Players[i];bool visible=!introduction && !replay && player.DirectOrder is DirectOrderKind.GuardZone or DirectOrderKind.AttackRun;
            for(int arm=0;arm<2;arm++)
            {
                var mark=orderCross[i,arm];mark.Enabled=visible;
                if(!visible)continue;
                var p=player.DirectPoint;
                SetPart(mark,p+new Point(-.2f,arm==0?-.2f:.2f),.04f,
                    p+new Point(.2f,arm==0?.2f:-.2f),.04f,.033f,"#d9ef9b");
                GroundLine(mark);
            }
        }
        Point ballPosition=m.Ball;float ballHeight=m.BallHeight;
        GoalReplay.Frame effectFrame=null;
        GoalReplay.Frame effectNextFrame=null;float effectBlend=0;
        UniqueSkill ballSkill=m.BallUniqueSkill;int ballSkillGrade=m.BallSkillGrade;
        LegendaryAbility ballLegend=m.BallLegendaryAbility;
        IReadOnlyList<FlightMark> flight=m.FlightTrail;
        IReadOnlyList<SprintDust.Puff> puffs=dust.Update(m);
        float effectTime=m.Seconds,flightSpeed=m.BallSpeed;
        float goalFxExtra=m.GoalHoldRemaining>0?Math.Max(0,m.GoalCelebrationElapsed-1):0;
        bool freeBall=m.Owner<0;
        if(introduction)
        {
            DrawCeremony(Game.MatchIntroAge,out ballPosition,out ballHeight);
            puffs=Array.Empty<SprintDust.Puff>();flight=Array.Empty<FlightMark>();freeBall=false;
        }
        else if(replay && Game.Replay.Sample(out var a,out var b,out float t))
        {
            var scorer=GoalReplay.Mix(a.Actors[Game.Replay.ScorerId].Position,b.Actors[Game.Replay.ScorerId].Position,t);
            float after=Math.Clamp((Game.Replay.PlaybackTime-Game.Replay.GoalTime)/2,0,1);
            int dir=Game.Replay.AttackDirection;
            var eye=WorldPoint(scorer+new Point(-dir*(22+after*2),.9f+after*3),5.6f+after);
            var focus=WorldPoint(scorer+new Point(dir*5,0),.9f);
            float gathering=CeremonyEase((Game.Replay.PlaybackTime-Game.Replay.GoalTime-.4f)/1.2f);
            var celebrationForward=new Point(MathF.Cos(Game.Replay.CelebrationFacing),MathF.Sin(Game.Replay.CelebrationFacing));
            var celebrationSide=new Point(-celebrationForward.Y,celebrationForward.X);
            eye=Vector3.Lerp(eye,WorldPoint(scorer+celebrationForward*8.5f+celebrationSide*6.2f,3.1f),gathering);
            focus=Vector3.Lerp(focus,WorldPoint(scorer+celebrationForward*.35f,1),gathering);
            sceneCamera.WorldPosition=eye;sceneCamera.WorldRotation=Rotation.LookAt(focus-eye);
            for(int i=0;i<22;i++)
            {
                var first=a.Actors[i];var second=b.Actors[i];int count=Math.Min(first.Count,second.Count);
                bool keeper=first.Keeper || first.Number==1;
                var front=Vector3.Lerp(PoseFront(first.Parts),PoseFront(second.Parts),t);
                var up=Vector3.Lerp(WorldPoint(first.Parts[6].B,first.Parts[6].ZB)-WorldPoint(first.Parts[6].A,first.Parts[6].ZA),
                    WorldPoint(second.Parts[6].B,second.Parts[6].ZB)-WorldPoint(second.Parts[6].A,second.Parts[6].ZA),t).Normal;
                var across=Vector3.Lerp(WorldPoint(first.Parts[10].A,first.Parts[10].ZA)-WorldPoint(first.Parts[7].A,first.Parts[7].ZA),
                    WorldPoint(second.Parts[10].A,second.Parts[10].ZA)-WorldPoint(second.Parts[7].A,second.Parts[7].ZA),t).Normal;
                for(int j=0;j<actors[i].Length;j++)
                {
                    if(!first.Visible || j>=count){actors[i][j].Enabled=false;continue;}
                    var x=first.Parts[j];var y=second.Parts[j];
                    bool articulated=!keeper || x.ArticulatedHand || y.ArticulatedHand;
                    var palmNormal=keeper?front:default;
                    if(articulated && j is 9 or 12)
                    {
                        float turn=MathF.IEEERemainder(y.PalmRoll-x.PalmRoll,360);
                        var fingers=WorldPoint(GoalReplay.Mix(x.B,y.B,t),x.ZB+(y.ZB-x.ZB)*t)-
                            WorldPoint(GoalReplay.Mix(x.A,y.A,t),x.ZA+(y.ZA-x.ZA)*t);
                        palmNormal=PalmNormal(front,up,across,fingers,x.PalmRoll+turn*t);
                    }
                    SetPart(actors[i][j],GoalReplay.Mix(x.A,y.A,t),x.ZA+(y.ZA-x.ZA)*t,GoalReplay.Mix(x.B,y.B,t),x.ZB+(y.ZB-x.ZB)*t,x.Width,TeamKitColor(i,x.Color));
                    FitBody(actors[i][j],j,GoalReplay.Mix(x.A,y.A,t),x.ZA+(y.ZA-x.ZA)*t,GoalReplay.Mix(x.B,y.B,t),x.ZB+(y.ZB-x.ZB)*t,
                        GoalReplay.Mix(first.Parts[10].A-first.Parts[7].A,second.Parts[10].A-second.Parts[7].A,t),
                        (first.Parts[10].ZA-first.Parts[7].ZA)*(1-t)+(second.Parts[10].ZA-second.Parts[7].ZA)*t,palmNormal,articulated);
                }
                ApplyPlayerDetails(i,first.CatalogId,first.Edition,first.Number,first.Visible);
            }
            foreach(var o in actors[22])o.Enabled=false;
            ballPosition=GoalReplay.Mix(a.Ball,b.Ball,t);ballHeight=a.BallHeight+(b.BallHeight-a.BallHeight)*t;
            flight=a.Trail;puffs=a.Dust;effectTime=a.SimulationTime+(b.SimulationTime-a.SimulationTime)*t;
            goalFxExtra=a.GoalPresentationExtra+(b.GoalPresentationExtra-a.GoalPresentationExtra)*t;
            flightSpeed=a.BallSpeed;freeBall=a.BallFree;
            effectFrame=a;effectNextFrame=b;effectBlend=t;
            ballSkill=a.BallUniqueSkill;ballSkillGrade=a.BallSkillGrade;ballLegend=a.BallLegendaryAbility;
        }
        else
        {
            var c=Game.Broadcast;float distance=c.ViewWidth*1.08f;
            var eye=WorldPoint(c.Center+new Point(0,distance*.78f),distance*.63f);
            if(standardMatch!=m){standardMatch=m;standardCameraBlend=0;wasManual=false;standardHoldUntil=0;}
            if(m.ManualRestartWaiting){standardSpot=m.SetPieceSpot;wasManual=true;}
            else if(wasManual){standardHoldUntil=m.Seconds+2.7f;wasManual=false;}
            if(m.GoalHoldRemaining>0){standardHoldUntil=0;wasManual=false;}
            // A long free kick must finish before the low set-piece view rises
            // back into the broadcast. End this session once its result settles.
            if(!m.ManualRestartWaiting && m.Seconds>=standardHoldUntil && !c.ShotFraming)standardHoldUntil=0;
            bool standard=!Game.ManualCameraActive && (m.ManualRestartWaiting ||
                standardHoldUntil>0 && (m.Seconds<standardHoldUntil || c.ShotFraming));
            float wanted=standard?1:0;
            standardCameraBlend+=Math.Clamp(wanted-standardCameraBlend,-Time.Delta*1.7f,Time.Delta*2.5f);
            var focus=WorldPoint(c.Center);
            if(standardCameraBlend>0)
            {
                var goal=m.World(new Point(105,34),0);var forward=(goal-standardSpot).Unit;
                var side=new Point(-forward.Y,forward.X);
                var closeEye=WorldPoint(standardSpot-forward*10.5f+side*1.2f,4.2f);
                var closeFocus=WorldPoint(standardSpot+forward*5.5f,.65f);
                eye=eye*(1-standardCameraBlend)+closeEye*standardCameraBlend;
                focus=focus*(1-standardCameraBlend)+closeFocus*standardCameraBlend;
                sceneCamera.FieldOfView=50;
            }
            // Let the scoring run finish before moving into a team-sized view.
            // Frame the actual players, so authored choreography and camera agree.
            if(!Game.ManualCameraActive && m.GoalHoldRemaining>0 && m.GoalScorerId>=0)
            {
                float blend=CeremonyEase((m.GoalCelebrationElapsed-.45f)/1.05f)*CeremonyEase(m.GoalHoldRemaining/.70f);
                var author=m.Players[m.GoalScorerId];
                var forward=new Point(MathF.Cos(m.GoalCelebrationFacing),MathF.Sin(m.GoalCelebrationFacing));
                var side=new Point(-forward.Y,forward.X);
                var closeEye=WorldPoint(author.Position+forward*8.5f+side*6.2f,3.1f);
                var closeFocus=WorldPoint(author.Position+forward*.35f,1.0f);
                eye=eye*(1-blend)+closeEye*blend;focus=focus*(1-blend)+closeFocus*blend;
                sceneCamera.FieldOfView=50;
            }
            sceneCamera.WorldPosition=eye;sceneCamera.WorldRotation=Rotation.LookAt(focus-eye);
            for(int i=0;i<23;i++)
            {
                var p=i==22?m.RefereeActor:m.Players[i];
                bool mannequin=Game.StrategyLabOpen && (m.StrategyWalkthrough || m.StrategyDummyMode) && p.Team==1 && !p.Keeper;
                if(mannequin){p.GaitPhase=0;p.VisualSpeed=0;}
                ReplayFigure.Pose(p,m,pose);
                if(i==22 && m.RefereeSpraying)
                {
                    // Paint the nearby end of the arc, rather than reaching
                    // over a metre to the ball at its centre.
                    var sprayPoint=m.SprayArcPoint(0);
                    PoseCeremonyReferee(3.65f,sprayPoint,.06f,(sprayPoint-p.Position).Unit);
                }
                for(int j=0;j<actors[i].Length;j++)
                {
                    if((p.SentOff && m.Seconds>=p.ImpactUntil) || p.InjuryRemovedFromField || j>=pose.Count){actors[i][j].Enabled=false;continue;}
                    var part=pose[j];SetPart(actors[i][j],part.A,part.ZA,part.B,part.ZB,part.Width,mannequin?"#98aaa6":TeamKitColor(i,part.Color));
                    FitPoseBody(actors[i][j],j,pose,p.Keeper);
                }
                ApplyPlayerDetails(i,p.Profile.CatalogId,p.Profile.Edition,p.Number,i<22 && (!p.SentOff || m.Seconds<p.ImpactUntil) && !p.InjuryRemovedFromField && !mannequin);
            }
        }
        UpdateMedicalArea();
        DrawTutorialPlayerHighlight();
        // ScenePanel cameras do not automatically create the outer game's audio listener.
        DrawGoalStrokes();
        stadiumListener.WorldTransform=sceneCamera.WorldTransform;
        GameAudio.UpdateCoachVoice(RenderScene,WorldPoint(Game.TechnicalArea.Coaches[0].Position,1.6f),
            !Game.StrategyLabOpen && !Game.Replay.Playing && !Game.GameSettingsOpen);
        string ballId=Game.Career?.EquippedBallId??"classic";
        // A satin shell catches the stadium key without changing the ball's
        // silhouette, cosmetic palette or physical/rendered diameter.
        matchBallMaterial??=Material.Load("materials/football/ball-match.vmat");
        if(ball.MaterialOverride!=matchBallMaterial)ball.MaterialOverride=matchBallMaterial;
        if(ballTrim.MaterialOverride!=matchBallMaterial)ballTrim.MaterialOverride=matchBallMaterial;
        if(ballPanels.MaterialOverride!=matchBallMaterial)ballPanels.MaterialOverride=matchBallMaterial;
        if(shownBallId!=ballId)
        {
            shownBallId=ballId;var design=ClubCosmetics.Ball(ballId);
            ball.Tint=Ink(design.Base);ballTrim.Tint=Ink(design.Trim);ballPanels.Tint=Ink(design.Panels);
        }
        var ballTransform=new Transform(WorldPoint(ballPosition,Math.Max(.11f,ballHeight)),Rotation.From(new Angles(m.Seconds*90,0,0)),new Vector3(.22f)*Units);
        ball.WorldTransform=ballTransform;ballTrim.WorldTransform=ballTransform;ballPanels.WorldTransform=ballTransform;
        for(int i=0;i<vision.Length;i++)
        {
            var p=m.Players[i];var sector=vision[i];
            if(sector==null)continue; // Existing ScenePanels can survive editor hotload briefly.
            sector.RenderingEnabled=!introduction && !replay && Game.ShowVision && p.InPlay;
            if(sector.RenderingEnabled)DrawVisionSector(sector,p,Game.SelectedPlayer==p.Id);
        }
        DrawMatchEffects(puffs,effectTime,flight,ballPosition,ballHeight,flightSpeed,freeBall,ballSkill,ballSkillGrade,ballLegend);
        DrawBallReadability(m,ballPosition,ballHeight,effectTime,flightSpeed,freeBall,replay,introduction,
            Game.Replay.SeekRevision,ballSkill!=UniqueSkill.None || ballLegend!=LegendaryAbility.None);
        for(int player=0;player<22;player++)
        {
            var actor=m.Players[player];var captured=effectFrame?.Actors[player];
            Point position=captured?.Position??actor.Position,velocity=captured?.Velocity??actor.Velocity;
            float facing=captured?.Facing??actor.Facing;
            if(captured!=null)
            {
                var next=effectNextFrame.Actors[player];
                position=GoalReplay.Mix(position,next.Position,effectBlend);
                velocity=GoalReplay.Mix(velocity,next.Velocity,effectBlend);
                float turn=next.Facing-facing;
                while(turn>MathF.PI)turn-=2*MathF.PI;
                while(turn<-MathF.PI)turn+=2*MathF.PI;
                facing+=turn*effectBlend;
            }
            var activeSkill=captured?.ActiveUniqueSkill??actor.ActiveUniqueSkill;
            int activeGrade=captured?.ActiveSkillGrade??actor.ActiveSkillGrade;
            bool visible=!introduction && (captured?.Visible??actor.InPlay);
            var signature=captured?.ActiveLegendaryAbility??actor.ActiveLegendaryAbility;
            float signatureUntil=captured?.LegendaryActiveUntil??actor.LegendaryActiveUntil;
            float signatureStarted=captured?.LegendaryActivatedAt??actor.LegendaryActivatedAt;
            if(visible && signature!=LegendaryAbility.None && effectTime<signatureUntil)
            {
                var target=captured?.LegendaryTargetA??actor.LegendaryTargetA;
                if((signature is LegendaryAbility.ChessPass or LegendaryAbility.LaserFinish) && (target-position).Length>.1f)
                    facing=MathF.Atan2(target.Y-position.Y,target.X-position.X);
                float age=effectTime+goalFxExtra-signatureStarted;
                if(signature==LegendaryAbility.LaserFinish)
                {
                    float kickAt=captured?.LastKickAt??actor.LastKickAt;
                    age=kickAt>=signatureStarted-.001f?effectTime+goalFxExtra-kickAt:-1;
                }
                DrawLegendaryWake(player,signature,position,facing,age,captured?.KickFoot??actor.KickFoot);continue;
            }
            if(visible && effectTime+goalFxExtra<(captured?.LegendaryRunUntil??actor.LegendaryRunUntil))
            {DrawLegendaryRunWake(player,position,captured?.LegendaryRunTarget??actor.LegendaryRunTarget);continue;}
            for(int extra=4;extra<LegendaryVisuals.MaximumStrokes;extra++)
                if(uniqueTrailObjects[player,extra]!=null)uniqueTrailObjects[player,extra].Enabled=false;
            bool active=visible && activeSkill!=UniqueSkill.None &&
                effectTime<(captured?.UniqueSkillActiveUntil??actor.UniqueSkillActiveUntil);
            // A seek can move replay time backward. Reset just this tiny sampled
            // wake instead of connecting old live positions to the replay actor.
            if(uniqueTrailCounts[player]>0 && effectTime<uniqueTrailTimes[player,0])
            {uniqueTrailCounts[player]=0;nextUniqueTrailSample[player]=0;}
            if(active && effectTime>=nextUniqueTrailSample[player])
            {
                if(uniqueTrailCounts[player]>0 && effectTime-uniqueTrailTimes[player,0]>.65f)
                    uniqueTrailCounts[player]=0;
                for(int sample=4;sample>0;sample--)
                {uniqueTrailPositions[player,sample]=uniqueTrailPositions[player,sample-1];uniqueTrailTimes[player,sample]=uniqueTrailTimes[player,sample-1];}
                uniqueTrailPositions[player,0]=position;uniqueTrailTimes[player,0]=effectTime;
                uniqueTrailCounts[player]=Math.Min(5,uniqueTrailCounts[player]+1);
                nextUniqueTrailSample[player]=effectTime+.075f;
            }
            string color=activeSkill switch
            {
                UniqueSkill.SteelPress or UniqueSkill.TimingTackle or UniqueSkill.ShoulderWall=>"#ffbf68",
                UniqueSkill.ReflexWall or UniqueSkill.AerialGuardian or UniqueSkill.SweeperKeeper or UniqueSkill.SafeHands=>"#6ee8e7",
                UniqueSkill.ThreadNeedle or UniqueSkill.LongDistributor or UniqueSkill.KeeperDistributor=>"#8adeb4",
                UniqueSkill.RecoveryBurst or UniqueSkill.Interceptor=>"#a1caff",
                _=>"#cb8bff"
            };
            if(activeGrade==2)color="#ffe09a";
              for(int segment=0;segment<4;segment++)
              {
                  var beam=uniqueTrailObjects[player,segment];
                  if(beam==null)beam=uniqueTrailObjects[player,segment]=Create(sphere,"Unique skill player wake");
                  if(active && velocity.Length<.5f)
                  {
                      float radius=.53f+.06f*MathF.Sin(effectTime*11);
                      Point start=segment switch
                      {
                          0=>new Point(0,-radius),1=>new Point(radius,0),
                          2=>new Point(0,radius),_=>new Point(-radius,0)
                      };
                      Point end=segment switch
                      {
                          0=>new Point(radius,0),1=>new Point(0,radius),
                          2=>new Point(-radius,0),_=>new Point(0,-radius)
                      };
                      SetPart(beam,position+start,.11f,
                          position+end,.11f,.065f,color);
                      beam.Enabled=true;
                      continue;
                  }
                  if(active && segment<2)
                  {
                      var forward=new Point(MathF.Cos(facing),MathF.Sin(facing));
                      var side=new Point(-forward.Y,forward.X);
                      var rear=position-forward*.68f;
                      SetPart(beam,rear+side*(segment==0?-.55f:.55f),.11f,
                          position+forward*.38f,.11f,.10f,color);
                      beam.Enabled=true;
                      continue;
                  }
                  beam.Enabled=active && segment+1<uniqueTrailCounts[player] &&
                      effectTime-uniqueTrailTimes[player,segment]<.48f;
                  if(!beam.Enabled)continue;
                  var a=uniqueTrailPositions[player,segment];var b=uniqueTrailPositions[player,segment+1];
                  if((a-b).Length>2.5f){beam.Enabled=false;continue;}
                  SetPart(beam,a,.07f,b,.07f,.19f-segment*.025f,color);
            }
        }
        DrawOffsideDecision(m,replay);
        DrawGroundLines();
        card.Enabled=!introduction && !replay && m.RefereeSignalUntil>m.Seconds && m.ShownCard>0 && actors[22][12].Enabled;
        if(card.Enabled)
        {
            // Follow the anatomical palm through the raise and recovery.
            // A fixed world height detached the card after enforcing arm reach.
            card.WorldTransform=new Transform(actors[22][12].WorldPosition+Vector3.Up*(.095f*Units),
                Rotation.Identity,new Vector3(.13f,.035f,.19f)*Units);
            card.Tint=Ink(m.ShownCard==2?"#ee4b3d":"#f4d85b");
        }
        PerformanceDiagnostics.RecordWorld(performanceStart);
    }
    void DrawVisionSector(SceneDynamicObject sector,Footballer player,bool selected)
    {
        const int radial=6,angular=14;
        Vertex Sample(int r,int a)
        {
            float distance=.12f+1.62f*r/radial;
            float lateral=(a/(float)angular*2-1);
            float angle=player.Facing+lateral*.42f;
            var at=player.Position+new Point(MathF.Cos(angle),MathF.Sin(angle))*distance;
            float edge=1-MathF.Pow(MathF.Abs(lateral),2.4f);
            float reach=1-r/(float)radial;
            byte alpha=(byte)Math.Clamp((int)((selected?60:36)*edge*reach),0,255);
            return new Vertex(WorldPoint(at,.038f),new Color32(174,197,183,alpha));
        }
        int count=0;
        for(int r=0;r<radial;r++)for(int a=0;a<angular;a++)
        {
            var p00=Sample(r,a);var p01=Sample(r,a+1);
            var p10=Sample(r+1,a);var p11=Sample(r+1,a+1);
            visionVertices[count++]=p00;visionVertices[count++]=p01;visionVertices[count++]=p10;
            visionVertices[count++]=p01;visionVertices[count++]=p11;visionVertices[count++]=p10;
        }
        sector.Init(Graphics.PrimitiveType.Triangles);
        sector.AddVertex(visionVertices.AsSpan());
    }
    public override void OnDeleted()
    {
        DisposeBallReadability();
        foreach(var sector in vision)sector?.Delete();
        foreach(var stroke in groundRings.Values)stroke?.Delete();
        foreach(var stroke in groundPings)stroke?.Delete();
        goalStrokeObject?.Delete();
        groundLineObject?.Delete();
        offsideDecisionObject?.Delete();
        animatedCrowd?.Delete();
        // ScenePanel clears RenderScene before dispatching OnDeleted. Retain
        // our own scene rather than leaving its lights/objects orphaned.
        var owned=ownedRenderScene??sceneCamera?.Scene;
        if(owned?.IsValid==true)owned.Destroy();
        ownedRenderScene=null;base.OnDeleted();
    }
    public static Model MakeBody(bool body)
    {
        // X follows the bone. Elliptical ribcage: flatter front/back, defined waist and shoulders.
        var vertices=new List<Vertex>();const int sides=12;
        float[] x={-.5f,-.4f,.28f,.5f};
        float[] radius=body?new[]{.34f,.40f,.5f,.43f}:new[]{.30f,.48f,.43f,.29f};
        float depth=body?.64f:1;
        Vector3 P(int ring,int side){float a=side*MathF.Tau/sides;return new Vector3(x[ring],MathF.Cos(a)*radius[ring],MathF.Sin(a)*radius[ring]*depth);}
        void Add(Vector3 p){var n=new Vector3(0,p.y,p.z/(depth*depth)).Normal;vertices.Add(new Vertex(p,n,Vector3.Forward,Vector2.Zero));}
        for(int r=0;r<3;r++)for(int s=0;s<sides;s++)
        {var a=P(r,s);var b=P(r+1,s);var c=P(r+1,s+1);var d=P(r,s+1);Add(a);Add(c);Add(b);Add(a);Add(d);Add(c);}
        for(int s=0;s<sides;s++)
        {
            foreach(var p in new[]{new Vector3(-.5f,0,0),P(0,s+1),P(0,s)})vertices.Add(new Vertex(p,Vector3.Backward,Vector3.Up,Vector2.Zero));
            foreach(var p in new[]{new Vector3(.5f,0,0),P(3,s),P(3,s+1)})vertices.Add(new Vertex(p,Vector3.Forward,Vector3.Up,Vector2.Zero));
        }
        return Build(vertices);
    }
    public static Model MakeSphere()=>MakeSphereMesh(10,16);
    public static Model MakeSmoothSphere()=>MakeSphereMesh(22,32);
    static Model MakeSphereMesh(int rings,int sides)
    {
        var v=new List<Vertex>();
        Vector3 P(int r,int s){float a=MathF.PI*r/rings,b=MathF.Tau*s/sides;return new Vector3(MathF.Cos(a),MathF.Sin(a)*MathF.Cos(b),MathF.Sin(a)*MathF.Sin(b))*.5f;}
        void Add(Vector3 p)=>v.Add(new Vertex(p,p.Normal,Vector3.Up,Vector2.Zero));
        for(int r=0;r<rings;r++)for(int s=0;s<sides;s++)
        {var a=P(r,s);var b=P(r+1,s);var c=P(r+1,s+1);var d=P(r,s+1);Add(a);Add(b);Add(c);Add(a);Add(c);Add(d);}
        return Build(v);
    }
    // Curved, nearly flush pentagons share the ball's centre and transform.
    // They replace small sphere decals, whose silhouette made the ball lumpy.
    public static Model MakeBallPanels(float capAngle,float radius)
    {
        var vertices=new List<Vertex>();
        void Add(Vector3 p)=>vertices.Add(new Vertex(p,p.Normal,Vector3.Up,Vector2.Zero));
        float phi=(1+MathF.Sqrt(5))*.5f;
        var directions=new List<Vector3>(12);
        foreach(int a in new[]{-1,1})foreach(int b in new[]{-1,1})
        {
            directions.Add(new Vector3(0,a,b*phi).Normal);
            directions.Add(new Vector3(a,b*phi,0).Normal);
            directions.Add(new Vector3(a*phi,0,b).Normal);
        }
        foreach(var normal in directions)
        {
            var tangent=Vector3.Cross(normal,MathF.Abs(normal.z)>.9f?Vector3.Forward:Vector3.Up).Normal;
            var side=Vector3.Cross(normal,tangent).Normal;
            Vector3 Surface(int corner,int step)
            {
                float bearing=corner*MathF.Tau/5,angle=capAngle*step/4;
                return (normal*MathF.Cos(angle)+(tangent*MathF.Cos(bearing)+side*MathF.Sin(bearing))*MathF.Sin(angle))*radius;
            }
            for(int corner=0;corner<5;corner++)for(int step=0;step<4;step++)
            {
                var a=Surface(corner,step);var b=Surface(corner,step+1);
                var c=Surface(corner+1,step+1);var d=Surface(corner+1,step);
                Add(a);Add(b);Add(c);Add(a);Add(c);Add(d);
            }
        }
        return Build(vertices);
    }
    public static Model MakeBox()
    {
        var v=new List<Vertex>();
        foreach(var n in new[]{Vector3.Forward,Vector3.Backward,Vector3.Left,Vector3.Right,Vector3.Up,Vector3.Down})
        {
            var u=Math.Abs(n.z)>.5f?Vector3.Forward:Vector3.Up;var w=Vector3.Cross(n,u);
            var a=(n-u-w)*.5f;var b=(n+u-w)*.5f;var c=(n+u+w)*.5f;var d=(n-u+w)*.5f;
            foreach(var p in new[]{a,b,c,a,c,d})v.Add(new Vertex(p,n,u,Vector2.Zero));
        }
        return Build(v);
    }
    static Model Build(List<Vertex> v)
    {var mesh=new Mesh(Material.Load("materials/default.vmat"));mesh.CreateVertexBuffer(v.Count,v);mesh.Bounds=new BBox(new Vector3(-.52f),new Vector3(.52f));return Model.Builder.AddMesh(mesh).WithViewBounds(new BBox(new Vector3(-.52f),new Vector3(.52f))).Create();}
}