FootballStadium.cs

Partial FootballWorldPanel that builds the stadium around the pitch: stands, crowd, hoardings, dugouts and floodlights. Batches static geometry by section and colour into shared procedural models and caches them for reuse.

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

// Original procedural stadium. Static geometry is batched by colour, not one object per spectator.
public sealed partial class FootballWorldPanel
{
    int stadiumSpectators,stadiumBatches;
    [SkipHotload] static (Model Model,string Colour,string Section)[] sharedStadium;
    [SkipHotload] static AnimatedFan[] sharedFans;
    [SkipHotload] static int sharedSpectators;
    [SkipHotload] static GoalStroke[] sharedFineStadiumStrokes;
    [SkipHotload] static int sharedStadiumVersion;
    const int StadiumGeometryVersion=4;
    readonly List<GoalStroke> fineStadiumStrokes=new();
    int stadiumInstanceVersion;
    void CreateStadiumBatch(Model model,string colour,string section)
    {
        var renderer=Create(model,"Stadium batch");renderer.Tint=Ink(colour);
        renderer.RenderType=ModelRenderer.ShadowRenderType.On;
        environmentSourceSections[renderer]=section;
    }
    void BuildStadium(bool addLights=true)
    {
        if(addLights)EnsureBroadcastLighting();
        // Native procedural models are shared across broadcast windows. Opening
        // the pitch again must not build another stadium's vertex buffers.
        if(sharedStadium!=null && sharedFans!=null && sharedFineStadiumStrokes!=null && sharedStadiumVersion==StadiumGeometryVersion)
        {
            foreach(var batch in sharedStadium)CreateStadiumBatch(batch.Model,batch.Colour,batch.Section);
            stadiumBatches=sharedStadium.Length;stadiumSpectators=sharedSpectators;
            animatedFans.AddRange(sharedFans);InitializeAnimatedCrowd();
            fineStadiumStrokes.AddRange(sharedFineStadiumStrokes);InitializeTechnicalSurface();
            stadiumInstanceVersion=StadiumGeometryVersion;
            return;
        }
        var groups=new Dictionary<(string Section,string Colour),List<Vertex>>();
        string section="Stadium base";
        void Box(Point p,float z,Vector3 size,string colour,bool openTop=false)
        {
            // Small solid rods lose raster coverage when the camera moves. The
            // shared feathered stroke batch keeps their true coverage below one
            // pixel, without a renderer or a draw call for every rail and leg.
            int thin=(size.x<=.11f?1:0)+(size.y<=.11f?1:0)+(size.z<=.11f?1:0);
            if(thin>=2)
            {
                bool x=size.x>=size.y && size.x>=size.z,y=!x && size.y>=size.z;
                var rodHalf=x?new Point(size.x*.5f,0):y?new Point(0,size.y*.5f):default;
                float height=x||y?0:size.z*.5f;
                float width=x?Math.Max(size.y,size.z):y?Math.Max(size.x,size.z):Math.Max(size.x,size.y);
                var stroke=new GoalStroke(p-rodHalf,z-height,p+rodHalf,z+height,width,colour);
                fineStadiumStrokes.Add(stroke);environmentSourceStrokeSections[stroke]=section;
                return;
            }
            var key=(section,colour);
            if(!groups.TryGetValue(key,out var list))groups[key]=list=new();
            var centre=WorldPoint(p,z);var half=size*(Units*.5f);
            foreach(var n in new[]{Vector3.Forward,Vector3.Backward,Vector3.Left,Vector3.Right,Vector3.Up,Vector3.Down})
            {
                if(openTop && n==Vector3.Up)continue;
                var u=Math.Abs(n.z)>.5f?Vector3.Forward:Vector3.Up;var w=Vector3.Cross(n,u);
                var a=n-u-w;var b=n+u-w;var c=n+u+w;var d=n-u+w;
                foreach(var corner in new[]{a,b,c,a,c,d})
                    list.Add(new Vertex(centre+new Vector3(corner.x*half.x,corner.y*half.y,corner.z*half.z),n,u,Vector2.Zero));
            }
        }
        void Head(Point p,float z,string colour)
        {
            var key=(section,colour);
            if(!groups.TryGetValue(key,out var list))groups[key]=list=new();
            var centre=WorldPoint(p,z);
            Vector3 P(int r,int s){float a=r*MathF.PI/4,b=s*MathF.Tau/8;return new Vector3(MathF.Cos(a),MathF.Sin(a)*MathF.Cos(b),MathF.Sin(a)*MathF.Sin(b))*(.13f*Units);}
            for(int r=0;r<4;r++)for(int s=0;s<8;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);
                foreach(var v in new[]{a,b,c,a,c,d})list.Add(new Vertex(centre+v,v.Normal,Vector3.Up,Vector2.Zero));
            }
        }
        Box(new Point(52.5f,34),-.45f,new Vector3(158,120,.5f),"#324948");
        // Four terraces, with aisles breaking up the audience into sections.
        string[] shirts={"#318db6","#cc6948","#dad8c8","#354559","#d7b456","#477963"};
        string[] skin={"#b97e57","#e5b48e","#875839"};
        for(int stand=0;stand<4;stand++)
        {
            section=new[]{"North stand","South stand","West stand","East stand"}[stand];
            bool lengthwise=stand<2;int seats=lengthwise?96:66;
            Point Along(float along,float back)=>lengthwise?new Point(along,stand==0?-11-back:79+back):new Point(stand==2?-13-back:118+back,along);
            Vector3 Size(float length,float depth,float height)=>lengthwise?new Vector3(length,depth,height):new Vector3(depth,length,height);
            float start=-5;
            float length=seats*1.2f;
            for(int row=0;row<7;row++)
            {
                float back=row*1.35f,level=.7f+row*.67f;
                Box(Along(start+length*.5f,back),level*.5f,Size(length+2,1.38f,level),"#69777a");
                for(int seat=0;seat<seats;seat++)
                {
                    if(seat%18==0 || seat%18==1)continue;
                    int hash=(seat*37+row*19+stand*43)%101;
                    var at=Along(start+seat*1.2f,back);
                    Box(at,level+.18f,Size(.62f,.6f,.18f),(seat/18+stand)%2==0?"#225d77":"#34768a");
                    if(hash%9==0)continue;
                    float stature=(hash%5)*.024f;
                    if(hash%11==0)
                    {
                        animatedFans.Add(new AnimatedFan(at,level+stature,stand,hash));
                        stadiumSpectators++;
                        continue;
                    }
                    Box(at,level+.61f+stature,Size(.38f,.29f,.57f),shirts[hash%shirts.Length]);
                    Head(at,level+1.02f+stature,skin[hash%skin.Length]);
                    // A few supporters hold both hands up; others sit with their arms by their sides.
                    float arms=hash%7==0?.96f:.57f;
                    for(int side=-1;side<=1;side+=2)
                        Box(Along(start+seat*1.2f+side*.25f,back),level+arms,Size(.105f,.14f,.4f),shirts[hash%shirts.Length]);
                    stadiumSpectators++;
                }
            }
            Box(Along(start+length*.5f,9.3f),3.3f,Size(length+2,.28f,6.6f),"#354957");
            // Thin canopy, supported behind the seats; its front edge stays outside the playing area.
            Box(Along(start+length*.5f,6.8f),8.2f,Size(length+3,7,.22f),"#344d5c");
            Box(Along(start+length*.5f,3.3f),8.15f,Size(length+3,.18f,.35f),"#9baeb3");
            for(int post=0;post<=seats;post+=18)
                Box(Along(start+post*1.2f,9.5f),4.1f,Size(.2f,.2f,8.2f),"#879a9c");
        }
        // Hoardings and railings, beyond the runoff area on every touchline.
        for(int edge=0;edge<4;edge++)
        {
            section=new[]{"North hoardings","South hoardings","West hoardings","East hoardings"}[edge];
            bool longEdge=edge<2;int count=longEdge?23:16;
            Point At(float along)=>longEdge?new Point(-5+along,edge==0?-6:74):new Point(edge==2?-8:113,-5+along);
            Vector3 Size(float a,float b,float z)=>longEdge?new Vector3(a,b,z):new Vector3(b,a,z);
            for(int i=0;i<count;i++)
            {
                var p=At(i*5);
                // Leave the central touchline open for the two technical areas and tunnel.
                if(edge==0 && p.X>=30 && p.X<=75)continue;
                Box(p,.7f,Size(4.85f,.18f,1.25f),i%3==0?"#e3ce78":i%3==1?"#227f9a":"#e4e8df");
                Box(p,.72f,Size(3.6f,.21f,.15f),i%3==1?"#e4e8df":"#24505a");
                Box(At(i*5-2.45f),1.15f,Size(.08f,.08f,2.3f),"#879a9c");
                Box(p,2.25f,Size(5,.07f,.07f),"#879a9c");
            }
        }
        // Two original dugouts under the main stand, with clear sightlines toward the pitch.
        for(int team=0;team<2;team++)
        {
            section=team==0?"Home dugout":"Away dugout";
            float x=TechnicalAreaPresentation.BenchX(team);
            string trim=team==0?"#4bb5d4":"#df8b65";
            // Its only top face is the painted surface. Neither this box nor
            // that surface enters the turf's one-metre runoff strip.
            float platformDepth=TechnicalSurfaceFrontY-TechnicalSurfaceBottomY;
            Box(new Point(x,(TechnicalSurfaceBottomY+TechnicalSurfaceFrontY)*.5f),-.07f,
                new Vector3(TechnicalSurfaceHalfWidth*2,platformDepth,.14f),"#52636a",openTop:true);
            Box(new Point(x,-8),.11f,new Vector3(14.5f,3.4f,.22f),"#334651");
            // Seats face the turf. High backs, cushions and armrests share the stadium batches.
            for(int seat=0;seat<9;seat++)
            {
                float sx=x+(seat-4)*1.3f;
                Box(new Point(sx,-7.65f),.48f,new Vector3(.77f,.78f,.17f),trim);
                Box(new Point(sx,-8.03f),.97f,new Vector3(.78f,.16f,1.05f),"#263f4c");
                Box(new Point(sx,-8.02f),1.4f,new Vector3(.52f,.20f,.24f),trim);
                for(int side=-1;side<=1;side+=2)
                {Box(new Point(sx+side*.42f,-7.67f),.71f,new Vector3(.07f,.62f,.08f),"#8a9da7");Box(new Point(sx+side*.29f,-7.8f),.25f,new Vector3(.06f,.06f,.50f),"#778d99");}
            }
            Box(new Point(x,-9.55f),1.22f,new Vector3(14.5f,.13f,2.45f),"#334e5d");
            // The shallow rear canopy leaves the seated characters visible to the broadcast camera.
            Box(new Point(x,-8.85f),2.45f,new Vector3(14.7f,1.7f,.12f),"#486574");
            Box(new Point(x,-7.96f),2.48f,new Vector3(14.7f,.10f,.18f),trim);
            foreach(float end in new[]{x-7.15f,x+7.15f})
            {Box(new Point(end,-8.7f),1.22f,new Vector3(.10f,1.7f,2.45f),"#536d7b");Box(new Point(end,-7.97f),1.22f,new Vector3(.09f,.09f,2.45f),"#a1b3bc");}
            // Dashed technical paint stays clear of the turf runoff surface.
            // Its layout is baked by Verification/generate_sideline_texture.py.
            // Equipment crate and a small cooler at the open end of each bench.
            Box(new Point(x+6.45f,-5.6f),.28f,new Vector3(.7f,.50f,.56f),"#e3e7dd");
            Box(new Point(x+6.45f,-5.6f),.58f,new Vector3(.72f,.53f,.06f),trim);
        }
        foreach(float x in new[]{-10f,115f})foreach(float y in new[]{-8f,76f})
        {
            section=$"Floodlight {(x<0?"west":"east")} {(y<0?"north":"south")}";
            var mast=new Point(x,y);
            Box(mast,11,new Vector3(.4f,.4f,22),"#84979c");
            Box(mast,22,new Vector3(4,.5f,2),"#344d5c");
            for(int col=0;col<5;col++)for(int row=0;row<2;row++)
                Box(mast+new Point((col-2)*.72f,y<0?.3f:-.3f),21.55f+row*.85f,new Vector3(.52f,.12f,.56f),"#fff9dd");
        }
        var bounds=new BBox(WorldPoint(new Point(-28,95),-1),WorldPoint(new Point(133,-27),25));
        var batches=new List<(Model Model,string Colour,string Section)>(groups.Count);
        foreach(var entry in groups)
        {
            var mesh=new Mesh(Material.Load("materials/default.vmat"));mesh.CreateVertexBuffer(entry.Value.Count,entry.Value);mesh.Bounds=bounds;
            var model=Model.Builder.AddMesh(mesh).WithViewBounds(bounds).Create();
            batches.Add((model,entry.Key.Colour,entry.Key.Section));
            CreateStadiumBatch(model,entry.Key.Colour,entry.Key.Section);stadiumBatches++;
        }
        sharedStadium=batches.ToArray();sharedFans=animatedFans.ToArray();sharedSpectators=stadiumSpectators;
        sharedFineStadiumStrokes=fineStadiumStrokes.ToArray();sharedStadiumVersion=StadiumGeometryVersion;
        stadiumInstanceVersion=StadiumGeometryVersion;
        InitializeTechnicalSurface();
        InitializeAnimatedCrowd();
    }

    void EnsureStadiumGeometry()
    {
        if(authoredEnvironment!=null)return;
        if(stadiumInstanceVersion==StadiumGeometryVersion)return;
        // An open broadcast panel survives hotload. Replace its old batched
        // dash geometry once, without duplicating lights or crowd objects.
        foreach(var renderer in RenderScene.GetAllComponents<ModelRenderer>().ToArray())
            if(renderer.GameObject.Name is "Stadium batch" or "Technical-area painted surface")renderer.GameObject.Destroy();
        technicalSurface=null;animatedCrowd?.Delete();animatedCrowd=null;
        animatedFans.Clear();fineStadiumStrokes.Clear();stadiumBatches=0;stadiumSpectators=0;
        BuildStadium(false);
    }
}