Code/Runtime/RetargetedWeapon.cs
#nullable enable
using Sandbox;
using HumanoidHandRetargeter.Target;
using HumanoidHandRetargeter.Calibration;
using HumanoidHandRetargeter.Retargeting;
using HumanoidHandRetargeter.Maths;
using HumanoidHandRetargeter.Skeleton;
namespace HumanoidHandRetargeter;
/// <summary>A single graph evaluates both original weapon tracks and retargeted arm tracks.
/// Parent this component's object to the first-person camera; gameplay calls Attack/Reload/Deploy.</summary>
[Title("Retargeted FPS Weapon"), Category("Animation")]
public sealed class RetargetedWeapon : Component
{
[Property] public Model? AnimationModel { get; set; }
[Property] public Model? WeaponModel { get; set; }
[Property] public Model? HandsModel { get; set; }
[Property] public global::Vector3 ViewOrigin { get; set; }
[Property, TextArea] public string LiveCalibration { get; set; } = "";
[Property] public bool Visible { get; set; } = true;
[Property] public bool ShowHands { get; set; } = true;
public SkinnedModelRenderer? ThirdPersonBody { get; set; }
public string WorldGripBone { get; set; } = "hand_R";
[Property] public bool Aiming { get; set; }
[Property] public bool Sprinting { get; set; }
[Property] public float Movement { get; set; }
[Property] public int SourceSkeleton { get; set; }
public bool UsesOriginalGraph => !string.IsNullOrWhiteSpace(LiveCalibration);
public AnimationGraph? OriginalGraph => driver?.AnimationGraph;
public Action<SceneModel.AnimTagEvent>? AnimationTag { get; set; }
private LiveHandSetup? live;
private HandRetargetProfile? profile;
private HandRetargeter.PoseStream? stream;
private string? loadedCalibration;
private readonly Dictionary<string, bool> boolParameters = new();
private readonly Dictionary<string, float> floatParameters = new();
private readonly Dictionary<string, int> intParameters = new();
private readonly Dictionary<string, global::Vector3> vectorParameters = new();
private readonly Dictionary<string, string> stringParameters = new();
private readonly Dictionary<string, Rotation> rotationParameters = new();
public void SetParameter(string name, bool value) => boolParameters[name] = value;
public void SetParameter(string name, float value) => floatParameters[name] = value;
public void SetParameter(string name, int value) => intParameters[name] = value;
public void SetParameter(string name, global::Vector3 value) => vectorParameters[name] = value;
public void SetParameter(string name, string value) => stringParameters[name] = value;
public void SetParameter(string name, Rotation value) => rotationParameters[name] = value;
public void SetEmpty(bool value) => empty = value;
public void DryFire() => dry = true;
public global::Transform? SourceBone(string name)
{
if(driver is null||WeaponModel?.Bones.GetBone(name) is not {} bone)return null;
var transform=driver.GetBoneWorldTransform(bone.Index);
if(UsesOriginalGraph)transform.Position-=ViewOrigin;
return transform;
}
private SceneModel? driver, weapon, hands;
private (int From,int To)[] weaponBones=Array.Empty<(int,int)>(), handBones=Array.Empty<(int,int)>();
private Model? loadedAnimation,loadedWeapon,loadedHands;
private bool attack,reload,deploy,holstered,empty,skipDeploy,dry;
public void Attack()=>attack=true;
public void Reload(bool emptyMagazine=false){empty=emptyMagazine;reload=true;}
public void Deploy(bool skipAnimation=false){holstered=false;deploy=true;skipDeploy=skipAnimation;}
public void Holster()=>holstered=true;
protected override void OnPreRender()=>Evaluate(Time.Delta);
private void Evaluate(float deltaTime)
{
if(AnimationModel!=loadedAnimation||WeaponModel!=loadedWeapon||HandsModel!=loadedHands||LiveCalibration!=loadedCalibration)Release();
if(driver is null)
{
if(AnimationModel is null||WeaponModel is null||HandsModel is null||AnimationModel.IsError||WeaponModel.IsError||HandsModel.IsError)return;
var owner = UsesOriginalGraph ? WeaponModel : AnimationModel;
if (UsesOriginalGraph)
{
live = LiveHandSetup.Deserialize(LiveCalibration); profile = live.Calibrate();
stream = new(profile, live.Motion);
}
(int,int)[] Map(Model model,string prefix)=>model.Bones.AllBones.Select(target=>
{
var name=target.Name;
var bone=owner.Bones.GetBone(prefix+name);
if(bone is null)throw new InvalidOperationException($"Animation model is missing '{prefix+name}'. Regenerate this weapon for the selected hands.");
return (bone.Index,target.Index);
}).ToArray();
weaponBones=Map(WeaponModel,"");
handBones=UsesOriginalGraph ? profile!.Target.Bones.Select(b => (b.Index, HandsModel.Bones.GetBone(b.Name)?.Index
?? throw new InvalidOperationException("Target model changed; re-export calibration for " + b.Name))).ToArray() : Map(HandsModel,WeaponClipBuilder.HandPrefix);
loadedAnimation=AnimationModel;loadedWeapon=WeaponModel;loadedHands=HandsModel;
loadedCalibration=LiveCalibration;
driver=new SceneModel(Scene.SceneWorld,owner,global::Transform.Zero){UseAnimGraph=true,RenderingEnabled=false};
driver.OnAnimTagEvent=tag=>AnimationTag?.Invoke(tag);
if (UsesOriginalGraph && (driver.AnimationGraph is null || driver.AnimationGraph.IsError))
throw new InvalidOperationException("Source weapon graph is unavailable. Restore its dependencies before using this prefab.");
weapon=new SceneModel(Scene.SceneWorld,WeaponModel,WorldTransform){UseAnimGraph=false};
hands=new SceneModel(Scene.SceneWorld,HandsModel,WorldTransform){UseAnimGraph=false};
weapon.SetAnimGraph("");hands.SetAnimGraph("");
}
weapon!.Transform=hands!.Transform=WorldTransform;
weapon.RenderingEnabled=Visible;hands.RenderingEnabled=Visible&&ShowHands;
// The camera-space meshes must not cast a detached pair of arms onto the world.
// In third person the body supplies the arm shadow and the held gun casts its own.
hands.Flags.CastShadows=false;
weapon.Flags.CastShadows=ThirdPersonBody is not null;
driver.Transform=UsesOriginalGraph ? global::Transform.Zero : new global::Transform(-ViewOrigin);
driver.SetAnimParameter("b_attack",attack);driver.SetAnimParameter("b_reload",reload);
driver.SetAnimParameter("b_deploy",deploy);driver.SetAnimParameter("b_empty",empty);
driver.SetAnimParameter("b_holster",holstered);driver.SetAnimParameter("b_deploy_skip",skipDeploy);
if (UsesOriginalGraph)
{
driver.SetAnimParameter("ironsights", Aiming ? 1 : 0);
driver.SetAnimParameter("skeleton", SourceSkeleton);
driver.SetAnimParameter("b_sprint", Sprinting);
driver.SetAnimParameter("move_bob", Movement);
driver.SetAnimParameter("b_attack_dry", dry);
}
foreach(var p in boolParameters)driver.SetAnimParameter(p.Key,p.Value);
foreach(var p in intParameters)driver.SetAnimParameter(p.Key,p.Value);
foreach(var p in floatParameters)driver.SetAnimParameter(p.Key,p.Value);
foreach(var p in vectorParameters)driver.SetAnimParameter(p.Key,p.Value);
foreach(var p in stringParameters)driver.SetAnimParameter(p.Key,p.Value);
foreach(var p in rotationParameters)driver.SetAnimParameter(p.Key,p.Value);
driver.Update(deltaTime);
// Anchor after evaluating this frame's weapon pose. Using the previous frame's
// source hand adds an unwanted second kick and lets the gun slip in the grip.
if(ThirdPersonBody is {} body && body.TryGetBoneTransform(WorldGripBone,out var hand)
&& SourceBone(WorldGripBone) is {} grip)
weapon.Transform=hand.ToWorld(grip.ToLocal(global::Transform.Zero));
attack=reload=deploy=skipDeploy=dry=false;
if (UsesOriginalGraph)
{
var world = profile!.Source.Bones.Select(b => FromEngine(driver.GetBoneWorldTransform(ownerIndex(b.Name)))).ToArray();
int ownerIndex(string name) => WeaponModel!.Bones.GetBone(name).Index;
var locals = profile.Source.Bones.Select(b => b.ParentIndex < 0 ? world[b.Index] : XForm.ToLocal(world[b.ParentIndex],world[b.Index])).ToArray();
var solved = stream!.Step(new Pose(locals)).ToWorld(profile.Target);
foreach(var (from,to) in weaponBones)
{
var transform=driver.GetBoneWorldTransform(from);transform.Position-=ViewOrigin;
weapon.SetBoneOverride(to,transform);
}
foreach(var (from,to) in handBones)
{
var pose=solved[from];pose.Pos-=live!.Motion.WeaponSpaceOffset ?? System.Numerics.Vector3.Zero;
var transform=ToEngine(pose);transform.Position-=ViewOrigin;hands.SetBoneOverride(to,transform);
}
weapon.Update(0);hands.Update(0);
return;
}
void Apply(SceneModel model,(int From,int To)[] bones)
{
foreach(var (from,to) in bones)model.SetBoneOverride(to,driver.GetBoneWorldTransform(from));
model.Update(0);
}
Apply(weapon,weaponBones);Apply(hands,handBones);
}
static XForm FromEngine(global::Transform t) => new(new System.Numerics.Vector3(t.Position.x,t.Position.y,t.Position.z)*2.54f,
new System.Numerics.Quaternion(t.Rotation.x,t.Rotation.y,t.Rotation.z,t.Rotation.w));
static global::Transform ToEngine(XForm t) => new(new global::Vector3(t.Pos.X,t.Pos.Y,t.Pos.Z)/2.54f,
new Rotation(t.Rot.X,t.Rot.Y,t.Rot.Z,t.Rot.W));
protected override void OnDisabled()=>Release();
protected override void OnDestroy()=>Release();
private void Release()
{
driver?.Delete();weapon?.Delete();hands?.Delete();driver=weapon=hands=null;
loadedAnimation=loadedWeapon=loadedHands=null;
live=null;profile=null;stream=null;loadedCalibration=null;
}
}