Editor/WeaponImporter/Tool/Engine/CharacterPoser.cs
using Sandbox;
using WeaponImporter.Core.Ik;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Tool;
/// <summary>
/// Poses a character with its own animgraph in a hidden editor scene and samples the result:
/// the reference pose grips are fitted to (holdtype idle, aiming straight ahead).
/// </summary>
public sealed class CharacterPoser : IDisposable
{
private Scene _scene;
private SkinnedModelRenderer _body;
public Model Model { get; }
public Skeleton Skeleton { get; }
public CharacterPoser( string modelPath )
{
Model = Model.Load( modelPath );
if ( Model is null || Model.IsError )
throw new InvalidOperationException( $"Character model '{modelPath}' could not be loaded." );
Skeleton = CharacterLibrary.SkeletonOf( Model );
_scene = Scene.CreateEditorScene();
using ( _scene.Push() )
{
var go = new GameObject( true, "character" );
_body = go.AddComponent<SkinnedModelRenderer>();
CharacterLibrary.Setup( _body, Model );
}
}
/// <summary>Settles the animgraph with the weapon's holdtype and returns the pose.</summary>
public CharacterPose Sample( int holdType, float seconds = 1.2f, string trigger = null, float triggerTime = 0f )
{
using ( _scene.Push() )
{
ApplyParameters( _body, holdType );
var steps = Math.Max( 1, (int)(seconds * 30f) );
var now = 0f;
for ( var i = 0; i < steps; i++ )
{
if ( trigger is not null && MathF.Abs( now - triggerTime ) < 1f / 60f )
_body.Set( trigger, true );
now += 1f / 30f;
_scene.EditorTick( now, 1f / 30f );
}
return CharacterLibrary.Sample( _body, Skeleton );
}
}
/// <summary>
/// Whether the graph actually poses the arms for a holdtype (a graph without the model's
/// sequences leaves the character in its bind pose).
/// </summary>
public bool Animates( WeaponImporter.Core.Grip.CharacterRig rig, int holdType )
{
var pose = Sample( holdType, 0.5f );
var rest = new CharacterPose( Skeleton, Skeleton.Bones.Select( b => b.RestLocal ).ToArray() );
bool Moved( int bone ) => System.Numerics.Vector3.Distance( pose.World[bone].Pos, rest.World[bone].Pos ) > 1f;
return Moved( rig.Right.Hand ) || (rig.Left is { } left && Moved( left.Hand ));
}
/// <summary>Settle time before an action is triggered (the holdtype idle blends in first).</summary>
public const float ActionLeadIn = 0.6f;
// Scene clock of the track sampler (kept monotonic across calls).
private float _clock = 1000f;
/// <summary>
/// Plays one action through the animgraph (trigger fired after <see cref="ActionLeadIn"/>)
/// and samples the pose at each normalized time of an action lasting <paramref name="seconds"/>,
/// in a single pass (60 Hz), the way <c>WeaponHold</c> times it at runtime.
/// </summary>
public List<(float Time, CharacterPose Pose)> SampleTrack( int holdType, string trigger, float seconds, int count )
{
var result = new List<(float, CharacterPose)>( count );
count = Math.Max( 2, count );
const float step = 1f / 60f;
using ( _scene.Push() )
{
// Fresh graph state: reset every parameter and let any earlier action finish, so the
// action starts from the settled holdtype idle.
_body.SceneModel?.ResetAnimParameters();
ApplyParameters( _body, holdType );
var clock = _clock;
for ( var i = 0; i < 150; i++ )
{
clock += step;
_scene.EditorTick( clock, step );
}
var now = 0f;
var next = 0;
var end = ActionLeadIn + seconds + step;
var fired = false;
while ( now <= end && next < count )
{
if ( !fired && trigger is not null && now >= ActionLeadIn - step * 0.5f )
{
_body.Set( trigger, true );
fired = true;
}
now += step;
clock += step;
_scene.EditorTick( clock, step );
var t = next / (float)(count - 1);
if ( now >= ActionLeadIn + t * seconds - step * 0.5f )
{
result.Add( (t, CharacterLibrary.Sample( _body, Skeleton )) );
next++;
}
}
_clock = clock;
}
return result;
}
/// <summary>
/// How long the character's own action runs after its trigger: the time until both hands are
/// back at their idle place (in the hold bone's frame) and stay there. 0 when the trigger
/// doesn't move the hands at all.
/// </summary>
public float MeasureAction( int holdType, string trigger, float maxSeconds = 5f )
{
if ( trigger is null )
return 0f;
const float step = 1f / 60f;
var hold = Model.Bones.GetBone( "hold_R" );
var left = Model.Bones.GetBone( "hand_L" );
var right = Model.Bones.GetBone( "hand_R" );
if ( left is null || right is null )
return 0f;
using ( _scene.Push() )
{
_body.SceneModel?.ResetAnimParameters();
ApplyParameters( _body, holdType );
var clock = _clock;
for ( var i = 0; i < 150; i++ )
{
clock += step;
_scene.EditorTick( clock, step );
}
(Vector3 L, Vector3 R) Hands()
{
var root = _body.WorldTransform;
_body.TryGetBoneTransformAnimation( left, out var l );
_body.TryGetBoneTransformAnimation( right, out var r );
// The body moves (steps, leans) during actions: measure hands against the chest.
var frame = hold is not null && _body.TryGetBoneTransformAnimation( hold, out var h ) ? root.ToLocal( h ) : Transform.Zero;
return (frame.PointToLocal( root.PointToLocal( l.Position ) ), frame.PointToLocal( root.PointToLocal( r.Position ) ));
}
var idle = Hands();
_body.Set( trigger, true );
var last = 0f;
var moved = false;
for ( var now = 0f; now < maxSeconds; now += step )
{
clock += step;
_scene.EditorTick( clock, step );
var (l, r) = Hands();
var deviation = MathF.Max( (l - idle.L).Length, (r - idle.R).Length );
if ( deviation > 1.0f )
{
last = now;
moved = true;
}
}
_clock = clock;
return moved ? last + step : 0f;
}
}
/// <summary>Animgraph parameters for a weapon held at the ready, aiming forward.</summary>
public static void ApplyParameters( SkinnedModelRenderer body, WeaponType type ) => ApplyParameters( body, WeaponTypes.HoldType( type ) );
/// <summary>Settles a weapon type's default hold.</summary>
public CharacterPose Sample( WeaponType type ) => Sample( WeaponTypes.HoldType( type ) );
/// <summary>Animgraph parameters for holding with <paramref name="holdType"/> (the citizen holdtype enum), aiming forward.</summary>
public static void ApplyParameters( SkinnedModelRenderer body, int holdType )
{
body.Set( "holdtype", holdType );
body.Set( "holdtype_handedness", 0 );
body.Set( "b_grounded", true );
body.Set( "move_x", 0f );
body.Set( "move_y", 0f );
body.Set( "move_speed", 0f );
body.Set( "move_groundspeed", 0f );
body.SetLookDirection( "aim_eyes", Vector3.Forward, 1f );
body.SetLookDirection( "aim_head", Vector3.Forward, 1f );
body.SetLookDirection( "aim_body", Vector3.Forward, 1f );
}
public void Dispose()
{
_scene?.Destroy();
_scene = null;
}
}