[DAY 41] Implementing 6-DOF Drone and Artificial Gravity

베리투스·2025년 9월 30일

TIL: Today I Learned

목록 보기
42/87

오늘은 지난번 Pawn 클래스 만들기에 이어 도전 과제를 수행했다. 먼저 캐릭터를 6자유도(6-DOF)로 움직이는 드론처럼 만들고, Q/E 키를 눌렀을 때 기체가 자연스럽게 기울어지는 효과까지 구현했다. 여기서 한 단계 더 나아가, 언리얼의 물리 엔진을 사용하지 않고 LineTraceTick 함수를 이용해 직접 "인공 중력" 시스템을 만들었다. 중력 가속도를 계산해 낙하하고, 바닥을 감지해 착지하며, 공중에 있을 땐 이동 속도를 줄이는 "에어 컨트롤"까지 구현하며 게임 캐릭터 이동의 깊이를 제대로 맛볼 수 있었다. 🛸


📌 목표

  • Pawn을 상하좌우, 전후, 회전까지 가능한 6자유도(6-DOF) 비행체로 구현한다.
  • FMath::FInterpTo를 사용해 키 입력에 따라 기체가 부드럽게 기울어지는 "롤(Roll)" 효과를 만든다.
  • 물리 엔진 없이 Tick 함수에서 직접 속도를 계산하는 인공 중력을 구현한다.
  • LineTrace를 사용해 바닥을 감지하는 GroundCheck 함수를 작성한다.
  • 지상과 공중의 이동 속도를 다르게 적용하는 에어 컨트롤(Air Control)을 구현한다.

📖 이론

  1. 6자유도 (6 Degrees of Freedom, 6-DOF)

    • 3차원 공간에서 물체가 가질 수 있는 6가지 움직임의 자유도를 의미한다.
    • 이동(Translation): X축(좌/우), Y축(전/후), Z축(상/하)
    • 회전(Rotation): Pitch(상/하), Yaw(좌/우), Roll(기울기)
    • 기존 WASD 이동에 상/하 이동(Elevate)과 좌/우 기울기(Roll)를 추가하여 구현했다.
  2. 보간 (Interpolation) - FMath::FInterpTo

    • A라는 값에서 B라는 값으로 부드럽게 변화시키고 싶을 때 사용하는 수학 기법이다.
    • FInterpTo(현재값, 목표값, DeltaTime, 보간속도) 형태로 사용한다.
    • 이번 과제에서는 드론의 현재 기울기(CurrentRollDeg)를 목표 기울기(TargetRollDeg)까지 부드럽게 변화시키는 데 사용했다. 이걸 쓰지 않으면 드론이 딱딱하게 꺾여서 보기에 좋지 않다.
  3. 라인 트레이스 (Line Trace)

    • 게임 월드에 보이지 않는 선(레이저)을 쏴서 어떤 물체에 부딪혔는지 감지하는 기능이다.
    • 캐릭터 발밑으로 짧은 선을 쏴서 바닥이 있는지 확인하는 GroundCheck에 아주 유용하다.
    • 충돌 지점의 정보(FHitResult)를 얻을 수 있는데, 특히 ImpactNormal(충돌면의 법선 벡터)을 확인하면 경사면인지 평지인지 구분할 수 있다. (Z값이 1에 가까울수록 평평함)
  4. 인공 중력 로직

    • 물리 공식 속도 = 이전 속도 + (가속도 * 시간)을 코드로 옮긴 것이다.
    • Tick 함수에서 매 프레임마다 VerticalVelocity += GravityAccel * DeltaTime; 코드를 실행하여 중력의 영향을 누적시킨다.
    • 계산된 VerticalVelocity만큼 캐릭터를 아래로 이동시키면 자연스러운 낙하가 구현된다.

💻 코드

MyPawn.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "MyPawn.generated.h"

class UCapsuleComponent;
class USkeletalMeshComponent;
class USpringArmComponent;
class UCameraComponent;

UCLASS()
class SPARTA6_7_API AMyPawn : public APawn
{
    GENERATED_BODY()

public:
    AMyPawn();

protected:
    // 게임 시작 시 호출
    virtual void BeginPlay() override;
    // 매 프레임 호출: 롤링, 중력 처리
    virtual void Tick(float DeltaTime) override;
    // 입력 컴포넌트 설정
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

    // 입력 처리 함수
    UFUNCTION() void Move(const FInputActionValue& value);    // 이동 (전/후/좌/우)
    UFUNCTION() void Look(const FInputActionValue& value);    // 시점 회전
    UFUNCTION() void Elevate(const FInputActionValue& Value); // 이동 (상/하)
    UFUNCTION() void Roll(const FInputActionValue& Value);    // 기울기 (좌/우)

private:
    // 컴포넌트
    UPROPERTY(VisibleAnywhere) UCapsuleComponent* Capsule;
    UPROPERTY(VisibleAnywhere) USkeletalMeshComponent* Mesh;
    UPROPERTY(VisibleAnywhere) USpringArmComponent* SpringArm;
    UPROPERTY(VisibleAnywhere) UCameraComponent* Camera;

    // 이동/회전 변수
    UPROPERTY(EditAnywhere, Category = "Movement") float MoveSpeed = 600.0f;
    UPROPERTY(EditAnywhere, Category = "Movement") float LookYawSpeed = 0.1f;
    UPROPERTY(EditAnywhere, Category = "Movement") float LookPitchSpeed = 0.1f;
    UPROPERTY(EditAnywhere, Category = "Movement") float MaxRollDeg = 30.0f;      // 최대 롤 각도
    UPROPERTY(EditAnywhere, Category = "Movement") float RollReturnSpeed = 8.0f;  // 롤 복귀 속도
    float TargetRollDeg = 0.0f;     // 목표 롤 각도
    float CurrentRollDeg = 0.0f;    // 현재 롤 각도
    FRotator MeshBaseRelativeRot;   // 메시 초기 회전값 (롤링 기준점)

    // 인공 중력 변수
    UPROPERTY(EditAnywhere, Category = "Gravity") float GravityAccel = -980.f;      // 중력 가속도
    UPROPERTY(EditAnywhere, Category = "Gravity") float MaxFallSpeed = 4000.f;      // 최대 낙하 속도
    UPROPERTY(EditAnywhere, Category = "Gravity") float GroundCheckDistance = 5.f;  // 지면 감지 거리
    UPROPERTY(EditAnywhere, Category = "Gravity") float AirControlRatio = 0.4f;     // 공중 제어 비율

    // 중력 상태 변수
    float VerticalVelocity = 0.f; // 현재 수직 속도
    bool  bIsGrounded = false;    // 지면 접촉 상태
    float LastElevateAxis = 0.f;  // 마지막 상승/하강 입력 값

    // 내부 함수
    // 인공 중력 계산 및 적용
    void ApplyArtificialGravity(float DeltaTime);
    // 라인 트레이스 지면 확인
    bool GroundCheck(FHitResult& OutHit) const; // OutHit: 충돌 결과
};

MyPawn.cpp

#include "MyPawn.h"
#include "Components/CapsuleComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "EnhancedInputComponent.h"
#include "MyPlayerController.h"

// 생성자
AMyPawn::AMyPawn()
{
    // 틱 함수 활성화
    PrimaryActorTick.bCanEverTick = true;

    // 캡슐 컴포넌트 생성
    Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
    Capsule->InitCapsuleSize(34.f, 88.f);
    Capsule->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
    Capsule->SetCollisionObjectType(ECC_Pawn);
    Capsule->SetCollisionResponseToAllChannels(ECR_Block);
    Capsule->SetSimulatePhysics(false); // 물리 시뮬레이션 비활성화
    RootComponent = Capsule; // 루트 컴포넌트로 설정

    // 메시 컴포넌트 생성
    Mesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Mesh"));
    Mesh->SetupAttachment(RootComponent); // 루트에 부착
    Mesh->SetRelativeLocation(FVector(0.f, 0.f, -88.f));
    Mesh->SetRelativeRotation(FRotator(0.f, -90.f, 0.f));
    Mesh->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
    Mesh->SetCollisionResponseToAllChannels(ECR_Ignore);
    Mesh->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
    Mesh->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Block);
    Mesh->SetSimulatePhysics(false);

    // 스프링암 컴포넌트 생성
    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->SetupAttachment(RootComponent);
    SpringArm->TargetArmLength = 350.f;
    SpringArm->bUsePawnControlRotation = true;
    SpringArm->bEnableCameraLag = true;
    SpringArm->CameraLagSpeed = 10.f;
    SpringArm->SetRelativeLocation(FVector(0.f, 0.f, 60.f));

    // 카메라 컴포넌트 생성
    Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
    Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
    Camera->bUsePawnControlRotation = false;

    // 컨트롤러 회전값 Pawn에 적용
    bUseControllerRotationYaw = true;
    bUseControllerRotationPitch = true;
    bUseControllerRotationRoll = false; // Roll은 수동 제어
}

// BeginPlay
void AMyPawn::BeginPlay()
{
    Super::BeginPlay();
    if (Mesh->IsSimulatingPhysics())
    {
        Mesh->SetSimulatePhysics(false);
    }
    // 메시 초기 회전값 저장 (롤링 기준점)
    MeshBaseRelativeRot = Mesh->GetRelativeRotation();
}

// Tick
void AMyPawn::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 현재 롤 각도를 목표 각도까지 부드럽게 보간
    CurrentRollDeg = FMath::FInterpTo(CurrentRollDeg, TargetRollDeg, DeltaTime, RollReturnSpeed);

    // 보간된 롤 각도를 메시에 적용
    FRotator NewRel = MeshBaseRelativeRot;
    NewRel.Pitch = MeshBaseRelativeRot.Pitch + CurrentRollDeg;
    Mesh->SetRelativeRotation(NewRel);

    // 인공 중력 로직 호출
    ApplyArtificialGravity(DeltaTime);
}

// 입력 컴포넌트 설정
void AMyPawn::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    // 입력 바인딩
    if (UEnhancedInputComponent* EnhancedInput
        = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        if (AMyPlayerController* PlayerController
            = Cast<AMyPlayerController>(GetController()))
        {
            if (PlayerController->MoveAction)
            {
                EnhancedInput->BindAction(PlayerController->MoveAction
                    , ETriggerEvent::Triggered
                    , this
                    , &AMyPawn::Move);
            }

            if (PlayerController->LookAction)
            {
                EnhancedInput->BindAction(PlayerController->LookAction
                    , ETriggerEvent::Triggered
                    , this
                    , &AMyPawn::Look);

            }

            if (PlayerController->ElevateAction)
            {
                EnhancedInput->BindAction(PlayerController->ElevateAction
                    , ETriggerEvent::Triggered
                    , this
                    , &AMyPawn::Elevate);
            }

            if (PlayerController->RollAction)
            {
                EnhancedInput->BindAction(PlayerController->RollAction
                    , ETriggerEvent::Triggered
                    , this
                    , &AMyPawn::Roll);
                EnhancedInput->BindAction(PlayerController->RollAction, ETriggerEvent::Completed, this, &AMyPawn::Roll);
            }
        }
    }
}

// 이동 처리
void AMyPawn::Move(const FInputActionValue& value)
{
    if (!Controller) return;

    const FVector2D Axis = value.Get<FVector2D>();
    const float Dt = GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.016f;
    FVector Local(Axis.Y, Axis.X, 0.f);
    if (Local.IsNearlyZero()) return;

    // 에어 컨트롤: 지상/공중 속도 비율 적용
    const float Ratio = bIsGrounded ? 1.0f : AirControlRatio;
    AddActorLocalOffset(Local.GetSafeNormal() * (MoveSpeed * Ratio) * Dt, /*bSweep=*/true);
}

// 시점 회전 처리
void AMyPawn::Look(const FInputActionValue& value)
{
    FVector2d LookInput = value.Get<FVector2d>();

    AddControllerYawInput(LookInput.X * LookYawSpeed);
    AddControllerPitchInput(LookInput.Y * LookPitchSpeed);
}

// 상하 이동 처리
void AMyPawn::Elevate(const FInputActionValue& Value)
{
    const float AxisZ = Value.Get<float>();
    // 상승 키 입력 상태 저장 (중력 무시용)
    LastElevateAxis = AxisZ;
    const float Dt = GetWorld() ? GetWorld()->GetDeltaSeconds() : 0.016f;
    if (!FMath::IsNearlyZero(AxisZ))
    {
        AddActorLocalOffset(FVector(0.f, 0.f, AxisZ) * (MoveSpeed * Dt), /*bSweep=*/true);

        // 수직 이동 시, 지상 상태 해제
        bIsGrounded = false;
    }
}

// 롤 처리
void AMyPawn::Roll(const FInputActionValue& Value)
{
    const float Axis = FMath::Clamp(Value.Get<float>(), -1.f, 1.f);
    // 목표 롤 각도 설정 (실제 회전은 Tick에서)
    TargetRollDeg = Axis * MaxRollDeg;
}

// 지면 감지 함수 (라인 트레이스)
bool AMyPawn::GroundCheck(FHitResult& OutHit) const
{
    if (!GetWorld() || !Capsule) return false;

    // 트레이스 시작/끝점 계산
    const FVector Start = GetActorLocation();
    const float HalfHeight = Capsule->GetScaledCapsuleHalfHeight();
    const float TraceLen = HalfHeight + GroundCheckDistance;
    const FVector End = Start - FVector(0.f, 0.f, TraceLen);

    // 트레이스 파라미터 설정 (자신 제외)
    FCollisionQueryParams Params(SCENE_QUERY_STAT(GroundCheck), false, this);

    // 라인 트레이스 실행
    const bool bHit = GetWorld()->LineTraceSingleByChannel(OutHit, Start, End, ECC_Visibility, Params);
    
    // 충돌 및 평평한 지면일 경우 true 반환
    return bHit && OutHit.ImpactNormal.Z > 0.5f;
}

// 인공 중력 적용 함수
void AMyPawn::ApplyArtificialGravity(float DeltaTime)
{
    if (!GetWorld()) return;

    // 상승 중이면 중력 무시, 속도 초기화
    if (LastElevateAxis > 0.f)
    {
        bIsGrounded = false;
        VerticalVelocity = 0.f;
        return;
    }
    
    // 지면 확인
    FHitResult GroundHit;
    const bool bOnGroundNow = GroundCheck(GroundHit);

    // 지상 착지 상태 처리
    if (bOnGroundNow && VerticalVelocity <= 0.f)
    {
        bIsGrounded = true;
        VerticalVelocity = 0.f;
        return;
    }

    // 공중 상태: 중력 가속도 적용
    bIsGrounded = false;
    VerticalVelocity = FMath::Clamp(VerticalVelocity + GravityAccel * DeltaTime, -MaxFallSpeed, MaxFallSpeed);

    // 계산된 속도로 월드 오프셋 이동
    const FVector GravityDelta(0.f, 0.f, VerticalVelocity * DeltaTime);
    FHitResult FallHit;
    AddActorWorldOffset(GravityDelta, /*bSweep=*/true, &FallHit);

    // 낙하 중 충돌 시 착지 처리
    if (FallHit.bBlockingHit && VerticalVelocity < 0.f && FallHit.ImpactNormal.Z > 0.5f)
    {
        bIsGrounded = true;
        VerticalVelocity = 0.f;
    }
}

⚠️ 실수

  • 드론이 바닥을 뚫고 추락하는 문제: ApplyArtificialGravity 함수에서 낙하 후 충돌을 감지(FallHit.bBlockingHit)했을 때, VerticalVelocity = 0.f; 코드를 넣어주지 않았다. 그 결과, 바닥에 닿아도 수직 속도가 계속 누적되어 바닥을 뚫고 지나가는 현상이 발생했다. 착지 시 속도 초기화는 필수였다.
  • 벽에 붙어서 떨어지지 않는 문제: GroundCheck에서 충돌 여부(bHit)만 확인하고 ImpactNormal을 체크하지 않았을 때 발생했다. 이 때문에 가파른 벽에 붙어서 떨어질 때도 GroundCheck가 true를 반환하여 캐릭터가 벽에 붙은 채로 미끄러지지 않는 버그가 있었다. OutHit.ImpactNormal.Z > 0.5f 조건을 추가하여 평평한 바닥만 '지면'으로 인식하도록 수정하여 해결했다.
  • 상승 키를 눌러도 중력 때문에 잘 안 올라가는 문제: Elevate 함수로 상승 이동을 해도 Tick에서 계속 중력이 작용하니 상승력이 약해지는 문제가 있었다. ApplyArtificialGravity 함수의 맨 처음에 상승 키 입력 여부를 확인하여, 상승 중일 때는 중력 계산 로직 전체를 건너뛰도록 수정했다.

✅ 핵심 요약

개념설명비고
6-DOF3개의 이동 축(X, Y, Z)과 3개의 회전 축(Pitch, Yaw, Roll)을 모두 제어하는 움직임.AddActorLocalOffsetAddControllerInput을 조합하여 구현.
FMath::FInterpTo현재 값에서 목표 값까지 지정된 속도로 부드럽게 보간하는 함수.애니메이션이나 시각 효과를 자연스럽게 만드는 데 매우 유용하다.
LineTrace지정된 시작점에서 끝점까지 선을 그어 충돌을 감지하는 기능.지면 감지, 총알 궤적, 시야 확인 등 활용도가 매우 높다.
인공 중력물리 엔진 없이 Tick에서 직접 가속도를 적분하여 속도를 계산하고 위치를 변경하는 방식.VerticalVelocity += Accel * DeltaTime;가 핵심 공식.
에어 컨트롤캐릭터의 상태(지상/공중)에 따라 이동 속도나 조작감을 다르게 만드는 게임 디자인 기법.bIsGrounded 같은 상태 변수를 통해 간단히 구현할 수 있다.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글