Editor utility that ports an AnimGraph2 source graph onto a target model. It writes the target skeleton, per-clip DMX/skel/anim sources, reuses matching recovered sequences, rebases the graph, and waits for the editor to compile generated assets.
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.EditorTools.Embedded.Datamodel;
using Sandbox;
namespace HumanoidRetargeter.EditorTools;
/// <summary>
/// The AnimGraph2 half of "Use the source's setup": the target's own skeleton, a clip per animation the source's graph
/// plays and that graph rebased onto them (see <see cref="SmartPortAnimGraph2Port"/>). A clip the source model also has
/// as a sequence reuses that sequence's retargeted file; any other is sampled from its compiled clip and retargeted the
/// same way.
/// </summary>
internal static class SmartPortAnimGraph2Setup
{
// A one-frame clip reports no frame rate, but its DMX needs one
const float SingleFrameRate = 30f;
// How long to wait between asking for compiles the editor refused
const int CompileRetryMilliseconds = 2000;
// Root motion smaller than this (engine units, degrees) is no root motion: the Human's crouch clips drift a tenth of a
// unit, and extracting that axis would take the pelvis' sway along it out of the pose
const float MotionTolerance = 1f;
// A sequence holds the same animation as a clip when its sampled bones are this close (engine units, radians): the
// two compilers compress differently
const float PositionTolerance = .05f;
const float RotationTolerance = .01f;
/// <summary>
/// The rig an AnimGraph2 source's clips are retargeted onto <paramref name="target"/> with: <paramref name="source"/>'s
/// skeleton, the IK goals <paramref name="graph"/> (its <c>.ag</c>) reaches for whose bones it has, and the attachment
/// bones of <paramref name="compiledSource"/> (its compiled model). Throws <see cref="ArgumentException"/> when the two
/// don't map.
/// </summary>
internal static SmartPortRig Rig( Model source, HumanoidRetargeter.Core.Skeleton.Skeleton target, string graph, string compiledSource )
=> new( TargetPickers.SkeletonFromModel( source ), target, IkTargets( source, SmartPortAnimGraph2Port.IkTargets( graph ) ),
SmartPortAttachments.BoneNames( compiledSource ) );
// The IK goals both bones of which the source model has, by its own bone names
static IReadOnlyDictionary<string, string> IkTargets( Model source, IReadOnlyDictionary<string, string> goals )
{
var bones = source.Bones.AllBones.ToDictionary( b => b.Name, b => b.Name, StringComparer.OrdinalIgnoreCase );
return goals.Where( g => bones.ContainsKey( g.Key ) && bones.ContainsKey( g.Value ) ).ToDictionary( g => bones[g.Key], g => bones[g.Value], StringComparer.Ordinal );
}
/// <summary>
/// Writes the target's skeleton and clips under <paramref name="dataFolder"/> and returns <paramref name="graph"/> (the
/// source's <c>.ag</c>) rebased onto them for <paramref name="modelPath"/>, with the skeleton and clip sources written.
/// <paramref name="vmdl"/> is the ported model, whose animation files are the recovered, retargeted sequences of
/// <paramref name="compiledSource"/> (the source's compiled model); both are null when there are no such sequences, and
/// every clip is sampled. A file that already holds what would be written is left as it is, so running this again
/// leaves it, and its compiled file, alone. Throws when the source graph's skeleton or a clip it plays doesn't load.
/// </summary>
internal static async Task<(string Graph, IReadOnlyList<string> Sources)> WriteAsync( string graph, string modelPath, string vmdl, string compiledSource, string dataFolder,
string name, SmartPortRig rig, CancellationToken token )
{
await EditorPipeline.SwitchToMainThread();
var folder = dataFolder + "/animgraph2";
var skeletonPath = AnimGraph2Document.SkeletonOf( graph );
var compiledSkeleton = AssetSystem.FindByPath( skeletonPath )?.GetCompiledFile( true );
if ( string.IsNullOrEmpty( compiledSkeleton ) || !File.Exists( compiledSkeleton ) )
throw new InvalidOperationException( "The source graph's skeleton has no compiled file: " + skeletonPath );
var export = new SmartPortAnimationExport( rig );
var bindPose = folder + "/" + name + "_skeleton.dmx";
var additiveBase = folder + "/additive_base.dmx";
var skeleton = folder + "/" + name + ".skel";
Directory.CreateDirectory( CitizenAnimationModels.ProjectFile( folder + "/clips" ) );
await WriteIfChangedAsync( bindPose, Dmx( bindPose, () => export.Write( identity: false ) ), token );
await WriteIfChangedAsync( additiveBase, Dmx( additiveBase, () => export.Write( identity: true ) ), token );
var masks = CompiledAssetRecovery.ReadData( compiledSkeleton, skeletonPath );
await WriteIfChangedAsync( skeleton, Encoding.UTF8.GetBytes(
SmartPortAnimGraph2Port.Skeleton( bindPose, masks, rig.BoneNames, rig.Target.Bones.Select( b => b.Name ).ToArray() ) ), token );
var sequences = vmdl is null ? new Dictionary<string, string>() : SmartPortAnimGraph2.AnimationFiles( vmdl );
var samples = compiledSource is null ? new Dictionary<string, (bool, int, XForm[][])>()
: await Task.Run( () => SmartPortAnimationExport.ReadSamples( compiledSource, rig ), token );
await EditorPipeline.SwitchToMainThread();
var animations = new Dictionary<string, string>( StringComparer.Ordinal );
var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
var invalid = Path.GetInvalidFileNameChars();
foreach ( var animation in AnimGraph2Document.Animations( AnimGraph2Document.CopyForModel( graph, modelPath ) ) )
{
token.ThrowIfCancellationRequested();
var clip = SourceClip.Load( animation );
var stem = AnimGraph2Document.AnimationStem( animation );
// Lowercase, like the compiled files the engine writes beside them
var file = string.Concat( stem.ToLowerInvariant().Select( c => invalid.Contains( c ) ? '_' : c ) );
for ( var suffix = 2; !files.Add( file ); suffix++ ) file = stem.ToLowerInvariant() + "_" + suffix;
// A sequence of the source model's with the clip's name and data is already recovered and retargeted
var source = sequences.TryGetValue( stem, out var sequence ) && samples.TryGetValue( stem, out var sample ) && clip.Matches( sample, rig.Source )
? sequence : folder + "/clips/" + file + ".dmx";
if ( source != sequence )
{
var fps = clip.FrameRate > 0 ? clip.FrameRate : SingleFrameRate;
var frames = clip.Sample( rig.Source );
var movement = clip.Movement( fps );
var dmx = await Task.Run( () => Dmx( source, () => export.Write( frames, fps, clip.IsAdditive, movement ) ), token );
await EditorPipeline.SwitchToMainThread();
await WriteIfChangedAsync( source, dmx, token );
}
var anim = folder + "/clips/" + file + ".anim";
// The clip's events (footsteps) are in its compiled data; the sequence's are in the model, which AnimGraph2 doesn't read
var compiledClip = AssetSystem.FindByPath( animation )?.GetCompiledFile( true );
var events = string.IsNullOrEmpty( compiledClip ) || !File.Exists( compiledClip ) ? null : CompiledAssetRecovery.ReadData( compiledClip, animation );
await WriteIfChangedAsync( anim, Encoding.UTF8.GetBytes(
SmartPortAnimGraph2Port.Clip( source, skeleton, clip.IsAdditive ? additiveBase : null, events, clip.RootMotionAxes() ) ), token );
animations[animation] = anim;
}
return (SmartPortAnimGraph2Port.Rebase( graph, modelPath, skeleton, animations, rig.BoneNames ), animations.Values.Prepend( skeleton ).ToArray());
}
// A DMX with element ids derived from its path rather than random, so the same content writes the same bytes
static byte[] Dmx( string path, Func<byte[]> write )
{
using var ids = ElementIds.Deterministic( path );
return write();
}
// Writes a generated source (asset path) unless it already holds exactly this, which keeps its compiled file current
static async Task WriteIfChangedAsync( string path, byte[] content, CancellationToken token )
{
var file = CitizenAnimationModels.ProjectFile( path );
if ( File.Exists( file ) && new FileInfo( file ).Length == content.Length && (await File.ReadAllBytesAsync( file, token )).AsSpan().SequenceEqual( content ) )
return;
await File.WriteAllBytesAsync( file, content, token );
}
/// <summary>
/// Of <paramref name="sources"/> (asset paths), those that still have no current compiled file after
/// <paramref name="timeoutSeconds"/>. The editor refuses compiles while its own content build runs, which hundreds of
/// new clips start, and that build compiles them in the meantime: keep asking until each is compiled or the time is up.
/// Sources a run left unchanged are current already and cost nothing.
/// </summary>
internal static async Task<string[]> UncompiledAsync( IReadOnlyList<string> sources, float timeoutSeconds, CancellationToken token )
{
await EditorPipeline.SwitchToMainThread();
var watch = System.Diagnostics.Stopwatch.StartNew();
string[] missing;
while ( (missing = sources.Where( path => !IsCompiled( path ) ).ToArray()).Length > 0 && watch.Elapsed.TotalSeconds < timeoutSeconds )
{
// Dependency order: the skeleton before the clips compiled against it
foreach ( var path in missing.OrderBy( p => p.EndsWith( ".skel", StringComparison.OrdinalIgnoreCase ) ? 0 : 1 ) )
if ( (AssetSystem.FindByPath( path ) ?? AssetSystem.RegisterFile( CitizenAnimationModels.ProjectFile( path ) )) is { } asset )
await asset.CompileIfNeededAsync();
await Task.Delay( CompileRetryMilliseconds, token );
}
return missing;
}
// Whether the compiled file beside the source is newer than every file it is compiled from; the engine writes
// compiled names in lowercase
static bool IsCompiled( string path )
{
var file = CitizenAnimationModels.ProjectFile( path );
var folder = Path.GetDirectoryName( file );
var compiled = Directory.Exists( folder ) ? Directory.EnumerateFiles( folder, Path.GetFileName( file ) + "_c",
new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive } ).FirstOrDefault() : null;
if ( compiled is null ) return false;
var stamp = File.GetLastWriteTimeUtc( compiled );
return Inputs( path ).All( input => File.GetLastWriteTimeUtc( input ) <= stamp );
}
// The project files a skeleton or clip source (asset path) is compiled from: itself, the DMX it imports and, for a
// clip, its additive base and its skeleton's inputs. Files outside the project don't change
static IEnumerable<string> Inputs( string path )
{
var file = Path.Combine( Project.Current.GetAssetsPath(), path );
if ( !File.Exists( file ) ) yield break;
yield return file;
if ( !path.EndsWith( ".skel", StringComparison.OrdinalIgnoreCase ) && !path.EndsWith( ".anim", StringComparison.OrdinalIgnoreCase ) ) yield break;
var source = (KvObject)Kv3.Parse( File.ReadAllText( file ) ).Root;
foreach ( var key in new[] { "m_sourceFilename", "m_additiveBaseFilename", "m_animationSkeletonName" } )
if ( source.GetString( key ) is { Length: > 0 } input )
foreach ( var dependency in Inputs( input ) )
yield return dependency;
}
/// <summary>A compiled AnimGraph2 clip (<c>Sandbox.Animation.Clip</c>), reached by reflection: older engines have none.</summary>
sealed class SourceClip
{
Func<float, int, Transform> _local;
Func<float, Transform> _rootMotion;
Dictionary<string, int> _bones;
public bool IsAdditive { get; private init; }
public int FrameCount { get; private init; }
public float FrameRate { get; private init; }
public float Duration { get; private init; }
public static SourceClip Load( string path )
{
var type = typeof( Model ).Assembly.GetType( "Sandbox.Animation.Clip" ) ?? throw new InvalidOperationException( "This engine has no AnimGraph2 clips." );
var clip = type.GetMethod( "Load", new[] { typeof( string ) } )?.Invoke( null, new object[] { path } );
T Get<T>( object owner, string property ) => (T)owner.GetType().GetProperty( property )!.GetValue( owner )!;
if ( clip is null || Get<bool>( clip, "IsError" ) ) throw new InvalidOperationException( "A clip the source graph plays did not load: " + path );
var skeleton = Get<object>( clip, "Skeleton" );
var bones = new Dictionary<string, int>( StringComparer.Ordinal );
var index = 0;
foreach ( var bone in Get<System.Collections.IEnumerable>( skeleton, "Bones" ) )
bones[Get<string>( bone, "Name" )] = index++;
return new SourceClip
{
_local = (Func<float, int, Transform>)Delegate.CreateDelegate( typeof( Func<float, int, Transform> ), clip, type.GetMethod( "GetLocalTransform" )! ),
_rootMotion = (Func<float, Transform>)Delegate.CreateDelegate( typeof( Func<float, Transform> ), clip, type.GetMethod( "GetRootMotion" )! ),
_bones = bones,
IsAdditive = Get<bool>( clip, "IsAdditive" ),
FrameCount = Math.Max( Get<int>( clip, "FrameCount" ), 1 ),
FrameRate = Get<float>( clip, "FrameRate" ),
Duration = Get<float>( clip, "Duration" ),
};
}
// The clip API samples by cycle, 0 to 1 over the clip
float Cycle( int frame ) => FrameCount > 1 ? frame / (float)(FrameCount - 1) : 0;
/// <summary>Whether a sequence's <paramref name="sample"/> (see <see cref="SmartPortAnimationExport.ReadSamples"/>) holds
/// this clip's animation: as additive, as long, and posed the same at the sampled frames.</summary>
public bool Matches( (bool Additive, int FrameCount, XForm[][] Poses) sample, HumanoidRetargeter.Core.Skeleton.Skeleton skeleton )
{
if ( sample.Additive != IsAdditive || sample.FrameCount != FrameCount ) return false;
var frames = SmartPortAnimationExport.SampleFrames( FrameCount );
for ( var f = 0; f < frames.Length; f++ )
for ( var b = 0; b < skeleton.Count; b++ )
{
if ( !_bones.TryGetValue( skeleton[b].Name, out var index ) ) continue;
var pose = ToXForm( _local( Cycle( frames[f] ), index ) );
var other = sample.Poses[f][b];
var dot = MathF.Min( MathF.Abs( System.Numerics.Quaternion.Dot( pose.Rot, other.Rot ) ), 1 );
if ( System.Numerics.Vector3.Distance( pose.Pos, other.Pos ) > PositionTolerance || 2 * MathF.Acos( dot ) > RotationTolerance ) return false;
}
return true;
}
/// <summary>Each frame's local transforms in <paramref name="skeleton"/>'s bone order (deltas for an additive clip);
/// bones the clip lacks keep their bind pose, or no change.</summary>
public XForm[][] Sample( HumanoidRetargeter.Core.Skeleton.Skeleton skeleton )
{
var indices = skeleton.Bones.Select( b => _bones.TryGetValue( b.Name, out var i ) ? i : -1 ).ToArray();
var frames = new XForm[FrameCount][];
for ( var f = 0; f < FrameCount; f++ )
{
var cycle = Cycle( f );
frames[f] = new XForm[indices.Length];
for ( var b = 0; b < indices.Length; b++ )
frames[f][b] = indices[b] < 0 ? (IsAdditive ? XForm.Identity : skeleton[b].RestLocal) : ToXForm( _local( cycle, indices[b] ) );
}
return frames;
}
/// <summary>The root motion (position, yaw in degrees) at a time in seconds for a clip sampled at <paramref name="fps"/>,
/// or null when the clip has none. Sampled here: the DMX is written off the main thread.</summary>
public Func<float, (System.Numerics.Vector3 Position, float Angle)> Movement( float fps )
{
var motion = RootMotion();
if ( RootMotionAxes() is null ) return null;
return time => motion[Math.Clamp( (int)MathF.Round( time * fps ), 0, motion.Length - 1 )];
}
/// <summary>The axes the clip's root motion moves along, or null when it has none.</summary>
public (bool X, bool Y, bool Z, bool Yaw)? RootMotionAxes()
{
var motion = RootMotion();
bool Moves( Func<(System.Numerics.Vector3 Position, float Angle), float> axis ) => motion.Any( m => MathF.Abs( axis( m ) ) > MotionTolerance );
var axes = (Moves( m => m.Position.X ), Moves( m => m.Position.Y ), Moves( m => m.Position.Z ), Moves( m => m.Angle ));
return axes is (false, false, false, false) ? null : axes;
}
(System.Numerics.Vector3 Position, float Angle)[] _motion;
(System.Numerics.Vector3 Position, float Angle)[] RootMotion()
=> _motion ??= Enumerable.Range( 0, FrameCount ).Select( f => _rootMotion( Cycle( f ) ) )
.Select( m => (new System.Numerics.Vector3( m.Position.x, m.Position.y, m.Position.z ), m.Rotation.Yaw()) ).ToArray();
static XForm ToXForm( Transform transform ) => new( new System.Numerics.Vector3( transform.Position.x, transform.Position.y, transform.Position.z ),
new System.Numerics.Quaternion( transform.Rotation.x, transform.Rotation.y, transform.Rotation.z, transform.Rotation.w ) );
}
}