A UI ScenePanel that previews a character model for a character creator. It creates a local render Scene and CameraComponent, manages orbit/zoom/pan/pitch interactivity, drives a CharacterVisual instance to update the skinned model renderer, and handles mouse input and lifecycle cleanup.
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox.UI;
namespace CharacterEditor;
/// <summary>Menu/metadata preview with only local native renderers, lights and shape volumes. It never spawns a gameplay actor.</summary>
public sealed class CharacterCreatorPreview : ScenePanel
{
public const float FaceZoom = 1;
public const float MaxZoom = 2;
public CharacterAppearanceData Appearance { get; set; }
public bool Portrait { get; set; }
public bool Hero { get; set; }
public float? Zoom { get; set; }
public float Yaw { get; set; }
public const float MinPitch = -25, MaxPitch = 35;
public float Pitch { get; set; }
public Action<float> PitchChanged { get; set; }
public Vector3 PanOffset { get; set; }
public bool Interactive { get; set; }
public bool ShowClothing { get; set; } = true;
public Action<float> OrbitChanged { get; set; }
public Action<float> ZoomChanged { get; set; }
public Action<Vector3> PanChanged { get; set; }
MouseButtons? dragButton;
public string Error { get; private set; }
public bool Loading => visual?.Loading == true;
public SkinnedModelRenderer Body => body;
CameraComponent camera;
SkinnedModelRenderer body;
CharacterVisual visual;
public CharacterCreatorPreview()
{
RenderScene = new Scene();
using var scope = RenderScene.Push();
camera = new GameObject
{
NetworkMode = NetworkMode.Never
}.Components.Create<CameraComponent>();
camera.FieldOfView = 34;
camera.ZNear = 0.25f;
camera.BackgroundColor = Color.Transparent;
CharacterStudioLighting.Create();
}
public void Orbit(float delta)
{
if (float.IsFinite(delta))
{
Yaw = (Yaw + delta) % 360;
OrbitChanged?.Invoke(Yaw);
}
}
public void SetZoom(float value)
{
if (float.IsFinite(value))
{
Zoom = Math.Clamp(value, 0, MaxZoom);
Portrait = Zoom >= FaceZoom - 0.001f;
ZoomChanged?.Invoke(Zoom.Value);
}
}
public void Pan(Vector2 delta)
{
if (!float.IsFinite(delta.x) || !float.IsFinite(delta.y) || !camera.IsValid())
return;
var distance = Framing().Distance;
var unitsPerPixel = 2 * distance * MathF.Tan(camera.FieldOfView * MathF.PI / 360) / MathF.Max(1, Box.Rect.Width);
var offset = PanOffset + (camera.WorldRotation.Left * delta.x + camera.WorldRotation.Up * delta.y) * unitsPerPixel;
PanOffset = new(Math.Clamp(offset.x, -35, 35), Math.Clamp(offset.y, -35, 35), Math.Clamp(offset.z, -75, 75));
PanChanged?.Invoke(PanOffset);
}
public void SetPitch(float value)
{
if (float.IsFinite(value))
{
Pitch = Math.Clamp(value, MinPitch, MaxPitch);
PitchChanged?.Invoke(Pitch);
}
}
public void Recenter()
{
Yaw = 0;
OrbitChanged?.Invoke(0);
PanOffset = Vector3.Zero;
PanChanged?.Invoke(PanOffset);
SetPitch(0);
}
public void Drag(Vector2 delta)
{
if (!Interactive || !HasMouseCapture)
return;
if (dragButton == MouseButtons.Middle)
Pan(delta);
else if (dragButton == MouseButtons.Right)
{
Orbit(delta.x * 0.35f);
SetPitch(Pitch - delta.y * 0.25f);
}
else if (dragButton == MouseButtons.Left)
Orbit(delta.x * 0.35f);
}
(Vector3 Position, Vector3 Target, float Distance) Framing()
{
var aspect = Box.Rect.Width / MathF.Max(1, Box.Rect.Height);
var zoom = Math.Clamp(Zoom ?? (Portrait ? FaceZoom : 0), 0, MaxZoom);
var bodyZoom = Math.Clamp(zoom, 0, FaceZoom);
var detailZoom = Math.Max(0, zoom - FaceZoom);
var fullDistance = Hero ? MathF.Max(112, 135 * aspect) : MathF.Max(125, 145 * aspect);
var faceDistance = Hero ? MathF.Max(28, 28 * aspect) : MathF.Max(22, 30 * aspect);
var distance = detailZoom > 0 ? faceDistance * MathF.Pow(14 / faceDistance, detailZoom) : fullDistance + (faceDistance - fullDistance) * bodyZoom;
return (new(distance, -distance * 0.15f, 44 + 20 * bodyZoom), new(0, 0, 37 + 25 * bodyZoom + 1.5f * detailZoom), distance);
}
protected override void OnMouseDown(MousePanelEvent e)
{
if (Interactive && !HasMouseCapture && e.MouseButton is MouseButtons.Left or MouseButtons.Right or MouseButtons.Middle)
{
dragButton = e.MouseButton;
SetMouseCapture(true);
e.StopPropagation();
return;
}
base.OnMouseDown(e);
}
protected override void OnMouseUp(MousePanelEvent e)
{
if (HasMouseCapture && e.MouseButton == dragButton)
{
dragButton = null;
SetMouseCapture(false);
e.StopPropagation();
return;
}
base.OnMouseUp(e);
}
public override void OnMouseWheel(Vector2 value)
{
if (!Interactive)
{
base.OnMouseWheel(value);
return;
}
SetZoom((Zoom ?? (Portrait ? 1 : 0)) - value.y * 0.035f);
}
public override void Tick()
{
base.Tick();
if (!camera.IsValid())
return;
if (HasMouseCapture)
{
if (!Interactive)
{
dragButton = null;
SetMouseCapture(false);
}
else
Drag(Mouse.Delta);
}
using var scope = RenderScene.Push();
// Native camera FOV is horizontal. Preserve room for the whole silhouette when
// a wide UI panel reduces its vertical view, while keeping tall portraits close.
var framing = Framing();
var baseLook = Rotation.LookAt(framing.Target - framing.Position);
var position = framing.Target + Rotation.FromAxis(baseLook.Left, -Math.Clamp(Pitch, MinPitch, MaxPitch)) * (framing.Position - framing.Target);
camera.WorldPosition = position + PanOffset;
camera.WorldRotation = Rotation.LookAt(framing.Target + PanOffset - camera.WorldPosition);
if (body.IsValid())
body.LocalRotation = Rotation.FromYaw(float.IsFinite(Yaw) ? Yaw : 0);
visual ??= new CharacterVisual(new GameObject { NetworkMode = NetworkMode.Never });
visual.Update(Appearance, ShowClothing);
body = visual.Body;
Error = visual.Error;
}
public override void Delete(bool immediate = false)
{
if (HasMouseCapture)
SetMouseCapture(false);
var scene = RenderScene;
base.Delete(immediate);
scene?.Destroy();
visual?.Dispose();
}
}