FootballGoalStrokes.cs

Part of the FootballWorldPanel class that builds the goal posts and net as prefiltered triangle ribbons. Goal frame segments are cached, net strands are regenerated each frame, and all strokes are written into one SceneDynamicObject vertex batch.

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

public sealed partial class FootballWorldPanel
{
    readonly record struct GoalStroke(Point A,float ZA,Point B,float ZB,float Width,string Color);
    readonly List<GoalStroke> staticGoalStrokes=new();
    readonly List<GoalStroke> goalFrameStrokes=new();
    readonly List<ModelRenderer> goalFrames=new();
    SceneDynamicObject goalStrokeObject;
    Vertex[] goalStrokeVertices=new Vertex[20000];
    int goalStrokeVertexCount;
    public int GoalNetStrandCount=>NetRows*(NetColumns-1)*2+(NetRows-1)*NetColumns*2;

    void InitializeGoalStrokes()
    {
        if(goalStrokeObject!=null && goalFrames.Count==goalFrameStrokes.Count)return;
        goalStrokeObject?.Delete();
        goalStrokeObject=GroundObject();
        staticGoalStrokes.Clear();goalFrameStrokes.Clear();
        foreach(float x in new[]{0f,105f})foreach(var e in PitchPerspective.Goal(x))
        {
            float rear=x==0?-2.4f:107.4f;
            if(e.Net && e.A.X==rear && e.B.X==rear)continue;
            bool front=!e.Net && e.A.X==x && e.B.X==x;
            var stroke=new GoalStroke(e.A,e.ZA,e.B,e.ZB,e.Net?.012f:front?PitchPerspective.PostWidth:.045f,e.Net?"#a1c6b9":"#f4f1dc");
            if(e.Net)staticGoalStrokes.Add(stroke);else goalFrameStrokes.Add(stroke);
        }
        // Old ScenePanels can survive hotload: remove their subpixel cubes once.
        foreach(var old in RenderScene.GetAllComponents<ModelRenderer>().ToArray())
            if(old.GameObject.Name is "Goal geometry" or "Goal net strand" or "Solid goal frame")old.GameObject.Destroy();
        goalFrames.Clear();
        foreach(var stroke in goalFrameStrokes)
        {
            var frame=Create(box,"Solid goal frame");
            frame.RenderType=ModelRenderer.ShadowRenderType.Off;
            SetPart(frame,stroke.A,stroke.ZA,stroke.B,stroke.ZB,stroke.Width,stroke.Color);
            var tr=frame.WorldTransform;var scale=tr.Scale;
            scale.x=(WorldPoint(stroke.B,stroke.ZB)-WorldPoint(stroke.A,stroke.ZA)).Length;
            frame.WorldTransform=new Transform(tr.Position,tr.Rotation,scale);goalFrames.Add(frame);
        }
    }

    void AddGoalStroke(GoalStroke stroke)
    {
        Vector3 a=WorldPoint(stroke.A,stroke.ZA),b=WorldPoint(stroke.B,stroke.ZB);
        var axis=(b-a).Normal;var middle=(a+b)*.5f;
        var view=(middle-sceneCamera.WorldPosition).Normal;
        var across=Vector3.Cross(axis,view).Normal;
        if(across.Length<.01f)return;
        // World-space ribbons follow every spring node. Their opacity preserves
        // the original strand's coverage when it becomes thinner than a pixel.
        float depth=Math.Max(1,Vector3.Dot(middle-sceneCamera.WorldPosition,sceneCamera.WorldRotation.Forward));
        float pixel=Math.Clamp(depth*2*MathF.Tan(sceneCamera.FieldOfView*MathF.PI/360)/Math.Max(1,Box.Rect.Width),.12f,15);
        float realHalf=stroke.Width*Units*.5f,half=Math.Max(realHalf,pixel*.5f),feather=pixel*.75f;
        float coverage=Math.Min(1,realHalf/half);
        var ink=Ink(stroke.Color);
        Vertex V(Vector3 at,int band)
        {
            float offset=band switch {0=>-half-feather,1=>-half,2=>half,_=>half+feather};
            return new Vertex(at+across*offset,new Color32((byte)(ink.r*255),(byte)(ink.g*255),(byte)(ink.b*255),
                (byte)(band is 0 or 3?0:255*coverage)));
        }
        for(int band=0;band<3;band++)
        {
            var p=V(a,band);var q=V(b,band);var r=V(b,band+1);var s=V(a,band+1);
            goalStrokeVertices[goalStrokeVertexCount++]=p;goalStrokeVertices[goalStrokeVertexCount++]=q;goalStrokeVertices[goalStrokeVertexCount++]=r;
            goalStrokeVertices[goalStrokeVertexCount++]=p;goalStrokeVertices[goalStrokeVertexCount++]=r;goalStrokeVertices[goalStrokeVertexCount++]=s;
        }
    }

    void DrawGoalStrokes()
    {
        EnsureStadiumGeometry();
        // Hotload preserves old arrays; grow once before the larger shared batch.
        if(goalStrokeVertices.Length<20000)goalStrokeVertices=new Vertex[20000];
        InitializeGoalStrokes();goalStrokeVertexCount=0;
        foreach(var stroke in staticGoalStrokes)AddGoalStroke(stroke);
        // Rails, armrests and thin sideline supports share this one dynamic
        // batch. Alpha coverage handles distant silhouettes without hard edges.
        foreach(var stroke in fineStadiumStrokes)AddGoalStroke(stroke);
        for(int side=0;side<2;side++)for(int row=0;row<NetRows;row++)for(int column=0;column<NetColumns;column++)
        {
            var point=NetPoint(side,row,column);float height=NetHeight(row);
            if(column<NetColumns-1)AddGoalStroke(new(point,height,NetPoint(side,row,column+1),height,.012f,"#a1c6b9"));
            if(row<NetRows-1)AddGoalStroke(new(point,height,NetPoint(side,row+1,column),NetHeight(row+1),.012f,"#a1c6b9"));
        }
        for(int i=0;i<goalFrameStrokes.Count;i++)
        {
            var stroke=goalFrameStrokes[i];var frame=goalFrames[i];
            frame.RenderType=ModelRenderer.ShadowRenderType.Off;
            float depth=Math.Max(1,Vector3.Dot(frame.WorldPosition-sceneCamera.WorldPosition,sceneCamera.WorldRotation.Forward));
            float pixel=depth*2*MathF.Tan(sceneCamera.FieldOfView*MathF.PI/360)/Math.Max(1,Box.Rect.Width);
            // Keep physical posts in close views and replays. Distant posts use
            // the same prefiltered ribbons as the net instead of hard pixel edges.
            frame.Enabled=stroke.Width*Units>pixel*(frame.Enabled?2:3);
            AddGoalStroke(stroke);
        }
        goalStrokeObject.Init(Graphics.PrimitiveType.Triangles);
        goalStrokeObject.AddVertex(goalStrokeVertices.AsSpan(0,goalStrokeVertexCount));
    }
}