A helper that renders a small preview scene inside a menu ScenePanel, with its own camera and lights. Shows a tinted model, a weapon with body groups, or a dressed character, and rotates or frames the subject each frame. Used by the vehicle dealer, inventory and ID photo UI.
using Sandbox.UI;
namespace HorizonRP;
/// <summary>
/// Un aperçu 3D dans un menu : une petite scène à part, rendue dans un
/// ScenePanel, avec sa caméra, sa lumière et un modèle qui tourne lentement.
/// Sert à la concession (le véhicule, à sa couleur), à l'inventaire (le
/// personnage en entier) et au portefeuille (la photo d'identité).
///
/// La scène est propre au panneau : elle n'a rien à voir avec la ville, n'est
/// pas en réseau, et disparaît avec le menu.
/// </summary>
public sealed class Preview3D
{
public ScenePanel Panel { get; private set; }
/// <summary>Degrés par seconde ; 0 pour une photo fixe.</summary>
public float TurnSpeed { get; set; } = 18f;
/// <summary>Le menu de l'arme fait tourner l'aperçu à la souris : lacet, tangage, et recul (1 = cadrage d'origine).</summary>
public float Yaw { get => yaw; set => yaw = value; }
public float Pitch { get; set; }
public float Zoom { get; set; } = 1f;
GameObject cameraObject;
GameObject subject;
string shownKey;
float yaw = 200f;
Vector3 lookAt;
float distance = 200f;
/// <summary>Cadrage « tête seule » : hauteur visée et recul (photo d'identité : le visage).</summary>
public float HeadHeight { get; set; } = 64f;
public float HeadDistance { get; set; } = 38f;
/// <summary>Rotation du personnage en cadrage tête seule : 0 de face, quelques degrés pour un trois-quarts.</summary>
public float HeadYaw { get; set; }
/// <summary>
/// Photo d'identité : seuls le visage et ce qui le touche (peau, cheveux,
/// pilosité, maquillage, yeux) sont portés ; pas de vêtements dans le cadre.
/// </summary>
public bool FaceOnly { get; set; }
/// <summary>Cadrage corps entier : recul, et rotation de départ (0 = de face ; l'inventaire reste fixe).</summary>
public float BodyDistance { get; set; } = 150f;
public float BodyYaw { get; set; } = 200f;
/// <summary>Hauteur visée en corps entier : plus basse, le personnage monte dans le cadre.</summary>
public float BodyHeight { get; set; } = 38f;
static bool IsFacePart( Clothing c )
{
if ( c is null ) return false;
if ( c.HasHumanSkin ) return true;
var category = c.Category.ToString();
return category.StartsWith( "Hair" ) || category.StartsWith( "Facial" ) || category.StartsWith( "Makeup" )
|| category.StartsWith( "Complexion" ) || category.StartsWith( "Eye" );
}
float height;
bool headMode;
GameObject weaponHolder;
SkinnedModelRenderer weaponRenderer;
int recenterFrames;
Vector3 lastBoundsCenter;
SkinnedModelRenderer body;
/// <summary>Branche l'aperçu sur le ScenePanel du menu (appelé à chaque image : sans effet si rien ne change).</summary>
public void Attach( ScenePanel panel )
{
if ( panel is null || Panel == panel )
return;
Panel = panel;
shownKey = null;
using ( Panel.RenderScene.Push() )
{
cameraObject = new GameObject( true, "Caméra de l'aperçu" );
var camera = cameraObject.Components.Create<CameraComponent>();
camera.IsMainCamera = true;
camera.FieldOfView = 30;
camera.BackgroundColor = Color.Transparent;
camera.ZNear = 1;
camera.ZFar = 4000;
var sun = new GameObject( true, "Lumière de l'aperçu" );
sun.WorldRotation = Rotation.From( 50, 140, 0 );
sun.Components.Create<DirectionalLight>();
sun.Components.Create<AmbientLight>();
subject = new GameObject( true, "Sujet de l'aperçu" );
}
}
/// <summary>Un modèle (véhicule, objet), teinté. Ne recharge que si le modèle ou la teinte changent.</summary>
public void ShowModel( string source, Color tint )
{
var key = $"modele|{source}|{tint.Hex}";
if ( Panel is null || key == shownKey )
return;
shownKey = key;
_ = LoadModel( source, tint, key );
}
async Task LoadModel( string source, Color tint, string key )
{
var model = await ModelSources.Load( source );
if ( Panel is null || shownKey != key )
return;
using ( Panel.RenderScene.Push() )
{
Clear();
var renderer = subject.Components.Create<ModelRenderer>();
renderer.Model = model;
renderer.Tint = tint;
var bounds = model.Bounds;
lookAt = bounds.Center;
distance = MathF.Max( 80, bounds.Size.Length * 1.6f );
height = bounds.Size.z * 0.35f;
}
}
/// <summary>Une arme (son modèle au sol), avec les pièces montées : des groupes de corps (nom, choix).</summary>
public void ShowWeapon( string source, IReadOnlyList<(string Group, int Choice)> parts )
{
var key = $"arme|{source}|{string.Join( ",", parts.Select( x => $"{x.Group}={x.Choice}" ) )}";
if ( Panel is null || key == shownKey )
return;
shownKey = key;
_ = LoadWeapon( source, parts, key );
}
async Task LoadWeapon( string source, IReadOnlyList<(string Group, int Choice)> parts, string key )
{
var model = await ModelSources.Load( source );
if ( Panel is null || shownKey != key )
return;
using ( Panel.RenderScene.Push() )
{
Clear();
// L'arme tourne autour de son centre : elle est décalée dans un enfant du sujet.
var bounds = model.Bounds;
var holder = new GameObject( subject, true, "Arme de l'aperçu" );
holder.LocalPosition = -bounds.Center;
var renderer = holder.Components.Create<SkinnedModelRenderer>();
renderer.Model = model;
// Pose de repos : un modèle de vue suivrait sinon son graphe (l'arme en main, devant la caméra).
renderer.UseAnimGraph = false;
foreach ( var (group, choice) in parts.Where( x => model.BodyParts.Any( b => b.Name == x.Group ) ) )
renderer.SetBodyGroup( group, choice );
// Les dimensions du modèle comptent aussi ce qui n'est pas dessiné (la place des
// bras d'un modèle de vue) : on recentre sur le maillage réel dès qu'il est posé.
weaponHolder = holder;
weaponRenderer = renderer;
recenterFrames = 60;
lookAt = Vector3.Zero;
distance = MathF.Max( 20, bounds.Size.Length * 1.5f );
height = 0;
}
}
/// <summary>Un personnage habillé par son apparence (JSON ClothingContainer), corps entier ou visage.</summary>
public void ShowCharacter( string appearanceJson, string sex, bool headOnly )
{
var key = $"perso|{headOnly}|{sex}|{appearanceJson}";
if ( Panel is null || key == shownKey )
return;
shownKey = key;
using ( Panel.RenderScene.Push() )
{
Clear();
body = subject.Components.Create<SkinnedModelRenderer>();
headMode = headOnly;
body.Model = Model.Load( RoleplayPlayer.BodyModelFor( sex ) );
var dresser = subject.Components.Create<Dresser>();
dresser.BodyTarget = body;
dresser.Source = Dresser.ClothingSource.Manual;
if ( !string.IsNullOrWhiteSpace( appearanceJson ) )
{
var container = ClothingContainer.CreateFromJson( appearanceJson );
CharacterRules.EnsureSkin( container, sex );
if ( FaceOnly && headOnly )
container.Clothing.RemoveAll( x => !IsFacePart( x.Clothing ) );
dresser.UpdateAppearance( container );
_ = TintedSkin.DressAsync( dresser, container );
}
// Corps entier, ou gros plan du visage pour une photo d'identité.
lookAt = headOnly ? new Vector3( 0, 0, HeadHeight ) : new Vector3( 0, 0, BodyHeight );
distance = headOnly ? HeadDistance : BodyDistance;
height = 0;
// Un personnage regarde vers +X, là où se tient la caméra : à 0°, il est de face.
yaw = headOnly ? HeadYaw : BodyYaw;
}
}
void Clear()
{
foreach ( var component in subject.Components.GetAll().ToList() )
component.Destroy();
foreach ( var child in subject.Children.ToList() )
child.Destroy();
}
/// <summary>À appeler dans l'OnUpdate du menu : fait tourner le sujet et place la caméra.</summary>
public void Tick()
{
if ( Panel is null || !subject.IsValid() )
return;
using ( Panel.RenderScene.Push() )
{
yaw += TurnSpeed * RealTime.Delta;
subject.WorldRotation = Rotation.From( Pitch, yaw, 0 );
// Les bornes du maillage suivent le déplacement avec une image de retard : on ne
// corrige que sur des bornes stables, jusqu'à ce que l'arme soit au centre.
if ( recenterFrames > 0 && weaponRenderer.IsValid() )
{
var bounds = weaponRenderer.Bounds;
var offset = subject.WorldTransform.PointToLocal( bounds.Center );
if ( bounds.Center.Distance( lastBoundsCenter ) < 0.01f && offset.Length > 0.2f )
{
weaponHolder.LocalPosition -= offset;
distance = MathF.Max( 12, bounds.Size.Length * 1.4f );
}
else if ( offset.Length <= 0.2f )
recenterFrames = 0;
lastBoundsCenter = bounds.Center;
recenterFrames = Math.Max( 0, recenterFrames - 1 );
}
// Tête seule : on vise l'os de la tête plutôt qu'une hauteur fixe, sinon un
// personnage de trois quarts sort du cadre (la tête n'est pas sur l'axe).
if ( headMode && body.IsValid() && body.TryGetBoneTransform( "head", out var head ) )
lookAt = head.Position + Vector3.Up * (HeadHeight - 64f);
var position = lookAt + Vector3.Forward * distance * Zoom + Vector3.Up * height;
cameraObject.WorldPosition = position;
cameraObject.WorldRotation = Rotation.LookAt( lookAt - position, Vector3.Up );
}
}
}