Posture: body drift compensation + debug gaze lines + ARKit eye range calibration

- Add BodyDriftCompensation parameter (0→1) to counter-rotate head when
  body animation moves the torso (bow, lean). Drift correction applied
  AFTER swing-twist on the first chain bone only, preventing the
  correction from being stripped by tilt decomposition or compounded
  through the chain.
- Add bDrawDebugGaze toggle: draws per-eye debug lines from Face mesh
  FACIAL_L/R_Eye bones (cyan = desired direction, green = actual bone
  Z-axis gaze) to visually verify eye contact accuracy.
- Cache Face mesh separately from Body mesh for correct eye bone transforms.
- Use eye bone midpoint (Face mesh) as EyeOrigin for pitch calculation
  instead of head bone, fixing vertical offset.
- Calibrate ARKit eye ranges: horizontal 30→40, vertical 20→35 to match
  MetaHuman actual eye deflection per curve unit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-02-26 16:37:52 +01:00
parent 9eca5a3cfe
commit fd0e2a2d58
7 changed files with 298 additions and 29 deletions

View File

@ -87,6 +87,9 @@ void FAnimNode_ElevenLabsPosture::Initialize_AnyThread(const FAnimationInitializ
CachedHeadRotation = FQuat::Identity;
CachedHeadCompensation = 1.0f;
CachedEyeCompensation = 1.0f;
CachedBodyDriftCompensation = 0.0f;
AncestorBoneIndices.Reset();
RefAccumAboveChain = FQuat::Identity;
HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE);
HeadRefPoseRotation = FQuat::Identity;
ChainBoneNames.Reset();
@ -124,13 +127,14 @@ void FAnimNode_ElevenLabsPosture::Initialize_AnyThread(const FAnimationInitializ
}
UE_LOG(LogElevenLabsPostureAnimNode, Log,
TEXT("ElevenLabs Posture AnimNode: Owner=%s Mesh=%s Head=%s Eyes=%s Chain=%d HeadComp=%.1f EyeComp=%.1f"),
TEXT("ElevenLabs Posture AnimNode: Owner=%s Mesh=%s Head=%s Eyes=%s Chain=%d HeadComp=%.1f EyeComp=%.1f DriftComp=%.1f"),
*Owner->GetName(), *SkelMesh->GetName(),
bApplyHeadRotation ? TEXT("ON") : TEXT("OFF"),
bApplyEyeCurves ? TEXT("ON") : TEXT("OFF"),
ChainBoneNames.Num(),
Comp->GetHeadAnimationCompensation(),
Comp->GetEyeAnimationCompensation());
Comp->GetEyeAnimationCompensation(),
Comp->GetBodyDriftCompensation());
}
else
{
@ -269,6 +273,50 @@ void FAnimNode_ElevenLabsPosture::CacheBones_AnyThread(const FAnimationCacheBone
ResolveEyeBone(DefaultLeftEyeBone, LeftEyeBoneIndex, LeftEyeRefPoseRotation);
ResolveEyeBone(DefaultRightEyeBone, RightEyeBoneIndex, RightEyeRefPoseRotation);
}
// ── Resolve ancestor chain for body drift compensation ────────────
// Walk from the parent of the first neck/head bone up to root.
// This lets us measure how much the spine/torso animation has
// shifted the coordinate frame at the neck.
AncestorBoneIndices.Reset();
RefAccumAboveChain = FQuat::Identity;
{
// Find the first bone of the posture chain (or fallback head bone)
FCompactPoseBoneIndex FirstBone =
(ChainBoneIndices.Num() > 0 && ChainBoneIndices[0].GetInt() != INDEX_NONE)
? ChainBoneIndices[0]
: HeadBoneIndex;
if (FirstBone.GetInt() != INDEX_NONE)
{
// Start from the parent of the first chain bone
FCompactPoseBoneIndex Current = RequiredBones.GetParentBoneIndex(FirstBone);
while (Current.GetInt() != INDEX_NONE)
{
AncestorBoneIndices.Add(Current);
Current = RequiredBones.GetParentBoneIndex(Current);
}
// Compute accumulated ref-pose rotation from root to parent-of-chain.
// AncestorBoneIndices is in child→root order, so we iterate in reverse
// (root first) to accumulate: RefAccum = R_root * R_spine1 * ... * R_parent
for (int32 i = AncestorBoneIndices.Num() - 1; i >= 0; --i)
{
const int32 MeshIdx = RequiredBones.MakeMeshPoseIndex(
AncestorBoneIndices[i]).GetInt();
const FQuat RefRot = (MeshIdx >= 0 && MeshIdx < RefPose.Num())
? RefPose[MeshIdx].GetRotation()
: FQuat::Identity;
RefAccumAboveChain = RefAccumAboveChain * RefRot;
}
UE_LOG(LogElevenLabsPostureAnimNode, Log,
TEXT("Body drift: %d ancestor bones above chain. RefAccum=(%s)"),
AncestorBoneIndices.Num(),
*RefAccumAboveChain.Rotator().ToCompactString());
}
}
}
}
@ -288,6 +336,7 @@ void FAnimNode_ElevenLabsPosture::Update_AnyThread(const FAnimationUpdateContext
CachedHeadRotation = PostureComponent->GetCurrentHeadRotation();
CachedHeadCompensation = PostureComponent->GetHeadAnimationCompensation();
CachedEyeCompensation = PostureComponent->GetEyeAnimationCompensation();
CachedBodyDriftCompensation = PostureComponent->GetBodyDriftCompensation();
}
}
@ -350,15 +399,16 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
const bool bHasEyeBones = (LeftEyeBoneIndex.GetInt() != INDEX_NONE);
UE_LOG(LogElevenLabsPostureAnimNode, Warning,
TEXT("[%s] Posture Evaluate: HeadComp=%.2f EyeComp=%.2f Valid=%s HeadRot=(%s) Eyes=%d Chain=%d EyeBones=%s"),
TEXT("[%s] Posture Evaluate: HeadComp=%.2f EyeComp=%.2f DriftComp=%.2f Valid=%s HeadRot=(%s) Eyes=%d Chain=%d Ancestors=%d"),
NodeRole,
CachedHeadCompensation,
CachedEyeCompensation,
CachedBodyDriftCompensation,
PostureComponent.IsValid() ? TEXT("YES") : TEXT("NO"),
*CachedHeadRotation.Rotator().ToCompactString(),
CachedEyeCurves.Num(),
ChainBoneIndices.Num(),
bHasEyeBones ? TEXT("YES") : TEXT("NO"));
AncestorBoneIndices.Num());
// Log first chain bone's anim vs ref delta (to see if compensation changes anything)
if (bApplyHeadRotation && ChainBoneIndices.Num() > 0
@ -590,20 +640,78 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
#else
// ── PRODUCTION ───────────────────────────────────────────────────────
// ── Body drift compensation ─────────────────────────────────────────
//
// When the animation bends the torso (bow, lean, etc.), all bones above
// the spine shift in world space. The posture rotation (CachedHeadRotation)
// was computed relative to the character standing upright, so the head
// drifts away from the target.
//
// Fix: measure how much the ancestor bones (root→parent-of-chain) have
// rotated compared to their ref pose, and counter-rotate the posture.
//
// BodyDrift = AnimAccum * RefAccum⁻¹
// AdjustedPosture = BodyDrift⁻¹ * Posture
//
// The BodyDriftCompensation parameter (0→1) controls how much of the
// drift is cancelled. At 0 the head follows body movement naturally;
// at 1 it stays locked on the target even during bows.
// ── Compute drift correction (applied AFTER swing-twist on first bone only) ──
//
// The drift correction transforms from animation-parent-space to ref-parent-space.
// It must be applied only to the FIRST bone in the chain (subsequent bones'
// parents are already corrected). It's applied AFTER swing-twist so the
// pitch/roll components don't get stripped by the tilt decomposition.
//
// DriftCorrection = AnimAccum⁻¹ * RefAccum
// BoneWorld = AnimAccum * DriftCorrection * CleanPosture = RefAccum * CleanPosture ✓
FQuat DriftCorrection = FQuat::Identity;
if (CachedBodyDriftCompensation > 0.001f && AncestorBoneIndices.Num() > 0)
{
FQuat AnimAccumAboveChain = FQuat::Identity;
bool bAllValid = true;
for (int32 i = AncestorBoneIndices.Num() - 1; i >= 0; --i)
{
const FCompactPoseBoneIndex Idx = AncestorBoneIndices[i];
if (Idx.GetInt() == INDEX_NONE || Idx.GetInt() >= Output.Pose.GetNumBones())
{
bAllValid = false;
break;
}
AnimAccumAboveChain = AnimAccumAboveChain * Output.Pose[Idx].GetRotation();
}
if (bAllValid)
{
const FQuat FullCorrection = AnimAccumAboveChain.Inverse() * RefAccumAboveChain;
const FQuat SafeCorrection = EnforceShortestPath(FullCorrection, FQuat::Identity);
DriftCorrection = FQuat::Slerp(FQuat::Identity, SafeCorrection,
CachedBodyDriftCompensation);
#if !UE_BUILD_SHIPPING
if (EvalDebugFrameCounter % 300 == 1)
{
const FRotator CorrRot = FullCorrection.Rotator();
UE_LOG(LogElevenLabsPostureAnimNode, Warning,
TEXT(" DriftCorrection: Y=%.1f P=%.1f R=%.1f | Comp=%.2f"),
CorrRot.Yaw, CorrRot.Pitch, CorrRot.Roll,
CachedBodyDriftCompensation);
}
#endif
}
}
if (bUseChain)
{
// ── Multi-bone neck chain: per-bone swing-twist ──────────────────
//
// Each bone in the chain gets a fractional rotation (via Slerp weight).
// The swing-twist decomposition is done PER-BONE using each bone's own
// tilt axis. This prevents parasitic ear-to-shoulder tilt that occurred
// when a single CleanRotation (derived from the tip bone) was applied
// to bones with different local orientations.
//
// Animation compensation: before applying posture, we blend out the
// animation's contribution proportionally to AnimationCompensation.
// Comp=1.0 → posture replaces animation (head always faces target).
// Comp=0.0 → posture is additive on top of animation (old behavior).
// Posture (CachedHeadRotation) is distributed fractionally across bones.
// Swing-twist removes parasitic roll per bone.
// Drift correction is applied AFTER swing-twist on the FIRST bone only,
// so it doesn't get stripped and doesn't compound through the chain.
for (int32 i = 0; i < ChainBoneIndices.Num(); ++i)
{
@ -621,8 +729,7 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
const FQuat CompensatedRot = ComputeCompensatedBoneRot(
AnimBoneRot, ChainRefPoseRotations[i], CachedHeadCompensation);
// Fractional posture rotation for this bone.
// Enforce shortest-path to prevent sign-flip jumps in Slerp.
// Fractional posture rotation (NO drift — drift applied separately)
const FQuat SafeHeadRot = EnforceShortestPath(CachedHeadRotation, FQuat::Identity);
const FQuat FractionalRot = FQuat::Slerp(
FQuat::Identity, SafeHeadRot, ChainBoneWeights[i]);
@ -635,7 +742,15 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
FQuat Swing, TiltTwist;
Combined.ToSwingTwist(BoneTiltAxis, Swing, TiltTwist);
BoneTransform.SetRotation(Swing.GetNormalized());
FQuat FinalRot = Swing.GetNormalized();
// Drift correction: only on the first bone, AFTER swing-twist
if (i == 0)
{
FinalRot = (DriftCorrection * FinalRot).GetNormalized();
}
BoneTransform.SetRotation(FinalRot);
}
}
else
@ -651,17 +766,19 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
const FQuat CompensatedRot = ComputeCompensatedBoneRot(
AnimBoneRot, HeadRefPoseRotation, CachedHeadCompensation);
// Apply posture on compensated rotation.
// Enforce shortest-path to prevent sign-flip jumps.
// Apply posture (NO drift)
const FQuat SafeHeadRot = EnforceShortestPath(CachedHeadRotation, CompensatedRot);
const FQuat Combined = SafeHeadRot * CompensatedRot;
// Remove ear-to-shoulder tilt using the bone's actual tilt axis
// Remove ear-to-shoulder tilt
const FVector BoneTiltAxis = CompensatedRot.RotateVector(FVector::RightVector);
FQuat Swing, TiltTwist;
Combined.ToSwingTwist(BoneTiltAxis, Swing, TiltTwist);
HeadTransform.SetRotation(Swing.GetNormalized());
// Drift correction AFTER swing-twist
const FQuat FinalRot = (DriftCorrection * Swing).GetNormalized();
HeadTransform.SetRotation(FinalRot);
}
}
#endif
@ -671,12 +788,13 @@ void FAnimNode_ElevenLabsPosture::GatherDebugData(FNodeDebugData& DebugData)
{
const FRotator DebugRot = CachedHeadRotation.Rotator();
FString DebugLine = FString::Printf(
TEXT("ElevenLabs Posture (eyes: %d curves, head: Y=%.1f P=%.1f, chain: %d bones, headComp: %.1f, eyeComp: %.1f)"),
TEXT("ElevenLabs Posture (eyes: %d, head: Y=%.1f P=%.1f, chain: %d, headComp: %.1f, eyeComp: %.1f, driftComp: %.1f)"),
CachedEyeCurves.Num(),
DebugRot.Yaw, DebugRot.Pitch,
ChainBoneIndices.Num(),
CachedHeadCompensation,
CachedEyeCompensation);
CachedEyeCompensation,
CachedBodyDriftCompensation);
DebugData.AddDebugItem(DebugLine);
BasePose.GatherDebugData(DebugData);
}

View File

@ -4,6 +4,7 @@
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/Actor.h"
#include "Math/UnrealMathUtility.h"
#include "DrawDebugHelpers.h"
DEFINE_LOG_CATEGORY(LogElevenLabsPosture);
@ -22,8 +23,8 @@ static const FName EyeLookOutRight(TEXT("eyeLookOutRight"));
// value 0→1. MaxEyeHorizontal/Vertical control the CASCADE threshold (when
// the head kicks in), but the visual eye deflection is always normalized by
// these fixed constants so the eye curves look correct at any threshold.
static constexpr float ARKitEyeRangeHorizontal = 30.0f;
static constexpr float ARKitEyeRangeVertical = 20.0f;
static constexpr float ARKitEyeRangeHorizontal = 40.0f;
static constexpr float ARKitEyeRangeVertical = 35.0f;
// ─────────────────────────────────────────────────────────────────────────────
// Construction
@ -56,14 +57,39 @@ void UElevenLabsPostureComponent::BeginPlay()
return;
}
// Cache skeletal mesh for head bone queries
CachedMesh = Owner->FindComponentByClass<USkeletalMeshComponent>();
// Cache skeletal mesh components: Body (first found) + Face (has FACIAL_L_Eye bone)
{
static const FName LEyeBone(TEXT("FACIAL_L_Eye"));
TArray<USkeletalMeshComponent*> AllSkelMeshes;
Owner->GetComponents<USkeletalMeshComponent>(AllSkelMeshes);
for (USkeletalMeshComponent* SMC : AllSkelMeshes)
{
if (!SMC) continue;
// First valid mesh → Body
if (!CachedMesh.IsValid())
{
CachedMesh = SMC;
}
// The mesh whose anim instance drives FACIAL_ bones → Face
if (!CachedFaceMesh.IsValid() && SMC->DoesSocketExist(LEyeBone) && SMC != CachedMesh.Get())
{
CachedFaceMesh = SMC;
}
}
}
if (!CachedMesh.IsValid())
{
UE_LOG(LogElevenLabsPosture, Warning,
TEXT("No SkeletalMeshComponent found on %s — head bone lookup will be unavailable."),
*Owner->GetName());
}
UE_LOG(LogElevenLabsPosture, Log,
TEXT("Mesh cache: Body=%s Face=%s"),
CachedMesh.IsValid() ? *CachedMesh->GetName() : TEXT("NONE"),
CachedFaceMesh.IsValid() ? *CachedFaceMesh->GetName() : TEXT("NONE"));
// Remember original actor facing for neutral reference.
// Apply the mesh forward offset so "neutral" aligns with where the face points.
@ -171,8 +197,20 @@ void UElevenLabsPostureComponent::TickComponent(
const FVector TargetPos = TargetActor->GetActorLocation() + TargetOffset;
// Eye origin = midpoint of FACIAL_L_Eye / FACIAL_R_Eye on the Face mesh
// (most accurate for pitch calculation). Falls back to head bone, then actor.
static const FName LEyeName(TEXT("FACIAL_L_Eye"));
static const FName REyeName(TEXT("FACIAL_R_Eye"));
FVector EyeOrigin;
if (CachedMesh.IsValid() && CachedMesh->DoesSocketExist(HeadBoneName))
if (CachedFaceMesh.IsValid()
&& CachedFaceMesh->DoesSocketExist(LEyeName)
&& CachedFaceMesh->DoesSocketExist(REyeName))
{
EyeOrigin = (CachedFaceMesh->GetSocketLocation(LEyeName)
+ CachedFaceMesh->GetSocketLocation(REyeName)) * 0.5f;
}
else if (CachedMesh.IsValid() && CachedMesh->DoesSocketExist(HeadBoneName))
{
EyeOrigin = CachedMesh->GetSocketLocation(HeadBoneName);
}
@ -331,6 +369,70 @@ void UElevenLabsPostureComponent::TickComponent(
UpdateEyeCurves(-CurrentEyeYaw, CurrentEyePitch);
}
// ── Debug gaze lines ────────────────────────────────────────────────────
// Per-eye debug: each eye gets two thin lines originating from its bone:
// Cyan = desired direction (eye bone → target)
// Green = actual eye bone forward (real rendered gaze from Face mesh)
// Uses CachedFaceMesh for correct animated transforms of FACIAL_ bones.
if (bDrawDebugGaze && TargetActor && GetWorld())
{
static const FName LeftEyeBone(TEXT("FACIAL_L_Eye"));
static const FName RightEyeBone(TEXT("FACIAL_R_Eye"));
// Prefer Face mesh (where FACIAL_ bones are actually animated)
USkeletalMeshComponent* EyeMesh = CachedFaceMesh.IsValid()
? CachedFaceMesh.Get()
: (CachedMesh.IsValid() ? CachedMesh.Get() : nullptr);
const FVector TargetPos = TargetActor->GetActorLocation() + TargetOffset;
if (EyeMesh
&& EyeMesh->DoesSocketExist(LeftEyeBone)
&& EyeMesh->DoesSocketExist(RightEyeBone))
{
const FTransform LeftEyeT = EyeMesh->GetSocketTransform(LeftEyeBone);
const FTransform RightEyeT = EyeMesh->GetSocketTransform(RightEyeBone);
const FVector LeftEyePos = LeftEyeT.GetLocation();
const FVector RightEyePos = RightEyeT.GetLocation();
// Half eye-spacing offset: keep cyan lines parallel (same gap as origin)
const FVector EyeOffset = (LeftEyePos - RightEyePos) * 0.5f;
const FVector MidPoint = (LeftEyePos + RightEyePos) * 0.5f;
const FVector MidToTarget = TargetPos - MidPoint;
const float LineLen = MidToTarget.Size();
// Desired direction from midpoint, offset per eye
const FVector LeftTargetPos = TargetPos + EyeOffset;
const FVector RightTargetPos = TargetPos - EyeOffset;
// ── Left eye ──
const FVector LeftGaze = LeftEyeT.GetRotation().GetAxisZ();
DrawDebugLine(GetWorld(), LeftEyePos, LeftTargetPos,
FColor::Cyan, false, -1.0f, 0, 0.1f);
DrawDebugLine(GetWorld(), LeftEyePos, LeftEyePos + LeftGaze * LineLen,
FColor::Green, false, -1.0f, 0, 0.1f);
// ── Right eye ──
const FVector RightGaze = RightEyeT.GetRotation().GetAxisZ();
DrawDebugLine(GetWorld(), RightEyePos, RightTargetPos,
FColor::Cyan, false, -1.0f, 0, 0.1f);
DrawDebugLine(GetWorld(), RightEyePos, RightEyePos + RightGaze * LineLen,
FColor::Green, false, -1.0f, 0, 0.1f);
}
else
{
// Fallback: single line from head bone
FVector HeadPos = Owner->GetActorLocation() + FVector(0.0f, 0.0f, 160.0f);
if (CachedMesh.IsValid() && CachedMesh->DoesSocketExist(HeadBoneName))
{
HeadPos = CachedMesh->GetSocketLocation(HeadBoneName);
}
DrawDebugLine(GetWorld(), HeadPos, TargetPos,
FColor::Cyan, false, -1.0f, 0, 0.1f);
}
}
// ── Debug (every ~2 seconds) ─────────────────────────────────────────
#if !UE_BUILD_SHIPPING
DebugFrameCounter++;

View File

@ -106,6 +106,25 @@ private:
FQuat LeftEyeRefPoseRotation = FQuat::Identity;
FQuat RightEyeRefPoseRotation = FQuat::Identity;
// ── Body drift compensation ─────────────────────────────────────────────
//
// When the animation bends the torso (bow, lean, etc.), all bones above
// the spine shift in world space. The posture rotation is calculated
// relative to the character standing upright, so without compensation
// the head drifts away from the target.
//
// We walk from the parent of the first chain bone up to root, accumulate
// the animated vs ref-pose rotation delta ("body drift"), and pre-rotate
// the posture to cancel it out.
/** Ancestor bone indices from parent-of-chain to root (child→root order).
* Resolved at CacheBones; used in Evaluate to compute animated drift. */
TArray<FCompactPoseBoneIndex> AncestorBoneIndices;
/** Precomputed accumulated ref-pose rotation from root to parent-of-chain.
* Static computed once at CacheBones. */
FQuat RefAccumAboveChain = FQuat::Identity;
// ── Animation compensation (separate head and eye) ─────────────────────
/** How much posture overrides the animation's head/neck rotation (0=additive, 1=override).
@ -116,6 +135,10 @@ private:
* Cached from the component during Update. */
float CachedEyeCompensation = 1.0f;
/** How much body drift is compensated (0=no compensation, 1=full).
* Cached from the component during Update. */
float CachedBodyDriftCompensation = 0.0f;
#if !UE_BUILD_SHIPPING
/** Frame counter for periodic diagnostic logging in Evaluate. */
int32 EvalDebugFrameCounter = 0;

View File

@ -153,6 +153,26 @@ public:
meta = (ClampMin = "0", ClampMax = "1"))
float EyeAnimationCompensation = 1.0f;
/** Compensate body animation below the neck (spine bending, leaning, etc.).
* When the torso bends, the head's world orientation shifts. This
* counter-rotates the posture to keep the head pointing at the target.
* 1.0 = full compensation head stays locked on target even during bows.
* 0.0 = no compensation head follows body movement (default).
* Start low (0.30.5) and increase as needed; full compensation can
* look unnatural on large body movements. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ElevenLabs|Posture",
meta = (ClampMin = "0", ClampMax = "1"))
float BodyDriftCompensation = 0.0f;
// ── Debug ────────────────────────────────────────────────────────────────
/** Draw debug lines showing gaze direction in the viewport.
* Green = head bone target (desired).
* Cyan = computed gaze direction (body yaw + head + eyes).
* Useful for verifying that eye contact is accurate. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ElevenLabs|Posture")
bool bDrawDebugGaze = false;
// ── Forward offset ──────────────────────────────────────────────────────
/** Yaw offset (degrees) between the actor's forward (+X) and the mesh's
@ -211,6 +231,9 @@ public:
/** Get eye animation compensation factor (0 = additive, 1 = full override). */
float GetEyeAnimationCompensation() const { return EyeAnimationCompensation; }
/** Get body drift compensation factor (0 = none, 1 = full). */
float GetBodyDriftCompensation() const { return BodyDriftCompensation; }
// ── UActorComponent overrides ────────────────────────────────────────────
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
@ -257,9 +280,12 @@ private:
/** Head bone rotation offset as quaternion (no Euler round-trip). */
FQuat CurrentHeadRotation = FQuat::Identity;
/** Cached skeletal mesh component on the owning actor. */
/** Cached skeletal mesh component on the owning actor (Body). */
TWeakObjectPtr<USkeletalMeshComponent> CachedMesh;
/** Cached Face skeletal mesh component (for eye bone transforms). */
TWeakObjectPtr<USkeletalMeshComponent> CachedFaceMesh;
#if !UE_BUILD_SHIPPING
/** Frame counter for periodic debug logging. */
int32 DebugFrameCounter = 0;