PlayerDetailRenderer.cs

Renderer for player figures shared by the pitch, replay and dressing room views. Draws the shirt-back number, kit and trim colours, legendary costumes, and swaps in modular, authored or legacy head meshes on the rig.

using Sandbox;
using Socer;
using System;
using System.Collections.Generic;

// Shared by the pitch, replay and dressing room. Artwork is depth tested geometry,
// attached to the rendered rig; it is never a screen-facing UI label.
public sealed class PlayerDetailRenderer
{
    [Sandbox.SkipHotload] static readonly Dictionary<int,Model> Numbers=new();
    [Sandbox.SkipHotload] static Model legacySphere;
    readonly Scene scene;
    ModelRenderer jersey,face,hair,bun,legendaryAccessory;
    ModelRenderer neck=null;
    // Retained for one hotload transition so retired prototype meshes can hide.
    ModelRenderer earLeft=null,earRight=null,hairStrands=null;
    int shownNumber=-1;
    int shownNumberRevision;
    Model legacyHead;
    readonly Material[] garmentMaterials=new Material[13];
    readonly (Color Skin,Color Fabric,Color Trim)[] garmentColors=new (Color,Color,Color)[13];
    string shownHeadId;
    bool shownClay;
    PlayerHeadProfile modularProfile;
    bool shownLowDetail;
    int modularGeneration;
    int modularRevision;
    Model modularFaceModel,modularHairModel,modularBeardModel;
    Material modularPalette;
    bool showingModularHead;
    bool showingAuthoredHead;
    string authoredHeadKey;
    Model authoredHeadModel;
    Material authoredHeadPalette;
    float nextModularRetry;
    const float Units=39.37f;
    public PlayerDetailRenderer(Scene scene)=>this.scene=scene;
    ModelRenderer Create(Model model,string name)
    {
        var renderer=new GameObject(scene,true,name).Components.Create<ModelRenderer>();
        renderer.Model=model;return renderer;
    }
    static Model Build(List<Vertex> vertices,Material material)
    {
        var bounds=new BBox(new Vector3(-.6f),new Vector3(.6f));
        var mesh=new Mesh(material);mesh.CreateVertexBuffer(vertices.Count,vertices);mesh.Bounds=bounds;
        return Model.Builder.AddMesh(mesh).WithViewBounds(bounds).Create();
    }
    static Material DecalMaterial(string name,Texture texture)
    {
        var material=Material.Load("materials/default.vmat").CreateCopy(name);
        material.SetFeature("F_ALPHA_TEST",1);
        material.Set("g_tColor",texture);
        material.Set("g_flModelTintAmount",1f);
        return material;
    }
    static Model NumberModel(int number)
    {
        int key=2000+number;
        if(Numbers.TryGetValue(key,out var model))return model;
        using var bitmap=new Bitmap(192,256);bitmap.Clear(Color.Transparent);
        bitmap.DrawText(new TextRendering.Scope(number.ToString(),Color.White,number>=10?124:190,"Roboto",900),bitmap.Rect,TextFlag.Center);
        var material=DecalMaterial($"socer-jersey-v2-{number}",bitmap.ToTexture());
        var vertices=new List<Vertex>();
        Vertex At(float u,float v)
        {
            float x=(.5f-v)*.72f,y=(.5f-u)*.65f;
            // Conform the number to the Blender torso's back profile.
            float f=Math.Clamp((x+.42f)/.62f,0,1);
            float radius=.37f+f*.11f,depth=.23f+f*.05f;
            float z=-MathF.Sqrt(Math.Max(.001f,1-y*y/(radius*radius)))*depth-.006f;
            return new Vertex(new Vector3(x,y,z),new Vector3(0,0,-1),Vector3.Forward,new Vector2(1-u,v));
        }
        const int columns=12,rows=8;
        for(int r=0;r<rows;r++)for(int c=0;c<columns;c++)
        {
            var a=At(c/(float)columns,r/(float)rows);var b=At((c+1f)/columns,r/(float)rows);
            var d=At(c/(float)columns,(r+1f)/rows);var e=At((c+1f)/columns,(r+1f)/rows);
            vertices.AddRange(new[]{a,b,e,a,e,d});
        }
        return Numbers[key]=Build(vertices,material);
    }
    Dictionary<string,ModelRenderer> headParts=new();
    static readonly Color Clay=new(.46f,.49f,.50f);
    static void Collapse(ModelRenderer renderer)
    {
        if(renderer==null)return;
        renderer.Enabled=true;
        renderer.WorldTransform=new Transform(Vector3.Zero,Rotation.Identity,new Vector3(.0001f));
    }
    public void UpdateUniform(ModelRenderer[] rig,string trim="#ffffff",bool staff=false,bool officer=false,string legendary=null)
    {
        var skin=rig[9].Tint;var trimColor=FootballFigureAssets.Linear(trim);
        var officerTrim=FootballFigureAssets.Linear("#c9aa65");
        bool coveredArms=officer || legendary is "messi_alien" or "zlatan_regal" or "maldini_security" or "buffon_veteran";
        for(int i=0;i<13;i++)
        {
            if((officer || legendary!=null) && i==6 || legendary=="pirlo_waistcoat" && i is 7 or 10 ||
                legendary=="buffon_veteran" && i is 9 or 12)
            {
                if(garmentMaterials[i]!=null){rig[i].Materials.SetOverride(0,null);garmentMaterials[i]=null;}
                continue;
            }
            var primary=rig[i].Tint;
            var palette=i is 2 or 5?(FootballFigureAssets.Linear("#17282b"),FootballFigureAssets.Linear("#e5e8dc"),FootballFigureAssets.Linear("#3b4850")):
                staff?(primary,primary,primary):
                (coveredArms && i is 7 or 8 or 10 or 11?primary:i is 9 or 12?primary:skin,
                    primary,coveredArms && i is 7 or 8 or 10 or 11?officer?officerTrim:primary:trimColor);
            if(garmentMaterials[i]!=null && garmentColors[i]==palette)continue;
            garmentColors[i]=palette;
            var shader=FootballFigureAssets.Palette(palette.Item1,palette.Item2,palette.Item3);
            if(garmentMaterials[i]!=shader){rig[i].Materials.SetOverride(0,shader);garmentMaterials[i]=shader;}
        }
    }
    public void Update(string catalogId,string edition,int number,bool visible,ModelRenderer[] rig,bool clay=false,string trim="#ffffff",bool lowDetail=false)
    {
        // Retire the old neck even in already-open hotloaded scenes.
        Collapse(neck);
        bool player=visible && number>0 && rig.Length>16 && rig[13].Enabled;
        if(!player)
        {
            // Mannequins and officials retain the rig's own tint.
            for(int i=0;i<13;i++)if(garmentMaterials[i]!=null){rig[i].Materials.SetOverride(0,null);garmentMaterials[i]=null;}
        }
        if(player)
        {
            bool officer=PlayerAppearance.HasOfficerTunic(catalogId,edition);
            var costume=PlayerAppearance.LegendaryCostume(catalogId,edition);
            string sleeve=officer?"#686b57":costume switch
            {
                "messi_alien"=>"#a9b4c2", "zlatan_regal"=>"#612336",
                "maldini_security"=>"#1b292d", "buffon_veteran"=>"#183b31", _=>null
            };
            // Keep club shorts and socks, but carry each costume's fabric through
            // the existing arm rig in the match, replay and dressing room.
            if(sleeve!=null)
            {
                var fabric=clay?Clay:FootballFigureAssets.Linear(sleeve);
                rig[7].Tint=rig[8].Tint=rig[10].Tint=rig[11].Tint=fabric;
            }
            UpdateUniform(rig,trim,officer:officer,legendary:costume);
            if(officer){rig[6].Model=FootballFigureAssets.MbappeOfficerTorso;rig[6].Tint=Color.White;}
            ApplyLegendaryCostume(catalogId,edition,rig,clay);
            if(jersey==null)jersey=Create(NumberModel(number),"Number printed on shirt back");
            if(shownNumber!=number || shownNumberRevision!=2){jersey.Model=NumberModel(number);shownNumber=number;shownNumberRevision=2;}
            jersey.WorldTransform=rig[6].WorldTransform;
            var shirt=rig[6].Tint;
            bool paleCostume=costume=="messi_alien";
            jersey.Tint=paleCostume?new Color(.025f,.04f,.055f):officer || costume!=null?Color.White:shirt.r+shirt.g+shirt.b>1.7f?new Color(.025f,.04f,.055f):Color.White;
        }
        if(jersey!=null)jersey.Enabled=player;
        if(player && PlayerAppearance.HasAuthoredHead(catalogId))
        {
            UpdateAuthoredHead(catalogId,edition,rig,clay,lowDetail);
            ApplyLegendaryAccessory(catalogId,edition,rig,clay);
            return;
        }
        showingAuthoredHead=false;
        ApplyLegendaryAccessory(player?catalogId:null,edition,rig,clay);
        var profile=player && !PlayerAppearance.HasModel(catalogId)?PlayerHeadProfiles.Find(catalogId):null;
        if(profile!=null)
        {
            UpdateModularHead(catalogId,profile,rig,clay,lowDetail);
            return;
        }
        showingModularHead=false;
        var identity=player?StylizedPlayerHeads.Find(catalogId):null;
        if(identity==null)
        {
            if(legacyHead!=null)rig[13].Model=legacyHead;
            HideFace();return;
        }
        headParts??=new();
        // Collapse meshes from the retired portrait-based prototype on hotload.
        if(headParts.Count==0)HideFace();
        var head=rig[13].WorldPosition;
        var up=rig[6].WorldRotation.Forward;
        var forward=(rig[15].WorldPosition+rig[16].WorldPosition)*.5f-head;
        forward-=up*Vector3.Dot(forward,up);
        if(forward.Length<.001f)forward=rig[6].WorldRotation.Up;
        var rotation=Rotation.LookAt(forward.Normal,up);
        legacyHead??=rig[13].Model;
        var asset=StylizedPlayerHeads.Get(identity);
        // Seat each chin against the collar using the shared style's head height.
        // Preserve the rig's head motion, lean and header contact offsets.
        bool imported=PlayerAppearance.HasModel(catalogId);
        head-=up*(imported?.025f:Math.Max(0,.22f-asset.Scale.z*.46f))*Units;
        var transform=new Transform(head,rotation,asset.Scale*Units);
        if(shownHeadId!=catalogId || shownClay!=clay)
        {
            foreach(var renderer in headParts.Values)Collapse(renderer);
            shownHeadId=catalogId;shownClay=clay;
        }
        foreach(var part in asset.Parts)
        {
            if((part.Name=="Sculpt")!=clay)continue;
            if(!headParts.TryGetValue(part.Name,out var renderer))
                headParts[part.Name]=renderer=Create(part.Model,$"Stylized head / {part.Name}");
            var model=imported?FootballFigureAssets.Head(catalogId,lowDetail):part.Model;
            if(renderer.Model!=model)renderer.Model=model;
            renderer.Enabled=true;
            if(imported)
            {
                if(clay){if(renderer.Materials.GetOverride(0)!=FootballFigureAssets.Clay)renderer.Materials.SetOverride(0,FootballFigureAssets.Clay);}
                else if(renderer.Materials.HasOverride(0))renderer.Materials.SetOverride(0,null);
            }
            renderer.WorldTransform=transform;renderer.Tint=clay?Clay:part.Tint;
        }
        face=headParts[clay?"Sculpt":"Skin"];hair=face;bun=null;
        // Keep transforms live in directly rendered ScenePanel scenes. Merely
        // disabling the original eyes can leave frozen black spheres on screen.
        for(int i=13;i<=16;i++)
        {
            rig[i].Enabled=true;
            if(rig[i].Model==null)rig[i].Model=legacySphere??=FootballWorldPanel.MakeSphere();
            rig[i].WorldTransform=new Transform(head,rotation,new Vector3(.0001f));
        }
    }
    public void Hide()
    {
        if(jersey!=null)jersey.Enabled=false;
        Collapse(legendaryAccessory);
        HideFace();
    }
    void ApplyLegendaryCostume(string id,string edition,ModelRenderer[] rig,bool clay)
    {
        var costume=PlayerAppearance.LegendaryCostume(id,edition);
        if(costume==null)return;
        void Bind(int slot,string part)
        {
            var model=FootballFigureAssets.LegendaryCostume(costume,part);
            if(model==null || model.IsError)return;
            rig[slot].Model=model;rig[slot].Tint=clay?Clay:Color.White;
            if(garmentMaterials[slot]!=null){rig[slot].Materials.SetOverride(0,null);garmentMaterials[slot]=null;}
            if(clay)rig[slot].Materials.SetOverride(0,FootballFigureAssets.Clay);
            else if(rig[slot].Materials.HasOverride(0))rig[slot].Materials.SetOverride(0,null);
        }
        Bind(6,"torso");
        if(id==PlayerAppearance.PirloId)
        {
            Bind(7,"upper_arm");Bind(10,"upper_arm");
        }
        if(id==PlayerAppearance.BuffonId)
        {
            Bind(9,"glove");Bind(12,"glove");
            // FitBody restores baseline hand scales every frame, including
            // replay and preview; this multiplier never accumulates.
            var left=rig[9].WorldTransform;left.Scale*=new Vector3(1.8f,1.55f,1.6f);rig[9].WorldTransform=left;
            var right=rig[12].WorldTransform;right.Scale*=new Vector3(1.8f,1.55f,1.6f);rig[12].WorldTransform=right;
        }
    }
    void ApplyLegendaryAccessory(string id,string edition,ModelRenderer[] rig,bool clay)
    {
        if(id!=PlayerAppearance.MessiId || edition!=CardRatings.Legendary)
        {
            Collapse(legendaryAccessory);return;
        }
        var model=FootballFigureAssets.LegendaryCostume("messi_alien","antennae");
        if(model==null || model.IsError){Collapse(legendaryAccessory);return;}
        legendaryAccessory??=Create(model,"Blender legendary Messi antennae");
        var head=rig[13].WorldPosition;
        var up=rig[6].WorldRotation.Forward;
        var forward=(rig[15].WorldPosition+rig[16].WorldPosition)*.5f-head;
        forward-=up*Vector3.Dot(forward,up);
        if(forward.Length<.001f)forward=rig[6].WorldRotation.Up;
        legendaryAccessory.Enabled=true;legendaryAccessory.Tint=clay?Clay:Color.White;
        legendaryAccessory.WorldTransform=new Transform(head-up*.025f*Units,Rotation.LookAt(forward.Normal,up),new Vector3(.17f*Units));
        if(clay)legendaryAccessory.Materials.SetOverride(0,FootballFigureAssets.Clay);
        else if(legendaryAccessory.Materials.HasOverride(0))legendaryAccessory.Materials.SetOverride(0,null);
    }
    void UpdateAuthoredHead(string id,string edition,ModelRenderer[] rig,bool clay,bool low)
    {
        headParts??=new();
        var head=rig[13].WorldPosition;var up=rig[6].WorldRotation.Forward;
        var forward=(rig[15].WorldPosition+rig[16].WorldPosition)*.5f-head;
        forward-=up*Vector3.Dot(forward,up);
        if(forward.Length<.001f)forward=rig[6].WorldRotation.Up;
        var rotation=Rotation.LookAt(forward.Normal,up);
        head-=up*.025f*Units;
        bool crowned=id==PlayerAppearance.ZlatanId && edition==CardRatings.Legendary;
        string key=id+"/"+low+"/"+clay+"/"+crowned+"/"+PlayerHeadProfiles.Generation;
        if(!showingAuthoredHead || authoredHeadKey!=key)
        {
            foreach(var renderer in headParts.Values)Collapse(renderer);
            authoredHeadKey=key;showingAuthoredHead=true;showingModularHead=false;
            authoredHeadModel=FootballFigureAssets.AuthoredHead(id,low,crowned);
            authoredHeadPalette=FootballFigureAssets.HeadPalette(id,PlayerHeadProfiles.Find(id),clay,crowned);
        }
        if(authoredHeadModel==null || authoredHeadModel.IsError){HideFace();return;}
        if(!headParts.TryGetValue("AuthoredLegend",out var faceRenderer))
            headParts["AuthoredLegend"]=faceRenderer=Create(authoredHeadModel,"Blender authored star head");
        faceRenderer.Model=authoredHeadModel;faceRenderer.Enabled=true;faceRenderer.Tint=clay?Clay:Color.White;
        if(faceRenderer.Materials.GetOverride(0)!=authoredHeadPalette)faceRenderer.Materials.SetOverride(0,authoredHeadPalette);
        faceRenderer.WorldTransform=new Transform(head,rotation,new Vector3(.23f*Units));
        face=faceRenderer;hair=null;bun=null;
        for(int i=13;i<=16;i++)
        {
            rig[i].Enabled=true;
            if(rig[i].Model==null)rig[i].Model=legacySphere??=FootballWorldPanel.MakeSphere();
            rig[i].WorldTransform=new Transform(head,rotation,new Vector3(.0001f));
        }
    }
    void UpdateModularHead(string id,PlayerHeadProfile profile,ModelRenderer[] rig,bool clay,bool low)
    {
        headParts??=new();
        var head=rig[13].WorldPosition;
        var up=rig[6].WorldRotation.Forward;
        var forward=(rig[15].WorldPosition+rig[16].WorldPosition)*.5f-head;
        forward-=up*Vector3.Dot(forward,up);
        if(forward.Length<.001f)forward=rig[6].WorldRotation.Up;
        var rotation=Rotation.LookAt(forward.Normal,up);
        legacyHead??=rig[13].Model;
        head-=up*.025f*Units;
        const float headScale=.23f;
        var transform=new Transform(head,rotation,new Vector3(headScale*Units));
        bool missingPart=!low && ((profile.HairStyle>0 && (modularHairModel==null || modularHairModel.IsError)) ||
            (profile.BeardStyle>0 && (modularBeardModel==null || modularBeardModel.IsError)));
        if(!showingModularHead || !ReferenceEquals(modularProfile,profile) || shownLowDetail!=low || shownClay!=clay ||
            modularGeneration!=PlayerHeadProfiles.Generation || modularRevision!=FootballFigureAssets.HeadRevision || shownHeadId==null)
        {
            foreach(var renderer in headParts.Values)
            {
                Collapse(renderer);
                // Retired LOD/import versions must not keep resource references
                // alive. Active parts bind their shared model again below.
                renderer.Model=null;
            }
            modularProfile=profile;shownLowDetail=low;shownClay=clay;modularGeneration=PlayerHeadProfiles.Generation;
            modularRevision=FootballFigureAssets.HeadRevision;
            shownHeadId=id+(low?"/far":"/near")+"/"+modularGeneration;
            showingModularHead=true;
            modularFaceModel=FootballFigureAssets.ModularHead(profile,low);
            modularHairModel=low?null:FootballFigureAssets.ModularHair(profile);
            modularBeardModel=low?null:FootballFigureAssets.ModularBeard(profile);
            modularPalette=FootballFigureAssets.HeadPalette(id,profile,clay);
            nextModularRetry=RealTime.Now+.5f;
        }
        else if(missingPart && RealTime.Now>=nextModularRetry)
        {
            // Cold imports may finish after the first frame. Retry only missing
            // parts at a bounded rate; never render the engine's ERROR model.
            modularHairModel=FootballFigureAssets.ModularHair(profile);
            modularBeardModel=FootballFigureAssets.ModularBeard(profile);
            nextModularRetry=RealTime.Now+.5f;
        }
        var palette=modularPalette;
        ModelRenderer Apply(string part,Model model,Transform placement)
        {
            if(model==null || model.IsError)
            {
                if(headParts.TryGetValue(part,out var absent))Collapse(absent);
                return null;
            }
            if(!headParts.TryGetValue(part,out var renderer))headParts[part]=renderer=Create(model,"Blender modular head / "+part);
            if(renderer.Model!=model)renderer.Model=model;
            if(renderer.Materials.GetOverride(0)!=palette)renderer.Materials.SetOverride(0,palette);
            renderer.Enabled=true;renderer.WorldTransform=placement;renderer.Tint=clay?Clay:Color.White;
            return renderer;
        }
        face=Apply(low?"ModularFar":"ModularFace",modularFaceModel,transform);
        hair=null;bun=null;
        if(!low)
        {
            if(profile.HairStyle>0)
            {
                var hairTransform=new Transform(head,rotation,transform.Scale*new Vector3(1,PlayerHeadProfiles.Width(profile.HeadShape),PlayerHeadProfiles.Length(profile.HeadShape)));
                hair=Apply("ModularHair",modularHairModel,hairTransform);
            }
            if(profile.BeardStyle>0)bun=Apply("ModularBeard",modularBeardModel,transform);
        }
        // Retire the generic sphere, black sphere eyes and capsule haircut in all
        // views. Live transforms also hide meshes retained by ScenePanel hotload.
        for(int i=13;i<=16;i++)
        {
            rig[i].Enabled=true;
            if(rig[i].Model==null)rig[i].Model=legacySphere??=FootballWorldPanel.MakeSphere();
            rig[i].WorldTransform=new Transform(head,rotation,new Vector3(.0001f));
        }
    }
    void HideFace()
    {
        shownHeadId=null;
        showingModularHead=false;
        Collapse(face);Collapse(hair);Collapse(bun);Collapse(earLeft);Collapse(earRight);Collapse(hairStrands);Collapse(neck);
        if(headParts!=null)foreach(var renderer in headParts.Values)Collapse(renderer);
    }
}