diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_FacialExpression.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_FacialExpression.cpp index 231e57f..2d321a6 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_FacialExpression.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_FacialExpression.cpp @@ -89,12 +89,9 @@ void FAnimNode_PS_AI_ConvAgent_FacialExpression::Evaluate_AnyThread(FPoseContext // covering eyes, eyebrows, cheeks, nose, and mouth mood. // The downstream Lip Sync node will override mouth-area curves // during speech, while non-mouth emotion curves pass through. - if (Output.Curve.IsValid()) + for (const auto& Pair : CachedEmotionCurves) { - for (const auto& Pair : CachedEmotionCurves) - { - Output.Curve.Set(Pair.Key, Pair.Value); - } + Output.Curve.Set(Pair.Key, Pair.Value); } } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_LipSync.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_LipSync.cpp index 7883747..1950069 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_LipSync.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_LipSync.cpp @@ -89,14 +89,11 @@ void FAnimNode_PS_AI_ConvAgent_LipSync::Evaluate_AnyThread(FPoseContext& Output) // Skip near-zero values so that the upstream Facial Expression node's // emotion curves (eyes, brows, mouth mood) pass through during silence. // During speech, active lip sync curves override emotion's mouth curves. - if (Output.Curve.IsValid()) + for (const auto& Pair : CachedCurves) { - for (const auto& Pair : CachedCurves) + if (FMath::Abs(Pair.Value) > 0.01f) { - if (FMath::Abs(Pair.Value) > 0.01f) - { - Output.Curve.Set(Pair.Key, Pair.Value); - } + Output.Curve.Set(Pair.Key, Pair.Value); } } } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_Posture.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_Posture.cpp index 9f36c21..719207b 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_Posture.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/AnimNode_PS_AI_ConvAgent_Posture.cpp @@ -144,6 +144,8 @@ void FAnimNode_PS_AI_ConvAgent_Posture::CacheBones_AnyThread(const FAnimationCac RightEyeBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); LeftEyeRefPoseRotation = FQuat::Identity; RightEyeRefPoseRotation = FQuat::Identity; + BodyBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); + BodyBoneRefPoseRotation = FQuat::Identity; ChainBoneIndices.Reset(); ChainRefPoseRotations.Reset(); @@ -247,6 +249,22 @@ void FAnimNode_PS_AI_ConvAgent_Posture::CacheBones_AnyThread(const FAnimationCac ResolveEyeBone(DefaultRightEyeBone, RightEyeBoneIndex, RightEyeRefPoseRotation); } + // ── Resolve body bone for body yaw rotation ───────────────────── + // The body yaw offset is applied to "pelvis" (or first skeleton bone) + // to rotate the entire skeleton without modifying actor rotation. + { + static const FName DefaultBodyBone(TEXT("pelvis")); + const int32 MeshIdx = RefSkeleton.FindBoneIndex(DefaultBodyBone); + if (MeshIdx != INDEX_NONE) + { + BodyBoneIndex = RequiredBones.MakeCompactPoseIndex( + FMeshPoseBoneIndex(MeshIdx)); + BodyBoneRefPoseRotation = (MeshIdx < RefPose.Num()) + ? RefPose[MeshIdx].GetRotation() + : FQuat::Identity; + } + } + // ── 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 @@ -328,6 +346,7 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Update_AnyThread(const FAnimationUpdateC CachedHeadCompensation = PostureComponent->GetHeadAnimationCompensation(); CachedEyeCompensation = PostureComponent->GetEyeAnimationCompensation(); CachedBodyDriftCompensation = PostureComponent->GetBodyDriftCompensation(); + CachedBodyYawOffset = PostureComponent->GetBodyYawOffset(); } } @@ -379,11 +398,6 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) // Evaluate the upstream pose (pass-through) BasePose.Evaluate(Output); - // Guard: in packaged+network builds the curve container may not be - // initialized yet (skeleton not fully loaded). All Output.Curve access - // must be gated on this flag to avoid null-pointer crashes. - const bool bCurveValid = Output.Curve.IsValid(); - // ── Periodic diagnostic (runs for EVERY instance, before any early return) ─ #if !UE_BUILD_SHIPPING if (++EvalDebugFrameCounter % 300 == 1) // ~every 5 seconds at 60fps @@ -437,7 +451,6 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) #if ELEVENLABS_EYE_DIAGNOSTIC == 1 // MODE 1: CTRL curves → tests CORRECT MetaHuman CTRL naming // Real format: CTRL_expressions_eyeLook{Dir}{L/R} (NOT eyeLookUpLeft!) - if (bCurveValid) { static const FName ForceCTRL(TEXT("CTRL_expressions_eyeLookUpL")); Output.Curve.Set(ForceCTRL, 1.0f); @@ -459,7 +472,6 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) #elif ELEVENLABS_EYE_DIAGNOSTIC == 2 // MODE 2: ARKit curves → tests if mh_arkit_mapping_pose drives eyes - if (bCurveValid) { static const FName ForceARKit(TEXT("eyeLookUpLeft")); Output.Curve.Set(ForceARKit, 1.0f); @@ -490,7 +502,6 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) (LookUp * LeftEyeRefPoseRotation).GetNormalized()); } // Zero curves to isolate - if (bCurveValid) { static const FName ZeroARKit(TEXT("eyeLookUpLeft")); static const FName ZeroCTRL(TEXT("CTRL_expressions_eyeLookUpLeft")); @@ -551,7 +562,6 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) } // (b) Blend CTRL eye curves: read animation's value, lerp with posture - if (bCurveValid) { const auto& CTRLMap = GetARKitToCTRLEyeMap(); for (const auto& Pair : CachedEyeCurves) @@ -566,15 +576,15 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) Output.Curve.Set(*CTRLName, BlendedValue); } } + } - // (c) Zero ARKit eye curves to prevent mh_arkit_mapping_pose - // from overwriting our carefully blended CTRL values. - // mh_arkit converts ARKit→CTRL additively; zeroing means - // it adds nothing for eyes, preserving our blend. - for (const auto& Pair : CachedEyeCurves) - { - Output.Curve.Set(Pair.Key, 0.0f); - } + // (c) Zero ARKit eye curves to prevent mh_arkit_mapping_pose + // from overwriting our carefully blended CTRL values. + // mh_arkit converts ARKit→CTRL additively; zeroing means + // it adds nothing for eyes, preserving our blend. + for (const auto& Pair : CachedEyeCurves) + { + Output.Curve.Set(Pair.Key, 0.0f); } } @@ -600,6 +610,18 @@ void FAnimNode_PS_AI_ConvAgent_Posture::Evaluate_AnyThread(FPoseContext& Output) return; } + // ── Apply body yaw rotation to pelvis bone ───────────────────────────── + // This replaces the old AddActorWorldRotation() approach — the actor rotation + // is never modified; the body turn is purely animation-driven (local, no replication). + if (FMath::Abs(CachedBodyYawOffset) > 0.1f + && BodyBoneIndex.GetInt() != INDEX_NONE + && BodyBoneIndex.GetInt() < Output.Pose.GetNumBones()) + { + const FQuat BodyYawQuat(FVector::UpVector, FMath::DegreesToRadians(CachedBodyYawOffset)); + FTransform& BodyTransform = Output.Pose[BodyBoneIndex]; + BodyTransform.SetRotation((BodyYawQuat * BodyTransform.GetRotation()).GetNormalized()); + } + // IMPORTANT: Even when posture is near-zero (head looking straight at target), // we still need to run compensation to REMOVE the animation's head contribution. // Only skip if BOTH posture is identity AND compensation is inactive (pure additive). diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_FacialExpressionComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_FacialExpressionComponent.cpp index 0197c33..35a40cc 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_FacialExpressionComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_FacialExpressionComponent.cpp @@ -335,13 +335,17 @@ void UPS_AI_ConvAgent_FacialExpressionComponent::TickComponent( } // ── Evaluate curves from playing animations ───────────────────────────── + // Compute into a local map first, then swap under lock to avoid + // racing with the AnimNode worker thread reading GetCurrentEmotionCurves(). + + TMap NewCurves; TMap ActiveCurves = EvaluateAnimCurves(ActiveAnim, ActivePlaybackTime); if (CrossfadeAlpha >= 1.0f) { // No crossfade — use active curves directly - CurrentEmotionCurves = MoveTemp(ActiveCurves); + NewCurves = MoveTemp(ActiveCurves); } else { @@ -349,7 +353,6 @@ void UPS_AI_ConvAgent_FacialExpressionComponent::TickComponent( TMap PrevCurves = EvaluateAnimCurves(PrevAnim, PrevPlaybackTime); // Collect all curve names from both anims - CurrentEmotionCurves.Reset(); TSet AllCurves; for (const auto& P : ActiveCurves) AllCurves.Add(P.Key); for (const auto& P : PrevCurves) AllCurves.Add(P.Key); @@ -364,7 +367,7 @@ void UPS_AI_ConvAgent_FacialExpressionComponent::TickComponent( if (FMath::Abs(Blended) > 0.001f) { - CurrentEmotionCurves.Add(CurveName, Blended); + NewCurves.Add(CurveName, Blended); } } } @@ -372,15 +375,21 @@ void UPS_AI_ConvAgent_FacialExpressionComponent::TickComponent( // ── Apply activation alpha to output curves ────────────────────────── if (CurrentActiveAlpha < 0.001f) { - CurrentEmotionCurves.Reset(); + NewCurves.Reset(); } else if (CurrentActiveAlpha < 0.999f) { - for (auto& Pair : CurrentEmotionCurves) + for (auto& Pair : NewCurves) { Pair.Value *= CurrentActiveAlpha; } } + + // Swap under lock — the AnimNode reads CurrentEmotionCurves from a worker thread + { + FScopeLock Lock(&EmotionCurveLock); + CurrentEmotionCurves = MoveTemp(NewCurves); + } } // ───────────────────────────────────────────────────────────────────────────── diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_PostureComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_PostureComponent.cpp index 00c96f5..97ff77c 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_PostureComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_PostureComponent.cpp @@ -99,6 +99,7 @@ void UPS_AI_ConvAgent_PostureComponent::BeginPlay() // Apply the mesh forward offset so "neutral" aligns with where the face points. OriginalActorYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset; TargetBodyWorldYaw = Owner->GetActorRotation().Yaw; + CurrentBodyWorldYaw = Owner->GetActorRotation().Yaw; if (bDebug) { @@ -179,6 +180,7 @@ void UPS_AI_ConvAgent_PostureComponent::ResetBodyTarget() if (AActor* Owner = GetOwner()) { TargetBodyWorldYaw = Owner->GetActorRotation().Yaw; + CurrentBodyWorldYaw = Owner->GetActorRotation().Yaw; } } @@ -333,19 +335,14 @@ void UPS_AI_ConvAgent_PostureComponent::TickComponent( if (bEnableBodyTracking) { const float BodyDelta = FMath::FindDeltaAngleDegrees( - Owner->GetActorRotation().Yaw, TargetBodyWorldYaw); + CurrentBodyWorldYaw, TargetBodyWorldYaw); if (FMath::Abs(BodyDelta) > 0.1f) { const float BodyStep = FMath::FInterpTo(0.0f, BodyDelta, SafeDeltaTime, BodyInterpSpeed); - // Only modify actor rotation on the authority (server/standalone). - // On clients, the rotation arrives via replication — calling - // AddActorWorldRotation here would fight with replicated updates, - // causing visible stuttering as the network periodically snaps - // the rotation back to the server's value. - if (Owner->HasAuthority()) - { - Owner->AddActorWorldRotation(FRotator(0.0f, BodyStep, 0.0f)); - } + // Track body facing internally instead of modifying the actor rotation. + // The AnimNode applies the delta as a bone rotation — purely local, + // no actor rotation change, no replication conflict. + CurrentBodyWorldYaw += BodyStep; } } @@ -354,7 +351,7 @@ void UPS_AI_ConvAgent_PostureComponent::TickComponent( float DeltaYaw = 0.0f; if (!HorizontalDir.IsNearlyZero(1.0f)) { - const float CurrentFacingYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset; + const float CurrentFacingYaw = CurrentBodyWorldYaw + MeshForwardYawOffset; DeltaYaw = FMath::FindDeltaAngleDegrees(CurrentFacingYaw, TargetWorldYaw); } @@ -467,6 +464,11 @@ void UPS_AI_ConvAgent_PostureComponent::TickComponent( // Eye yaw is negated to match ARKit curve direction convention. UpdateEyeCurves(-CurrentEyeYaw, CurrentEyePitch); + + // Body yaw offset = how much the body has turned relative to the actor's rotation. + // The AnimNode applies this as a bone rotation (pelvis) — purely local. + CachedBodyYawOffset = FMath::FindDeltaAngleDegrees( + Owner->GetActorRotation().Yaw, CurrentBodyWorldYaw) * CurrentActiveAlpha; } // ── Debug gaze lines ──────────────────────────────────────────────────── @@ -539,21 +541,22 @@ void UPS_AI_ConvAgent_PostureComponent::TickComponent( DebugFrameCounter++; if (DebugFrameCounter % 120 == 0) { - const float FacingYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset; + const float FacingYaw = CurrentBodyWorldYaw + MeshForwardYawOffset; const FVector TP = TargetActor->GetActorLocation() + TargetOffset; const FVector Dir = TP - Owner->GetActorLocation(); const float TgtYaw = FVector(Dir.X, Dir.Y, 0.0f).Rotation().Yaw; const float Delta = FMath::FindDeltaAngleDegrees(FacingYaw, TgtYaw); UE_LOG(LogPS_AI_ConvAgent_Posture, Log, - TEXT("Posture [%s -> %s]: Delta=%.1f | Head=%.1f/%.1f | Eyes=%.1f/%.1f | Body: enabled=%s TargetYaw=%.1f ActorYaw=%.1f"), + TEXT("Posture [%s -> %s]: Delta=%.1f | Head=%.1f/%.1f | Eyes=%.1f/%.1f | Body: enabled=%s TargetYaw=%.1f BodyYaw=%.1f (offset=%.1f)"), *Owner->GetName(), *TargetActor->GetName(), Delta, CurrentHeadYaw, CurrentHeadPitch, CurrentEyeYaw, CurrentEyePitch, bEnableBodyTracking ? TEXT("Y") : TEXT("N"), TargetBodyWorldYaw, - Owner->GetActorRotation().Yaw); + CurrentBodyWorldYaw, + CachedBodyYawOffset); } } } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/AnimNode_PS_AI_ConvAgent_Posture.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/AnimNode_PS_AI_ConvAgent_Posture.h index bff209f..e2c8265 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/AnimNode_PS_AI_ConvAgent_Posture.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/AnimNode_PS_AI_ConvAgent_Posture.h @@ -139,6 +139,20 @@ private: * Cached from the component during Update. */ float CachedBodyDriftCompensation = 0.0f; + // ── Body yaw (animation-only body tracking) ────────────────────────── + + /** Bone to apply body yaw rotation to. Rotates the entire skeleton + * visually without modifying the actor rotation (no replication conflict). + * Default "pelvis" works for MetaHuman and most humanoid skeletons. */ + FCompactPoseBoneIndex BodyBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); + + /** Reference pose rotation for the body bone. */ + FQuat BodyBoneRefPoseRotation = FQuat::Identity; + + /** Body yaw offset in degrees (relative to actor rotation). + * Cached from the component during Update. */ + float CachedBodyYawOffset = 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_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_FacialExpressionComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_FacialExpressionComponent.h index ec7f620..f83a325 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_FacialExpressionComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_FacialExpressionComponent.h @@ -3,6 +3,7 @@ #pragma once #include "CoreMinimal.h" +#include "HAL/CriticalSection.h" #include "Components/ActorComponent.h" #include "PS_AI_ConvAgent_Definitions.h" #include "PS_AI_ConvAgent_FacialExpressionComponent.generated.h" @@ -83,9 +84,14 @@ public: // ── Getters ─────────────────────────────────────────────────────────────── - /** Get the current emotion curves evaluated from the playing AnimSequence. */ + /** Get the current emotion curves evaluated from the playing AnimSequence. + * Returns a COPY — safe to call from any thread (AnimNode worker thread). */ UFUNCTION(BlueprintCallable, Category = "PS AI ConvAgent|FacialExpression") - const TMap& GetCurrentEmotionCurves() const { return CurrentEmotionCurves; } + TMap GetCurrentEmotionCurves() const + { + FScopeLock Lock(&EmotionCurveLock); + return CurrentEmotionCurves; + } /** Get the active emotion. */ UFUNCTION(BlueprintPure, Category = "PS AI ConvAgent|FacialExpression") @@ -159,9 +165,14 @@ private: // ── Curve output ───────────────────────────────────────────────────────── - /** Current blended emotion curves (evaluated each tick from playing anims). */ + /** Current blended emotion curves (evaluated each tick from playing anims). + * Protected by EmotionCurveLock — written in TickComponent (game thread), + * read by AnimNode via GetCurrentEmotionCurves() (worker thread). */ TMap CurrentEmotionCurves; + /** Lock protecting CurrentEmotionCurves from concurrent game/anim thread access. */ + mutable FCriticalSection EmotionCurveLock; + /** Current blend alpha (0 = fully inactive/passthrough, 1 = fully active). */ float CurrentActiveAlpha = 1.0f; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_PostureComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_PostureComponent.h index cf28961..a5097a8 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_PostureComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Public/PS_AI_ConvAgent_PostureComponent.h @@ -273,6 +273,15 @@ public: * Scaled by activation alpha for smooth passthrough when inactive. */ float GetBodyDriftCompensation() const { return BodyDriftCompensation * CurrentActiveAlpha; } + /** Get body yaw offset in degrees (relative to actor's rotation). + * Applied by the AnimNode as a bone rotation — purely local, no replication. + * Thread-safe copy, blended by activation alpha. */ + float GetBodyYawOffset() const + { + FScopeLock Lock(&PostureDataLock); + return CachedBodyYawOffset; + } + /** Reset the persistent body yaw target to the actor's current facing. * Call this when re-attaching a posture target so body tracking starts * fresh instead of chasing a stale yaw from the previous interaction. */ @@ -316,11 +325,17 @@ private: float TargetHeadYaw = 0.0f; float TargetHeadPitch = 0.0f; - /** Persistent body target — world yaw the actor should face. + /** Persistent body target — world yaw the body should face. * Only updated when head+eyes can't reach the target (overflow). * Same sticky pattern as TargetHeadYaw but for the body layer. */ float TargetBodyWorldYaw = 0.0f; + /** Interpolated actual body facing in world space. + * Replaces the old AddActorWorldRotation() approach — the actor rotation + * is never modified; instead this tracks the virtual body facing and the + * AnimNode applies the delta as a bone rotation (100% local, no replication). */ + float CurrentBodyWorldYaw = 0.0f; + /** Original actor yaw at BeginPlay (for neutral return when TargetActor is null). */ float OriginalActorYaw = 0.0f; @@ -338,6 +353,10 @@ private: /** Head bone rotation offset as quaternion (no Euler round-trip). */ FQuat CurrentHeadRotation = FQuat::Identity; + /** Body yaw offset in degrees (relative to actor rotation). + * Computed in TickComponent, read by AnimNode via GetBodyYawOffset(). */ + float CachedBodyYawOffset = 0.0f; + /** Cached skeletal mesh component on the owning actor (Body). */ TWeakObjectPtr CachedMesh;