학습일지/C와 C++

언리얼 엔진 리듬 게임 시스템 설계 (C++)

Tsukino Ren 2026. 4. 29. 00:16

언리얼 엔진 리듬 게임 시스템 설계 (C++)

리듬 게임 개발의 핵심은 청각적 신호(Audio)와 시각적 피드백(Visual), 그리고 사용자 입력(Input) 세 가지 요소가 하나의 시간 선상에서 오차 없이 결합되는 것입니다. 단순히 Tick에서 노트를 이동시키는 방식은 실무에서 반드시 "싱크 파괴"라는 결과를 초래합니다.

본 포스팅에서는 오디오 기반 동기화 기법과 이를 구현하기 위한 C++ 아키텍처를 정리합니다.


리듬 게임의 본질

리듬 게임 시스템의 정의

리듬 게임은 '특정 시점($T$)에 특정 위치($P$)에서 발생한 입력($I$)이 정답 데이터($D$)와 얼마나 일치하는가'를 판정하는 시스템입니다. 여기서 가장 중요한 변수는 시간($T$)입니다.

프레임 기반 vs 시간 기반 시스템

  • 프레임 기반 (Frame-based): Tick(DeltaTime)에 의존하여 노트를 이동시킵니다. 하드웨어 사양이나 연산 부하로 프레임 드랍이 발생하면, 실제 음악의 재생 지점과 노트의 위치가 어긋나게 됩니다.
  • 시간 기반 (Time-based): 음악의 절대 재생 시간(Audio Playback Time)을 마스터 클럭으로 삼습니다. 매 프레임마다 "현재 음악이 1.5초 지점이라면, 노트는 1.5초 지점에 해당하는 좌표에 있어야 한다"라고 강제 배치하는 방식입니다.

왜 오디오 시간이 절대 기준인가?

인간의 뇌는 시각적 오차보다 청각적 오차에 수십 배 민감합니다. 화면이 초당 30프레임으로 끊기더라도 음악은 끊기지 않고 흐르기 때문에, 모든 로직의 기준점은 엔진의 WorldTime이 아닌 AudioComponent의 재생 위치가 되어야만 합니다.


 

AudioComponent를 이용한 시간 동기화

언리얼 C++에서 UAudioComponent::GetPlayTime()을 호출하여 현재 재생 시간을 가져옵니다.

데이터 구조 설계 (Data-Driven)

노트는 데이터 기반으로 관리되어야 합니다. FNoteData 구조체를 통해 발생 시간과 타입을 정의합니다.

C++
 
USTRUCT(BlueprintType)
struct FNoteData
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere)
    float TargetTime; // 음악 내 정답 시간 (초 단위)

    UPROPERTY(EditAnywhere)
    int32 LaneIndex;  // 노출될 라인 번호
};

노트 이동 메커니즘

Tick에서 거리를 더하는 것이 아니라, 현재 시간 대비 위치를 재산출합니다.

공식: $CurrentPosition = (TargetTime - CurrentAudioTime) \times SpeedMultiplier$


Beat Manager (Subsystem)

실무에서는 UGameInstanceSubsystem을 상속받은 UBeatManager를 주로 사용합니다. 전역 접근이 가능하고 레벨 전환 시에도 음악 데이터를 유지하기 용이합니다.

오브젝트 풀링 (Object Pooling)

리듬 게임은 짧은 시간 내에 수백 개의 노트를 생성/파괴합니다. SpawnActor와 Destroy를 반복하면 가비지 컬렉션(GC) 부하로 인해 프레임 스파이크가 발생합니다. 반드시 미리 생성해둔 노트를 재사용하는 풀링 기법을 적용해야 합니다.

사용자 입력 딜레이 (Input Lag) 보정

블루투스 이어폰 등을 고려하여 GlobalOffset 변수를 도입합니다.

  • FinalJudgeTime = CurrentAudioTime - GlobalOffset

자주 발생하는 오류 및 해결 (Common Mistakes & Solutions)

자주 발생하는 오류 원인 해결 방법 (Solution)
싱크 밀림 현상 DeltaTime을 누적하여 시간 계산 AudioComponent->GetPlayTime()을 직접 참조
판정 불일치 입력 시점의 프레임 시간 사용 입력 이벤트 발생 즉시 오디오 타임스탬프 캡처
노트 끊김 현상 프레임 드랍 시 이동 거리 소실 시간 기반 절대 좌표 강제 업데이트 로직 적용

핵심 코드 구조 예시 (Code Structure)

[NoteActor.cpp] - 노트의 강제 동기화 이동

void ANoteActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (bIsActive)
    {
        // 1. 매니저로부터 마스터 클럭(오디오 시간) 수신
        float CurrentMusicTime = BeatManager->GetCurrentMusicTime();
        
        // 2. 남은 시간(초) 계산
        float TimeRemaining = TargetTime - CurrentMusicTime;

        // 3. 남은 시간에 따른 절대 위치 강제 배치
        // 판정선 좌표가 0이라면, TimeRemaining이 0일 때 위치도 0이 됨
        float NewY = TimeRemaining * MoveSpeed; 
        SetActorRelativeLocation(FVector(0, NewY, 0));

        // 4. 판정선 통과 시 미스 처리
        if (TimeRemaining < -0.15f) HandleMiss();
    }
}

[BeatManager.cpp] - 입력 판정 로직

void UBeatManager::OnInputPressed(int32 Lane)
{
    // 입력 순간의 오디오 시간을 즉시 캡처
    float ClickTime = AudioComponent->GetPlayTime();
    
    // 현재 라인에서 가장 앞서 있는 노트 검색
    ANoteActor* TargetNote = GetClosestNoteInLane(Lane);

    if (TargetNote)
    {
        // 정답 시간과 클릭 시간의 오차 절대값 계산
        float Diff = FMath::Abs(TargetNote->GetTargetTime() - ClickTime);
        
        // 판정 범위 비교 (예: 0.03초 이내 Perfect)
        if (Diff <= PerfectThreshold) ProcessScore(EJudge::Perfect);
        else if (Diff <= GoodThreshold) ProcessScore(EJudge::Good);
    }
}

마무리

리듬 게임에서 시각적인 요소는 오직 보여주기 위한 수단일 뿐이다. 모든 게임 로직의 오디오 타임스탬프에 있어야 한다. 이 원칙을 지키지 않으면 사양에 따라 판정이 달라지는 '불공정한 게임'이 된다.