미완성 프로젝트 리듬게임 트레이너 (DJGYM) 개발 중 발생한 문제 극복 사례를 정리한 글입니다.
리듬게임 플레이어들은 40ms 수준의 작은 오차 내에서도 박자가 틀렸음을 인지합니다. 따라서 위 프로젝트를 진행 함에 있어 가장 중요한 것은 오차를 최소화 하는 것 입니다.
오차를 최소화 하는 방법 중 하나는 프레임(화면 출력)과 판정을 분리하는 작업입니다. 유니티는 키보드 입력이 프레임에 종속되어 있기 때문에 최악의 경우 키보드 입력과 판정에 프레임 간격만큼의 오차가 발생합니다.
(참고 : EZ2ON 입출력 분리 관련 - 심화과정 | 작성자 RYUminus)
DJMAX Respect V게임의 고난이도 판정 허용 오차는 약 ±20ms로 알려져 있습니다.
60FPS 기준 16ms의 지연이 발생할 수 있으며 이는 20ms의 80%에 해당하는 값입니다.
144FPS 기준 7ms의 지연이 발생할 수 있으며 이는 20ms의 35%에 해당하는 값입니다.
심지어 최근 출시되는 키보드들은 8000hz의 폴링레이트를 가지고 있어 이를 지원하기 위해서라도 프레임과 판정 및 입력을 분리해야 합니다.
하지만 프레임 분리 작업에서 아래와 같은 제약사항이 존재했습니다.
- 유니티 엔진은 멀티스레드를 지원하지 않는다.
- 기존 코드 베이스와 자연스럽게 연결되어야 한다.
유니티 엔진 기능으론 프레임과 판정을 분리할 방법이 존재하지 않습니다. 따라서 프레임과 판을 분리하기 위해선 판정과 입력을 위한 별도의 스레드를 만들어야 했습니다.
하지만 유니티는 별도의 스레드에선 유니티의 GameObject와 Component를 사용할 수 없는 등 기본적으로 멀티스레드를 지원하지 않으며, Job System을 통해 제한적으로 멀티스레드를 지원합니다.
만약 단순히 입력 스레드를 생성해 로직을 분리하면 유니티 코드에 접근할 수 없고, Job System을 사용하면 기존 코드 베이스를 뒤엎어야 했습니다.
떨어지는 노트에 맞춰 키보드를 입력할 경우 입력된 시간에 따라 판정을 결정하는 것 뿐만 아니라 노트 처리 이펙트를 재생해야 합니다. 이펙트를 재생하는 것은 유니티 Component에 접근해야한다는 것을 의미합니다.
따라서 쓰레드로 로직을 분리하되 유니티 Component에 접근하는 기존 로직에 영향이 없도록 생산자-소비자 패턴을 사용하여 두 로직을 분리함과 동시에 메인 쓰레드에서 입력 정보에 쉽게 접근할 수 있도록 코드를 구현했습니다.
또한 간단한 버퍼스왑 로직을 추가하여 최적화를 진행했습니다.
public class PlayerInputModel : ModelBehaviour
{
...
// Update
public void OnChartProgress(double progress)
{
// KeyDown 이벤트 전파
lock (m_keyDownBackBuffer)
{
var swap = m_keyDownBuffer;
m_keyDownBuffer = m_keyDownBackBuffer;
m_keyDownBackBuffer = swap;
}
for (int i = 0; i < m_keyDownBuffer.Count; i++)
{
OnKeyDown.Invoke(m_keyDownBuffer[i]);
}
m_keyDownBuffer.Clear();
// KeyUp 이벤트 전파
...
}
// UpdateAsync
public void OnChartProgressAsync(double progress)
{
for (int i = 0; i < m_pollingList.Length; i++)
{
ushort state = GetAsyncKeyState((int)m_pollingList[i]);
if (state == 0x8000 && !m_keyStateList[i])
{
// KeyDownAsync 이벤트 전파 및 BackBuffer에 저장
m_keyStateList[i] = true;
OnKeyDownAsync.Invoke(new KeyDownAsync
{
lane = (NoteLane)i,
time = progress,
});
lock (m_keyDownBackBuffer)
{
m_keyDownBackBuffer.Add(new KeyDown
{
lane = (NoteLane)i,
time = progress,
});
}
}
else if (state == 0 && m_keyStateList[i])
{
// KeyUpAsync 이벤트 전파 및 BackBuffer에 저장
...
}
}
}
...
}
생산자인 별도의 스레드에서는 현재 키보드 입력 정보를 받아와 비동기 이벤트를 전파합니다. 판정 로직에서는 해당 이벤트를 구독하는 것 만으로 별도의 스레드 생성 없이 프레임과 분리된 채로 판정을 처리할 수 있습니다. 이벤트 이름에는 반드시 Async를 붙여 해당 이벤트가 별도의 쓰레드에서 동작함을 명시하여 실수를 방지합니다.
이후 이벤트 데이터를 Buffer에 저장하여 소비자인 메인 스레드에서 사용할 수 있도록 했습니다.
소비자인 메인 스레드에서는 Buffer에 저장된 데이터로 동기 이벤트를 전파합니다. 이를 통해 프레임에 종속적인 로직(이펙트 출력 등)에서는 해당 이벤트를 구독하여 기존 로직 그대로 동작할 수 있습니다.
이러한 구현을 통해 기존 로직들에게 멀티 스레드 로직을 숨기며, Async 이벤트를 구독하는 로직에서만 스레드를 관리하면서도 접근하기 쉽도록 만들었습니다.
프레임에 종속적인 로직과의 문제는 해결했지만 한 가지 다른 문제가 남아있습니다.
기존 코드에서는 시간과 연관된 로직을 동기화 하기 위해 유니티 Update 메시지가 아닌, 진행 시간을 매개변수로 받는 업데이트 로직을 사용했습니다. ChartPlayer가 채보를 재생시키는 순간 시작되는 타이머 값을 모두가 공유하여 사용함으로써 시간의 기준이 달라질 위험을 줄이고, 추후 추가할 일시정지, 되감기 기능 구현을 더 쉽게 만들어 줍니다.
구조에 대한 다이어그램은 아래와 같습니다.

ChartPlayer는 매 프레임 OnProgress 이벤트를 전파합니다. 해당 이벤트는 채보의 진행시간을 매개변수로 가지며, 노트를 그려주는 로직이나 판정, 입력 시스템은 이를 구독하여 시간에 맞게 노트를 그리거나 각종 처리를 진행합니다.
그런데 이러한 구조가 입력 및 판정 로직을 분리할 때 작은 문제를 발생시켰습니다.
판정 로직에서 노트가 얼마나 정확하게 처리되었는 지 확인하기 위해서는 키보드 입력 시간이 필요합니다. 하지만 키보드 입력 로직은 별도의 스레드에서 동작하기 때문에 프레임에 종속적인 OnProgress 이벤트나 Unity의 Time 클래스를 이용할 수 없습니다. 그렇다고 입력시간을 따로 제공하면 다른 로직과 시간이 엇갈리게 됩니다.
이를 해결하기 위해 스레드의 시작점을 입력 로직이 아니라 ChartPlayer로 이동시켜 매 루프마다 OnProgressAsync 이벤트를 전파하도록 수정했습니다.
PlayerInputModel 코드에서의 설명과 마찬가지로 기존 로직은 OnProgress를 구독하여 그대로 동작하며, 별도의 스레드에서 동작하는 로직은 OnProgressAsync를 구독하여 별도의 스레드에 쉽게 접근할 수 있도록 했고 progress 매개변수를 통해 시간을 동기화 했습니다.
ChartPlayer의 코드는 아래와 같습니다.
public class ChartPlayerModel : ModelBehaviour
{
...
public void Play(Chart chart, double bpm, Difficulty difficulty)
{
...
// OnPregressAsync 로직 시작
m_progress = 0;
m_abortFlag = false;
m_chartPlayThread = new Thread(ChartPlayWorker);
m_chartPlayThread.IsBackground = true;
m_chartPlayThread.Start(8000L); // 업데이트 주기 8000hz
// OnProgress 로직 시작
if (m_playChartCoroutine != null)
{
StopCoroutine(m_playChartCoroutine);
}
m_playChartCoroutine = StartCoroutine(PlayChartCoroutine());
}
public void Stop()
{
// OnProgress 로직 종료
if (m_playChartCoroutine != null)
{
StopCoroutine(m_playChartCoroutine);
m_playChartCoroutine = null;
}
// OnProgressAsync 로직 종료
if (m_chartPlayThread != null && m_chartPlayThread.IsAlive)
{
m_abortFlag = true;
m_chartPlayThread.Join();
m_chartPlayThread = null;
}
isPlaying = false;
OnChartPlayerEnded.Invoke();
}
// ChartPlayWorker(double frequency)
private void ChartPlayWorker(object param)
{
long frequency = (long)param;
// Stopwatch의 1tick == 100나노초, 1초 == 10 * 1000 * 1000 * 1tick
long interval = 10000000 / frequency;
var stopwatch = new System.Diagnostics.Stopwatch();
stopwatch.Start();
long prevTick = stopwatch.ElapsedTicks;
while (!m_abortFlag)
{
long now = stopwatch.ElapsedTicks;
long timeDiff = now - prevTick;
if (timeDiff >= interval)
{
double progress = now / 10000000d;
// OnProgress에서 사용할 수 있도록
m_progress = progress;
OnChartProgressAsync.Invoke(new ChartProgressAsync { progress = progress });
prevTick = now;
}
}
}
private IEnumerator PlayChartCoroutine()
{
double progress;
while ((progress = m_progress) < m_chart.EndTime)
{
// 각종 처리
...
OnChartProgress.Invoke(new ChartProgress
{
progress = progress
});
yield return null;
}
// 종료 예약 후 대기
m_stopCoroutine = StartCoroutine(StopAfter(c_delay));
while (true)
{
progress = m_progress;
OnChartProgress.Invoke(new ChartProgress
{
progress = progress
});
yield return null;
}
}
...
}
ChartPlayer에서 채보가 재생되면 별도의 스레드를 생성하여 재생 이후 진행한 시간을 계산하고 이를 OnProgressAsync를 통해 전파합니다. 또한 이 값을 맴버변수에 저장하여 메인 스레드의 OnProgress 이벤트에서 이를 사용하도록 해 두 스레드 간의 시간을 동기화 시킵니다.
다른 로직에선 아래 코드와 같은 방식으로 이벤트를 구독합니다.
public class PlayerInputModel : ModelBehaviour
{
...
protected override void Awake()
{
Assert.IsNotNull(m_playerModel);
base.Awake();
m_playerModel.OnChartProgress.AddListener(OnChartProgress);
m_playerModel.OnChartProgressAsync.AddListener(OnChartProgressAsync);
...
}
// Update
public void OnChartProgress(double progress)
{
...
}
// UpdateAsync
public void OnChartProgressAsync(double progress)
{
...
}
...
}
위 과정을 통해 수정된 구조의 다이어그램은 아래와 같습니다.

파란색은 메인 스레드, 빨간색은 별도의 스레드를 나타냅니다.
프레임에 종속적인 View들은 파란색의 메인 스레드 이벤트를 이용하며, 프레임과 독립적인 로직들은 빨간색의 Async 이벤트를 추가적으로 이용합니다.
이를 통해 기존 View들의 수정 없이 성공적으로 프레임과 판정을 분리할 수 있었고, 멀티스레드 사용으로 인해 발생하는 코드의 복잡성 증가를 최소화할 수 있었습니다.
프레임과 로직의 분리를 원한다면 간단히 OnProgressAsync 또는 Async가 붙은 이벤트를 구독하고 그렇지 않다면 Async가 붙은 이벤트를 피하면 됩니다.
프로젝트를 진행하며 겪은 문제 중 하나인 프레임과 판정을 분리하는 작업을 해결하는 과정을 정리한 글입니다.
누군가에게 도움이 되길 바라며, 참고자료로 ChartPlayer와 PlayerInput 실제 코드 전문을 첨부합니다.
using DJGYM.Events;
using DJGYM.UI;
using System.Collections;
using System.Runtime;
using System.Threading;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.Events;
namespace DJGYM.ChartPlayer
{
public class ChartPlayerModel : ModelBehaviour
{
public UnityEvent<ChartPlayerStarted> OnChartPlayerStarted;
public UnityEvent OnChartPlayerEnded;
public UnityEvent<NoteSpawn> OnNoteSpawn;
public UnityEvent<KeySoundSpawn> OnKeySoundSpawn;
public UnityEvent<ChartProgress> OnChartProgress;
public UnityEvent<ChartProgressAsync> OnChartProgressAsync;
private const float c_delay = 3f;
private Chart m_chart;
private Difficulty m_difficulty;
private double m_bpm;
private Coroutine m_playChartCoroutine;
private Coroutine m_stopCoroutine;
private bool isPlaying;
private Thread m_chartPlayThread;
private bool m_abortFlag;
// Shared Data
private double m_progress;
public Chart Chart { get { return m_chart; } }
public Difficulty Difficulty { get { return m_difficulty; } }
public double BPM { get { return m_bpm; } }
protected override void Awake()
{
base.Awake();
isPlaying = false;
}
// ChartPlayWorker(double frequency)
private void ChartPlayWorker(object param)
{
long frequency = (long)param;
// Stopwatch의 1tick == 100나노초, 1초 == 10 * 1000 * 1000 * 1tick
long interval = 10000000 / frequency;
var stopwatch = new System.Diagnostics.Stopwatch();
stopwatch.Start();
long prevTick = stopwatch.ElapsedTicks;
long correction = 0;
while (!m_abortFlag)
{
long now = stopwatch.ElapsedTicks;
long timeDiff = now - prevTick;
if (timeDiff >= interval - correction)
{
double progress = now / 10000000d - c_delay;
m_progress = progress;
OnChartProgressAsync.Invoke(new ChartProgressAsync { progress = progress });
correction = timeDiff - interval;
if (correction > interval)
{
correction = interval;
}
prevTick = now;
}
}
}
private IEnumerator PlayChartCoroutine()
{
Assert.IsNotNull(m_chart);
int barCount = 0;
double progress;
while ((progress = m_progress) < m_chart.Length.ToSecond(m_bpm))
{
// Delay만큼 미리 확인하여 이벤트 발생
while (m_chart.GetCurrentNote(out Note note) && note.Beat.ToSecond(m_bpm) <= progress + c_delay)
{
OnNoteSpawn.Invoke(new NoteSpawn
{
note = note.ToTimeNote(m_bpm),
});
m_chart.NextNote();
}
while (m_chart.GetCurrentKeySound(out KeySound keySound) && keySound.beat.ToSecond(m_bpm) <= progress + c_delay)
{
OnKeySoundSpawn.Invoke(new KeySoundSpawn
{
keySound = keySound.ToTimeKeySound(m_bpm),
});
m_chart.NextKeySound();
}
double barTime = new Beat(barCount * 4).ToSecond(m_bpm);
if (barTime <= progress + c_delay)
{
OnNoteSpawn.Invoke(new NoteSpawn
{
note = new TimeNote(NoteType.BarEvent, 0, barTime, 0d, barCount)
});
barCount++;
}
OnChartProgress.Invoke(new ChartProgress
{
progress = progress
});
yield return null;
}
// 종료 예약 후 대기
m_stopCoroutine = StartCoroutine(StopAfter(c_delay));
while (true)
{
progress = m_progress;
OnChartProgress.Invoke(new ChartProgress
{
progress = progress
});
yield return null;
}
}
private IEnumerator StopAfter(float second)
{
yield return new WaitForSeconds(second);
Stop();
}
public void Play(Chart chart, double bpm, Difficulty difficulty)
{
Assert.IsNotNull(chart);
m_chart = chart;
m_bpm = bpm;
m_difficulty = difficulty;
m_chart.Initialize();
if (m_stopCoroutine != null)
{
StopCoroutine(m_stopCoroutine);
m_stopCoroutine = null;
}
if (isPlaying)
{
Stop();
}
isPlaying = true;
OnChartPlayerStarted.Invoke(new ChartPlayerStarted
{
chart = m_chart,
difficulty = m_difficulty,
bpm = m_bpm
});
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
m_progress = -c_delay;
m_abortFlag = false;
m_chartPlayThread = new Thread(ChartPlayWorker);
m_chartPlayThread.IsBackground = true;
m_chartPlayThread.Start(8000L);
if (m_playChartCoroutine != null)
{
StopCoroutine(m_playChartCoroutine);
}
m_playChartCoroutine = StartCoroutine(PlayChartCoroutine());
}
public void Stop()
{
GCSettings.LatencyMode = GCLatencyMode.Interactive;
if (m_playChartCoroutine != null)
{
StopCoroutine(m_playChartCoroutine);
m_playChartCoroutine = null;
}
if (m_chartPlayThread != null && m_chartPlayThread.IsAlive)
{
m_abortFlag = true;
m_chartPlayThread.Join();
m_chartPlayThread = null;
}
isPlaying = false;
OnChartPlayerEnded.Invoke();
}
public void Pause()
{
}
}
}
using DJGYM.Events;
using DJGYM.UI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.Events;
namespace DJGYM.ChartPlayer
{
public class PlayerInputModel : ModelBehaviour
{
public UnityEvent<KeyDown> OnKeyDown;
public UnityEvent<KeyUp> OnKeyUp;
public UnityEvent<KeyDownAsync> OnKeyDownAsync;
public UnityEvent<KeyUpAsync> OnKeyUpAsync;
[SerializeField] private ChartPlayerModel m_playerModel;
private VirtualKeyCode[] m_pollingList;
private int[] m_keyStateList;
private int m_keyIndex;
// shared data
private List<KeyDown> m_keyDownBuffer;
private List<KeyDown> m_keyDownBackBuffer;
private List<KeyUp> m_keyUpBuffer;
private List<KeyUp> m_keyUpBackBuffer;
private bool m_keyDownDirty;
private bool m_keyUpDirty;
[DllImport("user32.dll")]
private static extern UInt16 GetAsyncKeyState(Int32 key);
protected override void Awake()
{
Assert.IsNotNull(m_playerModel);
base.Awake();
m_playerModel.OnChartPlayerStarted.AddListener(OnChartPlayerStarted);
m_playerModel.OnChartProgress.AddListener(OnChartProgress);
m_playerModel.OnChartProgressAsync.AddListener(OnChartProgressAsync);
m_pollingList = new VirtualKeyCode[(int)NoteLane.Count]
{
VirtualKeyCode.VK_Q,
VirtualKeyCode.VK_W,
VirtualKeyCode.VK_E,
VirtualKeyCode.VK_NUMPAD7,
VirtualKeyCode.VK_NUMPAD8,
VirtualKeyCode.VK_NUMPAD9,
VirtualKeyCode.VK_SPACE,
VirtualKeyCode.VK_RIGHT,
VirtualKeyCode.VK_CAPITAL,
VirtualKeyCode.VK_OEM_PLUS,
};
m_keyStateList = new int[(int)NoteLane.Count];
m_keyDownBuffer = new List<KeyDown>(10);
m_keyDownBackBuffer = new List<KeyDown>(10);
m_keyUpBuffer = new List<KeyUp>(10);
m_keyUpBackBuffer = new List<KeyUp>(10);
}
public void OnChartPlayerStarted()
{
foreach (ref int keyState in m_keyStateList.AsSpan())
{
keyState = 0;
}
m_keyIndex = 1;
m_keyDownBuffer.Clear();
m_keyDownBackBuffer.Clear();
m_keyDownDirty = false;
m_keyUpBuffer.Clear();
m_keyUpBackBuffer.Clear();
m_keyUpDirty = false;
}
public void OnChartProgress(double progress)
{
if (m_keyDownDirty)
{
lock (m_keyDownBackBuffer)
{
var swap = m_keyDownBuffer;
m_keyDownBuffer = m_keyDownBackBuffer;
m_keyDownBackBuffer = swap;
m_keyDownDirty = false;
}
for (int i = 0; i < m_keyDownBuffer.Count; i++)
{
OnKeyDown.Invoke(m_keyDownBuffer[i]);
}
m_keyDownBuffer.Clear();
}
if (m_keyUpDirty)
{
lock (m_keyUpBackBuffer)
{
var swap = m_keyUpBuffer;
m_keyUpBuffer = m_keyUpBackBuffer;
m_keyUpBackBuffer = swap;
m_keyUpDirty = false;
}
for (int i = 0; i < m_keyUpBuffer.Count; i++)
{
OnKeyUp.Invoke(m_keyUpBuffer[i]);
}
m_keyUpBuffer.Clear();
}
}
public void OnChartProgressAsync(double progress)
{
for (int i = 0; i < m_pollingList.Length; i++)
{
ushort state = GetAsyncKeyState((int)m_pollingList[i]);
if (state == 0 && m_keyStateList[i] != 0)
{
// KeyUp
int keyIndex = m_keyStateList[i];
m_keyStateList[i] = 0;
OnKeyUpAsync.Invoke(new KeyUpAsync
{
lane = (NoteLane)i,
time = progress,
keyIndex = keyIndex,
});
lock (m_keyUpBackBuffer)
{
m_keyUpBackBuffer.Add(new KeyUp
{
lane = (NoteLane)i,
time = progress,
keyIndex = keyIndex,
});
m_keyUpDirty = true;
}
}
else if (state == 0x8000 && m_keyStateList[i] == 0)
{
// KeyDown
int keyIndex = m_keyIndex++;
m_keyStateList[i] = keyIndex;
OnKeyDownAsync.Invoke(new KeyDownAsync
{
lane = (NoteLane)i,
time = progress,
keyIndex = keyIndex,
});
lock (m_keyDownBackBuffer)
{
m_keyDownBackBuffer.Add(new KeyDown
{
lane = (NoteLane)i,
time = progress,
keyIndex = keyIndex,
});
m_keyDownDirty = true;
}
}
}
}
private void OnChartProgress(ChartProgress data) => OnChartProgress(data.progress);
private void OnChartPlayerStarted(ChartPlayerStarted data) => OnChartPlayerStarted();
private void OnChartProgressAsync(ChartProgressAsync data) => OnChartProgressAsync(data.progress);
}
}