FootballWorldEnvironment.cs

Part of the FootballWorldPanel partial class. Builds an editor-only source stadium (pitch, corner flags, goal previews, batched editor detail meshes), clones a scene's FootballEnvironment into the broadcast RenderScene, combines opaque stadium meshes into batched renderers, and copies crowd and fine-stroke data from authoring sections into runtime coordinates.

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

public sealed partial class FootballWorldPanel
{
    FootballEnvironment authoredEnvironment;
    readonly Dictionary<ModelRenderer,string> environmentSourceSections=new();
    readonly Dictionary<GoalStroke,string> environmentSourceStrokeSections=new();
    readonly Dictionary<Model,Vertex[]> environmentSourceVertices=new();
    public IReadOnlyDictionary<ModelRenderer,string> EnvironmentSourceSections=>environmentSourceSections;
    public bool UsesAuthoredEnvironment=>authoredEnvironment!=null;
    public int EnvironmentSpectatorCount=>stadiumSpectators;
    public Vertex[] EnvironmentSourceVertices(Model model)=>environmentSourceVertices.TryGetValue(model,out var vertices)?vertices:null;

    // One-time authoring source, never used to rebuild an authored scene on load.
    // The editor bakes these models to ordinary source .vmdl assets.
    public static FootballWorldPanel BuildEnvironmentSource()
    {
        var panel=new FootballWorldPanel{RenderScene=new Scene()};
        panel.ownedRenderScene=panel.RenderScene;
        panel.sceneCamera=new GameObject(panel.RenderScene,true,"Source camera").Components.Create<CameraComponent>();
        panel.box=MakeBox();sharedStadium=null;sharedTechnicalSurface=null;
        panel.BuildStadium();
        var before=panel.RenderScene.GetAllComponents<ModelRenderer>().ToHashSet();
        panel.AddBox(new Point(52.5f,34),-.4f,new Vector3(123,84,.5f),"#1a4033");
        foreach(var renderer in panel.RenderScene.GetAllComponents<ModelRenderer>().Where(r=>!before.Contains(r)))panel.environmentSourceSections[renderer]="Pitch foundation";
        panel.InitializePitchSurface();panel.environmentSourceSections[panel.pitchSurface]="Playing surface";
        panel.environmentSourceSections[panel.technicalSurface]="Technical-area paint";
        foreach(var position in new[]{new Point(0,0),new Point(0,68),new Point(105,0),new Point(105,68)})
        {
            before=panel.RenderScene.GetAllComponents<ModelRenderer>().ToHashSet();
            panel.Beam(position,0,position,1.5f,.04f,"#e9e8d4");
            panel.AddBox(position+new Point(.18f,0),1.4f,new Vector3(.38f,.025f,.24f),"#efbc4b");
            foreach(var renderer in panel.RenderScene.GetAllComponents<ModelRenderer>().Where(r=>!before.Contains(r)))panel.environmentSourceSections[renderer]=$"Corner flag {position.X:0}-{position.Y:0}";
        }
        panel.BuildEnvironmentEditorDetails();
        return panel;
    }

    void BuildEnvironmentEditorDetails()
    {
        var batches=new Dictionary<(string Section,string Colour),List<Vertex>>();
        var cubeVertices=box.GetVertices();var cubeIndices=box.GetIndices();
        var cube=cubeIndices?.Length>0?cubeIndices.Select(i=>cubeVertices[i]).ToArray():cubeVertices;
        void Rod(string section,GoalStroke stroke)
        {
            var a=WorldPoint(stroke.A,stroke.ZA);var b=WorldPoint(stroke.B,stroke.ZB);var delta=b-a;
            if(delta.Length<.001f)return;
            var key=(section,stroke.Color);
            if(!batches.TryGetValue(key,out var list))batches[key]=list=new();
            var rotation=Rotation.LookAt(delta.Normal,Math.Abs(delta.Normal.z)>.95f?Vector3.Forward:Vector3.Up);
            var transform=new Transform((a+b)*.5f,rotation,new Vector3(delta.Length,stroke.Width*Units,stroke.Width*Units));
            foreach(var vertex in cube)list.Add(new Vertex(transform.PointToWorld(vertex.Position),transform.NormalToWorld(vertex.Normal).Normal,Vector3.Up,Vector2.Zero));
        }
        foreach(var pair in environmentSourceStrokeSections)Rod(pair.Value,pair.Key);
        foreach(float x in new[]{0f,105f})foreach(var edge in PitchPerspective.Goal(x))
            Rod(x==0?"West goal preview":"East goal preview",new(edge.A,edge.ZA,edge.B,edge.ZB,edge.Net?.012f:PitchPerspective.PostWidth,edge.Net?"#a1c6b9":"#f4f1dc"));
        foreach(var pair in batches)
        {
            var bounds=new BBox(pair.Value[0].Position,pair.Value[0].Position);
            foreach(var vertex in pair.Value)bounds=bounds.AddPoint(vertex.Position);
            var mesh=new Mesh(Material.Load("materials/default.vmat"));mesh.CreateVertexBuffer(pair.Value.Count,pair.Value);mesh.Bounds=bounds;
            var renderer=Create(Model.Builder.AddMesh(mesh).WithViewBounds(bounds).Create(),"Editor detail");
            renderer.Tint=Ink(pair.Key.Colour);renderer.Tags.Add("football-editor-detail");
            environmentSourceSections[renderer]=pair.Key.Section;
        }
    }

    public void CopySectionMetadata(FootballEnvironmentSection section)
    {
        string[] stands={"North stand","South stand","West stand","East stand"};
        var transform=section.WorldTransform;
        foreach(var fan in animatedFans.Where(f=>stands[f.Stand]==section.SectionId))
        {
            var at=WorldPoint(fan.Position,fan.Level);
            var right=fan.Stand<2?Vector3.Forward:new Vector3(0,-1,0);
            section.Fans.Add(new(){LocalBase=transform.PointToLocal(at),LocalRight=transform.PointToLocal(at+right)-transform.PointToLocal(at),
                LocalUp=transform.PointToLocal(at+Vector3.Up)-transform.PointToLocal(at),Stand=fan.Stand,Hash=fan.Hash});
        }
        foreach(var pair in environmentSourceStrokeSections.Where(p=>p.Value==section.SectionId))
        {
            var stroke=pair.Key;
            section.FineStrokes.Add(new(){LocalA=transform.PointToLocal(WorldPoint(stroke.A,stroke.ZA)),LocalB=transform.PointToLocal(WorldPoint(stroke.B,stroke.ZB)),Width=stroke.Width*Units,Color=stroke.Color});
        }
    }

    bool InitializeAuthoredEnvironment()
    {
        var source=Game?.Scene.GetAllComponents<FootballEnvironment>().FirstOrDefault(e=>e.Enabled && e.GameObject.Enabled);
        if(source==null)return false;
        // The game camera only displays UI over an empty main-scene background.
        // Render the stadium once, through the existing broadcast ScenePanel.
        foreach(var renderer in source.GameObject.GetComponentsInChildren<ModelRenderer>())renderer.Tags.Add(FootballEnvironment.SceneTag);
        foreach(var camera in Game.Scene.GetAllComponents<CameraComponent>())camera.RenderExcludeTags.Add(FootballEnvironment.SceneTag);
        var anchor=new GameObject(RenderScene,true,"Authored football environment");
        GameObject clone;
        using(RenderScene.Push())clone=source.GameObject.Clone(source.WorldTransform,anchor,true,"Football environment");
        authoredEnvironment=clone.Components.Get<FootballEnvironment>();
        if(authoredEnvironment==null)throw new InvalidOperationException("The authored stadium clone lost its environment marker.");
        foreach(var renderer in clone.GetComponentsInChildren<ModelRenderer>())
            if(renderer.Tags.Has("football-editor-detail"))renderer.Enabled=false;
        BatchAuthoredStadium(clone);
        foreach(var section in clone.GetComponentsInChildren<FootballEnvironmentSection>())
        {
            var transform=section.WorldTransform;
            foreach(var fan in section.Fans)
            {
                var at=transform.PointToWorld(fan.LocalBase);
                var right=transform.PointToWorld(fan.LocalBase+fan.LocalRight)-at;
                var up=transform.PointToWorld(fan.LocalBase+fan.LocalUp)-at;
                animatedFans.Add(new(new Point(at.x/Units,-at.y/Units),at.z/Units,fan.Stand,fan.Hash,right,up));
            }
            foreach(var stroke in section.FineStrokes)
            {
                var a=transform.PointToWorld(stroke.LocalA);var b=transform.PointToWorld(stroke.LocalB);
                fineStadiumStrokes.Add(new(new Point(a.x/Units,-a.y/Units),a.z/Units,new Point(b.x/Units,-b.y/Units),b.z/Units,
                    stroke.Width/Units*Math.Max(Math.Abs(transform.Scale.x),Math.Max(Math.Abs(transform.Scale.y),Math.Abs(transform.Scale.z))),stroke.Color));
            }
        }
        stadiumBatches=RenderScene.GetAllComponents<ModelRenderer>().Count(r=>r.Enabled && r.GameObject.Name=="Stadium batch");
        // Static supporters live in the baked meshes; animated supporters keep
        // their section-local anchors and inherit editor translations/rotations.
        stadiumSpectators=authoredEnvironment.SpectatorCount;
        InitializeAnimatedCrowd();InitializeTechnicalSurface();
        return true;
    }

    // Keep useful section objects in the editor, then combine their opaque
    // default-material geometry once for the broadcast. Editing a section's
    // transform/tint is applied before combining; authored sources stay intact.
    void BatchAuthoredStadium(GameObject root)
    {
        var groups=root.GetComponentsInChildren<ModelRenderer>()
            .Where(r=>r.Enabled && r.GameObject.Name=="Stadium batch" && r.MaterialOverride==null && !r.HasMaterialGroups && r.Model!=null && r.Model.Materials.Length==1 &&
                r.Model.Materials[0].ResourcePath=="materials/default.vmat")
            .GroupBy(r=>(r.Tint,r.RenderType)).ToArray();
        foreach(var group in groups)
        {
            var renderers=group.ToArray();
            if(renderers.Length<2)continue;
            var vertices=new List<Vertex>();
            BBox bounds=default;bool hasBounds=false;
            foreach(var renderer in renderers)
            {
                var source=renderer.Model.GetVertices();var indices=renderer.Model.GetIndices();
                var transform=renderer.WorldTransform;
                int start=renderer.Model.GetIndexStart(0),count=renderer.Model.GetIndexCount(0),offset=renderer.Model.GetBaseVertex(0);
                if(count<=0){start=0;count=indices?.Length>0?indices.Length:source.Length;offset=0;}
                for(int i=start;i<start+count;i++)
                {
                    int index=indices?.Length>0?offset+(int)indices[i]:i;
                    var vertex=source[index];var point=transform.PointToWorld(vertex.Position);
                    vertices.Add(new Vertex(point,transform.NormalToWorld(vertex.Normal).Normal,Vector3.Forward,
                        new Vector2(vertex.TexCoord0.x,vertex.TexCoord0.y)));
                    bounds=hasBounds?bounds.AddPoint(point):new BBox(point,point);hasBounds=true;
                }
            }
            if(vertices.Count==0)continue;
            var mesh=new Mesh(Material.Load("materials/default.vmat"));mesh.CreateVertexBuffer(vertices.Count,vertices);mesh.Bounds=bounds;
            var batch=Create(Model.Builder.AddMesh(mesh).WithViewBounds(bounds).Create(),"Stadium batch");
            batch.Tint=group.Key.Tint;batch.RenderType=group.Key.RenderType;batch.Tags.Add(FootballEnvironment.SceneTag);
            foreach(var renderer in renderers)renderer.Enabled=false;
        }
    }
}