Code/WeaponImporter/WeaponHold.cs
namespace WeaponImporter;
/// <summary>
/// Holds a weapon in a character's third-person hands using baked data. The weapon follows the
/// character's animated hold bone, both arms reach weapon-space hand targets with a two-bone IK
/// that keeps the animation's own elbow bend, fingers take baked local rotations and the support hand
/// follows a per-action contact track. Actions start on rising edges of character animgraph bools
/// and play the matching sequence on the weapon renderer.
/// </summary>
[Title( "Weapon Hold" )]
[Category( "Weapons" )]
[Icon( "pan_tool" )]
public sealed class WeaponHold : Component
{
private const string IdleRole = "idle";
private const string FireRole = "fire";
private const string AimedFireRole = "adsfire";
private const string ReloadStartRole = "reloadstart";
private const string InsertRole = "reloadinsert";
private const string ReloadEndRole = "reloadend";
private const float DefaultActionSeconds = 1f;
private const float ReachGateSeconds = 0.35f;
private const float ReachFadeDistance = 6f;
private const float ActionBlendSeconds = 0.3f;
private static readonly (string Role, string Trigger)[] DefaultTriggers =
{
("fire", "b_attack"),
("reload", "b_reload"),
("draw", "b_deploy"),
};
/// <summary>Character renderer. When empty the first ancestor renderer whose model has <see cref="HoldBone"/> is used.</summary>
[Property] public SkinnedModelRenderer Body { get; set; }
/// <summary>Weapon renderer. When empty the renderer on this GameObject is used.</summary>
[Property] public SkinnedModelRenderer Weapon { get; set; }
/// <summary>
/// Citizen holdtype the character uses with this weapon (1 pistol, 2 rifle, 3 shotgun, 4 item,
/// 6 melee, 7 rpg): the hold the importer found the weapon fits best. -1 leaves it to the game.
/// </summary>
[Property] public int HoldType { get; set; } = -1;
/// <summary>
/// A grip profile made by the importer. When set, its values replace the grip properties
/// below (hands, fingers, weapon placement, contacts, actions, hold type).
/// </summary>
[Property] public WeaponGripProfile Profile { get; set; }
/// <summary>Where the right elbow points, weapon space (zero = follow the animation only).</summary>
[Property] public Vector3 RightElbowHint { get; set; }
/// <summary>Where the left elbow points, weapon space (zero = follow the animation only).</summary>
[Property] public Vector3 LeftElbowHint { get; set; }
/// <summary>How much the elbows lean toward their hints (0 = the animation decides alone).</summary>
[Property, Range( 0, 1 )] public float ElbowHintWeight { get; set; } = 0.35f;
/// <summary>Character bone the weapon is held by (animation pose).</summary>
[Property] public string HoldBone { get; set; } = "hold_R";
/// <summary>Weapon model transform relative to the hold bone, "px,py,pz,qx,qy,qz,qw".</summary>
[Property] public string WeaponInHold { get; set; }
/// <summary>hand_R bone transform in weapon-model space (7 floats).</summary>
[Property] public string RightHand { get; set; }
/// <summary>hand_L bone transform in weapon-model space (7 floats, "" = one-handed).</summary>
[Property] public string LeftHand { get; set; }
/// <summary>Right finger local rotations, "bone=qx,qy,qz,qw;bone=...".</summary>
[Property] public string RightFingers { get; set; }
/// <summary>Left finger local rotations, "bone=qx,qy,qz,qw;bone=...".</summary>
[Property] public string LeftFingers { get; set; }
/// <summary>Per-action hand contact tracks (JSON, role keys lowercase).</summary>
[Property] public string Contacts { get; set; }
/// <summary>Per-action duration, weapon sequence and trigger parameter (JSON, role keys lowercase).</summary>
[Property] public string Actions { get; set; }
/// <summary>
/// Replacement character animations per action (JSON, role keys lowercase):
/// <c>{"reload":{"model":"","sequence":"reload_rifle","blend":0.2}}</c>. The sequence plays over
/// the upper body while the animgraph keeps the legs; "" model = the character's own model.
/// </summary>
[Property] public string CharacterActions { get; set; }
/// <summary>Temporal smoothing of IK corrections (0 = none, 1 = 0.1 s half-life).</summary>
[Property, Range( 0, 1 )] public float Smoothing { get; set; } = 0.35f;
/// <summary>Inches past full reach before the support hand starts letting go (it fades out over the next 6 in).</summary>
[Property] public float ReachSlack { get; set; } = 1.5f;
/// <summary>Whether the left hand supports the weapon at all.</summary>
[Property] public bool SupportHand { get; set; } = true;
/// <summary>
/// Seconds a changed grip (hand targets, weapon placement, finger pose) takes to ease in,
/// so editing a grip while an animation plays never makes the hands or the weapon jump.
/// </summary>
[Property, Range( 0, 1 )] public float TransitionSeconds { get; set; } = 0.15f;
/// <summary>
/// How much of the contact correction is applied (1 = hands on the weapon, 0 = the original
/// animation untouched; the weapon still follows the hold bone). The editor's Original /
/// Corrected comparison drives this.
/// </summary>
[Property, Range( 0, 1 )] public float Correction { get; set; } = 1f;
/// <summary>Current firing-hand IK weight (0 = its own animation, 1 = on the weapon).</summary>
public float RightWeight { get; private set; }
/// <summary>Current support-hand IK weight (0 = animated, 1 = on the weapon).</summary>
public float LeftWeight { get; private set; }
/// <summary>
/// The player is aiming down the sights. Set it from your input; the first-person viewmodel
/// raises the sights, holds them and lowers them again through its animgraph.
/// </summary>
[Property] public bool Aiming { get; set; }
/// <summary>
/// Shells a shell-by-shell reload loads (weapons with reload start / insert / end clips). Set it
/// from your ammo before the reload starts; 0 skips the reload.
/// </summary>
[Property] public int ShellsToLoad { get; set; } = 4;
/// <summary>
/// The weapon is empty (set it from your ammo): reloads play the empty reload and the last
/// shot plays the last-round fire, when the weapon has those animations (the animgraph's b_empty).
/// </summary>
[Property] public bool Empty { get; set; }
/// <summary>A shell-by-shell reload is running.</summary>
public bool ShellReloading => _shellReload;
/// <summary>Shells still to insert in the running shell reload (the current one included).</summary>
public int ShellsRemaining => _stopShells ? (_role == InsertRole ? Math.Min( 1, _shellsLeft ) : 0) : _shellsLeft;
/// <summary>One shell went in (add it to your ammo).</summary>
public event Action ShellInserted;
/// <summary>Ends a shell reload after the shell being inserted (firing does this too).</summary>
public void StopReload()
{
if ( _shellReload )
_stopShells = true;
}
private bool _shellReload;
private bool _stopShells;
private int _shellsLeft;
/// <summary>An action started (fire, reload, draw...), from the character's animgraph or <see cref="Play"/>.</summary>
public event Action<string> ActionStarted;
/// <summary>Current action role ("idle" when no action runs).</summary>
public string CurrentRole => _role;
/// <summary>Normalized time (0..1) of the current action.</summary>
public float CurrentTime => _duration > 0f ? Math.Clamp( _elapsed / _duration, 0f, 1f ) : 0f;
/// <summary>The weapon renderer the hold moves (<see cref="Weapon"/>, else the one on this object).</summary>
public SkinnedModelRenderer WeaponRenderer => Weapon.IsValid() ? Weapon : Components.Get<SkinnedModelRenderer>( FindMode.EnabledInSelf );
/// <summary>Weapon sequence of an action role ("" when it has none).</summary>
public string SequenceFor( string role ) => role != null && _actions.TryGetValue( role, out var info ) ? info.Sequence : "";
/// <summary>Inches the right wrist stayed short of its target on the last update.</summary>
public float RightShortfall { get; private set; }
/// <summary>Inches the left wrist stayed short of its target on the last update.</summary>
public float LeftShortfall { get; private set; }
// Action state.
private string _role = IdleRole;
private float _elapsed;
// Contact weights cross-fade from what was applied when an action starts.
private float _sinceAction = float.MaxValue;
private float _leftApplied = 1f, _rightApplied = 1f;
private float _leftFrom = 1f, _rightFrom = 1f;
private float _duration = DefaultActionSeconds;
private bool _paused;
private bool _started;
private bool _snap = true;
private bool _cleanPose;
private float _smoothTime;
private float _reachGate = 1f;
private double _lastTick = double.NaN;
// Parsed data (re-parsed when the source strings change).
private string _srcWeaponInHold, _srcRightHand, _srcLeftHand, _srcRightFingers, _srcLeftFingers, _srcContacts, _srcActions, _srcCharacterActions;
private readonly Dictionary<string, OverlayInfo> _overlays = new( StringComparer.OrdinalIgnoreCase );
private readonly CharacterOverlay _overlay = new();
private OverlayInfo _activeOverlay;
private readonly List<BoneCollection.Bone> _upperBones = new();
// Pelvis-to-head chain (and the spine under the clavicles): pinned to their animation pose
// whenever arm overrides force the skeleton to be evaluated again, which otherwise leans the
// citizen/human spine back and tips the head up.
private readonly List<BoneCollection.Bone> _postureBones = new();
private bool _parsedOnce;
private Transform _weaponInHold = global::Transform.Zero;
private Transform _rightHand = global::Transform.Zero;
private Transform _leftHand = global::Transform.Zero;
private bool _hasRightHand, _hasLeftHand;
// Grip transitions: what was shown when a baked transform changed, eased to the new value.
private TransformBlend _weaponInHoldBlend, _rightHandBlend, _leftHandBlend;
private Transform _leftHandNow = global::Transform.Zero;
private readonly List<FingerRotation> _rightFingers = new();
private readonly List<FingerRotation> _leftFingers = new();
private readonly Dictionary<string, HoldContact[]> _contacts = new( StringComparer.OrdinalIgnoreCase );
private readonly Dictionary<string, ActionInfo> _actions = new( StringComparer.OrdinalIgnoreCase );
private readonly List<TriggerBinding> _triggers = new();
private readonly HashSet<string> _warned = new();
// Bone caches.
private SkinnedModelRenderer _autoBody;
private SkinnedModelRenderer _cachedBody;
private Model _cachedModel;
private string _cachedHoldBone;
private BoneCollection.Bone _holdBone;
private ArmChain _rightChain, _leftChain;
private bool _fingersDirty = true;
private SkinnedModelRenderer _overrideOwner;
private SkinnedModelRenderer _cachedWeapon;
private Model _cachedWeaponModel;
private readonly Dictionary<string, BoneCollection.Bone> _weaponBones = new( StringComparer.Ordinal );
// Per-update scratch.
private Transform _weaponWorldAtRead;
private Func<HoldContact, Transform> _resolveContact;
/// <summary>Starts an action now: plays its weapon sequence and runs its contact track.</summary>
public void Play( string role )
{
RefreshData();
_paused = false;
_started = true;
role = NormalizeRole( role );
if ( role == "reload" && Empty && HasClip( "emptyreload" ) )
role = "emptyreload";
// A reload on a weapon with a shell-by-shell reload runs it (start, shells, end).
if ( role is "reload" or "tacticalreload" or "emptyreload" && HasClip( InsertRole ) && !HasClip( role ) )
{
if ( ShellsToLoad <= 0 )
return;
role = HasClip( ReloadStartRole ) ? ReloadStartRole : InsertRole;
}
StartAction( role, ResolveWeapon( ResolveBody() ) );
}
/// <summary>
/// Editor helper: pauses on an action at a normalized time (null/"" = idle). Poses shown after
/// a scrub are exact (smoothing is bypassed) until <see cref="Play"/> is called.
/// </summary>
public void Scrub( string role, float normalizedTime )
{
RefreshData();
var weapon = ResolveWeapon( ResolveBody() );
role = NormalizeRole( role );
float t = float.IsFinite( normalizedTime ) ? Math.Clamp( normalizedTime, 0f, 1f ) : 0f;
_paused = true;
_started = true;
_snap = true;
_role = role;
_actions.TryGetValue( role, out var info );
_duration = PlayWeaponSequence( weapon, info, role == IdleRole, t, paused: true );
if ( _activeOverlay == null || !string.Equals( _activeOverlay.Role, role, StringComparison.OrdinalIgnoreCase ) )
BeginOverlay( role, info );
_elapsed = t * _duration;
}
/// <summary>
/// Runs one full update: advances the action by <paramref name="deltaTime"/>, places the
/// weapon and writes the arm bone overrides. Works in editor scenes when called manually.
/// </summary>
public void Apply( float deltaTime )
{
Run( float.IsFinite( deltaTime ) ? MathF.Max( 0f, deltaTime ) : 0f, advance: true, write: true );
}
/// <summary>
/// Clears this frame's arm overrides so the next animation update evaluates the character's
/// procedural helper bones (twist, elbow) cleanly; the arms keep those corrections when they
/// are written. The component does this itself; call it before ticking the scene when driving
/// the hold manually with <see cref="Apply"/>.
/// </summary>
public void BeginFrame()
{
if ( _overrideOwner.IsValid() && _overrideOwner.SceneModel != null )
_overrideOwner.SceneModel.ClearBoneOverrides();
_cleanPose = true;
}
/// <summary>
/// Reads the action trigger parameters now. <see cref="WeaponHoldSystem"/> calls this every
/// frame before the animgraph runs, because graphs such as the citizen's reset triggers
/// (b_attack, b_reload) as soon as they consume them. Call it yourself after setting a
/// parameter when driving the hold manually with <see cref="Apply"/>.
/// </summary>
public void ReadTriggers()
{
if ( _paused || !_started )
return;
var body = ResolveBody();
if ( !body.IsValid() || body.Model == null )
return;
RefreshData();
PollTriggers( body, ResolveWeapon( body ) );
}
/// <summary>Parses "px,py,pz,qx,qy,qz,qw" (invariant culture, quaternion normalized).</summary>
public static bool TryParse( string s, out Transform t ) => HoldData.TryParseTransform( s, out t );
/// <summary>Formats a transform as "px,py,pz,qx,qy,qz,qw" with 6 decimals (invariant culture).</summary>
public static string Format( Transform t ) => HoldData.FormatTransform( t );
/// <inheritdoc />
protected override void OnEnabled()
{
_snap = true;
_lastTick = double.NaN;
}
/// <inheritdoc />
protected override void OnUpdate() => Tick();
/// <inheritdoc />
protected override void OnPreRender() => Tick();
/// <inheritdoc />
protected override void OnDisabled() => ClearOverrides();
/// <inheritdoc />
protected override void OnDestroy()
{
ClearOverrides();
_overlay.Dispose();
}
private void Tick()
{
double now = Time.Now;
if ( _lastTick == now )
{
// Second call this frame (OnPreRender): solve on the fresh animation pose and write.
Run( 0f, advance: false, write: true );
return;
}
// First call (OnUpdate): advance and place the weapon, but leave the arms to the
// animation update so its procedural helpers are evaluated cleanly.
_lastTick = now;
Run( float.IsFinite( Time.Delta ) ? MathF.Max( 0f, Time.Delta ) : 0f, advance: true, write: false );
}
private void Run( float dt, bool advance, bool write )
{
var body = ResolveBody();
if ( body != _overrideOwner )
ClearOverrides();
if ( !body.IsValid() || body.Model == null || body.SceneModel == null )
return;
var weapon = ResolveWeapon( body );
RefreshData();
if ( !EnsureBones( body ) )
{
ClearOverrides();
return;
}
if ( !_started )
{
_started = true;
StartAction( IdleRole, weapon );
}
if ( HoldType >= 0 && advance && body.GetInt( "holdtype" ) != HoldType )
body.Set( "holdtype", HoldType );
if ( !_paused )
{
PollTriggers( body, weapon );
if ( advance )
AdvanceTime( dt, weapon );
}
var bodyTx = body.WorldTransform;
UpdateOverlay( body );
if ( !TryGetAnim( body, _holdBone, out var holdWorld ) )
return;
double now = RealTime.Now;
var weaponInHold = _weaponInHoldBlend.Evaluate( _weaponInHold, now );
var rightHand = _rightHandBlend.Evaluate( _rightHand, now );
_leftHandNow = _leftHandBlend.Evaluate( _leftHand, now );
float correction = Math.Clamp( Correction, 0f, 1f );
var holdModel = bodyTx.ToLocal( holdWorld ).WithScale( 1f );
var weaponModel = holdModel.ToWorld( weaponInHold ).WithScale( 1f );
if ( weapon.IsValid() )
{
_weaponWorldAtRead = weapon.WorldTransform;
weapon.WorldTransform = bodyTx.ToWorld( weaponModel );
}
var sceneModel = body.SceneModel;
sceneModel.ClearBoneOverrides();
_overrideOwner = body;
_smoothTime += dt;
if ( !write )
{
_cleanPose = true;
return;
}
bool clean = _cleanPose;
_cleanPose = false;
bool snap = _snap || _paused || Smoothing <= 0f;
float stepTime = _smoothTime;
float alpha = snap ? 1f : ArmSolver.SmoothingFactor( Smoothing, stepTime );
_smoothTime = 0f;
// Replacement animation over the upper body (spine, neck, head, clavicles); the arms are
// written below from the same blended pose.
if ( _overlay.Active && _overlay.Weight > 1e-4f )
{
foreach ( var bone in _upperBones )
if ( TryGetAnim( body, bone, out var w ) )
sceneModel.SetBoneOverride( bone.Index, bodyTx.ToLocal( w ) );
}
// Right hand: on the weapon unless the action's contact track releases it (the arm then
// plays its own animation; the weapon still follows the animated hold bone).
RightShortfall = 0f;
RightWeight = 0f;
if ( _rightChain != null && _hasRightHand )
{
_rightChain.ReadAnimation( this, body, bodyTx, clean );
_rightChain.Now = now;
_contacts.TryGetValue( _role, out var rightKeys );
float rightContact = HoldData.EvaluateContacts( rightKeys, false, _elapsed, _duration, rightHand, null, out _ );
rightContact = _rightApplied = CrossFade( _rightFrom, rightContact, snap );
rightContact *= correction;
RightWeight = rightContact;
var target = weaponModel.ToWorld( rightHand );
RightShortfall = _rightChain.Solve( target, alpha, snap, Hint( weaponModel, RightElbowHint ), ElbowHintWeight );
if ( rightContact > 1e-4f )
_rightChain.Write( sceneModel, rightContact );
}
// Left hand: contact track, reach release, smoothing.
LeftShortfall = 0f;
LeftWeight = 0f;
if ( _leftChain != null && _hasLeftHand )
{
_leftChain.ReadAnimation( this, body, bodyTx, clean );
_leftChain.Now = now;
_contacts.TryGetValue( _role, out var keys );
_resolveContact ??= ResolveContactTarget;
float contactWeight = HoldData.EvaluateContacts( keys, true, _elapsed, _duration, _leftHandNow,
keys != null ? _resolveContact : null, out var targetInWeapon );
contactWeight = _leftApplied = CrossFade( _leftFrom, contactWeight, snap );
var target = weaponModel.ToWorld( targetInWeapon );
// Out of reach (a character gesture pulled the weapon away): let go gradually the
// further the grip is, instead of snapping the hand back to its animation.
float over = _leftChain.TargetDistance( target.Position ) - _leftChain.Reach - MathF.Max( 0f, ReachSlack );
float fade = Math.Clamp( over / ReachFadeDistance, 0f, 1f );
float gateTarget = SupportHand ? 1f - fade * fade * (3f - 2f * fade) : 0f;
_reachGate = snap
? gateTarget
: MoveTowards( _reachGate, gateTarget, stepTime / ReachGateSeconds );
float weight = Math.Clamp( contactWeight * _reachGate * correction, 0f, 1f );
LeftWeight = weight;
LeftShortfall = _leftChain.Solve( target, alpha, snap, Hint( weaponModel, LeftElbowHint ), ElbowHintWeight );
// Also when released: the character's animgraph attaches its left hand to the right
// hand with its own IK rule, so a corrected right arm would drag the free left arm
// along and it would snap back when the rule ends. Writing its animation pose keeps
// the released arm exactly on its own animation.
if ( weight > 1e-4f || RightWeight > 1e-4f )
_leftChain.Write( sceneModel, weight );
}
// Overrides only take effect when the skeleton is evaluated; the animation update has
// already run this frame, so evaluate again (no time advance) to show them now. That
// evaluation bends the spine differently, so the posture chain keeps this frame's pose.
if ( sceneModel.HasBoneOverrides() )
{
if ( !(_overlay.Active && _overlay.Weight > 1e-4f) )
foreach ( var bone in _postureBones )
if ( TryGetAnim( body, bone, out var w ) )
sceneModel.SetBoneOverride( bone.Index, bodyTx.ToLocal( w ) );
sceneModel.Update( 0f );
}
_snap = false;
}
/// <summary>
/// Animation pose of a character bone in world space: the animgraph result, blended toward the
/// action's replacement sequence on the upper body while one plays.
/// </summary>
private bool TryGetAnim( SkinnedModelRenderer body, BoneCollection.Bone bone, out Transform world )
{
if ( !body.TryGetBoneTransformAnimation( bone, out world ) )
return false;
if ( _overlay.Active && _overlay.Weight > 1e-4f && _upperSet.Contains( bone.Index ) && _overlay.TryGetWorld( bone.Name, out var overlay ) )
{
var w = _overlay.Weight;
world = new Transform( Vector3.Lerp( world.Position, overlay.Position, w ), ArmSolver.Slerp( world.Rotation, overlay.Rotation, w ), world.Scale );
}
return true;
}
private readonly HashSet<int> _upperSet = new();
private void UpdateOverlay( SkinnedModelRenderer body )
{
if ( !_overlay.Active || _activeOverlay == null )
return;
float blend = MathF.Max( 1e-3f, _activeOverlay.Blend );
float fadeIn = _elapsed / blend;
float fadeOut = (_duration - _elapsed) / blend;
float w = Math.Clamp( MathF.Min( fadeIn, fadeOut ), 0f, 1f );
w = w * w * (3f - 2f * w);
if ( _paused )
w = _elapsed <= 0f || _elapsed >= _duration ? w : 1f;
float seconds = _overlay.Duration > 0f && _duration > 0f ? _elapsed / _duration * _overlay.Duration : _elapsed;
_overlay.Update( body, seconds, w );
}
private void AdvanceTime( float dt, SkinnedModelRenderer weapon )
{
_elapsed += dt;
if ( _sinceAction < float.MaxValue )
_sinceAction += dt;
if ( _role == IdleRole )
{
if ( _duration > 0f && _elapsed >= _duration )
_elapsed %= _duration;
return;
}
if ( _elapsed < _duration )
return;
// Shell-by-shell reload: start -> one insert per shell -> end.
if ( _role == ReloadStartRole )
{
if ( _shellsLeft > 0 && !_stopShells )
StartAction( InsertRole, weapon );
else
FinishShells( weapon );
return;
}
if ( _role == InsertRole )
{
_shellsLeft = Math.Max( 0, _shellsLeft - 1 );
ShellInserted?.Invoke();
if ( _shellsLeft > 0 && !_stopShells )
StartAction( InsertRole, weapon );
else
FinishShells( weapon );
return;
}
StartAction( IdleRole, weapon );
}
private void FinishShells( SkinnedModelRenderer weapon )
{
_shellsLeft = 0;
StartAction( HasClip( ReloadEndRole ) ? ReloadEndRole : IdleRole, weapon );
}
private bool HasClip( string role ) => _actions.TryGetValue( role, out var a ) && !string.IsNullOrEmpty( a.Sequence );
private void StartAction( string role, SkinnedModelRenderer weapon )
{
ShellReloadState( role );
_role = role;
_elapsed = 0f;
_sinceAction = 0f;
_leftFrom = _leftApplied;
_rightFrom = _rightApplied;
_actions.TryGetValue( role, out var info );
_duration = PlayWeaponSequence( weapon, info, role == IdleRole, 0f, paused: false );
BeginOverlay( role, info );
if ( role != IdleRole )
ActionStarted?.Invoke( role );
}
/// <summary>
/// Shell reload bookkeeping as actions change, and the character's own shell reload: the
/// citizen/human graph plays its loading loop while b_reloading is on, one shell per
/// b_reloading_insert.
/// </summary>
private void ShellReloadState( string role )
{
var body = ResolveBody();
bool shellPart = role is ReloadStartRole or InsertRole;
if ( shellPart && !_shellReload )
{
_shellReload = true;
_stopShells = false;
_shellsLeft = Math.Max( 0, ShellsToLoad );
if ( body.IsValid() )
body.Set( "b_reloading", true );
}
if ( role == InsertRole && body.IsValid() )
body.Set( "b_reloading_insert", true );
if ( !shellPart && _shellReload )
{
_shellReload = false;
if ( body.IsValid() )
body.Set( "b_reloading", false );
}
}
private void BeginOverlay( string role, ActionInfo info )
{
_activeOverlay = null;
_overlay.End();
if ( role == IdleRole || !_overlays.TryGetValue( role, out var overlay ) )
return;
var body = ResolveBody();
if ( !body.IsValid() )
return;
if ( !_overlay.Begin( body, overlay, out var error ) )
{
if ( error != null )
WarnOnce( $"overlay:{role}:{error}", $"Weapon Hold: character animation for '{role}' ignored ({error})." );
return;
}
_activeOverlay = overlay;
// Without an explicit duration the replacement animation decides how long the action lasts.
if ( (info == null || info.Seconds <= 0f) && _overlay.Duration > 0f )
_duration = _overlay.Duration;
}
/// <summary>Plays the action's weapon sequence and returns the action duration in seconds.</summary>
private float PlayWeaponSequence( SkinnedModelRenderer weapon, ActionInfo info, bool loop, float normalized, bool paused )
{
float seconds = info != null && info.Seconds > 0f ? info.Seconds : 0f;
float sequenceSeconds = 0f;
if ( weapon.IsValid() && info != null && !string.IsNullOrEmpty( info.Sequence ) )
{
weapon.UseAnimGraph = false;
var sequence = weapon.Sequence;
// The model is the authority: the renderer's own sequence list lags a frame behind a
// model change. A model without any animations (a static prop) has nothing to play.
var model = weapon.Model;
var animated = model != null && !model.IsError && model.AnimationCount > 0;
if ( animated && !model.AnimationNames.Contains( info.Sequence ) )
{
WarnOnce( $"seq:{info.Sequence}", $"Weapon Hold: weapon model has no sequence '{info.Sequence}' (action '{info.Role}')." );
}
else if ( animated )
{
if ( sequence.Name != info.Sequence )
sequence.Name = info.Sequence;
sequence.Looping = loop;
sequenceSeconds = sequence.Duration;
sequence.Time = normalized * sequenceSeconds;
sequence.PlaybackRate = paused
? 0f
: (!loop && seconds > 0f && sequenceSeconds > 0f ? sequenceSeconds / seconds : 1f);
}
}
if ( seconds > 0f )
return seconds;
return sequenceSeconds > 0f ? sequenceSeconds : DefaultActionSeconds;
}
private void PollTriggers( SkinnedModelRenderer body, SkinnedModelRenderer weapon )
{
TriggerBinding best = null;
for ( int i = 0; i < _triggers.Count; i++ )
{
var binding = _triggers[i];
bool value = body.GetBool( binding.Parameter );
if ( value && !binding.Previous )
{
binding.Role = ChooseRole( binding );
binding.Seconds = _actions.TryGetValue( binding.Role, out var chosen ) && chosen.Seconds > 0f ? chosen.Seconds : DefaultActionSeconds;
if ( best == null || binding.Seconds > best.Seconds )
best = binding;
}
binding.Previous = value;
}
if ( best == null )
return;
// During a shell reload: another reload is ignored; firing ends it after this shell.
if ( _shellReload )
{
if ( best.Role is FireRole or AimedFireRole )
StopReload();
return;
}
if ( string.IsNullOrEmpty( best.Role ) )
return;
// A shorter action does not cut a longer one that is still running (fire during reload).
if ( _role != IdleRole && _role != best.Role )
{
float remaining = _duration - _elapsed;
if ( remaining > 0f && best.Seconds < remaining )
return;
}
StartAction( best.Role, weapon );
}
private Transform ResolveContactTarget( HoldContact key )
{
var target = _leftHandNow;
if ( !string.IsNullOrEmpty( key.Follow ) && TryGetWeaponBone( key.Follow, out var boneInModel, out var restInModel ) )
target = boneInModel.ToWorld( restInModel.ToLocal( _leftHandNow ) );
return HoldData.ApplyOffset( key, target );
}
private bool TryGetWeaponBone( string name, out Transform current, out Transform rest )
{
current = global::Transform.Zero;
rest = global::Transform.Zero;
var weapon = _cachedWeapon;
if ( !weapon.IsValid() || weapon.Model == null )
return false;
if ( weapon.Model != _cachedWeaponModel )
{
_weaponBones.Clear();
_cachedWeaponModel = weapon.Model;
}
if ( !_weaponBones.TryGetValue( name, out var bone ) )
{
bone = weapon.Model.Bones.GetBone( name );
_weaponBones[name] = bone;
if ( bone == null )
WarnOnce( $"wbone:{name}", $"Weapon Hold: weapon model has no bone '{name}' to follow." );
}
if ( bone == null || !weapon.TryGetBoneTransformAnimation( bone, out var world ) )
return false;
current = _weaponWorldAtRead.ToLocal( world ).WithScale( 1f );
rest = weapon.Model.GetBoneTransform( bone.Index ).WithScale( 1f );
return true;
}
private SkinnedModelRenderer ResolveBody()
{
if ( Body.IsValid() )
return Body;
var hold = HoldBone ?? "";
if ( _autoBody.IsValid() && _autoBody.Model != null && _autoBody.Model.Bones.HasBone( hold ) )
return _autoBody;
_autoBody = null;
for ( var go = GameObject; go.IsValid(); go = go.Parent )
{
foreach ( var renderer in go.Components.GetAll<SkinnedModelRenderer>( FindMode.EnabledInSelf ) )
{
if ( renderer == Weapon || renderer.Model == null )
continue;
if ( renderer.Model.Bones.HasBone( hold ) )
{
_autoBody = renderer;
return renderer;
}
}
}
return null;
}
private SkinnedModelRenderer ResolveWeapon( SkinnedModelRenderer body )
{
var weapon = Weapon.IsValid() ? Weapon : Components.Get<SkinnedModelRenderer>( FindMode.EnabledInSelf );
if ( weapon == body )
weapon = null;
_cachedWeapon = weapon;
return weapon;
}
private bool EnsureBones( SkinnedModelRenderer body )
{
var model = body.Model;
if ( body == _cachedBody && model == _cachedModel && HoldBone == _cachedHoldBone )
{
if ( _fingersDirty )
MapFingers( _fingersBlend );
return _holdBone != null;
}
_cachedBody = body;
_cachedModel = model;
_cachedHoldBone = HoldBone;
_snap = true;
var bones = model.Bones;
_holdBone = string.IsNullOrEmpty( HoldBone ) ? null : bones.GetBone( HoldBone );
if ( _holdBone == null )
WarnOnce( $"hold:{model.Name}:{HoldBone}", $"Weapon Hold: character model '{model.Name}' has no hold bone '{HoldBone}'." );
_rightChain = ArmChain.Build( bones, "R" );
_leftChain = ArmChain.Build( bones, "L" );
if ( _rightChain == null )
WarnOnce( $"armR:{model.Name}", $"Weapon Hold: character model '{model.Name}' lacks arm_upper_R/arm_lower_R/hand_R." );
if ( _leftChain == null )
WarnOnce( $"armL:{model.Name}", $"Weapon Hold: character model '{model.Name}' lacks arm_upper_L/arm_lower_L/hand_L." );
// Upper body for replacement animations: spine_0 and below, minus the arm subtrees.
_upperBones.Clear();
_upperSet.Clear();
var spine = bones.GetBone( "spine_0" ) ?? bones.GetBone( "spine_1" );
if ( spine != null )
{
var arms = new HashSet<int>();
_rightChain?.Collect( arms );
_leftChain?.Collect( arms );
var stack = new Stack<BoneCollection.Bone>();
stack.Push( spine );
while ( stack.Count > 0 )
{
var bone = stack.Pop();
_upperSet.Add( bone.Index );
if ( !arms.Contains( bone.Index ) )
_upperBones.Add( bone );
if ( bone.Children != null )
foreach ( var child in bone.Children )
stack.Push( child );
}
}
// Posture chain: every ancestor of the head and of the arms (not the root, not the arms).
_postureBones.Clear();
var posture = new HashSet<int>();
var armBones = new HashSet<int>();
_rightChain?.Collect( armBones );
_leftChain?.Collect( armBones );
void Climb( BoneCollection.Bone bone )
{
for ( var b = bone; b != null && b.Parent != null; b = b.Parent )
if ( !armBones.Contains( b.Index ) && posture.Add( b.Index ) )
_postureBones.Add( b );
}
if ( bones.GetBone( "head" ) is { } head )
Climb( head );
foreach ( var name in new[] { "arm_upper_R", "arm_upper_L" } )
if ( bones.GetBone( name )?.Parent is { } shoulder )
Climb( shoulder );
MapFingers();
return _holdBone != null;
}
private void MapFingers( bool blend = false )
{
_fingersDirty = false;
_fingersBlend = false;
double now = RealTime.Now;
_rightChain?.MapFingers( _rightFingers, blend, now, TransitionSeconds );
_leftChain?.MapFingers( _leftFingers, blend, now, TransitionSeconds );
}
private bool _fingersBlend;
/// <summary>An elbow hint (weapon space) in character model space, or null when unset.</summary>
private static Vector3? Hint( Transform weaponModel, Vector3 hint )
=> hint.LengthSquared > 1e-6f ? weaponModel.PointToWorld( hint ) : null;
/// <summary>Copies an assigned profile's values over the grip properties.</summary>
private void ApplyProfile()
{
var p = Profile;
if ( p == null )
return;
WeaponInHold = p.WeaponOffset;
HoldBone = string.IsNullOrEmpty( p.HoldBone ) ? HoldBone : p.HoldBone;
var rightPrimary = !string.Equals( p.PrimaryHand, "L", StringComparison.OrdinalIgnoreCase );
RightHand = rightPrimary ? p.PrimaryGripTransform : p.SecondaryGripTransform;
LeftHand = rightPrimary ? p.SecondaryGripTransform : p.PrimaryGripTransform;
RightFingers = rightPrimary ? p.PrimaryFingerPose : p.SecondaryFingerPose;
LeftFingers = rightPrimary ? p.SecondaryFingerPose : p.PrimaryFingerPose;
RightElbowHint = rightPrimary ? p.PrimaryElbowHint : p.SecondaryElbowHint;
LeftElbowHint = rightPrimary ? p.SecondaryElbowHint : p.PrimaryElbowHint;
SupportHand = p.TwoHanded;
Contacts = p.Contacts;
Actions = p.Actions;
CharacterActions = p.CharacterActions;
HoldType = p.HoldType;
Smoothing = p.Smoothing;
ReachSlack = p.ReachSlack;
TransitionSeconds = p.TransitionSeconds;
}
private void RefreshData()
{
ApplyProfile();
if ( _parsedOnce
&& _srcWeaponInHold == WeaponInHold && _srcRightHand == RightHand && _srcLeftHand == LeftHand
&& _srcRightFingers == RightFingers && _srcLeftFingers == LeftFingers
&& _srcContacts == Contacts && _srcActions == Actions && _srcCharacterActions == CharacterActions )
{
return;
}
bool firstParse = !_parsedOnce;
_parsedOnce = true;
// A changed grip eases in from what is on screen (no jump while an animation plays).
double now = RealTime.Now;
if ( firstParse || _srcWeaponInHold != WeaponInHold )
{
_srcWeaponInHold = WeaponInHold;
var previous = _weaponInHold;
if ( !ParseTransformProperty( WeaponInHold, nameof( WeaponInHold ), out _weaponInHold ) )
_weaponInHold = global::Transform.Zero;
_weaponInHoldBlend.Changed( previous, !firstParse, now, TransitionSeconds );
}
if ( firstParse || _srcRightHand != RightHand )
{
_srcRightHand = RightHand;
var previous = _rightHand;
bool had = _hasRightHand;
_hasRightHand = ParseTransformProperty( RightHand, nameof( RightHand ), out _rightHand );
_rightHandBlend.Changed( previous, !firstParse && had && _hasRightHand, now, TransitionSeconds );
}
if ( firstParse || _srcLeftHand != LeftHand )
{
_srcLeftHand = LeftHand;
var previous = _leftHand;
bool had = _hasLeftHand;
_hasLeftHand = ParseTransformProperty( LeftHand, nameof( LeftHand ), out _leftHand );
_leftHandBlend.Changed( previous, !firstParse && had && _hasLeftHand, now, TransitionSeconds );
}
if ( firstParse || _srcRightFingers != RightFingers )
{
_srcRightFingers = RightFingers;
if ( !HoldData.TryParseFingers( RightFingers, _rightFingers, out var error ) )
WarnOnce( "RightFingers:" + RightFingers, $"Weapon Hold: RightFingers ignored ({error})." );
_fingersDirty = true;
_fingersBlend = !firstParse;
}
if ( firstParse || _srcLeftFingers != LeftFingers )
{
_srcLeftFingers = LeftFingers;
if ( !HoldData.TryParseFingers( LeftFingers, _leftFingers, out var error ) )
WarnOnce( "LeftFingers:" + LeftFingers, $"Weapon Hold: LeftFingers ignored ({error})." );
_fingersDirty = true;
_fingersBlend = !firstParse;
}
bool rebuildTriggers = false;
if ( firstParse || _srcContacts != Contacts )
{
_srcContacts = Contacts;
if ( !HoldData.TryParseContacts( Contacts, _contacts, out var error ) )
WarnOnce( "Contacts:" + Contacts, $"Weapon Hold: Contacts ignored ({error})." );
rebuildTriggers = true;
}
if ( firstParse || _srcActions != Actions )
{
_srcActions = Actions;
if ( !HoldData.TryParseActions( Actions, _actions, out var error ) )
WarnOnce( "Actions:" + Actions, $"Weapon Hold: Actions ignored ({error})." );
rebuildTriggers = true;
}
if ( firstParse || _srcCharacterActions != CharacterActions )
{
_srcCharacterActions = CharacterActions;
if ( !CharacterOverlay.TryParse( CharacterActions, _overlays, out var error ) )
WarnOnce( "CharacterActions:" + CharacterActions, $"Weapon Hold: CharacterActions ignored ({error})." );
_activeOverlay = null;
_overlay.End();
}
if ( rebuildTriggers )
RebuildTriggers();
}
private void RebuildTriggers()
{
_triggers.Clear();
foreach ( var info in _actions.Values )
{
if ( info.Role == IdleRole || string.IsNullOrWhiteSpace( info.Trigger ) )
continue;
AddTrigger( info.Role, info.Trigger.Trim(), info.Seconds > 0f ? info.Seconds : DefaultActionSeconds );
}
// Roles with a contact track but no action entry still react to the usual parameters.
foreach ( var (role, trigger) in DefaultTriggers )
{
if ( !_actions.ContainsKey( role ) && _contacts.ContainsKey( role ) )
AddTrigger( role, trigger, DefaultActionSeconds );
}
}
/// <summary>
/// One binding per animgraph parameter (one rising edge), remembering every action that uses
/// it: fire and aimed fire share b_attack, the reloads share b_reload.
/// </summary>
private void AddTrigger( string role, string parameter, float seconds )
{
for ( int i = 0; i < _triggers.Count; i++ )
{
if ( !string.Equals( _triggers[i].Parameter, parameter, StringComparison.Ordinal ) )
continue;
if ( !_triggers[i].Roles.Contains( role ) )
_triggers[i].Roles.Add( role );
return;
}
_triggers.Add( new TriggerBinding { Role = role, Parameter = parameter, Seconds = seconds, Roles = { role } } );
}
/// <summary>
/// The action a rising edge starts: aimed fire while aiming (when the weapon has that clip),
/// otherwise the plain action for the parameter (fire, reload, draw), otherwise the first one.
/// </summary>
private string ChooseRole( TriggerBinding binding )
{
// Shell-by-shell reload when the weapon has an insert clip (and shells to load).
if ( binding.Roles.Contains( InsertRole ) && HasClip( InsertRole ) )
return ShellsToLoad <= 0 ? "" : HasClip( ReloadStartRole ) ? ReloadStartRole : InsertRole;
// Empty: the empty reload / last-round fire when the weapon has them.
if ( Empty && binding.Roles.Contains( "emptyreload" ) && HasClip( "emptyreload" ) )
return "emptyreload";
if ( Empty && binding.Roles.Contains( "fireempty" ) && HasClip( "fireempty" ) )
return "fireempty";
if ( binding.Roles.Count == 1 )
return binding.Roles[0];
if ( Aiming && binding.Roles.Contains( AimedFireRole ) && HasClip( AimedFireRole ) )
return AimedFireRole;
foreach ( var plain in PlainRoles )
if ( binding.Roles.Contains( plain ) )
return plain;
return binding.Roles.FirstOrDefault( r => r != AimedFireRole ) ?? binding.Roles[0];
}
private static readonly string[] PlainRoles = { FireRole, "reload", "draw" };
private bool ParseTransformProperty( string value, string property, out Transform t )
{
if ( string.IsNullOrWhiteSpace( value ) )
{
t = global::Transform.Zero;
return false;
}
if ( HoldData.TryParseTransform( value, out t ) )
return true;
WarnOnce( property + ":" + value, $"Weapon Hold: {property} '{value}' is not 'px,py,pz,qx,qy,qz,qw'; ignored." );
t = global::Transform.Zero;
return false;
}
private void WarnOnce( string key, string message )
{
if ( _warned.Add( key ) )
Log.Warning( message );
}
private void ClearOverrides()
{
if ( _overrideOwner.IsValid() && _overrideOwner.SceneModel != null )
_overrideOwner.SceneModel.ClearBoneOverrides();
_overrideOwner = null;
_snap = true;
}
private static string NormalizeRole( string role )
{
return string.IsNullOrWhiteSpace( role ) ? IdleRole : role.Trim().ToLowerInvariant();
}
/// <summary>Eases from the weight applied when the action started to the action's own weight.</summary>
private float CrossFade( float from, float to, bool snap )
{
if ( snap || _sinceAction >= ActionBlendSeconds )
return to;
return from + (to - from) * HoldData.SmoothStep( _sinceAction / ActionBlendSeconds );
}
private static float MoveTowards( float value, float target, float maxDelta )
{
if ( maxDelta <= 0f )
return value;
return value < target ? MathF.Min( target, value + maxDelta ) : MathF.Max( target, value - maxDelta );
}
/// <summary>Eases a baked transform from the value shown when it changed to its new value.</summary>
private struct TransformBlend
{
private Transform _from;
private double _start;
private float _seconds;
private bool _active;
/// <summary>Records a change; <paramref name="previous"/> is the old baked value.</summary>
public void Changed( Transform previous, bool blend, double now, float seconds )
{
if ( !blend || seconds <= 1e-4f )
{
_active = false;
return;
}
// Continue from what is on screen (a change during a running transition starts from it).
_from = _active ? Evaluate( previous, now ) : previous;
_start = now;
_seconds = seconds;
_active = true;
}
public Transform Evaluate( Transform target, double now )
{
if ( !_active )
return target;
float t = (float)((now - _start) / _seconds);
if ( t >= 1f )
{
_active = false;
return target;
}
return HoldData.Lerp( _from, target, HoldData.SmoothStep( t ) );
}
}
private sealed class TriggerBinding
{
public string Role;
public readonly List<string> Roles = new();
public string Parameter;
public float Seconds;
public bool Previous;
}
/// <summary>
/// One arm subtree (arm_upper_X and every descendant, parents before children) with its
/// per-frame animation pose, the smoothed IK correction and the posed result.
/// </summary>
private sealed class ArmChain
{
private const int UpperSlot = 0;
private BoneCollection.Bone[] _bones;
private int[] _parent;
private int _lowerSlot, _handSlot;
private Transform[] _anim;
private Transform[] _local;
private Transform[] _pose;
private bool[] _hasFinger;
private Rotation[] _finger;
// Finger pose transitions: the rotation shown when the pose changed (none = from the
// animation), eased to the new pose.
private bool[] _hasFingerFrom;
private Rotation[] _fingerFrom;
private double _fingerStart = double.NegativeInfinity;
private float _fingerSeconds;
/// <summary>Clock (RealTime.Now) of the current update, for transitions.</summary>
public double Now;
// Upper-arm twist helpers: how much of the correction's twist each one cancels (1 at the
// shoulder, fading down the arm) so the shoulder skin doesn't wring against the chest.
private float[] _twistCancel;
// The clavicle takes part of a large arm swing, like a real shoulder, so the deltoid isn't
// pinched against the chest when the arm is pulled onto the weapon.
private const float ClavicleShare = 0.3f;
private const float ClavicleMaxDegrees = 20f;
private const float ClavicleEngageDegrees = 6f;
private const float ClavicleFullDegrees = 24f;
private BoneCollection.Bone _clavicle;
private Transform _clavicleAnim;
private Rotation _clavicleDelta = Rotation.Identity;
// Local transforms of the non-joint bones (twist and elbow helpers, fingers, attachments)
// from the last clean animation update, which includes the model's procedural
// constraints. Overridden bones lose those constraints, so the chain re-applies them.
private Transform[] _final;
private bool[] _hasFinal;
private Vector3 _upperAxis = Vector3.Forward;
// Smoothed local-frame corrections (post-multiplied onto the FK rotation).
private Rotation _upperDelta = Rotation.Identity;
private Rotation _lowerDelta = Rotation.Identity;
private Rotation _handDelta = Rotation.Identity;
private bool _hasHistory;
public float Reach { get; private set; }
public static ArmChain Build( BoneCollection bones, string side )
{
var upper = bones.GetBone( "arm_upper_" + side );
var lower = bones.GetBone( "arm_lower_" + side );
var hand = bones.GetBone( "hand_" + side );
if ( upper == null || lower == null || hand == null )
return null;
var list = new List<BoneCollection.Bone>();
var parents = new List<int>();
var stack = new Stack<(BoneCollection.Bone Bone, int Parent)>();
stack.Push( (upper, -1) );
while ( stack.Count > 0 )
{
var (bone, parent) = stack.Pop();
int slot = list.Count;
list.Add( bone );
parents.Add( parent );
var children = bone.Children;
if ( children == null )
continue;
for ( int i = children.Count - 1; i >= 0; i-- )
stack.Push( (children[i], slot) );
}
var chain = new ArmChain
{
_bones = list.ToArray(),
_parent = parents.ToArray(),
_lowerSlot = list.FindIndex( b => b.Index == lower.Index ),
_handSlot = list.FindIndex( b => b.Index == hand.Index ),
};
if ( chain._lowerSlot < 0 || chain._handSlot < 0 )
return null;
int n = list.Count;
chain._anim = new Transform[n];
chain._local = new Transform[n];
chain._pose = new Transform[n];
chain._hasFinger = new bool[n];
chain._finger = new Rotation[n];
chain._hasFingerFrom = new bool[n];
chain._fingerFrom = new Rotation[n];
chain._twistCancel = new float[n];
var clavicle = upper.Parent;
if ( clavicle != null && (clavicle.Name.Contains( "clav", StringComparison.OrdinalIgnoreCase ) || clavicle.Name.Contains( "shoulder", StringComparison.OrdinalIgnoreCase )) )
chain._clavicle = clavicle;
chain._final = new Transform[n];
chain._hasFinal = new bool[n];
// Bone.LocalTransform is the model-space rest pose; work in the upper arm's frame.
var upperRest = upper.LocalTransform;
var toElbow = upperRest.ToLocal( lower.LocalTransform ).Position;
float upperLength = toElbow.Length;
if ( upperLength > 1e-3f )
{
chain._upperAxis = toElbow / upperLength;
for ( int i = 0; i < n; i++ )
{
if ( chain._parent[i] != UpperSlot || !list[i].Name.Contains( "twist", StringComparison.OrdinalIgnoreCase ) )
continue;
// Half at the shoulder, easing off down the arm (the citizen's own constraints
// take -0.5 and -0.1 of the arm's twist).
var offset = upperRest.ToLocal( list[i].LocalTransform ).Position;
float along = 1f - Math.Clamp( Vector3.Dot( offset, chain._upperAxis ) / upperLength, 0f, 1f );
chain._twistCancel[i] = 0.5f * along * along;
}
}
return chain;
}
public void MapFingers( List<FingerRotation> fingers, bool blend, double now, float seconds )
{
// What is shown right now becomes the starting point of the new pose.
float progress = FingerProgress( now );
for ( int i = 0; i < _bones.Length; i++ )
{
if ( blend && _hasFinger[i] )
{
_fingerFrom[i] = _hasFingerFrom[i] && progress < 1f ? ArmSolver.Slerp( _fingerFrom[i], _finger[i], progress ) : _finger[i];
_hasFingerFrom[i] = true;
}
else
{
_hasFingerFrom[i] = false;
}
}
_fingerStart = blend ? now : double.NegativeInfinity;
_fingerSeconds = seconds;
for ( int i = 0; i < _bones.Length; i++ )
{
_hasFinger[i] = false;
if ( i == UpperSlot || i == _lowerSlot || i == _handSlot )
continue;
for ( int f = 0; f < fingers.Count; f++ )
{
if ( string.Equals( fingers[f].Bone, _bones[i].Name, StringComparison.Ordinal ) )
{
_hasFinger[i] = true;
_finger[i] = fingers[f].Rotation;
break;
}
}
}
}
private float FingerProgress( double now )
{
if ( _fingerSeconds <= 1e-4f || double.IsNegativeInfinity( _fingerStart ) )
return 1f;
return HoldData.SmoothStep( (float)((now - _fingerStart) / _fingerSeconds) );
}
/// <summary>Bone indices of the subtree.</summary>
public void Collect( HashSet<int> into )
{
foreach ( var b in _bones )
into.Add( b.Index );
if ( _clavicle != null )
into.Add( _clavicle.Index );
}
/// <summary>Reads the animation-only pose of the subtree into character model space.</summary>
public void ReadAnimation( WeaponHold owner, SkinnedModelRenderer body, Transform bodyTx, bool clean )
{
if ( _clavicle != null )
_clavicleAnim = owner.TryGetAnim( body, _clavicle, out var clavicleWorld ) ? bodyTx.ToLocal( clavicleWorld ) : global::Transform.Zero;
if ( clean )
{
for ( int i = 0; i < _bones.Length; i++ )
{
int parent = _parent[i];
if ( parent < 0 || i == _lowerSlot || i == _handSlot )
continue;
if ( body.TryGetBoneTransform( _bones[i], out var child ) && body.TryGetBoneTransform( _bones[parent], out var parentFinal ) )
{
_final[i] = parentFinal.ToLocal( child ).WithScale( 1f );
_hasFinal[i] = true;
}
}
}
for ( int i = 0; i < _bones.Length; i++ )
{
if ( owner.TryGetAnim( body, _bones[i], out var world ) )
_anim[i] = bodyTx.ToLocal( world );
else
_anim[i] = _parent[i] >= 0 ? _anim[_parent[i]].ToWorld( _bones[_parent[i]].LocalTransform.ToLocal( _bones[i].LocalTransform ) ) : global::Transform.Zero;
if ( _parent[i] >= 0 )
_local[i] = _hasFinal[i] ? _final[i] : _anim[_parent[i]].ToLocal( _anim[i] );
}
float a = (_anim[_lowerSlot].Position - _anim[UpperSlot].Position).Length;
float b = (_anim[_handSlot].Position - _anim[_lowerSlot].Position).Length;
Reach = a + b;
}
public float TargetDistance( Vector3 target ) => (target - _anim[UpperSlot].Position).Length;
/// <summary>Solves towards the model-space target and updates the smoothed correction. Returns the shortfall.</summary>
public float Solve( Transform target, float alpha, bool snap, Vector3? elbowHint = null, float hintWeight = 0f )
{
var clavicleDelta = ClavicleSwing( target.Position );
var pivot = _clavicleAnim.Position;
Vector3 Moved( Vector3 p ) => pivot + clavicleDelta * (p - pivot);
var upperAnim = clavicleDelta * _anim[UpperSlot].Rotation;
var lowerAnim = clavicleDelta * _anim[_lowerSlot].Rotation;
var handAnim = clavicleDelta * _anim[_handSlot].Rotation;
var solution = ArmSolver.Solve( Moved( _anim[UpperSlot].Position ), Moved( _anim[_lowerSlot].Position ), Moved( _anim[_handSlot].Position ), target.Position, elbowHint, hintWeight );
var q1 = solution.UpperDelta;
var q2 = solution.LowerDelta;
// Express the corrections in each bone's own FK frame so they ride along with the body.
var upperDelta = upperAnim.Inverse * q1 * upperAnim;
var lowerFk = q1 * lowerAnim;
var lowerDelta = lowerFk.Inverse * q2 * lowerFk;
var handFk = q2 * q1 * handAnim;
var handDelta = handFk.Inverse * target.Rotation;
if ( snap || !_hasHistory || alpha >= 1f )
{
_upperDelta = upperDelta;
_lowerDelta = lowerDelta;
_handDelta = handDelta;
_clavicleDelta = clavicleDelta;
_hasHistory = true;
}
else if ( alpha > 0f )
{
_upperDelta = ArmSolver.Slerp( _upperDelta, upperDelta, alpha );
_lowerDelta = ArmSolver.Slerp( _lowerDelta, lowerDelta, alpha );
_handDelta = ArmSolver.Slerp( _handDelta, handDelta, alpha );
_clavicleDelta = ArmSolver.Slerp( _clavicleDelta, clavicleDelta, alpha );
}
return solution.Shortfall;
}
/// <summary>Share of the wrist's swing (animated wrist -> target, about the clavicle) the clavicle takes.</summary>
private Rotation ClavicleSwing( Vector3 target )
{
if ( _clavicle == null )
return Rotation.Identity;
var pivot = _clavicleAnim.Position;
var swing = ArmSolver.FromTo( _anim[_handSlot].Position - pivot, target - pivot );
float angle = MathF.Acos( Math.Clamp( MathF.Abs( swing.w ), 0f, 1f ) ) * 2f * 180f / MathF.PI;
if ( angle < 1e-3f )
return Rotation.Identity;
// Hierarchical: small corrections stay in the elbow and wrist; the clavicle joins in
// only once the arm has to swing a lot, and never takes more than its share.
float engage = HoldData.SmoothStep( (angle - ClavicleEngageDegrees) / (ClavicleFullDegrees - ClavicleEngageDegrees) );
if ( engage <= 0f )
return Rotation.Identity;
float share = MathF.Min( angle * ClavicleShare * engage, ClavicleMaxDegrees ) / angle;
return ArmSolver.Weighted( swing, share );
}
/// <summary>Forward kinematics with the weighted correction; writes an override for every bone of the subtree.</summary>
public void Write( SceneModel sceneModel, float weight )
{
var upperDelta = ArmSolver.Weighted( _upperDelta, weight );
var lowerDelta = ArmSolver.Weighted( _lowerDelta, weight );
var handDelta = ArmSolver.Weighted( _handDelta, weight );
var upperTwistInverse = ArmSolver.Twist( upperDelta, _upperAxis ).Inverse;
var clavicleDelta = ArmSolver.Weighted( _clavicleDelta, weight );
if ( _clavicle != null )
sceneModel.SetBoneOverride( _clavicle.Index, new Transform( _clavicleAnim.Position, clavicleDelta * _clavicleAnim.Rotation ) );
for ( int i = 0; i < _bones.Length; i++ )
{
Transform pose;
int parent = _parent[i];
if ( parent < 0 )
{
pose = _anim[i];
if ( _clavicle != null )
pose = new Transform( _clavicleAnim.Position + clavicleDelta * (pose.Position - _clavicleAnim.Position), clavicleDelta * pose.Rotation );
}
else if ( _hasFinger[i] )
{
var local = _local[i];
var finger = _finger[i];
float progress = FingerProgress( Now );
if ( progress < 1f )
finger = ArmSolver.Slerp( _hasFingerFrom[i] ? _fingerFrom[i] : local.Rotation, finger, progress );
local.Rotation = ArmSolver.Slerp( local.Rotation, finger, weight );
pose = _pose[parent].ToWorld( local );
}
else if ( _twistCancel[i] > 0f )
{
var local = _local[i];
local.Rotation = ArmSolver.Slerp( Rotation.Identity, upperTwistInverse, _twistCancel[i] ) * local.Rotation;
pose = _pose[parent].ToWorld( local );
}
else
{
pose = _pose[parent].ToWorld( _local[i] );
}
if ( i == UpperSlot )
pose.Rotation = pose.Rotation * upperDelta;
else if ( i == _lowerSlot )
pose.Rotation = pose.Rotation * lowerDelta;
else if ( i == _handSlot )
pose.Rotation = pose.Rotation * handDelta;
_pose[i] = pose;
sceneModel.SetBoneOverride( _bones[i].Index, pose );
}
}
}
}