Merge posture fixes: cascade two-step, ARKit normalization, diagonal tilt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-02-25 14:44:56 +01:00
commit 81bcd67428
4 changed files with 63 additions and 19 deletions

View File

@ -28,7 +28,7 @@ void FAnimNode_ElevenLabsPosture::Initialize_AnyThread(const FAnimationInitializ
// Find the ElevenLabsPostureComponent on the owning actor. // Find the ElevenLabsPostureComponent on the owning actor.
PostureComponent.Reset(); PostureComponent.Reset();
CachedEyeCurves.Reset(); CachedEyeCurves.Reset();
CachedHeadRotation = FRotator::ZeroRotator; CachedHeadRotation = FQuat::Identity;
HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE);
if (const FAnimInstanceProxy* Proxy = Context.AnimInstanceProxy) if (const FAnimInstanceProxy* Proxy = Context.AnimInstanceProxy)
@ -142,7 +142,7 @@ void FAnimNode_ElevenLabsPosture::Update_AnyThread(const FAnimationUpdateContext
// Cache posture data from the component (game thread safe copy). // Cache posture data from the component (game thread safe copy).
CachedEyeCurves.Reset(); CachedEyeCurves.Reset();
CachedHeadRotation = FRotator::ZeroRotator; CachedHeadRotation = FQuat::Identity;
if (PostureComponent.IsValid()) if (PostureComponent.IsValid())
{ {
@ -218,11 +218,51 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
} }
#else #else
// ── PRODUCTION: Apply real head rotation ───────────────────────── // ── PRODUCTION: Apply real head rotation ─────────────────────────
if (!CachedHeadRotation.IsNearlyZero(0.1f)) if (!CachedHeadRotation.Equals(FQuat::Identity, 0.001f))
{ {
const FQuat HeadOffset = CachedHeadRotation.Quaternion(); const FQuat BoneRot = HeadTransform.GetRotation();
// Pre-multiply: apply offset in parent space (neck) const FQuat Combined = CachedHeadRotation * BoneRot;
HeadTransform.SetRotation((HeadOffset * HeadTransform.GetRotation()).GetNormalized());
// Remove Y-axis tilt from final rotation
FQuat Swing, TiltTwist;
Combined.ToSwingTwist(FVector::RightVector, 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
} }
@ -230,10 +270,11 @@ void FAnimNode_ElevenLabsPosture::Evaluate_AnyThread(FPoseContext& Output)
void FAnimNode_ElevenLabsPosture::GatherDebugData(FNodeDebugData& DebugData) void FAnimNode_ElevenLabsPosture::GatherDebugData(FNodeDebugData& DebugData)
{ {
const FRotator DebugRot = CachedHeadRotation.Rotator();
FString DebugLine = FString::Printf( 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)"),
CachedEyeCurves.Num(), CachedEyeCurves.Num(),
CachedHeadRotation.Yaw, CachedHeadRotation.Pitch); DebugRot.Yaw, DebugRot.Pitch);
DebugData.AddDebugItem(DebugLine); DebugData.AddDebugItem(DebugLine);
BasePose.GatherDebugData(DebugData); BasePose.GatherDebugData(DebugData);
} }

View File

@ -275,13 +275,16 @@ void UElevenLabsPostureComponent::TickComponent(
{ {
FScopeLock Lock(&PostureDataLock); FScopeLock Lock(&PostureDataLock);
// MetaHuman head bone axis mapping (independent quaternions to avoid // MetaHuman head bone axis mapping:
// diagonal coupling that FRotator causes when both axes are non-zero): // Z-axis rotation = nod up/down → our HeadPitch
// Z-axis rotation = nod up/down → our HeadPitch
// X-axis rotation = turn left/right → our HeadYaw // X-axis rotation = turn left/right → our HeadYaw
//
// Store raw composed quaternion. The parasitic Y-axis tilt from
// composition is removed in the AnimNode AFTER composing with the
// bone's reference rotation (which also contributes Y-coupling).
const FQuat NodQuat(FVector::UpVector, FMath::DegreesToRadians(-CurrentHeadPitch)); const FQuat NodQuat(FVector::UpVector, FMath::DegreesToRadians(-CurrentHeadPitch));
const FQuat TurnQuat(FVector::ForwardVector, FMath::DegreesToRadians(CurrentHeadYaw)); const FQuat TurnQuat(FVector::ForwardVector, FMath::DegreesToRadians(CurrentHeadYaw));
CurrentHeadRotation = (TurnQuat * NodQuat).Rotator(); CurrentHeadRotation = TurnQuat * NodQuat;
// Eye yaw is negated to match ARKit curve direction convention. // Eye yaw is negated to match ARKit curve direction convention.
UpdateEyeCurves(-CurrentEyeYaw, CurrentEyePitch); UpdateEyeCurves(-CurrentEyeYaw, CurrentEyePitch);

View File

@ -62,9 +62,9 @@ private:
* Copied from the component during Update (game thread safe). */ * Copied from the component during Update (game thread safe). */
TMap<FName, float> CachedEyeCurves; TMap<FName, float> CachedEyeCurves;
/** Head rotation offset (yaw + pitch) to apply to the head bone. /** Head rotation offset as quaternion (no Euler round-trip, avoids
* Copied from the component during Update. */ * parasitic tilt on diagonals). Copied from the component during Update. */
FRotator CachedHeadRotation = FRotator::ZeroRotator; FQuat CachedHeadRotation = FQuat::Identity;
/** Resolved head bone index in the skeleton. */ /** Resolved head bone index in the skeleton. */
FCompactPoseBoneIndex HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE); FCompactPoseBoneIndex HeadBoneIndex = FCompactPoseBoneIndex(INDEX_NONE);

View File

@ -139,10 +139,10 @@ public:
return CurrentEyeCurves; return CurrentEyeCurves;
} }
/** Get current head rotation offset (yaw + pitch, applied by AnimNode). /** Get current head rotation offset (applied by AnimNode as FQuat to avoid
* Euler round-trip that reintroduces parasitic tilt on diagonals).
* Thread-safe copy. */ * Thread-safe copy. */
UFUNCTION(BlueprintCallable, Category = "ElevenLabs|Posture") FQuat GetCurrentHeadRotation() const
FRotator GetCurrentHeadRotation() const
{ {
FScopeLock Lock(&PostureDataLock); FScopeLock Lock(&PostureDataLock);
return CurrentHeadRotation; return CurrentHeadRotation;
@ -194,8 +194,8 @@ private:
/** 8 ARKit eye look curves (eyeLookUpLeft, eyeLookDownRight, etc.). */ /** 8 ARKit eye look curves (eyeLookUpLeft, eyeLookDownRight, etc.). */
TMap<FName, float> CurrentEyeCurves; TMap<FName, float> CurrentEyeCurves;
/** Head bone rotation offset (Yaw + Pitch). */ /** Head bone rotation offset as quaternion (no Euler round-trip). */
FRotator CurrentHeadRotation = FRotator::ZeroRotator; FQuat CurrentHeadRotation = FQuat::Identity;
/** Cached skeletal mesh component on the owning actor. */ /** Cached skeletal mesh component on the owning actor. */
TWeakObjectPtr<USkeletalMeshComponent> CachedMesh; TWeakObjectPtr<USkeletalMeshComponent> CachedMesh;