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