Editor/RigControlEditor/RigPoseCapture.cs
using Editor;
using Marionette;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Marionette.Tools;
/// <summary>
/// Takes a pose the model's own animation graph is holding and writes it into a .riganim as
/// keyframes — the bridge from "the engine already knows how to stand/sit/aim like this" to
/// "now animate away from it by hand".
///
/// rig_capture_pose <model> <output.riganim> [params] [props] [frame]
///
/// The case it was written for is sitting. s&box's BaseChair and SitMoveMode drive citizen's
/// graph into a sit, and that pose is a good hundred numbers nobody wants to author twice — but
/// there is no way to get at it from Marionette, which only ever poses bones by hand. So this
/// runs the graph in a scratch scene, waits for the blend to finish, and reads the result off the
/// skeleton in exactly the parent-space form a keyframe stores.
///
/// WHY IT DOES NOT READ THE LIVE SCENE. Sampling the editor's own scene would mean pressing play
/// in it, and a scene that has been played is not the scene that was saved. A scratch scene has
/// no such cost and gets an identical answer: the graph is deterministic given its parameters.
///
/// WHAT COMES OUT IS FK, NOT A REFERENCE TO THE CLIP. The keyframes are baked. Change citizen's
/// sit animation later and this clip keeps the pose it captured, which is the point — a starting
/// pose that moves under you is worse than no starting pose.
///
/// Examples, from the editor console:
///
/// rig_capture_pose models/effigy/gearhead_citizen.vmdl animations/gearhead_sit.riganim "sit=1"
///
/// rig_capture_pose models/effigy/gearhead_citizen.vmdl animations/gearhead_sit.riganim "sit=1" \
/// "models/effigy/broadcast_chair.vmdl;models/effigy/broadcast_desk.vmdl@15,0,0"
///
/// The second adds the chair and the desk as reference props, placed where the scene puts them
/// relative to his feet, so the clip opens with something to aim his hands at rather than empty
/// space.
/// </summary>
internal static class RigPoseCapture
{
/// <summary>How long to let the graph run before reading the pose, in ticks at 60hz — three
/// seconds. Generous: ticks are cheap, and the cost of stopping one short is a pose captured
/// mid-blend, which looks plausible and is wrong everywhere.</summary>
private const int SettleTicks = 180;
/// <summary>How many ticks to give the renderer to build its bone array before giving up. It
/// is usually one; how many it really takes is an engine detail and clearly differs per model
/// (see RigWaveBuilder.WaitForBones).</summary>
private const int MaxSetupTicks = 64;
/// <summary>How still the skeleton has to be, in inches of total movement across every bone
/// in one tick, to stop waiting early.
///
/// EARLY EXIT, NOT A REQUIREMENT. A living idle never goes still — citizen's graph breathes
/// and sways under every pose it holds, including the sit — so waiting for stillness waits
/// for ever. This only saves time on a graph that does hold perfectly still.</summary>
private const float SettleEpsilon = 0.002f;
/// <summary>Consecutive still ticks needed to take that early exit. More than one because a
/// blend can pass through a momentary standstill at the top of an ease.</summary>
private const int StillTicks = 10;
// CONSOLE ONLY, NO [Menu] — same reasoning as RigSampleBuilder.Build. It takes a model path
// and a parameter string, which is not a thing a menu item can ask for, and a menu entry that
// opens a dialog to collect them is a tool rather than the one-liner this is.
[ConCmd( "rig_capture_pose" )]
public static void Capture( string modelPath, string output, string parameters = "",
string props = "", int frame = 0 )
{
if ( string.IsNullOrWhiteSpace( modelPath ) || string.IsNullOrWhiteSpace( output ) )
{
Log.Error( "[capture] usage: rig_capture_pose <model> <output.riganim> [params] [props] [frame]" );
return;
}
var model = Model.Load( modelPath );
if ( model?.Bones is null )
{
Log.Error( $"[capture] couldn't load {modelPath}" );
return;
}
if ( frame < 0 )
{
Log.Error( $"[capture] frame {frame} is before the start of the clip" );
return;
}
var scene = Scene.CreateEditorScene();
try
{
using var scope = scene.Push();
var renderer = new GameObject( true, "capture" ).GetOrAddComponent<SkinnedModelRenderer>( false );
renderer.Model = model;
// The whole point: the graph is what holds the pose. RigSampleBuilder and
// RigWaveBuilder both turn this OFF, because they author poses themselves and a graph
// would fight them for the skeleton. Here it is the source.
renderer.UseAnimGraph = true;
renderer.Enabled = true;
var wanted = ParseParameters( parameters );
if ( !Settle( scene, renderer, model, wanted, out var world ) )
{
Log.Error( $"[capture] no bone of {modelPath} ever read back a pose, after " +
$"{MaxSetupTicks} ticks. The model loaded, so this is the renderer never " +
$"building its bone array rather than a naming problem — and it reads from " +
$"the outside as every bone sitting at the origin." );
return;
}
Landmarks( world );
var doc = Open( output, model, out var asset );
if ( doc is null )
return;
// Parent space, not world: that is the form a keyframe stores, and the conversion is
// the same one RigViewport.TryGetLocalTransform does when you key a bone by hand.
// Skipped for a bone whose parent didn't read back, rather than falling back to the
// model's transform — a root's parent IS the model, but a child's silently is not,
// and that mistake puts a limb across the room.
var captured = 0;
foreach ( var bone in model.Bones.AllBones )
{
if ( !world.TryGetValue( bone.Name, out var boneWorld ) )
continue;
var parentWorld = bone.Parent is { } parent
? world.TryGetValue( parent.Name, out var p ) ? p : (Transform?)null
: renderer.WorldTransform;
if ( parentWorld is not { } into )
{
Log.Warning( $"[capture] skipped {bone.Name}: its parent {bone.Parent?.Name} never read back" );
continue;
}
doc.GetOrAddTrack( bone.Name ).SetKeyframe( frame, into.ToLocal( boneWorld ) );
captured++;
}
// A clip whose FrameCount is behind the frame just keyed has a keyframe past the end
// of its own timeline, which reads in the editor as the capture having done nothing.
if ( doc.FrameCount <= frame )
doc.FrameCount = frame + 1;
foreach ( var prop in ParseProps( props ) )
{
// Replaced by name rather than appended, so re-running the command with a moved
// desk moves the desk instead of stacking a second one on top of the first.
doc.ReferenceProps.RemoveAll( p => p?.Name == prop.Name );
doc.ReferenceProps.Add( prop );
}
asset.SaveToDisk( doc );
Log.Info( $"[capture] wrote {output} — {captured} bones keyed at frame {frame}" +
( wanted.Count > 0 ? $", graph at {string.Join( ", ", wanted.Select( p => $"{p.Key}={p.Value}" ) )}" : "" ) +
( doc.ReferenceProps.Count > 0 ? $", {doc.ReferenceProps.Count} reference props" : "" ) );
}
catch ( Exception e )
{
Log.Error( $"[capture] threw: {e}" );
}
finally
{
scene.Destroy();
}
}
/// <summary>
/// Let the graph run itself in, and hand back the world pose it arrives at.
///
/// A GRAPH BLENDS, so this cannot sample on the frame the parameter is set. Ask citizen for a
/// sit and it takes the better part of a second to get there; sample early and you get a
/// real, readable, completely wrong pose — the standing one, or a frame somewhere between.
/// That is the same trap BroadcastChair.Measure hit when it measured the fit on the frame of
/// the press, and it is the failure this method exists to avoid.
///
/// AND A GRAPH NEVER FULLY STOPS. The first version of this waited for the skeleton to go
/// still and gave up after four hundred ticks having never seen it, because citizen's idle
/// breathes and sways under every pose it holds — the sit included. So the wait is a fixed
/// three seconds, long past any blend, with an early exit for the rare graph that does hold
/// perfectly still. Whatever residual sway is left gets logged rather than hidden: a pose
/// captured on an inhale is a fine starting pose, but you should be told that is what it is.
/// </summary>
private static bool Settle( Scene scene, SkinnedModelRenderer renderer, Model model,
Dictionary<string, string> parameters, out Dictionary<string, Transform> world )
{
world = new Dictionary<string, Transform>();
var previous = new Dictionary<string, Vector3>();
var still = 0;
var setup = 0;
var moved = 0f;
var readable = 0;
for ( var tick = 0; tick < MaxSetupTicks + SettleTicks; tick++ )
{
// Re-applied every tick rather than once at the start. Some graph parameters are
// declared auto-reset, and a parameter set before the graph has finished its own
// setup is a parameter set on nothing.
Apply( renderer, parameters );
scene.EditorTick( ( tick + 1 ) / 60f, 1f / 60f );
var current = new Dictionary<string, Transform>();
moved = 0f;
readable = 0;
foreach ( var bone in model.Bones.AllBones )
{
if ( !renderer.TryGetBoneTransform( bone, out var boneWorld ) )
continue;
current[bone.Name] = boneWorld;
readable++;
if ( previous.TryGetValue( bone.Name, out var was ) )
moved += ( boneWorld.Position - was ).Length;
previous[bone.Name] = boneWorld.Position;
}
// Every bone at the origin is what an unbuilt bone array looks like, and it is
// perfectly still — so the setup wait cannot be "did anything read back", it has to
// be "did anything read back somewhere other than the origin".
var spread = current.Count > 0 ? current.Values.Max( t => t.Position.Length ) : 0f;
if ( readable == 0 || spread < 0.001f )
{
if ( ++setup >= MaxSetupTicks )
return false;
still = 0;
continue;
}
world = current;
// Only count stillness once the blend has had time to be over, or a graph that
// happens to start where it is going exits on its first tick.
if ( tick > 30 && moved < SettleEpsilon && ++still >= StillTicks )
{
Log.Info( $"[capture] the graph went still after {tick + 1} ticks, " +
$"{readable} of {model.Bones.AllBones.Count()} bones readable" );
return true;
}
if ( moved >= SettleEpsilon )
still = 0;
}
Log.Info( $"[capture] ran the graph {MaxSetupTicks + SettleTicks} ticks, {readable} of " +
$"{model.Bones.AllBones.Count()} bones readable. Still drifting {moved:0.000}in per " +
$"tick across the whole skeleton when sampled — that is the idle breathing, not an " +
$"unfinished blend, unless it is a large number." );
return readable > 0;
}
/// <summary>
/// The few heights worth knowing about the pose that was just taken, in the model's own
/// space — which is the space a reference prop's offset is in.
///
/// Here because a captured sit is the one pose whose props have to be placed against the
/// FIGURE rather than against the floor. A chair placed at z 0 with its seat at 18.4 is
/// correct as furniture and wrong under this pose unless the pose happens to seat him at
/// 18.4 too, and the gap is a couple of inches of him sunk into the cushion — small enough
/// to look like bad weighting and be chased for an hour. These numbers make it arithmetic.
/// </summary>
private static void Landmarks( Dictionary<string, Transform> world )
{
if ( world.Count == 0 )
return;
var lowest = world.Values.Min( t => t.Position.z );
var highest = world.Values.Max( t => t.Position.z );
var hips = world.FirstOrDefault( b =>
b.Key.Equals( "pelvis", StringComparison.OrdinalIgnoreCase )
|| b.Key.Equals( "hips", StringComparison.OrdinalIgnoreCase ) );
Log.Info( $"[capture] pose spans z {lowest:0.0} to {highest:0.0}" +
( hips.Key is null ? "" : $", {hips.Key} at z {hips.Value.Position.z:0.0}, " +
$"x {hips.Value.Position.x:0.0} — a seat belongs a little under that" ) );
// The bones nearest the floor, named. On a sit that is the feet and the thighs, and the
// thigh root is the closest thing the skeleton has to "where the cushion is" — a seat
// height read off the pose rather than assumed from the chair.
var low = world.OrderBy( b => b.Value.Position.z ).Take( 6 )
.Select( b => $"{b.Key} {b.Value.Position.z:0.0}" );
Log.Info( "[capture] lowest bones: " + string.Join( ", ", low ) );
}
/// <summary>Push the requested graph parameters at the renderer, typed by what they look
/// like: true/false is a bool, a whole number an int, anything else a float. An animgraph
/// enum — "sit" is one — takes its index as an int.</summary>
private static void Apply( SkinnedModelRenderer renderer, Dictionary<string, string> parameters )
{
foreach ( var (name, value) in parameters )
{
if ( bool.TryParse( value, out var flag ) )
renderer.Set( name, flag );
else if ( int.TryParse( value, out var whole ) )
renderer.Set( name, whole );
else if ( float.TryParse( value, out var number ) )
renderer.Set( name, number );
else
Log.Warning( $"[capture] don't know what type \"{name}={value}\" is - skipped" );
}
}
/// <summary>"sit=1,sit_offset_height=0" - comma separated, because a console command's
/// argument is one string and spaces would end it.</summary>
private static Dictionary<string, string> ParseParameters( string text )
{
var parsed = new Dictionary<string, string>();
if ( string.IsNullOrWhiteSpace( text ) )
return parsed;
foreach ( var pair in text.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) )
{
var split = pair.Split( '=', 2 );
if ( split.Length != 2 )
{
Log.Warning( $"[capture] \"{pair}\" isn't name=value - skipped" );
continue;
}
parsed[split[0].Trim()] = split[1].Trim();
}
return parsed;
}
/// <summary>
/// "path.vmdl;path.vmdl@x,y,z;path.vmdl@x,y,z@pitch,yaw,roll" - semicolons between props,
/// because commas are already spoken for by the vectors inside one.
///
/// The offsets are relative to the MODEL'S ORIGIN, which for a playermodel is between his
/// feet on the floor. That is the same frame the scene places the furniture in relative to
/// him, so the numbers can be copied straight across rather than re-derived.
/// </summary>
private static IEnumerable<ReferenceProp> ParseProps( string text )
{
if ( string.IsNullOrWhiteSpace( text ) )
yield break;
foreach ( var entry in text.Split( ';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) )
{
var fields = entry.Split( '@' );
var path = fields[0].Trim();
var model = Model.Load( path );
if ( model is null )
{
Log.Warning( $"[capture] couldn't load prop {path} - skipped" );
continue;
}
var prop = new ReferenceProp
{
Name = System.IO.Path.GetFileNameWithoutExtension( path ),
Model = model
};
if ( fields.Length > 1 && Vector3.TryParse( fields[1], out var position ) )
prop.Position = position;
if ( fields.Length > 2 && Angles.TryParse( fields[2], out var angles ) )
prop.Rotation = angles;
yield return prop;
}
}
/// <summary>
/// The clip to write into: the one already at that path, or a new one.
///
/// Reused rather than always created so a second capture at a different frame lands in the
/// same clip - "sit at 0, lean forward at 30" is two runs of this command, and a version that
/// clobbered would make the second one throw the first away.
/// </summary>
private static RigAnimDocument Open( string output, Model model, out Asset asset )
{
asset = null;
if ( Project.Current?.GetAssetsPath() is not { } assetsPath )
{
Log.Error( "[capture] no current project" );
return null;
}
var relative = output.EndsWith( ".riganim", StringComparison.OrdinalIgnoreCase )
? output
: output + ".riganim";
var absolute = System.IO.Path.Combine( assetsPath,
relative.Replace( '/', System.IO.Path.DirectorySeparatorChar ) );
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( absolute ) );
if ( AssetSystem.FindByPath( relative ) is { } existing
&& existing.TryLoadResource<RigAnimDocument>( out var doc ) && doc is not null )
{
asset = existing;
// A clip captured against one model and then re-captured against another is almost
// certainly a typo, and silently mixing two skeletons' tracks in one document is not
// a thing anyone would ask for on purpose.
if ( doc.SourceModel is not null && doc.SourceModel != model )
{
Log.Error( $"[capture] {relative} is a clip for {doc.SourceModel.ResourcePath}, " +
$"not {model.ResourcePath}. Capture into a different file, or delete that one first." );
return null;
}
doc.SourceModel = model;
return doc;
}
asset = AssetSystem.CreateResource( "riganim", absolute );
if ( asset is null )
{
Log.Error( $"[capture] couldn't create {absolute}" );
return null;
}
return new RigAnimDocument { SourceModel = model };
}
}