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 102128e..7c61c47 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/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 a15eea1..e57eb09 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 @@ -25,11 +25,14 @@ void FAnimNode_ElevenLabsPosture::Initialize_AnyThread(const FAnimationInitializ { BasePose.Initialize(Context); - // Find the ElevenLabsPostureComponent on the owning actor. + // Reset all cached state. PostureComponent.Reset(); CachedEyeCurves.Reset(); CachedHeadRotation = FQuat::Identity; HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); + ChainBoneNames.Reset(); + ChainBoneWeights.Reset(); + ChainBoneIndices.Reset(); if (const FAnimInstanceProxy* Proxy = Context.AnimInstanceProxy) { @@ -44,21 +47,28 @@ void FAnimNode_ElevenLabsPosture::Initialize_AnyThread(const FAnimationInitializ PostureComponent = Comp; HeadBoneName = Comp->GetHeadBoneName(); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT("=== ElevenLabs Posture AnimNode ===")); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" Owner: %s | Mesh: %s"), - *Owner->GetName(), *SkelMesh->GetName()); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" HeadRotation: %s | EyeCurves: %s"), + // Cache neck bone chain configuration + const TArray& Chain = Comp->GetNeckBoneChain(); + if (Chain.Num() > 0) + { + ChainBoneNames.Reserve(Chain.Num()); + ChainBoneWeights.Reserve(Chain.Num()); + for (const FElevenLabsNeckBoneEntry& Entry : Chain) + { + if (!Entry.BoneName.IsNone() && Entry.Weight > 0.0f) + { + ChainBoneNames.Add(Entry.BoneName); + ChainBoneWeights.Add(Entry.Weight); + } + } + } + + UE_LOG(LogElevenLabsPostureAnimNode, Log, + TEXT("ElevenLabs Posture AnimNode: Owner=%s Mesh=%s Head=%s Eyes=%s Chain=%d"), + *Owner->GetName(), *SkelMesh->GetName(), bApplyHeadRotation ? TEXT("ON") : TEXT("OFF"), - bApplyEyeCurves ? TEXT("ON") : TEXT("OFF")); -#if ELEVENLABS_AXIS_DIAGNOSTIC - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" >>> AXIS DIAGNOSTIC MODE ACTIVE <<<")); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" Phase 0 (0-3s): Pitch=20 | Phase 1 (3-6s): Yaw=20 | Phase 2 (6-9s): Roll=20")); -#endif + bApplyEyeCurves ? TEXT("ON") : TEXT("OFF"), + ChainBoneNames.Num()); } else { @@ -75,48 +85,56 @@ void FAnimNode_ElevenLabsPosture::CacheBones_AnyThread(const FAnimationCacheBone { BasePose.CacheBones(Context); - // Resolve head bone index from the skeleton + // Reset all bone indices HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); + ChainBoneIndices.Reset(); - if (!HeadBoneName.IsNone()) + if (const FAnimInstanceProxy* Proxy = Context.AnimInstanceProxy) { - if (const FAnimInstanceProxy* Proxy = Context.AnimInstanceProxy) - { - const FBoneContainer& RequiredBones = Proxy->GetRequiredBones(); - const FReferenceSkeleton& RefSkeleton = RequiredBones.GetReferenceSkeleton(); - const int32 MeshIndex = RefSkeleton.FindBoneIndex(HeadBoneName); + const FBoneContainer& RequiredBones = Proxy->GetRequiredBones(); + const FReferenceSkeleton& RefSkeleton = RequiredBones.GetReferenceSkeleton(); + // ── Resolve neck bone chain ────────────────────────────────────── + if (ChainBoneNames.Num() > 0) + { + ChainBoneIndices.Reserve(ChainBoneNames.Num()); + for (int32 i = 0; i < ChainBoneNames.Num(); ++i) + { + const int32 MeshIndex = RefSkeleton.FindBoneIndex(ChainBoneNames[i]); + if (MeshIndex != INDEX_NONE) + { + const FCompactPoseBoneIndex CompactIdx = + RequiredBones.MakeCompactPoseIndex(FMeshPoseBoneIndex(MeshIndex)); + ChainBoneIndices.Add(CompactIdx); + + UE_LOG(LogElevenLabsPostureAnimNode, Log, + TEXT(" Chain bone [%d] '%s' → index %d (weight=%.2f)"), + i, *ChainBoneNames[i].ToString(), CompactIdx.GetInt(), + ChainBoneWeights[i]); + } + else + { + // Bone not found — placeholder to keep arrays parallel + ChainBoneIndices.Add(FCompactPoseBoneIndex(INDEX_NONE)); + UE_LOG(LogElevenLabsPostureAnimNode, Warning, + TEXT(" Chain bone [%d] '%s' NOT FOUND in skeleton!"), + i, *ChainBoneNames[i].ToString()); + } + } + } + + // ── Resolve fallback single head bone ──────────────────────────── + if (!HeadBoneName.IsNone()) + { + const int32 MeshIndex = RefSkeleton.FindBoneIndex(HeadBoneName); if (MeshIndex != INDEX_NONE) { HeadBoneIndex = RequiredBones.MakeCompactPoseIndex( FMeshPoseBoneIndex(MeshIndex)); - // Log reference pose rotation for diagnostic - const TArray& RefPose = RefSkeleton.GetRefBonePose(); - if (MeshIndex < RefPose.Num()) - { - const FQuat RefRot = RefPose[MeshIndex].GetRotation(); - const FRotator RefEuler = RefRot.Rotator(); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT("Head bone '%s' index=%d | RefPose rotation: P=%.2f Y=%.2f R=%.2f"), - *HeadBoneName.ToString(), HeadBoneIndex.GetInt(), - RefEuler.Pitch, RefEuler.Yaw, RefEuler.Roll); - } - - // Also log parent bone info - const int32 ParentMeshIdx = RefSkeleton.GetParentIndex(MeshIndex); - if (ParentMeshIdx != INDEX_NONE) - { - const FName ParentName = RefSkeleton.GetBoneName(ParentMeshIdx); - if (ParentMeshIdx < RefPose.Num()) - { - const FRotator ParentEuler = RefPose[ParentMeshIdx].GetRotation().Rotator(); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" Parent bone: '%s' | RefPose rotation: P=%.2f Y=%.2f R=%.2f"), - *ParentName.ToString(), - ParentEuler.Pitch, ParentEuler.Yaw, ParentEuler.Roll); - } - } + UE_LOG(LogElevenLabsPostureAnimNode, Log, + TEXT("Head bone '%s' resolved to index %d."), + *HeadBoneName.ToString(), HeadBoneIndex.GetInt()); } else { @@ -124,7 +142,6 @@ void FAnimNode_ElevenLabsPosture::CacheBones_AnyThread(const FAnimationCacheBone TEXT("Head bone '%s' NOT FOUND in skeleton. Available bones:"), *HeadBoneName.ToString()); - // List first 10 bone names to help debug const int32 NumBones = FMath::Min(RefSkeleton.GetNum(), 10); for (int32 i = 0; i < NumBones; ++i) { @@ -166,115 +183,125 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output) } // ── 2. Apply head bone rotation ───────────────────────────────────────── - if (bApplyHeadRotation - && HeadBoneIndex.GetInt() != INDEX_NONE - && HeadBoneIndex.GetInt() < Output.Pose.GetNumBones()) + if (!bApplyHeadRotation || CachedHeadRotation.Equals(FQuat::Identity, 0.001f)) { - FTransform& HeadTransform = Output.Pose[HeadBoneIndex]; + return; + } + + const bool bUseChain = (ChainBoneIndices.Num() > 0); #if ELEVENLABS_AXIS_DIAGNOSTIC - // ── DIAGNOSTIC: Cycle through axis test rotations ────────────── - // Phase 0 (0-3s): FRotator(20, 0, 0) = Pitch component only - // Phase 1 (3-6s): FRotator(0, 20, 0) = Yaw component only - // Phase 2 (6-9s): FRotator(0, 0, 20) = Roll component only - // Then repeats. - // - // Watch the head and note what happens in each phase: - // "nod up/down", "turn left/right", "tilt ear-to-shoulder" - // + // ── DIAGNOSTIC: Cycle through axis test rotations ────────────── + if (HeadBoneIndex.GetInt() != INDEX_NONE + && HeadBoneIndex.GetInt() < Output.Pose.GetNumBones()) + { static float DiagTimer = 0.0f; static int32 DiagLogCounter = 0; - DiagTimer += 1.0f / 30.0f; // approximate, animation thread doesn't have real delta + DiagTimer += 1.0f / 30.0f; const int32 Phase = ((int32)(DiagTimer / 10.0f)) % 3; FRotator DiagRotation = FRotator::ZeroRotator; const TCHAR* PhaseName = TEXT("???"); switch (Phase) { - case 0: - DiagRotation = FRotator(20.0f, 0.0f, 0.0f); - PhaseName = TEXT("PITCH=20 (Y-axis rot)"); - break; - case 1: - DiagRotation = FRotator(0.0f, 20.0f, 0.0f); - PhaseName = TEXT("YAW=20 (Z-axis rot)"); - break; - case 2: - DiagRotation = FRotator(0.0f, 0.0f, 20.0f); - PhaseName = TEXT("ROLL=20 (X-axis rot)"); - break; + case 0: DiagRotation = FRotator(20.0f, 0.0f, 0.0f); PhaseName = TEXT("PITCH=20"); break; + case 1: DiagRotation = FRotator(0.0f, 20.0f, 0.0f); PhaseName = TEXT("YAW=20"); break; + case 2: DiagRotation = FRotator(0.0f, 0.0f, 20.0f); PhaseName = TEXT("ROLL=20"); break; } + FTransform& HeadTransform = Output.Pose[HeadBoneIndex]; const FQuat HeadOffset = DiagRotation.Quaternion(); - // Pre-multiply: apply in parent space HeadTransform.SetRotation((HeadOffset * HeadTransform.GetRotation()).GetNormalized()); - DiagLogCounter++; - if (DiagLogCounter % 90 == 0) + if (++DiagLogCounter % 90 == 0) { UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT("DIAG Phase %d: %s | Timer=%.1f"), - Phase, PhaseName, DiagTimer); + TEXT("DIAG Phase %d: %s | Timer=%.1f"), Phase, PhaseName, DiagTimer); } + } #else - // ── PRODUCTION: Apply real head rotation ───────────────────────── - if (!CachedHeadRotation.Equals(FQuat::Identity, 0.001f)) + // ── PRODUCTION ─────────────────────────────────────────────────────── + + if (bUseChain) + { + // ── Multi-bone neck chain: distribute rotation across bones ────── + // + // 1. Clean the total rotation ONCE via swing-twist on the tip bone + // (removes parasitic ear-to-shoulder tilt). + // 2. Slerp a fraction to each bone in the chain. + + // Find the tip bone (last valid bone) for swing-twist reference + FCompactPoseBoneIndex TipBoneIdx(INDEX_NONE); + for (int32 i = ChainBoneIndices.Num() - 1; i >= 0; --i) { + if (ChainBoneIndices[i].GetInt() != INDEX_NONE + && ChainBoneIndices[i].GetInt() < Output.Pose.GetNumBones()) + { + TipBoneIdx = ChainBoneIndices[i]; + break; + } + } + + if (TipBoneIdx.GetInt() == INDEX_NONE) + { + return; // No valid bones in chain + } + + // Swing-twist: remove tilt from the composed rotation + const FQuat TipBoneRot = Output.Pose[TipBoneIdx].GetRotation(); + const FQuat Combined = CachedHeadRotation * TipBoneRot; + const FVector BoneTiltAxis = TipBoneRot.RotateVector(FVector::RightVector); + FQuat Swing, TiltTwist; + Combined.ToSwingTwist(BoneTiltAxis, Swing, TiltTwist); + + // Extract clean offset = Swing * Inverse(TipBoneRot) + const FQuat CleanRotation = (Swing * TipBoneRot.Inverse()).GetNormalized(); + + // Distribute fractional rotation to each bone + for (int32 i = 0; i < ChainBoneIndices.Num(); ++i) + { + const FCompactPoseBoneIndex BoneIdx = ChainBoneIndices[i]; + if (BoneIdx.GetInt() == INDEX_NONE + || BoneIdx.GetInt() >= Output.Pose.GetNumBones()) + { + continue; + } + + const FQuat FractionalRot = FQuat::Slerp(FQuat::Identity, CleanRotation, ChainBoneWeights[i]); + FTransform& BoneTransform = Output.Pose[BoneIdx]; + BoneTransform.SetRotation( + (FractionalRot * BoneTransform.GetRotation()).GetNormalized()); + } + } + else + { + // ── Fallback: single head bone (original behavior) ────────────── + if (HeadBoneIndex.GetInt() != INDEX_NONE + && HeadBoneIndex.GetInt() < Output.Pose.GetNumBones()) + { + FTransform& HeadTransform = Output.Pose[HeadBoneIndex]; const FQuat BoneRot = HeadTransform.GetRotation(); const FQuat Combined = CachedHeadRotation * BoneRot; - // Remove Y-axis tilt from final rotation + // Remove ear-to-shoulder tilt using the bone's actual tilt axis + const FVector BoneTiltAxis = BoneRot.RotateVector(FVector::RightVector); FQuat Swing, TiltTwist; - Combined.ToSwingTwist(FVector::RightVector, Swing, TiltTwist); + Combined.ToSwingTwist(BoneTiltAxis, Swing, TiltTwist); HeadTransform.SetRotation(Swing.GetNormalized()); - - // ── Diagnostic log (every ~60 frames) ── - static int32 DiagCounter = 0; - if (++DiagCounter % 60 == 0) - { - const FRotator OffsetRot = CachedHeadRotation.Rotator(); - const FRotator BoneRefRot = BoneRot.Rotator(); - const FRotator CombRot = Combined.Rotator(); - const FRotator SwingRot = Swing.Rotator(); - const FRotator TiltRot = TiltTwist.Rotator(); - - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT("=== HEAD ROTATION DIAG ===")); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" Offset Q: X=%.4f Y=%.4f Z=%.4f W=%.4f (P=%.1f Y=%.1f R=%.1f)"), - CachedHeadRotation.X, CachedHeadRotation.Y, - CachedHeadRotation.Z, CachedHeadRotation.W, - OffsetRot.Pitch, OffsetRot.Yaw, OffsetRot.Roll); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" BoneRef Q: X=%.4f Y=%.4f Z=%.4f W=%.4f (P=%.1f Y=%.1f R=%.1f)"), - BoneRot.X, BoneRot.Y, BoneRot.Z, BoneRot.W, - BoneRefRot.Pitch, BoneRefRot.Yaw, BoneRefRot.Roll); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" Combined Q: X=%.4f Y=%.4f Z=%.4f W=%.4f (P=%.1f Y=%.1f R=%.1f)"), - Combined.X, Combined.Y, Combined.Z, Combined.W, - CombRot.Pitch, CombRot.Yaw, CombRot.Roll); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" Swing Q: X=%.4f Y=%.4f Z=%.4f W=%.4f (P=%.1f Y=%.1f R=%.1f)"), - Swing.X, Swing.Y, Swing.Z, Swing.W, - SwingRot.Pitch, SwingRot.Yaw, SwingRot.Roll); - UE_LOG(LogElevenLabsPostureAnimNode, Warning, - TEXT(" TiltTwist: X=%.4f Y=%.4f Z=%.4f W=%.4f (P=%.1f Y=%.1f R=%.1f)"), - TiltTwist.X, TiltTwist.Y, TiltTwist.Z, TiltTwist.W, - TiltRot.Pitch, TiltRot.Yaw, TiltRot.Roll); - } } -#endif } +#endif } 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)"), + TEXT("ElevenLabs Posture (eyes: %d curves, head: Y=%.1f P=%.1f, chain: %d bones)"), CachedEyeCurves.Num(), - DebugRot.Yaw, DebugRot.Pitch); + DebugRot.Yaw, DebugRot.Pitch, + ChainBoneIndices.Num()); DebugData.AddDebugItem(DebugLine); BasePose.GatherDebugData(DebugData); } 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 4164762..69490b1 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 @@ -66,9 +66,23 @@ private: * parasitic tilt on diagonals). Copied from the component during Update. */ FQuat CachedHeadRotation = FQuat::Identity; - /** Resolved head bone index in the skeleton. */ + // ── Multi-bone neck chain (resolved at CacheBones) ────────────────────── + + /** Bone names for the neck chain (root-to-tip order). + * Cached from the component at initialization. */ + TArray ChainBoneNames; + + /** Resolved compact pose bone indices, parallel to ChainBoneNames. */ + TArray ChainBoneIndices; + + /** Rotation weights, parallel to ChainBoneNames. */ + TArray ChainBoneWeights; + + // ── Fallback single-bone (when chain is empty) ────────────────────────── + + /** Resolved head bone index in the skeleton (fallback). */ FCompactPoseBoneIndex HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); - /** Head bone name cached from the component at initialization. */ + /** Head bone name cached from the component at initialization (fallback). */ FName HeadBoneName; }; 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 7a08617..bf4ae30 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 @@ -11,6 +11,25 @@ class USkeletalMeshComponent; DECLARE_LOG_CATEGORY_EXTERN(LogElevenLabsPosture, Log, All); +// ───────────────────────────────────────────────────────────────────────────── +// Neck bone chain entry for distributing head rotation across multiple bones +// ───────────────────────────────────────────────────────────────────────────── + +USTRUCT(BlueprintType) +struct PS_AI_AGENT_ELEVENLABS_API FElevenLabsNeckBoneEntry +{ + GENERATED_BODY() + + /** Bone name in the skeleton (e.g. "neck_01", "neck_02", "head"). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FName BoneName; + + /** Fraction of the total head rotation applied to this bone. + * All weights in the chain should sum to ~1.0 for correct total deflection. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (ClampMin = "0", ClampMax = "1")) + float Weight = 0.0f; +}; + // ───────────────────────────────────────────────────────────────────────────── // UElevenLabsPostureComponent // @@ -128,6 +147,15 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ElevenLabs|Posture") FName HeadBoneName = FName(TEXT("head")); + // ── Neck bone chain ───────────────────────────────────────────────────── + + /** Optional chain of bones to distribute head rotation across. + * Order: root-to-tip (e.g. neck_01 → neck_02 → head). + * Weights should sum to ~1.0. + * If empty, falls back to single-bone behavior (HeadBoneName, weight 1.0). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ElevenLabs|Posture") + TArray NeckBoneChain; + // ── Getters (read by AnimNode) ─────────────────────────────────────────── /** Get current eye gaze curves (8 ARKit eye look curves). @@ -151,6 +179,9 @@ public: /** Get the head bone name (used by AnimNode to resolve bone index). */ FName GetHeadBoneName() const { return HeadBoneName; } + /** Get the neck bone chain (used by AnimNode to resolve bone indices). */ + const TArray& GetNeckBoneChain() const { return NeckBoneChain; } + // ── UActorComponent overrides ──────────────────────────────────────────── virtual void BeginPlay() override; virtual void TickComponent(float DeltaTime, ELevelTick TickType,