I’m building a custom movement system for my project (kind of a physics-driven movement framework). Everything works great, except for one annoying issue with GYRO STABILIZATION. Video showcase of the problem
Here’s the setup hierarchy:
Wheel -> Direction
Wheel -> Gyro -> Turner -> Body -> Head
Visual representation of hierarchy:

The Wheel is the root component, and it drives physics / instant alignment to surface normals. The Gyro applies spring-based stabilization to keep the player aligned with gravity or surface normal if it's fast enough (for doing tricks like death loops etc).
The Problem
When I drive up a ramp that is aligned with the forward vector of the Wheel, the Gyro behaves just perfectly - it oscillates slightly forward and backward (like a spring), which is exactly what I want
But when I drive onto a ramp rotated around the Z axis (e.g., about 20 degrees sideways), the Gyro starts rocking side-to-side as well, like it’s adding an unwanted roll or yaw component. It should only stabilize in the pitch axis (forward/back tilt), not start twisting sideways.
It looks like the stabilization is somehow mixing in Yaw, but I can’t pinpoint where.
Relevant Code
Here’s the function where the issue lives: (trimmed version so you don’t have to read my whole movement component)
Function that converts vector to rotator (honestly i used chatgpt for this one cuz my wont work):
FRotator UFG_MovementComponent::MakeAlignedRotator(const FVector &FSurfaceNormal, const FRotator &CurrentRotation)
{
FRotator Rot = CurrentRotation;
Rot.Roll = FMath::GridSnap(Rot.Roll, RotMinStep);
Rot.Pitch = FMath::GridSnap(Rot.Pitch, RotMinStep);
Rot.Yaw = FMath::GridSnap(Rot.Yaw, RotMinStep);
FVector N = FSurfaceNormal;
FVector Fwd = UKismetMathLibrary::GetForwardVector(Rot);
FVector Up = UKismetMathLibrary::GetUpVector(Rot);
// Calculate the axis and angle between the current up and normal vectors
FVector Axis = FVector::CrossProduct(Up, N);
float AxisLen = Axis.Size();
FVector NewFwd;
if (AxisLen < KINDA_SMALL_NUMBER)
{
// If the vectors are almost parallel (0° or 180°)
if (FVector::DotProduct(Up, N) < 0.0f)
{
// If it's a 180° angle, reverse the forward vector
NewFwd = -Fwd;
}
else
{
// If the vectors are the same, leave the forward vector as is
NewFwd = Fwd;
}
}
else
{
Axis /= AxisLen;
float Angle = FMath::Acos(FMath::Clamp(FVector::DotProduct(Up, N), -1.0f, 1.0f));
FQuat Q(Axis, Angle);
NewFwd = Q.RotateVector(Fwd);
}
// Project the new forward vector onto the plane perpendicular to the normal vector (to ensure no component along the normal vector)
NewFwd = (NewFwd - N * FVector::DotProduct(NewFwd, N)).GetSafeNormal();
// Combine the matrix and convert it to a rotation
FRotationMatrix RotM = UKismetMathLibrary::MakeRotFromXZ(NewFwd, N);
FRotator NewRot = RotM.Rotator();
NewRot.Roll = FMath::GridSnap(NewRot.Roll, RotMinStep);
NewRot.Pitch = FMath::GridSnap(NewRot.Pitch, RotMinStep);
NewRot.Yaw = FMath::GridSnap(NewRot.Yaw, RotMinStep);
return NewRot;
}
Function that alignes wheel and direction to surface:
void UFG_MovementComponent::AlignToSurface(float DeltaTime)
{
// If the wheel or gyro does not exist, or if the direction is not set, exit
if (!Wheel || !Gyro || !Direction)
return;
// Check if the wheel is currently on the ground
if (!GetGroundStatus(TraceLength))
return;
// Get the surface normal
FVector NewSurfaceNormal = GetSurfaceNormal(NormalTraceLength, SurfaceNormal);
if (NewSurfaceNormal == SurfaceNormal)
return;
SurfaceNormal = NewSurfaceNormal;
// Get the current rotation of the wheel
FRotator CurrentRot = Wheel->GetComponentRotation();
FRotator GyroRot = Gyro->GetComponentRotation();
FRotator CurrentDirectionRot = Direction->GetComponentRotation();
// Calculate the target rotation based on the surface normal and current rotation
FRotator TargetRot = MakeAlignedRotator(SurfaceNormal, CurrentRot);
// Apply the target rotation to the wheel
Wheel->SetWorldRotation(TargetRot);
Gyro->SetWorldRotation(GyroRot);
FRotator TargetDirectionRot = MakeAlignedRotator(SurfaceNormal, CurrentDirectionRot);
Direction->SetWorldRotation(TargetDirectionRot);
}
Function that aligns Wheel and Direction to the SurfaceNormal
void UFG_MovementComponent::AlignToSurface(float DeltaTime)
{
// If the wheel or gyro does not exist, or if the direction is not set, exit
if (!Wheel || !Gyro || !Direction)
return;
// Check if the wheel is currently on the ground
if (!GetGroundStatus(TraceLength))
return;
// Get the surface normal
FVector NewSurfaceNormal = GetSurfaceNormal(NormalTraceLength, SurfaceNormal);
if (NewSurfaceNormal == SurfaceNormal)
return;
SurfaceNormal = NewSurfaceNormal;
// Get the current rotation of the wheel
FRotator CurrentRot = Wheel-\>GetComponentRotation();
FRotator GyroRot = Gyro-\>GetComponentRotation();
FRotator CurrentDirectionRot = Direction-\>GetComponentRotation();
// Calculate the target rotation based on the surface normal and current rotation
FRotator TargetRot = MakeAlignedRotator(SurfaceNormal, CurrentRot);
// Apply the target rotation to the wheel
Wheel-\>SetWorldRotation(TargetRot);
Gyro-\>SetWorldRotation(GyroRot);
FRotator TargetDirectionRot = MakeAlignedRotator(SurfaceNormal, CurrentDirectionRot);
Direction-\>SetWorldRotation(TargetDirectionRot);
}
Then there is the MAIN THING that, i guess, causes this problem
void UFG_MovementComponent::ApplyGyroStabilization(float DeltaTime)
{
// Check if Gyro component is valid
if (!Gyro)
return;
// Get the current rotation of the Wheel component
FRotator WheelRot = Wheel->GetComponentRotation();
// Get the current rotation of the Gyro component
FRotator GyroRot = Gyro->GetComponentRotation();
// Get the direction of the wheel drive force
FRotator DirectionRot = MakeAlignedRotator(SurfaceNormal, WheelRot);
// Calculate the gravity aligned rotation
FRotator GravityRot = MakeAlignedRotator(-GravityDirection, WheelRot);
GEngine->AddOnScreenDebugMessage(1, 1.f, FColor::Red, FString::Printf(TEXT("GravityRot: %f, %f, %f"), GravityRot.Roll, GravityRot.Pitch, GravityRot.Yaw));
GEngine->AddOnScreenDebugMessage(2, 1.f, FColor::Green, FString::Printf(TEXT("GravityRot: %f, %f, %f"), WheelRot.Roll, WheelRot.Pitch, WheelRot.Yaw));
// Calculate the alignment coefficient based on the wheel drive force
float AlignCoeff = FMath::GetMappedRangeValueClamped(
FVector2D(0.f, 3000.f),
FVector2D(1.f, 1.f),
FMath::Abs(WheelDriveForce)); // YOU CAN IGNORE THIS, IT JUST CALCULATE HOW, BASED ON SPEED IT WILL ALIGN TO SURFACE RATHER THAN GRAVITY
// Calculate the target rotation based on the alignment coefficient
FRotator TargetRot = UKismetMathLibrary::RLerp(
GravityRot,
DirectionRot,
AlignCoeff, true); // AND THIS ALSO
// Calculate the new rotation of the Gyro component
FRotator NewRot;
if (bGrounded)
{
NewRot = CalculateSpringRotation(DeltaTime, GyroRot, TargetRot, GyroSpringVelocity, GyroStiffness, GyroDamping);
}
else
{
NewRot = CalculateSpringRotation(DeltaTime, GyroRot, GyroRot, GyroSpringVelocity, GyroStiffness, GyroDamping);
}
// Apply the new rotation to the Gyro component
Gyro->SetWorldRotation(NewRot);
}
Function that springs the rotation using rot-velocity (also changes the velocity variable, because of & (yeah i know, basic stuff, but just in case))
FRotator UFG_MovementComponent::CalculateSpringRotation(float DeltaTime, FRotator CurrentRotation, FRotator TargetRotation, FRotator &CurrentVelocity, float SpringStiffness, float SpringDamping)
{
FRotator SpringOffset = (TargetRotation - CurrentRotation).GetNormalized();
FRotator SpringAccel = SpringOffset * SpringStiffness - CurrentVelocity * SpringDamping;
CurrentVelocity += SpringAccel * FMath::Clamp(DeltaTime, 0.005f, 0.033f);
FRotator NewRot = CurrentRotation + CurrentVelocity * DeltaTime;
return NewRot;
}
Context:
Custom movement, fully C++ (no CharacterMovementComponent). Wheel aligns to SurfaceNormal, and the Gyro applies the spring stabilization. The Wheel isn’t aligned with the Direction because when I tried steering the Wheel as the root component, it caused replication issues. I plan to make this multiplayer, so I had to separate them, though I’m wondering if there’s a reliable way to steer the root Wheel without breaking replication.
Would really appreciate any insight If anyone solved something similar (gyro tilt, motorcycle lean, mech stabilization, etc.), I’d love to hear how you approached it.
Sorry if the comments are a bit sparse, Codeium helped with the docstrings, should be readable though.
Also would be nice to see if you know how to optimize it, i am not so good at c++ unreal (well, as you can see)
Expected behavior:
Ideally, the gyro should stabilize by oscillating only around its local pitch axis (forward/back tilt) relative to the wheel’s forward direction (Direction component, not the root) similar to a spring trying to keep balance.
It shouldn’t introduce any side roll or yaw rotation when the ramp is slightly rotated around Z.