#include "Player/Body/DescentBodyAnim.h" #include "Player/Body/DescentBodyProfile.h" #include "Core/DescentMath.h" #include "Core/Tuning/DescentTune_Body.h" #include "TheDescent.h" #include "Animation/AnimInstanceProxy.h" #include "Animation/AnimNodeBase.h" #include "Animation/AnimSequence.h" #include "Animation/AttributesRuntime.h" #include "AnimationRuntime.h" #include "BonePose.h" #include "HAL/IConsoleManager.h" #include "TwoBoneIK.h" namespace { namespace BodyTune = Descent::Tune::Body; constexpr int32 BodySeqInt(EDescentBodySeq S) { return static_cast(S); } /** Looping sequences wrap, one-shots stop on their last frame. */ double BodyAdvanceTime(EDescentBodySeq Seq, double Time, double Step, const FDescentBodyLengths& Len) { const double L = Len[Seq]; if (L <= 0.0) { return 0.0; } Time += Step; if (Descent::Body::SeqLoops(Seq)) { Time = FMath::Fmod(Time, L); return Time < 0.0 ? Time + L : Time; } return FMath::Clamp(Time, 0.0, L); } double BodySmooth(double X) { X = FMath::Clamp(X, 0.0, 1.0); return X * X * (3.0 - 2.0 * X); } } // --------------------------------------------------------------------------------------------------------- // Names and lengths // --------------------------------------------------------------------------------------------------------- const TCHAR* Descent::Body::SeqAssetName(EDescentBodySeq Seq) { static const TCHAR* Names[SeqCount] = { TEXT("Idle"), TEXT("Walk_Fwd"), TEXT("Walk_Bwd"), TEXT("Walk_Left"), TEXT("Walk_Right"), TEXT("Jog_Fwd"), TEXT("Jog_Bwd"), TEXT("Jog_Left"), TEXT("Jog_Right"), TEXT("Jump_Start"), TEXT("Jump_Apex"), TEXT("Jump_Fall_Loop"), TEXT("Jump_Land"), TEXT("Idle_Relaxed"), TEXT("Swim_Idle"), TEXT("Swim_Fwd"), TEXT("Swim_Left"), TEXT("Swim_Right"), TEXT("Touch"), TEXT("Push_Button"), TEXT("Run_Fwd") }; const int32 I = BodySeqInt(Seq); return (I >= 0 && I < SeqCount) ? Names[I] : TEXT("?"); } const TCHAR* Descent::Body::SeqRoleName(EDescentBodySeq Seq) { // the "role" of a row of the animation table (Config/DescentBody.json); a test holds the table and this list together static const TCHAR* Roles[SeqCount] = { TEXT("Idle"), TEXT("WalkFwd"), TEXT("WalkBwd"), TEXT("WalkLeft"), TEXT("WalkRight"), TEXT("JogFwd"), TEXT("JogBwd"), TEXT("JogLeft"), TEXT("JogRight"), TEXT("JumpStart"), TEXT("JumpApex"), TEXT("FallLoop"), TEXT("Land"), TEXT("IdleRelaxed"), TEXT("SwimIdle"), TEXT("SwimFwd"), TEXT("SwimLeft"), TEXT("SwimRight"), TEXT("Touch"), TEXT("PushButton"), TEXT("RunFwd") }; const int32 I = BodySeqInt(Seq); return (I >= 0 && I < SeqCount) ? Roles[I] : TEXT("?"); } FString UDescentBodyAnimInstance::SeqAssetPath(int32 Set, EDescentBodySeq Seq) { // Set 1: the role's sequence baked onto the model by Tools/ue/assets_14_body_anims.py. Set 0: the model's own pack sequence for // the role, where it has one (Twinblast's pack has no swim and no reach: its profile names fall-backs). const FDescentBodyProfile& Profile = FDescentBodyProfile::Get(); const FString Role = Descent::Body::SeqRoleName(Seq); return Set == 1 ? Profile.BakedPath(Role) : Profile.LegacyPath(Role); } const TCHAR* Descent::Body::MotionName(EDescentBodyMotion Motion) { switch (Motion) { case EDescentBodyMotion::Idle: return TEXT("idle"); case EDescentBodyMotion::Moving: return TEXT("moving"); case EDescentBodyMotion::Jumping: return TEXT("jumping"); case EDescentBodyMotion::Falling: return TEXT("falling"); case EDescentBodyMotion::Landing: return TEXT("landing"); case EDescentBodyMotion::Swimming: return TEXT("swimming"); case EDescentBodyMotion::Held: return TEXT("held"); } return TEXT("?"); } bool Descent::Body::SeqLoops(EDescentBodySeq Seq) { return Seq != EDescentBodySeq::JumpStart && Seq != EDescentBodySeq::JumpApex && Seq != EDescentBodySeq::Land && Seq != EDescentBodySeq::Touch && Seq != EDescentBodySeq::PushButton; } FDescentBodyLengths::FDescentBodyLengths(int32 InSet) { using ESeq = EDescentBodySeq; // measured from the assets (the pack: the inspection of 2026-10-11; the retargeted set: Saved/ImportState/twinblast_anims.json); // LoadSequences overwrites the lengths with the assets' own static const double Pack[Descent::Body::SeqCount] = { 10.0, 2.2333, 1.9, 1.8667, 2.0333, 1.8667, 1.5667, 1.5667, 1.7333, 0.3, 0.2667, 1.8333, 0.1667, 16.1, 1.8333, 1.8333, 1.8333, 1.8333, 10.0, 10.0, 1.8667 }; static const double Baked[Descent::Body::SeqCount] = { 6.7083, 3.7583, 2.7167, 2.7917, 2.7917, 1.0667, 1.0667, 1.0667, 1.0667, 0.8667, BodyTune::JumpApexSecSet1, 3.0, 0.8667, 6.7083, 2.9333, 1.0667, 1.0, 1.0, 4.8667, 2.8333, BodyTune::RunLengthSet1Sec }; Set = InSet == 1 ? 1 : 0; for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { Sec[I] = Set == 1 ? Baked[I] : Pack[I]; SpeedCmS[I] = 0.0; RootSpeedCmS[I] = 0.0; bReversed[I] = false; BobSteps[I] = 0; BobPhase[I] = 0.0; bRootLock[I] = Set == 1; } for (int32 D = 0; D < 4; ++D) { const int32 W = BodySeqInt(ESeq::WalkFwd) + D, J = BodySeqInt(ESeq::JogFwd) + D; SpeedCmS[W] = Set == 1 ? BodyTune::WalkSpeedSet1CmS[D] : BodyTune::WalkAnimSpeedCmS; SpeedCmS[J] = Set == 1 ? BodyTune::JogSpeedSet1CmS : BodyTune::JogAnimSpeedCmS; if (Set == 1) { BobSteps[W] = BodyTune::WalkBobStepsSet1[D]; BobPhase[W] = BodyTune::WalkBobPhaseSet1[D]; BobSteps[J] = BodyTune::JogBobStepsSet1; BobPhase[J] = BodyTune::JogBobPhaseSet1; } } const int32 Run = BodySeqInt(ESeq::RunFwd); SpeedCmS[Run] = Set == 1 ? BodyTune::RunSpeedSet1CmS : BodyTune::JogAnimSpeedCmS; if (Set == 1) { BobSteps[Run] = BodyTune::RunBobStepsSet1; BobPhase[Run] = BodyTune::RunBobPhaseSet1; } bReversed[BodySeqInt(ESeq::JogBwd)] = Set == 1; } void FDescentBodyLengths::ApplyProfile(const FDescentBodyProfile& Profile) { if (Set != 1) { return; } for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { const FString Role = Descent::Body::SeqRoleName(static_cast(I)); if (const FDescentBodyAnimRow* Row = Profile.FindRow(Role)) { bReversed[I] = Row->bReversed; bRootLock[I] = Row->bRootLock; } const FDescentBodyAnimMeasure* M = Profile.FindMeasure(Role); if (M && M->SpeedCmS > 1.0) { SpeedCmS[I] = M->SpeedCmS * Profile.Scale; RootSpeedCmS[I] = M->RootSpeedCmS * Profile.Scale; BobSteps[I] = M->BobSteps; BobPhase[I] = M->BobPhase; } } } // --------------------------------------------------------------------------------------------------------- // The state // --------------------------------------------------------------------------------------------------------- void FDescentBodyFader::Play(EDescentBodySeq Seq, double StartTime, double InFadeSec) { if (Seq == Cur) { return; } Prev = Cur; PrevTime = CurTime; Cur = Seq; CurTime = StartTime; FadeSec = InFadeSec; Fade = InFadeSec > 0.0 ? 0.0 : 1.0; } void FDescentBodyFader::Advance(double Dt, double Rate, const FDescentBodyLengths& Len) { CurTime = BodyAdvanceTime(Cur, CurTime, Dt * Rate, Len); PrevTime = BodyAdvanceTime(Prev, PrevTime, Dt * Rate, Len); if (Fade < 1.0) { Fade = FadeSec > 0.0 ? FMath::Min(1.0, Fade + Dt / FadeSec) : 1.0; } } void FDescentBodyAnimState::Step(const FDescentBodyAnimInput& In, double Dt, const FDescentBodyLengths& Len) { using ESeq = EDescentBodySeq; Dt = FMath::Max(0.0, Dt); bHeldNow = In.bHeld; const bool bSwim = !In.bHeld && In.bSwimming; const bool bAir = !In.bHeld && !bSwim && In.bInAir; SpeedCmS = In.bHeld ? 0.0 : In.VelLocalCmS.Size(); bMovingNow = !bSwim && SpeedCmS > BodyTune::MoveFromCmS; // ---- idle against the locomotion clip ---- const double MoveTarget = bSwim ? 0.0 : FMath::Clamp((SpeedCmS - BodyTune::MoveFromCmS) / (BodyTune::MoveFullCmS - BodyTune::MoveFromCmS), 0.0, 1.0); MoveAlpha = Descent::Damp(MoveAlpha, MoveTarget, BodyTune::MoveBlendK, Dt); double TurnTarget = 0.0; if (bMovingNow) { const bool bWasJog = bJog, bWasRun = bRun; if (bJog) { if (SpeedCmS < BodyTune::JogAboveCmS - BodyTune::JogHysteresisCmS) { bJog = false; } } else if (SpeedCmS > BodyTune::JogAboveCmS + BodyTune::JogHysteresisCmS) { bJog = true; } // the run clip (sprint) above RunAboveCmS, in the retargeted set const bool bCanRun = Len.Set == 1 && !Len.bOldLoco && Len[ESeq::RunFwd] > 0.0; if (bRun) { if (!bCanRun || SpeedCmS < BodyTune::RunAboveCmS - BodyTune::RunHysteresisCmS) { bRun = false; } } else if (bCanRun && SpeedCmS > BodyTune::RunAboveCmS + BodyTune::RunHysteresisCmS) { bRun = true; } const double F = In.VelLocalCmS.X, R = In.VelLocalCmS.Y; ESeq Want = ESeq::RunFwd; if (bRun) { // One run clip for every direction: the legs are turned toward where he goes and the chest stays with the view (the proxy // turns the pelvis and turns the spine back). More than LowerBackFromDeg off the view he runs backward: the clip in // reverse, the legs toward the opposite of where he goes. const double Angle = FMath::RadiansToDegrees(FMath::Atan2(R, F)); // 0 ahead, +90 to his right if (bLocoBackward) { if (FMath::Abs(Angle) < BodyTune::LowerBackFromDeg - BodyTune::LowerBackHysteresisDeg) { bLocoBackward = false; } } else if (FMath::Abs(Angle) > BodyTune::LowerBackFromDeg + BodyTune::LowerBackHysteresisDeg) { bLocoBackward = true; } TurnTarget = FMath::Clamp(bLocoBackward ? Angle - (Angle >= 0.0 ? 180.0 : -180.0) : Angle, -BodyTune::LowerTurnMaxDeg, BodyTune::LowerTurnMaxDeg); } else { bLocoBackward = false; // One clip at a time (the clips have different numbers of strides, so blending two of them smears the legs): // the direction of the larger component, with a margin so a diagonal does not flicker between two clips. const int32 CurDir = Loco.Cur == ESeq::RunFwd ? 0 : ((BodySeqInt(Loco.Cur) - BodySeqInt(ESeq::WalkFwd)) & 3); // 0 fwd, 1 bwd, 2 left, 3 right const bool bFwdAxis = CurDir < 2 ? (FMath::Abs(F) >= 0.8 * FMath::Abs(R)) : (FMath::Abs(F) > 1.25 * FMath::Abs(R)); const int32 Dir = bFwdAxis ? (F >= 0.0 ? 0 : 1) : (R >= 0.0 ? 3 : 2); Want = static_cast(BodySeqInt(bJog ? ESeq::JogFwd : ESeq::WalkFwd) + Dir); } if (Want != Loco.Cur) { const double Frac = Len[Loco.Cur] > 0.0 ? Loco.CurTime / Len[Loco.Cur] : 0.0; // keep the place in the cycle Loco.Play(Want, Frac * Len[Want], MoveAlpha < 0.05 ? 0.0 : ((bJog != bWasJog || bRun != bWasRun) ? BodyTune::GaitFadeSec : BodyTune::DirectionFadeSec)); } // The play rate from the speed the clip's LEGS cover the ground at (the planted foot against the body, measured per baked // clip), so the feet do not skate. (Before: from the source's root travel, which the retargeted legs fall 27-33 % short of.) const int32 Cur = BodySeqInt(Loco.Cur); const double ClipSpeed = (Len.bOldLoco && Len.RootSpeedCmS[Cur] > 1.0) ? Len.RootSpeedCmS[Cur] : Len.SpeedCmS[Cur]; LocoRate = FMath::Clamp(SpeedCmS / (ClipSpeed > 1.0 ? ClipSpeed : BodyTune::JogAnimSpeedCmS), BodyTune::MinPlayRate, BodyTune::MaxPlayRate); } else { bRun = false; bLocoBackward = false; } LowerTurnDeg = Descent::Damp(LowerTurnDeg, TurnTarget, BodyTune::LowerTurnK, Dt); const bool bBackward = Len.bReversed[BodySeqInt(Loco.Cur)] != (bLocoBackward && Loco.Cur == ESeq::RunFwd); Loco.Advance(Dt, bBackward ? -LocoRate : LocoRate, Len); IdleTime = Len[ESeq::Idle] > 0.0 ? FMath::Fmod(IdleTime + Dt, Len[ESeq::Idle]) : 0.0; RelaxTime = Len[ESeq::IdleRelaxed] > 0.0 ? FMath::Fmod(RelaxTime + Dt, Len[ESeq::IdleRelaxed]) : 0.0; RelaxAlpha = Descent::Damp(RelaxAlpha, In.bRelaxed ? 1.0 : 0.0, BodyTune::RelaxBlendK, Dt); // ---- the action channel: in the air, landing, swimming ---- // Starts a clip of the channel from its first frame. While the channel has no weight there is nothing to fade from. auto PlayAct = [this](ESeq Seq, double FadeSec) { if (ActionAlpha < 0.05) { Act.Cur = Act.Prev = Seq; Act.CurTime = Act.PrevTime = 0.0; Act.Fade = 1.0; } else if (Act.Cur == Seq) { Act.CurTime = 0.0; } else { Act.Play(Seq, 0.0, FadeSec); } }; double ActionTarget = 0.0, ActRate = 1.0; if (bSwim && Len.Set == 1) { // The retargeted set: tread water, or a stroke forward / to a side (backward and straight up or down tread). const double F = In.VelLocalCmS.X, R = In.VelLocalCmS.Y, Speed3 = FMath::Sqrt(F * F + R * R + In.VerticalCmS * In.VerticalCmS); const double Planar = FMath::Max(F, FMath::Abs(R)); bSwimStroke = Planar > (bSwimStroke ? BodyTune::SwimMoveBelowCmS : BodyTune::SwimMoveFromCmS); const bool bSide = Act.Cur == ESeq::SwimLeft || Act.Cur == ESeq::SwimRight; const ESeq Want = !bSwimStroke ? ESeq::SwimIdle : ((bSide ? FMath::Abs(R) > 0.8 * F : FMath::Abs(R) > 1.25 * F) ? (R >= 0.0 ? ESeq::SwimRight : ESeq::SwimLeft) : ESeq::SwimFwd); if (Action != EAction::Swim || Act.Cur != Want) { Action = EAction::Swim; if (ActionAlpha < 0.05) { Act.Cur = Act.Prev = Want; Act.CurTime = Act.PrevTime = 0.0; Act.Fade = 1.0; } else { Act.Play(Want, 0.0, BodyTune::SwimClipFadeSec); } } ActionTarget = 1.0; ActRate = bSwimStroke ? FMath::Clamp(Speed3 / BodyTune::SwimAnimSpeedCmS, BodyTune::SwimMinRate, BodyTune::SwimMaxRate) : 1.0; AirTime = 0.0; // the arm stroke made in code: its rate follows the swim speed (slow strokes with a long glide, quick ones at a sprint) if (bSwimStroke) { SwimStroke = FMath::Frac(SwimStroke + Dt * FMath::Lerp(BodyTune::SwimStrokeMinHz, BodyTune::SwimStrokeMaxHz, FMath::Clamp(Speed3 / BodyTune::SwimStrokeFullCmS, 0.0, 1.0))); } SwimScullSec += Dt; } else if (bSwim) { if (Action != EAction::Swim) { Action = EAction::Swim; if (Act.Cur != ESeq::FallLoop) { PlayAct(ESeq::FallLoop, BodyTune::SwimFadeSec); } // already the fall loop: it simply slows down } ActionTarget = 1.0; ActRate = BodyTune::SwimPlayRate; AirTime = 0.0; } else if (bAir) { if (!bWasAir && In.VerticalCmS > BodyTune::JumpMinUpCmS) { Action = EAction::JumpStart; PlayAct(ESeq::JumpStart, BodyTune::JumpStartFadeSec); } AirTime += Dt; if (Action == EAction::JumpStart && Act.CurTime >= Len[ESeq::JumpStart] - 1e-6) { Action = EAction::Apex; PlayAct(ESeq::JumpApex, BodyTune::ApexFadeSec); } else if (Action == EAction::Apex && Act.CurTime >= Len[ESeq::JumpApex] - 1e-6) { Action = EAction::Fall; PlayAct(ESeq::FallLoop, BodyTune::FallFadeSec); } else if ((Action == EAction::None || Action == EAction::Land || Action == EAction::Swim) && AirTime >= BodyTune::FallAfterSec) { Action = EAction::Fall; PlayAct(ESeq::FallLoop, BodyTune::FallFadeSec); } ActionTarget = (Action == EAction::JumpStart || Action == EAction::Apex || Action == EAction::Fall) ? 1.0 : 0.0; } else { if (bWasAir && !In.bHeld && AirTime >= BodyTune::LandAfterAirSec) { Action = EAction::Land; PlayAct(ESeq::Land, BodyTune::LandFadeSec); } else if (Action != EAction::Land || In.bHeld || Act.CurTime >= Len[ESeq::Land] - 1e-6) { Action = EAction::None; } ActionTarget = Action == EAction::Land ? 1.0 : 0.0; AirTime = 0.0; } ActionAlpha = Descent::Damp(ActionAlpha, ActionTarget, BodyTune::ActionBlendK, Dt); Act.Advance(Dt, ActRate, Len); bWasAir = bAir; // ---- 2026-10-12: the swim shift, the calmer upper body of the own view, the upper-body overlay (jack-in, "use") ---- const bool bSwimClips = bSwim && Len.Set == 1; if (!bSwimClips) { bSwimStroke = false; } SwimAlpha = bSwimClips ? ActionAlpha : Descent::Damp(SwimAlpha, 0.0, BodyTune::ActionBlendK, Dt); SwimMove = Descent::Damp(SwimMove, bSwimStroke ? 1.0 : 0.0, BodyTune::SwimMoveBlendK, Dt); UpperCalm = In.bOwnView ? BodyTune::UpperBodyCalmOwnView * FMath::Clamp(MoveAlpha, 0.0, 1.0) * (1.0 - FMath::Clamp(ActionAlpha, 0.0, 1.0)) : 0.0; double OverTarget = 0.0; if (Len.Set == 1 && In.JackSec >= 0.0 && In.JackSec < BodyTune::JackEndSec) { // Touch_R: out to its furthest reach while the arm raises, reaches and plugs; held there through the feed; back the same way. const double Out = FMath::Clamp(In.JackSec / BodyTune::JackReachSec, 0.0, 1.0); const double Back = FMath::Clamp((In.JackSec - BodyTune::JackHoldEndSec) / (BodyTune::JackEndSec - BodyTune::JackHoldEndSec), 0.0, 1.0); OverSeq = ESeq::Touch; JackReach = BodySmooth(Out) * (1.0 - BodySmooth(Back)); OverTime = BodyTune::TouchPeakSec * JackReach; OverTarget = 1.0; } else { JackReach = Descent::Damp(JackReach, 0.0, BodyTune::OverlayBlendK, Dt); } if (OverTarget > 0.0 && OverSeq == ESeq::Touch) { /* the jack-in owns the overlay */ } else if (Len.Set == 1 && In.UseSec >= 0.0 && In.UseSec * BodyTune::UsePlayRate < Len[ESeq::PushButton] && (OverSeq == ESeq::PushButton || OverAlpha < 0.05)) { OverSeq = ESeq::PushButton; OverTime = In.UseSec * BodyTune::UsePlayRate; OverTarget = (Len[ESeq::PushButton] - OverTime) > 0.25 ? 1.0 : 0.0; // out before the clip ends } OverAlpha = Descent::Damp(OverAlpha, OverTarget, BodyTune::OverlayBlendK, Dt); } FVector FDescentBodyAnimState::SwimHandEyeCm(double Stroke, double Move, double ScullSec, bool bLeft) { // The right hand, in the eye's frame (x ahead along the view, y right, z up); the left is its mirror. Every key is inside a // 72 x 44.5 degree picture (|y| < 0.72 x, -z < 0.40 x) and within the arm's reach of a shoulder under the eye. // The breaststroke: a glide with the hands together ahead (0 to 0.30 of the cycle), the sweep out and back, the recovery // under the chin, the push forward. static const double KeyAt[6] = { 0.0, 0.30, 0.45, 0.60, 0.78, 1.0 }; // (first try: the sweep and the recovery sat ON the picture's edges, wrists at 0.95 of its width and 0.98 of its height; these are well inside) static const FVector Key[6] = { FVector(44.0, 5.0, -9.0), FVector(44.0, 5.0, -9.0), FVector(40.0, 20.0, -9.0), FVector(34.0, 18.0, -10.0), FVector(32.0, 6.0, -10.0), FVector(44.0, 5.0, -9.0) }; const double U = FMath::Frac(Stroke); int32 I = 0; while (I < 4 && U >= KeyAt[I + 1]) { ++I; } const double A = (U - KeyAt[I]) / (KeyAt[I + 1] - KeyAt[I]); const FVector StrokeAt = FMath::Lerp(Key[I], Key[I + 1], A * A * (3.0 - 2.0 * A)); // treading water: both hands scull low near the lower corners, out and in const double S = 2.0 * UE_DOUBLE_PI * BodyTune::SwimScullHz * ScullSec; const FVector ScullAt(32.0 + 1.5 * FMath::Cos(S), 15.0 + 3.0 * FMath::Sin(S), -10.5 + 0.5 * FMath::Sin(2.0 * S)); FVector At = FMath::Lerp(ScullAt, StrokeAt, BodySmooth(Move)); if (bLeft) { At.Y = -At.Y; } return At; } bool FDescentBodyAnimState::ViewBob(const FDescentBodyLengths& Len, double AheadSec, double& OutSignal, double* OutFootfallHz) const { OutSignal = 0.0; if (OutFootfallHz) { *OutFootfallHz = 0.0; } if (Len.BobSteps[BodySeqInt(Loco.Cur)] <= 0) { return false; } if (OutFootfallHz && Len[Loco.Cur] > 0.0) { *OutFootfallHz = Len.BobSteps[BodySeqInt(Loco.Cur)] * LocoRate / Len[Loco.Cur]; } auto Wave = [&](EDescentBodySeq Seq, double Time) { const int32 I = BodySeqInt(Seq); if (Len.Sec[I] <= 0.0 || Len.BobSteps[I] <= 0) { return 0.0; } const double T = Time + AheadSec * (Len.bReversed[I] ? -LocoRate : LocoRate); return FMath::Cos(2.0 * UE_DOUBLE_PI * Len.BobSteps[I] * T / Len.Sec[I] - Len.BobPhase[I]); }; const double Fade = BodySmooth(Loco.Fade); OutSignal = (Fade * Wave(Loco.Cur, Loco.CurTime) + (1.0 - Fade) * Wave(Loco.Prev, Loco.PrevTime)) * BodySmooth(MoveAlpha) * (1.0 - FMath::Clamp(ActionAlpha, 0.0, 1.0)); return true; } void FDescentBodyAnimState::BuildClips(FDescentBodyClipList& Out) const { Out.Reset(); const double A = FMath::Clamp(ActionAlpha, 0.0, 1.0); const double M = FMath::Clamp(MoveAlpha, 0.0, 1.0) * (1.0 - A); const double LocoFade = BodySmooth(Loco.Fade), ActFade = BodySmooth(Act.Fade); const double Stand = (1.0 - FMath::Clamp(MoveAlpha, 0.0, 1.0)) * (1.0 - A); const double Relax = BodySmooth(RelaxAlpha); const FDescentBodyClip All[6] = { { EDescentBodySeq::Idle, IdleTime, Stand * (1.0 - Relax) }, { EDescentBodySeq::IdleRelaxed, RelaxTime, Stand * Relax }, { Loco.Cur, Loco.CurTime, M * LocoFade }, { Loco.Prev, Loco.PrevTime, M * (1.0 - LocoFade) }, { Act.Cur, Act.CurTime, A * ActFade }, { Act.Prev, Act.PrevTime, A * (1.0 - ActFade) } }; double Sum = 0.0; for (const FDescentBodyClip& C : All) { if (C.Weight > 1e-3) { Out.Add(C); Sum += C.Weight; } } if (Out.Num() == 0) { Out.Add({ EDescentBodySeq::Idle, IdleTime, 1.0 }); return; } for (FDescentBodyClip& C : Out) { C.Weight /= Sum; } } EDescentBodyMotion FDescentBodyAnimState::Motion() const { if (bHeldNow) { return EDescentBodyMotion::Held; } switch (Action) { case EAction::Swim: return EDescentBodyMotion::Swimming; case EAction::JumpStart: case EAction::Apex: return EDescentBodyMotion::Jumping; case EAction::Fall: return EDescentBodyMotion::Falling; case EAction::Land: return EDescentBodyMotion::Landing; default: break; } return bMovingNow ? EDescentBodyMotion::Moving : EDescentBodyMotion::Idle; } // --------------------------------------------------------------------------------------------------------- // The proxy: samples and blends the pack's sequences itself (no anim graph) // --------------------------------------------------------------------------------------------------------- struct FDescentBodyAnimProxy : public FAnimInstanceProxy { FDescentBodyAnimProxy() = default; explicit FDescentBodyAnimProxy(UAnimInstance* Instance) : FAnimInstanceProxy(Instance) {} virtual bool Evaluate(FPoseContext& Output) override; // written on the game thread by UDescentBodyAnimInstance::NativeUpdateAnimation, read by the evaluation that follows it FDescentBodyClipList Clips; const UAnimSequence* Seqs[Descent::Body::SeqCount] = {}; double ArmRaiseLeftDeg = 0.0, ArmInLeftDeg = 0.0, ArmRaiseRightDeg = 0.0, ArmInRightDeg = 0.0; // 2026-10-12 bool RootLock[Descent::Body::SeqCount] = {}; // per clip (the table's root_lock): the root stays at the reference pose (in place) FDescentBodyBones Bones; // the model's bone names by role (its profile) double LowerTurnDeg = 0.0; // pelvis turned toward the run's direction, spine turned back double AnchorWeight = 0.0; // 1 own view; the swim's weight in any other view bool bFlatFeet = true; // the look-down lean turns the feet flat again; the swim's pitch does not double SwimIK = 0.0; // weight of the two swim-arm IKs FVector SwimHandLeft = FVector::ZeroVector, SwimHandRight = FVector::ZeroVector; double UpperCalm = 0.0; // share of the base pose's upper body replaced by the idle pose double IdleTime = 0.0; EDescentBodySeq OverSeq = EDescentBodySeq::Touch; double OverTime = 0.0, OverAlpha = 0.0; bool bAnchor = false; // the body under the camera (own view) FVector AnchorTarget = FVector::ZeroVector; double AnchorVertical = 0.0, LeanDeg = 0.0; double JackIK = 0.0; // weight of the right-arm IK to JackTarget FVector JackTarget = FVector::ZeroVector; void Sample(const UAnimSequence* Seq, double Time, bool bLock, FCompactPose& Pose, FBlendedCurve& Curve, UE::Anim::FStackAttributeContainer& Attributes) const { FAnimationPoseData Data(Pose, Curve, Attributes); Seq->GetAnimationPose(Data, FAnimExtractContext(Time, false)); if (bLock) { Pose[FCompactPoseBoneIndex(0)] = Pose.GetBoneContainer().GetRefPoseTransform(FCompactPoseBoneIndex(0)); } } /** Blends Alpha of a sequence's pose into the spine and everything above it (head, arms); the pelvis and legs keep the base pose. */ void BlendUpperBody(FPoseContext& Output, EDescentBodySeq Which, double Time, double Alpha, bool bPelvisHeight = false) const; }; namespace { /** * Turns one upper arm about its shoulder, in the mesh's own frame (+Y forward, +Z up, +X the character's left): RaiseDeg lifts a * forward-pointing arm, InDeg swings it toward the middle. The forearm and hand follow rigidly; nothing else moves. */ void BodyTurnUpperArm(FCompactPose& Pose, const FName& Bone, double RaiseDeg, double InDeg, bool bLeft) { if (FMath::IsNearlyZero(RaiseDeg) && FMath::IsNearlyZero(InDeg)) { return; } const FBoneContainer& Bones = Pose.GetBoneContainer(); const int32 MeshIndex = Bones.GetPoseBoneIndexForBoneName(Bone); if (MeshIndex == INDEX_NONE) { return; } const FCompactPoseBoneIndex Index = Bones.MakeCompactPoseIndex(FMeshPoseBoneIndex(MeshIndex)); if (Index.GetInt() == INDEX_NONE) { return; } FQuat ParentRot = FQuat::Identity; // the parent's rotation in component space for (FCompactPoseBoneIndex P = Bones.GetParentBoneIndex(Index); P.GetInt() != INDEX_NONE; P = Bones.GetParentBoneIndex(P)) { ParentRot = Pose[P].GetRotation() * ParentRot; } const FQuat Raise(FVector::XAxisVector, FMath::DegreesToRadians(RaiseDeg)); const FQuat In(FVector::ZAxisVector, FMath::DegreesToRadians(bLeft ? InDeg : -InDeg)); FTransform& T = Pose[Index]; T.SetRotation((ParentRot.Inverse() * (Raise * In) * ParentRot * T.GetRotation()).GetNormalized()); } } namespace { FCompactPoseBoneIndex BodyCompact(const FBoneContainer& Bones, const FName& Bone) { const int32 MeshIndex = Bones.GetPoseBoneIndexForBoneName(Bone); return MeshIndex != INDEX_NONE ? Bones.MakeCompactPoseIndex(FMeshPoseBoneIndex(MeshIndex)) : FCompactPoseBoneIndex(INDEX_NONE); } /** A bone's transform in the component's space, from the local pose. */ FTransform BodyComponentSpace(const FCompactPose& Pose, FCompactPoseBoneIndex Index) { const FBoneContainer& Bones = Pose.GetBoneContainer(); FTransform T = Pose[Index]; for (FCompactPoseBoneIndex P = Bones.GetParentBoneIndex(Index); P.GetInt() != INDEX_NONE; P = Bones.GetParentBoneIndex(P)) { T = T * Pose[P]; } return T; } /** * The body under the camera (the player's own view; the camera itself is not touched). The whole body is leaned back about its * feet by LeanDeg (the feet are turned flat again), then the root is moved so the neck bone is at Target: sideways and * forward always, in height by the share Vertical (0 on the ground: the feet stay on the floor; 1 swimming). */ void BodyAnchorNeck(FCompactPose& Pose, const FDescentBodyBones& Names, const FVector& Target, double Weight, double Vertical, double LeanDeg, bool bFlatFeet) { const FCompactPoseBoneIndex Root(0), Neck = BodyCompact(Pose.GetBoneContainer(), Names.Neck); if (Neck.GetInt() == INDEX_NONE) { return; } if (!FMath::IsNearlyZero(LeanDeg)) { // about the mesh's +X (his left): up goes backward, so the legs come forward under the eye (swimming: the whole body follows the view's pitch) Pose[Root].SetRotation((FQuat(FVector::XAxisVector, FMath::DegreesToRadians(LeanDeg)) * Pose[Root].GetRotation()).GetNormalized()); if (bFlatFeet) { BodyTurnUpperArm(Pose, Names.FootL, -LeanDeg, 0.0, true); BodyTurnUpperArm(Pose, Names.FootR, -LeanDeg, 0.0, false); } } FVector Move = (Target - BodyComponentSpace(Pose, Neck).GetLocation()) * FMath::Clamp(Weight, 0.0, 1.0); Move.Z *= FMath::Clamp(Vertical, 0.0, 1.0); Pose[Root].AddToTranslation(Move); } /** * Takes a hand to a point of the component's space (analytic two-bone IK on upper arm and forearm), by Alpha. ElbowHint: where * the elbow bends to, from where it is. bKeepHandTurn: the hand keeps the clip's turn in space (the jack-in); otherwise its turn on the forearm. */ void BodyArmIK(FCompactPose& Pose, const FName& UpperName, const FName& LowerName, const FName& HandName, const FVector& Effector, const FVector& ElbowHintOffset, double Alpha, bool bKeepHandTurn) { const FBoneContainer& Bones = Pose.GetBoneContainer(); const FCompactPoseBoneIndex Upper = BodyCompact(Bones, UpperName), Lower = BodyCompact(Bones, LowerName), Hand = BodyCompact(Bones, HandName); if (Upper.GetInt() == INDEX_NONE || Lower.GetInt() == INDEX_NONE || Hand.GetInt() == INDEX_NONE || Bones.GetParentBoneIndex(Lower) != Upper || Bones.GetParentBoneIndex(Hand) != Lower) { return; } const FTransform ParentCS = BodyComponentSpace(Pose, Bones.GetParentBoneIndex(Upper)); FTransform UpperCS = Pose[Upper] * ParentCS, LowerCS = Pose[Lower] * UpperCS, HandCS = Pose[Hand] * LowerCS; const FQuat HandTurn = HandCS.GetRotation(); const FVector ElbowHint = LowerCS.GetLocation() + ElbowHintOffset; AnimationCore::SolveTwoBoneIK(UpperCS, LowerCS, HandCS, ElbowHint, Effector, false, 1.0, 1.0); HandCS.SetRotation(HandTurn); const float A = static_cast(FMath::Clamp(Alpha, 0.0, 1.0)); const FTransform NewLocal[3] = { UpperCS.GetRelativeTransform(ParentCS), LowerCS.GetRelativeTransform(UpperCS), bKeepHandTurn ? HandCS.GetRelativeTransform(LowerCS) : Pose[Hand] }; const FCompactPoseBoneIndex Which[3] = { Upper, Lower, Hand }; for (int32 I = 0; I < 3; ++I) { FTransform& T = Pose[Which[I]]; T.Blend(FTransform(T), NewLocal[I], A); T.NormalizeRotation(); } } } bool FDescentBodyAnimProxy::Evaluate(FPoseContext& Output) { TArray> Use; TArray> Times; TArray> Weights; float Sum = 0.f; TArray> Locks; for (const FDescentBodyClip& C : Clips) { const UAnimSequence* Seq = Seqs[BodySeqInt(C.Seq)]; if (Seq && C.Weight > 1e-3) { Locks.Add(RootLock[BodySeqInt(C.Seq)]); Use.Add(Seq); Times.Add(C.Time); Weights.Add(static_cast(C.Weight)); Sum += static_cast(C.Weight); } } if (Use.Num() == 0) { Output.ResetToRefPose(); return true; } if (Use.Num() == 1) { FAnimationPoseData Data(Output); Use[0]->GetAnimationPose(Data, FAnimExtractContext(Times[0], false)); if (Locks[0]) { Output.Pose[FCompactPoseBoneIndex(0)] = Output.Pose.GetBoneContainer().GetRefPoseTransform(FCompactPoseBoneIndex(0)); } } else { const int32 N = Use.Num(); TArray> Poses; TArray> Curves; TArray> Attributes; Poses.SetNum(N); Curves.SetNum(N); Attributes.SetNum(N); for (int32 I = 0; I < N; ++I) { Weights[I] /= Sum; Poses[I].SetBoneContainer(&Output.Pose.GetBoneContainer()); Curves[I].InitFrom(Output.Curve); Sample(Use[I], Times[I], Locks[I], Poses[I], Curves[I], Attributes[I]); } FAnimationPoseData OutData(Output); FAnimationRuntime::BlendPosesTogether(Poses, Curves, Attributes, Weights, OutData); } BlendUpperBody(Output, EDescentBodySeq::Idle, IdleTime, UpperCalm, true); BlendUpperBody(Output, OverSeq, OverTime, OverAlpha); // Running sideways: the pelvis (and with it the legs) turned about the upright axis toward where he goes, the spine turned back // by the same, so the chest stays with the view. After the blends above, which write the spine. if (!FMath::IsNearlyZero(LowerTurnDeg)) { BodyTurnUpperArm(Output.Pose, Bones.Pelvis, 0.0, LowerTurnDeg, true); BodyTurnUpperArm(Output.Pose, Bones.Spine, 0.0, -LowerTurnDeg, true); } BodyTurnUpperArm(Output.Pose, Bones.UpperArmL, ArmRaiseLeftDeg, ArmInLeftDeg, true); BodyTurnUpperArm(Output.Pose, Bones.UpperArmR, ArmRaiseRightDeg, ArmInRightDeg, false); if (AnchorWeight > 1e-3) { BodyAnchorNeck(Output.Pose, Bones, AnchorTarget, AnchorWeight, AnchorVertical, LeanDeg, bFlatFeet); } // after the anchor: the targets are in the component's space if (JackIK > 1e-3) { BodyArmIK(Output.Pose, Bones.UpperArmR, Bones.LowerArmR, Bones.HandR, JackTarget, FVector(-25.0, -10.0, -30.0), JackIK, true); } if (SwimIK > 1e-3) { // the swim stroke made in code: elbows out and a little down (the mesh's +X is his left), the hand keeps its turn on the forearm BodyArmIK(Output.Pose, Bones.UpperArmR, Bones.LowerArmR, Bones.HandR, SwimHandRight, FVector(-30.0, -6.0, -16.0), SwimIK, false); BodyArmIK(Output.Pose, Bones.UpperArmL, Bones.LowerArmL, Bones.HandL, SwimHandLeft, FVector(30.0, -6.0, -16.0), SwimIK, false); } return true; } void FDescentBodyAnimProxy::BlendUpperBody(FPoseContext& Output, EDescentBodySeq Which, double Time, double Alpha, bool bPelvisHeight) const { const UAnimSequence* Seq = Seqs[BodySeqInt(Which)]; if (!Seq || Alpha <= 1e-3) { return; } const FBoneContainer& Container = Output.Pose.GetBoneContainer(); const FCompactPoseBoneIndex Spine = BodyCompact(Container, Bones.Spine); if (Spine.GetInt() == INDEX_NONE) { return; } FCompactPose Pose; FBlendedCurve Curve; UE::Anim::FStackAttributeContainer Attributes; Pose.SetBoneContainer(&Container); Curve.InitFrom(Output.Curve); Sample(Seq, Time, RootLock[BodySeqInt(Which)], Pose, Curve, Attributes); // parents come before their children in a compact pose: one pass marks the spine and everything below it TArray> Upper; Upper.SetNumZeroed(Output.Pose.GetNumBones()); const float A = static_cast(FMath::Clamp(Alpha, 0.0, 1.0)); if (bPelvisHeight) { // The pelvis' bounce too (the retargeted jog throws it 10 cm up and down, up to 5 cm in a frame): toward the idle's height. const FCompactPoseBoneIndex Pelvis = BodyCompact(Container, Bones.Pelvis); if (Pelvis.GetInt() != INDEX_NONE) { FVector At = Output.Pose[Pelvis].GetTranslation(); At.Z = FMath::Lerp(At.Z, Pose[Pelvis].GetTranslation().Z, static_cast(A)); Output.Pose[Pelvis].SetTranslation(At); } } for (FCompactPoseBoneIndex B : Output.Pose.ForEachBoneIndex()) { const FCompactPoseBoneIndex Parent = Container.GetParentBoneIndex(B); Upper[B.GetInt()] = B == Spine || (Parent.GetInt() != INDEX_NONE && Upper[Parent.GetInt()]); if (Upper[B.GetInt()]) { FTransform& T = Output.Pose[B]; T.Blend(FTransform(T), Pose[B], A); T.NormalizeRotation(); } } } // --------------------------------------------------------------------------------------------------------- // The instance // --------------------------------------------------------------------------------------------------------- namespace { TAutoConsoleVariable CVarBodyAnimSet(TEXT("descent.Body.AnimSet"), BodyTune::AnimSetDefault, TEXT("The body's animations. 0 = the Twinblast pack's own sequences (the set of 2026-10-11). 1 = the user's Fab animations retargeted onto Twinblast (default).")); constexpr double BodyLandSecSet1 = 0.40; // of the retargeted landing clip (0.87 s): the impact; then the feet belong to the movement again TAutoConsoleVariable CVarBodyDebugRunRole(TEXT("descent.Body.DebugRunRole"), TEXT(""), TEXT("Debug: the baked role played as the run instead of RunFwd (a role of Config/DescentBody.json, to compare candidates). Empty = RunFwd.")); TAutoConsoleVariable CVarBodyDebugOldLoco(TEXT("descent.Body.DebugOldLoco"), 0, TEXT("Debug: 1 = locomotion as before 2026-10-12's third task (no run clip, play rate from the source's root travel), to measure the foot slide it had.")); } int32 UDescentBodyAnimInstance::WantedSet() { return CVarBodyAnimSet.GetValueOnGameThread() == 0 ? 0 : 1; } void UDescentBodyAnimInstance::SetWantedSet(int32 Set) { CVarBodyAnimSet->Set(Set == 0 ? 0 : 1, ECVF_SetByConsole); } FAnimInstanceProxy* UDescentBodyAnimInstance::CreateAnimInstanceProxy() { return new FDescentBodyAnimProxy(this); } bool UDescentBodyAnimInstance::LoadSequences() { const int32 Wanted = WantedSet(); const FDescentBodyProfile& Profile = FDescentBodyProfile::Get(); const FString RunRole = CVarBodyDebugRunRole.GetValueOnGameThread(); if (Sequences.Num() == Descent::Body::SeqCount && LoadedForSet == Wanted && LoadedRunRole == RunRole && LoadedModel == Profile.Name) { return CountLoadedSequences() == Descent::Body::SeqCount; } LoadedForSet = Wanted; LoadedRunRole = RunRole; LoadedModel = Profile.Name; bool bAll = false; // the wanted set; when one of the retargeted sequences is missing (the import module was not run), the pack's own set for (int32 Set = Wanted; Set >= 0 && !bAll; --Set) { Sequences.Reset(); Sequences.SetNum(Descent::Body::SeqCount); Lengths = FDescentBodyLengths(Set); bAll = true; for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { const bool bDebugRun = Set == 1 && I == BodySeqInt(EDescentBodySeq::RunFwd) && !RunRole.IsEmpty(); // another baked role as the run, to look at it const FString Path = bDebugRun ? Profile.BakedPath(RunRole) : SeqAssetPath(Set, static_cast(I)); Sequences[I] = Path.IsEmpty() ? nullptr : LoadObject(nullptr, *Path, nullptr, LOAD_NoWarn | LOAD_Quiet); if (Sequences[I]) { Lengths.Sec[I] = Sequences[I]->GetPlayLength(); } else { bAll = false; if (!bLoggedMissing) { bLoggedMissing = true; UE_LOG(LogDescent, Error, TEXT("Body: animation '%s' is missing (model %s, set %d, role %s). Set 1 is baked by Tools\\import_assets.ps1 -Only body_anims from Config\\DescentBody.json; set 0 is the model's own pack."), *Path, *Profile.Name, Set, Descent::Body::SeqRoleName(static_cast(I))); } } } } if (Lengths.Set == 1) { Lengths.ApplyProfile(Profile); const FDescentBodyAnimMeasure* DebugRun = RunRole.IsEmpty() ? nullptr : Profile.FindMeasure(RunRole); if (DebugRun && DebugRun->SpeedCmS > 1.0) { const int32 Run = BodySeqInt(EDescentBodySeq::RunFwd); Lengths.SpeedCmS[Run] = DebugRun->SpeedCmS * Profile.Scale; Lengths.RootSpeedCmS[Run] = DebugRun->RootSpeedCmS * Profile.Scale; Lengths.BobSteps[Run] = DebugRun->BobSteps; Lengths.BobPhase[Run] = DebugRun->BobPhase; } Lengths.Sec[BodySeqInt(EDescentBodySeq::JumpApex)] = FMath::Min(Lengths.Sec[BodySeqInt(EDescentBodySeq::JumpApex)], BodyTune::JumpApexSecSet1); Lengths.Sec[BodySeqInt(EDescentBodySeq::Land)] = FMath::Min(Lengths.Sec[BodySeqInt(EDescentBodySeq::Land)], BodyLandSecSet1); } State = FDescentBodyAnimState(); // clip times of the other set mean nothing here return bAll; } int32 UDescentBodyAnimInstance::CountLoadedSequences() const { int32 N = 0; for (const TObjectPtr& S : Sequences) { N += S ? 1 : 0; } return N; } const UAnimSequence* UDescentBodyAnimInstance::GetSequence(EDescentBodySeq Seq) const { const int32 I = BodySeqInt(Seq); return Sequences.IsValidIndex(I) ? Sequences[I].Get() : nullptr; } void UDescentBodyAnimInstance::NativeInitializeAnimation() { Super::NativeInitializeAnimation(); LoadSequences(); } void UDescentBodyAnimInstance::NativeUpdateAnimation(float DeltaSeconds) { Super::NativeUpdateAnimation(DeltaSeconds); LoadSequences(); // returns at once unless the set, the model or the debug run role changed Lengths.bOldLoco = CVarBodyDebugOldLoco.GetValueOnGameThread() != 0; State.Step(Input, DeltaSeconds, Lengths); FDescentBodyAnimProxy& Proxy = GetProxyOnGameThread(); State.BuildClips(Proxy.Clips); for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { Proxy.Seqs[I] = Sequences.IsValidIndex(I) ? Sequences[I].Get() : nullptr; } Proxy.ArmRaiseLeftDeg = Input.ArmRaiseLeftDeg; Proxy.ArmInLeftDeg = Input.ArmInLeftDeg; Proxy.ArmRaiseRightDeg = Input.ArmRaiseRightDeg; Proxy.ArmInRightDeg = Input.ArmInRightDeg; for (int32 I = 0; I < Descent::Body::SeqCount; ++I) { Proxy.RootLock[I] = Lengths.bRootLock[I]; } Proxy.Bones = FDescentBodyProfile::Get().Bones; Proxy.LowerTurnDeg = State.LowerTurnDeg; // the swim arms: the hands' places are given in the eye's frame, so they are in the picture whatever the body does Proxy.SwimIK = Lengths.Set == 1 ? FMath::Clamp(State.SwimAlpha * Input.SwimArms, 0.0, 1.0) : 0.0; Proxy.SwimHandLeft = Input.EyeToComponent.TransformPosition(FDescentBodyAnimState::SwimHandEyeCm(State.SwimStroke, State.SwimMove, State.SwimScullSec, true)); Proxy.SwimHandRight = Input.EyeToComponent.TransformPosition(FDescentBodyAnimState::SwimHandEyeCm(State.SwimStroke, State.SwimMove, State.SwimScullSec, false)); Proxy.UpperCalm = State.UpperCalm; Proxy.IdleTime = State.IdleTime; Proxy.OverSeq = State.OverSeq; Proxy.OverTime = State.OverTime; Proxy.OverAlpha = State.OverAlpha; Proxy.AnchorWeight = Input.AnchorWeight; Proxy.bFlatFeet = Input.bFlatFeet; Proxy.AnchorTarget = Input.AnchorTarget; Proxy.AnchorVertical = Input.AnchorVertical; Proxy.LeanDeg = Input.LeanDeg; Proxy.JackIK = Input.bJackTarget ? State.JackReach * State.OverAlpha : 0.0; Proxy.JackTarget = Input.JackTarget; }