Posture: neck bone chain distribution + swing-twist axis fix + log cleanup

- Add NeckBoneChain config (FElevenLabsNeckBoneEntry USTRUCT) to distribute
  head rotation across multiple bones (e.g. neck_01=0.25, neck_02=0.35,
  head=0.40) for more natural neck arc movement.
- Swing-twist now uses bone's actual tilt axis (BoneRot.RotateVector) instead
  of hardcoded FVector::RightVector — accounts for MetaHuman ~11.5° reference
  pose rotation on head bone.
- Clean up diagnostic logging: reduced to Log level, removed verbose per-frame
  quaternion dumps.
- Backward compatible: empty NeckBoneChain falls back to single HeadBoneName.

KNOWN ISSUE: diagonal tilt is more visible with neck chain — needs investigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-02-25 15:05:21 +01:00
parent c3abc420cf
commit 8df6967ee5
4 changed files with 196 additions and 124 deletions

View File

@ -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<FElevenLabsNeckBoneEntry>& 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<FTransform>& 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);
}

View File

@ -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<FName> ChainBoneNames;
/** Resolved compact pose bone indices, parallel to ChainBoneNames. */
TArray<FCompactPoseBoneIndex> ChainBoneIndices;
/** Rotation weights, parallel to ChainBoneNames. */
TArray<float> 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;
};

View File

@ -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<FElevenLabsNeckBoneEntry> 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<FElevenLabsNeckBoneEntry>& GetNeckBoneChain() const { return NeckBoneChain; }
// ── UActorComponent overrides ────────────────────────────────────────────
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,