diff --git a/Unreal/PS_AI_Agent/Content/MetaHumans/Animations/BodyBP.uasset b/Unreal/PS_AI_Agent/Content/MetaHumans/Animations/BodyBP.uasset index d39b0d4..e685565 100644 Binary files a/Unreal/PS_AI_Agent/Content/MetaHumans/Animations/BodyBP.uasset and b/Unreal/PS_AI_Agent/Content/MetaHumans/Animations/BodyBP.uasset differ diff --git a/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset b/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset index 0630cdd..1360d80 100644 Binary files a/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset and b/Unreal/PS_AI_Agent/Content/MetaHumans/Taro/BP_Taro.uasset differ diff --git a/Unreal/PS_AI_Agent/Content/NewElevenLabsEmotionPoseMap.uasset b/Unreal/PS_AI_Agent/Content/NewElevenLabsEmotionPoseMap.uasset index 6200916..2c3a5c5 100644 Binary files a/Unreal/PS_AI_Agent/Content/NewElevenLabsEmotionPoseMap.uasset and b/Unreal/PS_AI_Agent/Content/NewElevenLabsEmotionPoseMap.uasset differ diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/AnimNode_ElevenLabsPosture.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/AnimNode_ElevenLabsPosture.cpp index 70ee384..71ae89a 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/AnimNode_ElevenLabsPosture.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/AnimNode_ElevenLabsPosture.cpp @@ -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); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/ElevenLabsPostureComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/ElevenLabsPostureComponent.cpp index 7948177..143a648 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/ElevenLabsPostureComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Private/ElevenLabsPostureComponent.cpp @@ -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(); + // Cache skeletal mesh components: Body (first found) + Face (has FACIAL_L_Eye bone) + { + static const FName LEyeBone(TEXT("FACIAL_L_Eye")); + TArray AllSkelMeshes; + Owner->GetComponents(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++; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/AnimNode_ElevenLabsPosture.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/AnimNode_ElevenLabsPosture.h index 7abb87b..11fd1a1 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/AnimNode_ElevenLabsPosture.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/AnimNode_ElevenLabsPosture.h @@ -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 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; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/ElevenLabsPostureComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/ElevenLabsPostureComponent.h index 6ed8eb1..bc8bcbe 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/ElevenLabsPostureComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Agent_ElevenLabs/Source/PS_AI_Agent_ElevenLabs/Public/ElevenLabsPostureComponent.h @@ -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.3–0.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 CachedMesh; + /** Cached Face skeletal mesh component (for eye bone transforms). */ + TWeakObjectPtr CachedFaceMesh; + #if !UE_BUILD_SHIPPING /** Frame counter for periodic debug logging. */ int32 DebugFrameCounter = 0;