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