1. 핵심 개념
SceneComponent -> AActor ->
- 사원수에서 회전행렬로 변환하는 켄 슈메이크 알고리즘
- 사원수의 보간 방식
- 언리얼의 Rotator 구현
2. 상세 내용
2.1~2.3.2 절은 TIL260506 참고
2.3. 사원수(Quaternion)
2.3.3. 사원수와 오일러 각 변환
사원수 -> 회전 행렬
2.4. 언리얼에서 사원수 구현
제미나이 사고모델 피셜: MathFwd.h(타입 지정; 컴파일 타임) -> Rotator.h(입력) -> Quat.h(수학적 변환; 로직) -> Transform.h(시스템 저장(아키텍처)) -> UnrealMathSSE.h(하드웨어 가속(실행))
언리얼에서 액터가 자신의 transform을 회전시킬 때 내부적으로 사원수를 사용한다. 실제로 그런지 찾아보자.
우선 Actor 클래스에서 AActor::SetActorRotation 을 구현한 코드를 보겠다.
// UnrealEngine/Engine/Source/Runtime/Engine/Private/Actor.cpp
bool AActor::SetActorRotation(FRotator NewRotation, ETeleportType Teleport)
{
#if ENABLE_NAN_DIAGNOSTIC
if (NewRotation.ContainsNaN())
{
logOrEnsureNanError(TEXT("AActor::SetActorRotation found NaN in FRotator NewRotation"));
NewRotation = FRotator::ZeroRotator;
}
#endif
if (RootComponent)
{
return RootComponent->MoveComponent(FVector::ZeroVector, NewRotation, true, nullptr, MOVECOMP_NoFlags, Teleport);
}
return false;
}
bool AActor::SetActorRotation(const FQuat& NewRotation, ETeleportType Teleport)
{
#if ENABLE_NAN_DIAGNOSTIC
if (NewRotation.ContainsNaN())
{
logOrEnsureNanError(TEXT("AActor::SetActorRotation found NaN in FQuat NewRotation"));
}
#endif
if (RootComponent)
{
return RootComponent->MoveComponent(FVector::ZeroVector, NewRotation, true, nullptr, MOVECOMP_NoFlags, Teleport);
}
return false;
}
SetActorRotation 코드를 보면 액터를 회전하는 코드는 FRotator나 FQuat 구조체만을 받게 하고 있다.
// UnrealEngine/Engine/Source/Runtime/Engine/Source/Runtime/Core/Public/Math/Quat.h
struct alignas(16) TQuat
{
// Can't have a UE_REQUIRES in the declaration because of the forward declarations, so check for allowed types here.
static_assert(std::is_floating_point_v<T>, "TQuat only supports float and double types.");
public:
/** Type of the template param (float or double) */
using FReal = T;
using QuatVectorRegister = TVectorRegisterType<T>;
/** The quaternion's X-component. */
T X;
/** The quaternion's Y-component. */
T Y;
/** The quaternion's Z-component. */
T Z;
/** The quaternion's W-component. */
T W;
// ...
}
// UnrealEngine/Engine/Source/Runtime/Engine/Source/Runtime/Core/Public/Math/Rotator.h
template<typename T>
struct TRotator
{
// Can't have a UE_REQUIRES in the declaration because of the forward declarations, so check for allowed types here.
static_assert(std::is_floating_point_v<T>, "TRotator only supports float and double types.");
public:
using FReal = T;
/** Rotation around the right axis (around Y axis), Looking up and down (0=Straight Ahead, +Up, -Down) */
T Pitch;
/** Rotation around the up axis (around Z axis), Turning around (0=Forward, +Right, -Left)*/
T Yaw;
/** Rotation around the forward axis (around X axis), Tilting your head, (0=Straight, +Clockwise, -CCW) */
T Roll;
// ...
}
FQuat는 사원수를 나타내는 구조체로 실제 회전 시 사원수를 이용하는 것을 알 수 있다. 이때 프로그래머는 FRotator 구조체를 사용해서 pitch(y), yaw(z), roll(x)을 직접 조정해서 원하는 축으로 오일러 각을 이용한 회전을 수행할 수 있다.
실제 구현을 보면 FRotator도 내부적으로 사원수를 사용하는지 알기 위해서 앞서 보았던 AActor::SetActorRotation의 구현부에서 출력부에서 호출하는 RootComponent->MoveComponent(FVector::ZeroVector, NewRotation, true, nullptr, MOVECOMP_NoFlags, Teleport);를 찾아보자.
RootComponent는 SceneComponent로 액터의 실제 transform이 담긴 클래스이다. SceneComponent에 구현도나 MoveComponent의 구현을 봐보자.
// UnrealEngine/Engine/Source/Runtime/Engine/Private/Components/SceneComponent.cpp
// FRotator version. This could be a simple wrapper to the FQuat version, but in the case of no significant change in location or rotation (as FRotator),
// we avoid passing through to the FQuat version because conversion can generate a false negative for the rotation equality comparison done using a strict tolerance.
bool USceneComponent::MoveComponent(const FVector& Delta, const FRotator& NewRotation, bool bSweep, FHitResult* Hit, EMoveComponentFlags MoveFlags, ETeleportType Teleport)
{
if (GetAttachParent() == nullptr)
{
if (Delta.IsZero() && NewRotation.Equals(GetRelativeRotation(), SCENECOMPONENT_ROTATOR_TOLERANCE))
{
if (Hit)
{
Hit->Init();
}
return true;
}
return MoveComponentImpl(Delta, RelativeRotationCache.RotatorToQuat_ReadOnly(NewRotation), bSweep, Hit, MoveFlags, Teleport);
}
return MoveComponentImpl(Delta, NewRotation.Quaternion(), bSweep, Hit, MoveFlags, Teleport);
}
bool USceneComponent::MoveComponentImpl(const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, EMoveComponentFlags MoveFlags, ETeleportType Teleport)
{
SCOPE_CYCLE_COUNTER(STAT_MoveComponentSceneComponentTime);
// static things can move before they are registered (e.g. immediately after streaming), but not after.
if (!IsValidChecked(this) || CheckStaticMobilityAndWarn(SceneComponentStatics::MobilityWarnText))
{
if (OutHit)
{
*OutHit = FHitResult();
}
return false;
}
// Fill in optional output param. SceneComponent doesn't sweep, so this is just an empty result.
if (OutHit)
{
*OutHit = FHitResult(1.f);
}
ConditionalUpdateComponentToWorld();
// early out for zero case
if( Delta.IsZero() )
{
// Skip if no vector or rotation.
if (NewRotation.Equals(GetComponentTransform().GetRotation(), SCENECOMPONENT_QUAT_TOLERANCE))
{
return true;
}
}
// just teleport, sweep is supported for PrimitiveComponents. This will update child components as well.
const bool bMoved = InternalSetWorldLocationAndRotation(GetComponentLocation() + Delta, NewRotation, false, Teleport);
// Only update overlaps if not deferring updates within a scope
if (bMoved && !IsDeferringMovementUpdates())
{
// need to update overlap detection in case PrimitiveComponents are attached.
UpdateOverlaps();
}
return true;
}
USceneComponent::MoveComponent() 메서드를 보면 FRotator을 받더라도 실제로는 로테이터를 RotatorToQuat_ReadOnly()을 사용해서 사원수인 FQuat로 바꾸어 실제 회전은 USceneComponent::MoveComponentImpl() 메서드에서 진행되는 것을 알 수 있다. 언리얼에서 회전은 결국 모두 사원수인 FQuat 구조체로 일어나는 것이다.
최종적으로 회전에 사용할 사원수는 InternalSetWorldLocationAndRotation() 메서드에서 적용이 된다.
// UnrealEngine/Engine/Source/Runtime/Engine/Private/Components/SceneComponent.cpp
bool USceneComponent::InternalSetWorldLocationAndRotation(FVector NewLocation, const FQuat& RotationQuat, bool bNoPhysics, ETeleportType Teleport)
{
checkSlow(bComponentToWorldUpdated);
FQuat NewRotationQuat(RotationQuat);
#if ENABLE_NAN_DIAGNOSTIC
if (NewRotationQuat.ContainsNaN())
{
logOrEnsureNanError(TEXT("USceneComponent:InternalSetWorldLocationAndRotation found NaN in NewRotationQuat: %s"), *NewRotationQuat.ToString());
NewRotationQuat = FQuat::Identity;
}
#endif
// If attached to something, transform into local space
if (GetAttachParent() != nullptr)
{
FTransform const ParentToWorld = GetAttachParent()->GetSocketTransform(GetAttachSocketName());
// in order to support mirroring, you'll have to use FTransform.GetrelativeTransform
// because negative scale should flip the rotation
if (FTransform::AnyHasNegativeScale(GetRelativeScale3D(), ParentToWorld.GetScale3D()))
{
FTransform const WorldTransform = FTransform(RotationQuat, NewLocation, GetRelativeScale3D() * ParentToWorld.GetScale3D());
FTransform const RelativeTransform = WorldTransform.GetRelativeTransform(ParentToWorld);
if (!IsUsingAbsoluteLocation())
{
NewLocation = RelativeTransform.GetLocation();
}
if (!IsUsingAbsoluteRotation())
{
NewRotationQuat = RelativeTransform.GetRotation();
}
}
else
{
if (!IsUsingAbsoluteLocation())
{
NewLocation = ParentToWorld.InverseTransformPosition(NewLocation);
}
if (!IsUsingAbsoluteRotation())
{
// Quat multiplication works reverse way, make sure you do Parent(-1) * World = Local, not World*Parent(-) = Local (the way matrix does)
NewRotationQuat = ParentToWorld.GetRotation().Inverse() * NewRotationQuat;
}
}
}
const FRotator NewRelativeRotation = RelativeRotationCache.QuatToRotator_ReadOnly(NewRotationQuat);
bool bDiffLocation = !NewLocation.Equals(GetRelativeLocation());
bool bDiffRotation = !NewRelativeRotation.Equals(GetRelativeRotation());
if (bDiffLocation || bDiffRotation)
{
SetRelativeLocation_Direct(NewLocation);
// Here it is important to compute the quaternion from the rotator and not the opposite.
// In some cases, similar quaternions generate the same rotator, which create issues.
// When the component is loaded, the rotator is used to generate the quaternion, which
// is then used to compute the ComponentToWorld matrix. When running a blueprint script,
// it is required to generate that same ComponentToWorld otherwise the FComponentInstanceDataCache
// might fail to apply to the relevant component. In order to have the exact same transform
// we must enforce the quaternion to come from the rotator (as in load)
if (bDiffRotation)
{
SetRelativeRotation_Direct(NewRelativeRotation);
RelativeRotationCache.RotatorToQuat(NewRelativeRotation);
}
#if ENABLE_NAN_DIAGNOSTIC
if (GetRelativeRotation().ContainsNaN())
{
logOrEnsureNanError(TEXT("USceneComponent:InternalSetWorldLocationAndRotation found NaN in RelativeRotation: %s"), *GetRelativeRotation().ToString());
SetRelativeRotation_Direct(FRotator::ZeroRotator);
}
#endif
UpdateComponentToWorldWithParent(GetAttachParent(),GetAttachSocketName(), SkipPhysicsToEnum(bNoPhysics), RelativeRotationCache.GetCachedQuat(), Teleport);
// we need to call this even if this component itself is not navigation relevant
if (IsRegistered() && bCanEverAffectNavigation)
{
PostUpdateNavigationData();
}
return true;
}
return false;
}
드디어 최종적으로 if (bDiffRotation) 문에서 회전이 적용되는 코드를 찾을 수 있다. 회전하기 이전에 부모의 Rotation 역행렬과 회전 사원수를 곱해서 부모의 회전값 영향을 상쇄하고 사원수를 사용해서 회전을 계산하는 모습을 볼 수 있다. 코드를 보면 다음과 같은 주석을 볼 수 있다.
[!help]
Here it is important to compute the quaternion from the rotator and not the opposite.
In some cases, similar quaternions generate the same rotator, which create issues.
When the component is loaded, the rotator is used to generate the quaternion, which is then used to compute the ComponentToWorld matrix.
When running a blueprint script, it is required to generate that same ComponentToWorld otherwise the FComponentInstanceDataCache might fail to apply to the relevant component.
In order to have the exact same transform we must enforce the quaternion to come from the rotator (as in load)
비슷한 사원수(similar quaternion)은 서로 같은 로테이터를 만드는 경우가 있다. 이는 사원수로 회전을 계산할 때 식 4.14에서 볼 수 있듯이 벡터의 회전을 순허수 사원수와 일대일로 대응시키기 위해 회전 사원수를 두 번 곱하기 때문이다. 이때문에 qpq−1=(−q)p(−q)−1처럼 부호가 마이너스여도 똑같은 회전을 만들어내는 double-cover 문제가 있다.
언리얼에서는 유저 인터페이스의 직관성, 사원수의 double-cover 특성 등의 이유로 액터의 회전값을 Rotator로 정의했다. 비록 회전 연산은 내부적으로 사원수를 쓰지만 이를 반영할 때는 다시 한 번 로테이션 값으로 변환해서 사용한다.
SetRelativeRotation_Direct()과 같이 사원수 <-> 로테이터 변환 과정을 생략하고 직접적으로 대입할 수 있게 하는 메서드가 존재한다. 이 경우 프로그래머가 UpdateComponentToWorld()을 명시적으로 불러서 변경된 회전값을 실제 렌더링, 물리, 자식 컴포넌트 들에게 반영해줘야 한다.
3. 질문 및 해결 (Q&A)
- 언리얼과 짐벌락
- 내부 구조를 살펴보기 이전에는 막연하게 로테이터로 유저가 입력을 줘도 내부적으로 사원수로 회전 연산을 수행해서 짐벌락을 예방한다고 알고 있었다. 그러나 실제로는 Rotator로 회전값이 정의되어서 Rotator 값을 직접 조정한다거나, 사원수 회전의 결과값이 pitch 90도 같은 경우 여전히 짐벌락 현상이 발생할 여지가 있었다.
- 언리얼의 Rotator는 짐벌락 현상이 발생하면 강제로 다른 축을 0으로 고정하여 문제를 회피한다고 한다.(확인 필요) 이 때문에 카메라가 흔들리는(wobble) 문제가 있다고 한다.
- 만약 언리얼에서 완전히 짐벌락 문제에서 자유로워지고 싶다면 회전을 철저하게 사원수(Fquat)로 다루던가, 각도를 벡터로 구성하여 회전행렬을 만들던가, 액터의 Rotator를 직접적으로 변경하는 일을 피해야 할 것이다.
- Rotator는 유저 인터페이스, 최종 결과를 담는 저장용 공간 정도로 보고 실제 연산은 회전 행렬이, 사원수를 이용하도록 하자.
- 추가적인 궁금점
- Rotator의 짐벌락 회피 구현(하드코딩)
- 카메라 흔들림(wobble)은 근원적으로 사원수 <-> 로테이터 간 전환 문제에서 발생한다고 하는데 실제로 그러한지 찾아볼 필요가 있다.
4. 관련 문서 (Links)
- [double-cover](Why Do Quaternions Double-Cover? – Nathan Reed’s coding blog)
- [[이전 관련 노트]]
- 참고한 외부 링크
'내일배움캠프 > TIL' 카테고리의 다른 글
| TIL260511 - Unreal (0) | 2026.05.11 |
|---|---|
| TIL260508 - CPP (0) | 2026.05.08 |
| TIL260506 - Quaternion (0) | 2026.05.06 |
| TIL260504 - Quaternion (0) | 2026.05.04 |
| TIL260430 - CPP (0) | 2026.04.30 |