Editor/WeaponImporter/Tool/Engine/CorrectedAnimationBaker.cs
using Sandbox;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Generation;
using WeaponImporter.Core.Maths;
using WeaponImporter.Core.Output;
using WeaponImporter.Core.Rig;
using WeaponImporter.Core.Setup;
namespace WeaponImporter.Tool;
/// <summary>
/// Bakes the corrected third-person animation: each character action is played in a hidden
/// scene with the same Weapon Hold the game uses, the corrected pose is recorded every frame and
/// written as new clips on a model based on the character (<c><name>_corrected.vmdl</c>).
/// The character's own animations are never changed.
/// </summary>
public static class CorrectedAnimationBaker
{
public const float Fps = 30f;
public sealed class Result
{
public string ModelPath { get; set; } = "";
public List<(AnimationRole Role, string Sequence)> Clips { get; } = new();
public List<string> Files { get; } = new();
public List<string> Errors { get; } = new();
}
/// <summary>Actions baked: the idle loop and every action the character triggers itself.</summary>
public static readonly AnimationRole[] Roles = { AnimationRole.Idle, AnimationRole.Fire, AnimationRole.Reload, AnimationRole.Draw };
public static async Task<Result> BakeAsync( ImportSession session, string folder, string name, IProgress<string> progress, CancellationToken cancel )
{
await EditorThread.SwitchToMainThread();
var result = new Result();
var setup = session.Setup;
var characterPath = session.CharacterModel;
var model = Model.Load( characterPath );
if ( model is null || model.IsError )
{
result.Errors.Add( $"character '{characterPath}' could not be loaded" );
return result;
}
var skeleton = CharacterLibrary.SkeletonOf( model );
var generated = $"{folder}/generated";
var vmdl = new VmdlBuilder { BaseModel = characterPath };
var scene = Scene.CreateEditorScene();
try
{
SkinnedModelRenderer body;
WeaponHold hold;
using ( scene.Push() )
{
var bodyObject = new GameObject( true, "character" );
body = bodyObject.AddComponent<SkinnedModelRenderer>();
CharacterLibrary.Setup( body, model );
var weaponObject = new GameObject( true, "weapon" );
var weapon = weaponObject.AddComponent<SkinnedModelRenderer>();
weapon.Model = PreviewModels.BuildWeaponModel( session.Analysis );
weapon.UseAnimGraph = false;
hold = weaponObject.AddComponent<WeaponHold>();
hold.Body = body;
hold.Weapon = weapon;
WeaponBaker.ConfigureHold( hold, session );
hold.TransitionSeconds = 0f;
}
var clock = 1000f;
const float step = 1f / Fps;
void Tick( float dt )
{
hold.BeginFrame();
clock += dt;
scene.EditorTick( clock, dt );
hold.Apply( dt );
}
foreach ( var role in Roles )
{
cancel.ThrowIfCancellationRequested();
var trigger = AnimationRoles.GraphTrigger( role );
if ( role != AnimationRole.Idle && trigger is null )
continue;
progress?.Report( $"Baking corrected {AnimationRoles.Label( role )}" );
var seconds = role == AnimationRole.Idle ? 2f : ActionTiming.Seconds( setup, session.Analysis?.Asset, role );
var frames = new List<XForm[]>();
using ( scene.Push() )
{
body.SceneModel?.ResetAnimParameters();
CharacterPoser.ApplyParameters( body, setup.EffectiveHoldType );
hold.Play( "idle" );
for ( var t = 0f; t < 2f; t += step )
Tick( step );
if ( trigger is not null )
body.Set( trigger, true );
hold.Play( role.ToString().ToLowerInvariant() );
var count = Math.Max( 2, (int)MathF.Ceiling( seconds * Fps ) + 1 );
for ( var f = 0; f < count; f++ )
{
if ( f > 0 )
Tick( step );
frames.Add( Sample( body, skeleton ) );
}
}
// Let the editor breathe between actions.
await EditorThread.Delay( 1, cancel );
var sequence = $"{role.ToString().ToLowerInvariant()}_corrected";
var clip = new Clip( sequence, Fps, AnimationRoles.Loops( role ), frames );
var file = $"{generated}/{name}_{sequence}.dmx";
AssetCompiler.WriteText( file, AnimationDmxWriter.Write( skeleton, clip, DmxSpace.SourceZUpInches, sequence ) );
result.Files.Add( file );
vmdl.Animations.Add( new VmdlAnimation { Name = sequence, File = file, Looping = AnimationRoles.Loops( role ), FrameCount = clip.FrameCount, Fps = Fps } );
result.Clips.Add( (role, sequence) );
}
}
finally
{
scene.Destroy();
}
var modelPath = $"{folder}/{name}_corrected.vmdl";
AssetCompiler.WriteText( modelPath, vmdl.Build() );
result.Files.Add( modelPath );
result.ModelPath = modelPath;
return result;
}
/// <summary>The final (corrected) pose of the character as parent-relative locals.</summary>
private static XForm[] Sample( SkinnedModelRenderer body, Skeleton skeleton )
{
var root = body.WorldTransform;
var world = new XForm[skeleton.Count];
var locals = new XForm[skeleton.Count];
var bones = body.Model.Bones;
for ( var i = 0; i < skeleton.Count; i++ )
{
var bone = bones.GetBone( skeleton[i].Name );
world[i] = bone is not null && body.TryGetBoneTransform( bone, out var tx )
? root.ToLocal( tx ).ToCore()
: (skeleton[i].ParentIndex >= 0 ? XForm.Compose( world[skeleton[i].ParentIndex], skeleton[i].RestLocal ) : skeleton[i].RestLocal);
var parent = skeleton[i].ParentIndex;
locals[i] = parent < 0 ? world[i] : XForm.ToLocal( world[parent], world[i] );
}
return locals;
}
}