[개발회고록] bmx2ogg와 Rhythmus
두번째 개발회고록인데, 생각해보면 제가 대학생때부터 삽질해 왔던 작업내역이니 사실상 정말 오랫동안 만지고 있던 프로젝트네요. 하지만 아직도 끝을 보지 못하고 있습니다. 본디 사이드 프로젝트는 빠르게 진행하고 마쳐야 맞는 건데 그 점에서는 완벽하게 실패한 프로젝트네요 ^^;
그래도 분명 오래 걸리게 만든 역경들이 있었고, 이것들을 해결하면서 얻어간 것들도 참 많았습니다. 궁극적으로는 작업이 다 끝나고 정리하면 좋지 않을까 하고 막연하게 생각하고 있었는데, 너무 오래 하기도 했고, 이제는 정리해두지 않으면 더 이상 기약할 수 없을 것 같아 이렇게 글을 써 봅니다.
이것은 무엇인고?
태초에 Be-Music Script라는 물건이 있었습니다. 나무위키로부터 빌려온 내용을 보면 —
BMS 포맷은 일본의 프로그래머인 Yane Urao가 1998년에 제안한 것이다. 이 포맷은 그 이후에 만들어진 수많은 BMS 구동기에서도 차용되었다. 기원이 기원인 만큼 비트매니아 시리즈와 플레이 방법이 같으며, 비트매니아 시리즈가 발전해가면 BMS도 같이 발전해 나간다. 비트매니아 시리즈가 7키를 사용하는 beatmania IIDX를 출시하고 대세가 되자 BMS 파일의 표준도 7키를 지원하는 BME 형태로 전환되었으며, 5키/10키 BMS는 거의 멸종된 상태이다. 난이도 체계나 판정, 게이지 또한 투덱에서 그대로 따왔다.
처음에는 WAV 형식의 사운드 파일과 BMP 형식의 그림 파일밖에 사용하지 못했으나, BMS 플레이어가 발전하면서 그림 파일을 JPG나 PNG로, 사운드 파일을 MP3나 Ogg Vorbis로[1] 사용할 수 있게 되었으며, 일부 시뮬레이터에서는 MPEG1 포맷의 동영상 파일도 지원하게 되었다. 후대에 나온 차세대 구동기 양대산맥인 Qwilight과 beatoraja에서는 Flac 음원과 와이드 해상도의 MP4 포맷의 동영상을 기본 지원하기에 이른다.
이야기가 긴데, 몇 가지 중요한 내용을 도출해 낼 수 있습니다.
- 비트매니아 시리즈의 데이터, 다시 말해 채널과 채보를 통해 음악을 재생할 수 있는 일종의 스크립트 언어
- 구조가 계속해서 발전해옴 (bms, bme, pms, bmson ...)
- 다양한 멀티미디어 파일들을 지원 (wav, mp3, ogg, flac, bmp, jpg, png, mpeg, mp4, ...)
참고로 비트매니아 시리즈가 뭐냐면...
이렇게 생긴 게임이요. 아마 이 글을 찾아오실 분들은 이미 다 알고 있을 것 같아 길게 설명은 안하는 걸로 ^^...
첫 시작: bmx2ogg
위에서 bms 파일이 음악 채보 스크립트 언어라고 했잖아요? 이 프로그램은 음악 채보 스크립트를 믹싱하여 음원 파일로 인코딩합니다. 아마 CHILD씨가 만든 bmx2wav 프로젝트에서 영감을 받아 직접 만들어보기로 했던 것으로 기억합니다. 굳이 이미 있는 것을 놔두고 만든 이유라면,
- 용량 큰 Wav 파일 대신 효율적인 인코딩을 사용(Ogg, flac)을 하여 결과물을 얻고 싶었다.
- 인코딩 된 파일에 bms파일의 메타데이터를 자동으로 삽입하고 싶었다.
- BMS 파일 파싱을 직접 해보고 싶었다
- 최신 트렌드(?)의 도입 (CI, cmake 등...)
그러니까, 새로운 기능을 추가해서 편의성을 챙기는 동시에, 제 스스로 이루고 싶었던 목표도 있었다고 봐야 될 것 같습니다.
만들고서 저 스스로도 잘 썼고, 이미 레거시가 된 프로젝트지만 아직도 사람들이 종종 찾아주고 문의도 주는 것을 보면 마냥 쓸모없는 걸 만들지는 않았나 봅니다.
만들면서 크게 두가지 부분에서 많은 깨달음을 얻을 수 있었습니다.
Be-Music Script 데이터의 처리
Be-Music Script는 그 자체만으로도 꽤 복잡한 처리 로직을 가지고 있습니다. 몇 가지를 추려보면,
- Be-Music Script에서는 모든 객체들이 Measure-based입니다. 무슨 소리인고 하면, 몇분의 몇박자에 어떤 이벤트가 발생하는가?의 정의가 연속으로 들어 있습니다.
하지만, Be-Music Script의 output은 결국 음악이어야 하잖아요? 따라서, 이벤트가 발생하는 Measure 기반의 시점을 Time-based로 변환해주는 과정이 필요합니다. - 그런데, Measure을 Time으로 변환하기 위해서는 BPM을 알아야 합니다. 그런데 BPM 이벤트 또한 Measure에 위치해 있습니다. 그래서, BPM 변환이 일어날 때마다, 그 시점에 대한 시간을 계산해두어 축적해야 특정 Measure에 대하여 올바르게 시간을 구할 수 있습니다.
- BPM 이외에도, STOP 및 STP 커맨드와의 중복, 가끔 변태같은 WARP(?) 기능 구현 등 시간에 영향을 미치는 요소들이 지대하게 많이 있어 죄다 처리해 주어야 합니다.
- 더 끔찍한 것은, 마디(Measure)의 길이 자체가 가변입니다.
이런게 왜 있냐면, BMS는 기본적으로 4박입니다. 여기에서 3박을 표현하려면, Measure length가 0.75가 되면 되는 것이죠.
문제는 3박이든 4박이든 상관없이, 이벤트가 벌어지는 마디수는(Measure) 동일하다는 겁니다.
마디의 길이가 가변이라서 처리하기가 복잡한 부분에 대한 예를 들어보면,
위 두 파일 모두 6번째 마디에서 BPM 변경 이벤트가 일어나고 있습니다. 그런데 이전 마디의 길이가 다릅니다. 따라서, 첫번째 파일의 경우 15박에서, 두번째 파일의 경우 20박에서 BPM 변환이 일어나야 합니다. 그래서, 오브젝트의 위치를 계산할 때는 마디 수로 계산하기 보다는 박자수로 계산을 해야 좋습니다.
그런데 BMS의 이런 로직은 MIDI의 time signature과도 꽤 흡사한 특징을 가지고 있습니다. 개인적으로 알고리즘 문제로 내면 굉장히 재밌을 것 같습니다 ㅎㅎ.
아니 뭐 그래도 이 정도만 어찌어찌 구현하면 다 될거 같네~
... 이게 끝이 아닙니다. Be-Music Script의 기본 스펙도 복잡하지만, 드러나지 않는 점에서 비롯되는 까다로운 점 또한 몇 가지 있습니다.
- Shift-JIS 인코딩과의 싸움
BMS의 유래가 일본이고, 오래전에 만들어진 포멧이다 보니 CP932(Shift-JIS) 인코딩으로 만들어진 파일들이 어마어마하게 많습니다. 그냥 읽으면 태그가 깨지는 건 기본이고, 파일명도 깨진 상태로 읽어들이게 되어 리소스를 제대로 찾지를 못하고, 인코딩 시 음이 빠지는 경험을 하게 됩니다. 사실 이 점도 제가 이 툴을 다시 개발하게 된 이유중 하나였습니다...
저 같은 경우는 이를 크로스플랫폼에서 지원하기 위해 삽질을 두배로 했습니다. 윈도우와 리눅스의 인코딩 변환 라이브러리와 사용법이 다르고, 이를 코드로 녹여내는 것이 첫 경험이었네요. 이미 다 만들어진 프레임워크 위에서 만든 게 아닌, 진정한 크로스플랫폼 개발이 아니었을까... 싶습니다. - 파편화되고 괴상한 스펙들
수많은 테스트 파일들을 돌려봐서 안정화 되었다 싶으면 이상한 input들이 쉬지 않고 들어옵니다. hitkey님이 정리해 둔 문서를 보면 정말 인풋 패턴들이 가관입니다. 근래 저는1,123456
을 measure length라고 주장하는 인풋 파일에 당한 적이 있네요. 왜 콤마를 decimal point로 쓰고 있는건지...
리소스 파일들의 포멧도 가관입니다. alpha-channel BMP는 물론이고, TGA같은 희귀 리소스도 쓰는 건 물론이요, 인코딩 된 파일이 끔찍하게 깨져 나오길래 원인을 찾아보니 4bit mono WAV가 input으로 들어간 경우도 있었고...
이러한 괴짜 포멧들을 모두 지원하는 오픈소스 찾기는 굉장히 힘듭니다. 직접 만들거나, 혹은 어느 정도 기능 추가를 해서 쓸 생각을 해야 하고, 이 모든 것들에 대한 테스트 케이스 또한 항상 구비해두어야 합니다.
어떻게 보면 레거시 포멧의 끝판왕이자, 제일 현업에 가까운 형태의 데이터가 아닌가 싶습니다. 이러한 포멧을 지원할 수 있으면서도 앞으로의 포멧 변화에 대해 대처 가능한 설계를 고민해 보는 것만으로도 상당한 실력자가 아닐까 싶네요. 아니... 그냥 레거시 없는 세상을 찾아 가는 방향은 어떨까...
직접 Mixer 개발하기, 그리고 시행착오
물론 Mixer도 직접 개발했는데, Naive한 알고리즘은 아래와 같았습니다.
- WAV로 일단 메모리에 인코딩
- WAV 데이터를 libvorbis에 때려박아 인코딩 된 오디오를 얻음
- 해당 오디오 데이터를 파일로 그대로 출력
사실 저 중에서 (1)이 진정한 Mixer 로직이죠? 간단하게 써놓아서 그렇지, 세부 사항은 아래와 같습니다.
- 현재 시각에서, 가장 먼저 발생하는 Sound 이벤트를 찾음
- 해당 Sound의 duration만큼 target audio에 mixing을 수행함
- 다시 처음으로 돌아가서, 마지막 이벤트를 처리할 때까지 반복
여기서 Sound의 Duration을 설정하는 것 또한 고려해야 할 점이 많습니다.
- 같은 채널이 재생이 채 끝나기도 전에 다시 재생되면, 재생되고 있던 오디오는 Stop
- 롱노트의 경우, 재생이 채 끝나기도 전에 노트가 끝나면, 재생되고 있던 오디오는 Stop
그리고 mixing의 경우, 여기에서는 Add 방식으로 했습니다. 물론 Cutoff도 고려해야 하는데, 이 부분에 대해서도 정답은 없습니다. channel count 만큼 division 한다는 이야기도 있으나, 저는 max threshold(INT32_MAX)를 넘어가면 최대값으로 고정시키는 방식으로 만들었습니다. 소리가 들쭉날쭉한 편을 좋아하지 않아서요.
그리고, 의외로 mixing 방식에 따라 성능 차이가 굉장히 심하게 납니다. 제가 foobar과 같이 실시간 audio stream을 BMS에도 만들어 보자! 라는 생각이 들어서, 아래와 같이 믹싱 알고리즘을 바꿔서 만든 적이 있었습니다.
- 매 encoding byte에 대해서, 모든 채널을 뒤져서 mixing을 수행함.
- 음악이 끝날 때까지 이를 계속 반복함
더 간단하지만, 실시간으로 음악 스트리밍도 가능하고. 성능도 크게 하락이 없을 거라고 생각했습니다. 왜냐하면 어차피 모든 채널로부터 순차적으로 오디오 데이터를 읽고/쓰기 때문에, 메모리 공간성 측면에서 큰 차이가 없을 것이라고 여겼기 때문이죠.
하지만 당시 2배 이상의 성능 차이가 있었습니다. 이 방식이 월등하게 느렸습니다.
지금 생각해보면, 아마 매 channel을 iterate하면서 branch prediction이 제대로 동작할 수 없는 환경이 만들어져 성능에 악영향을 준 것이 아닌가 생각이 됩니다. 이를 해결하기 위해서는 mixing window를 만들어서 보다 일괄적으로 작업을 수행할 수 있도록 하고, 오디오 재생이 끝났는지 여부를 확인하여 자동으로 정리해주어 불필요한 branch를 타는 것을 최대한 막아주어야 할 것 같습니다. (테스트는 안 해봐서 잘은 모르겠네요)
그리고, 지금이야 단순한 믹싱 프로그램이지만 궁극적으로는 리듬게임을 만들 수 있도록 하는 실시간 믹싱 설계도 염두에 두고 있었습니다. 이 경우에는 실시간 audio streaming을 지원해야 하고, low-latency도 중요한 요소입니다. 이 경우에는 어느 정도의 부하는 피할 수 없겠죠. 필요하다면 BGM들을 미리 렌더링하는 꼼수를 도입하는 것도 생각해봄직 할 듯 합니다.
두 번째 목표: Rhythmus_java, SimpleBmxPlayer
이제 파서와 믹서를 만들었으니, 게임을 만들어 볼 수 있지 않을까! 라는 생각이 들었고, 바로 실천에 옮기게 되었습니다.
하고 싶은 것들이 여러가지였는데, 위에서 개발한 bmsbel 엔진 (bms 파서 엔진)을 토대로 구현하게 되었습니다.
Rhythmus_java
가장 먼저 만들었던 작업은 Rhythmus_java 였습니다. 왜 java인고 하니, 게임 엔진으로 java 기반의 libGDX를 사용했기 때문이죠.
libGDX를 사용하게 된 이유는 여러 가지가 있는데,
- 크로스 플랫폼
java 기반으로 된 프레임워크라서, Windows/Linux/Mac은 물론이고 Android/iOS도 지원합니다!
사실 모든 플랫폼에서 돌아가는 (리듬)게임을 만들어 보는 것 또한 목표였습니다. - 2D 특화 엔진
사실 근래의 드라이버는 내부에서 3D 연산을 거치기 때문에 이런 게 별로 의미가 없다는 것은 알지만... 게임 개발인 문외한인 저로서는 일단 쉬운 입문 난이도를 가진 2D 특화 엔진을 가지고 개발을 시작하고 싶었습니다. - 인풋, 사운드 기능 제공
덕분에 이 프레임워크 하나면 다른 걸 쓸 필요가 없습니다 ㅎㅎ. - 자유로운 라이선스
Apache License를 따르고 있어 해당 엔진을 활용한 작품의 사용이 자유롭습니다.
어차피 리듬게임은 2D 엔진이기도 하고, 소리만 잘 재생하면 되기 때문에 아주 탁월한 선택일 것으로 생각했습니다.
실제로 잘 만들었고 잘 돌아갔습니다. 뿌듯 ㅎ. 영상 링크
그런데... 의외로 한계점이 있더라고요.
- Java와 프레임워크 특유의 성능 한계
근래의 시스템은 멀티코어가 기본인데, 이 조합으로는 시스템의 자원을 제대로 활용할 수 없었습니다.
그리고 메모리 최적화 면에서도 나쁘진 않았지만 좋다고 말할 수도 없었습니다. 사실은 UMPC에서 돌아갈만한 사양의 게임도 목표 중 하나로 생각하고 있었거든요. - 사운드 동시 재생, 딜레이, 이펙트의 한계
이게 제일 컸습니다. 일반적인 게임과 다르게 리듬게임(BMS)은 동시에 재생되는 사운드 채널의 수가 최대 약 1000개 가까이 될 수가 있습니다. 그런데 안드로이드 기준 이에 한참 못 미치는 수 (약 10개?)의 사운드가 동시 재생되는 경우에도 오류가 쉴새없이 나오고, 사운드가 씹히는 문제가 있었습니다.
사실 이 점은 믹서만 새로 갖다 붙이면 어느 정도 해결을 볼 수는 있었겠지만, 아무튼 실제 게임 경험에 굉장히 큰 문제를 유발했습니다. - 지원 파일 포멧의 한계
위에서 말씀했듯이 BMS 특성상 괴상한(?) 포멧의 리소스들이 종종 사용되는데, 보통의 라이브러리들은 그런 포멧을 제대로 지원하지 못하더라고요. 이를 해결하려면 직접 라이브러리에 기능을 추가하는 방법 뿐일 것입니다.
보통은 개발이 쉬우면 그만큼 등가 교환을 해야 한다는 걸 깨달을 수 있었습니다. 그래서, 좀 더 낮은 레벨에서의 개발을 해보기로 생각했습니다.
SimpleBmxPlayer
libGDX에서의 한계점을 체험했던 저는 보다 자유도가 높고, 동시에 기존의 조건들을 만족할 수 있는 새로운 프레임워크를 찾아 모험을 떠났습니다. 그리고 SDL을 새 게임 엔진으로 쓰기로 결정했는데요,
- C++ 기반 프레임워크
저레벨에서 각종 메서드 접근이 가능해서 새로운 디코더를 붙이거나, OpenGL API를 직접 호출하는 등의 작업을 쉽게 할 수 있었습니다. 또한 비교적 최적화가 용이하다는 장점 또한 가지고 있습니다. - 기존 장점들을 모두 포함
크로스플랫폼, 라이선스, 인풋/사운드 기능, 2D 특화 등 많은 장점들을 그대로 들고 있었습니다.
사실 이 엔진은 굉장히 만족한 엔진이었습니다. 몇 가지 디코더를 열심히 붙이긴 했지만, 어찌됐든 이것저것 다 붙일 수 있었고, 궁극적으로 잘 돌아갔거든요.
사실 이번 작업에서의 목표가,
- UMPC에서도 돌아갈 수 있는 최적화
사실 이건 지금 생각해보면 별로 의미가 없는 목표이긴 한데 ^^; 어렸을 때 궁핍하게 UMPC로 엄마 몰래 힘겹게 게임을 하던 생각이 나서, 거기서도 돌릴 수 있는 게임을 만들면 어떨까 하는 생각이 있었습니다. - LR2 및 각종 스텝매니아 테마 지원
리듬게임은 플레이도 중요하지만, 테마와 작동 환경에서 오는 만족감 또한 중요하다고 생각을 합니다. 그런 점에 있어서 기존 생태계를 이용할 수 있다면 좋을 것 같다는 생각이 들어서, 그렇게 설계를 해 보았습니다.
실제로 이 목표들을 어느 정도 이룰 수 있었고, 아래 정도의 수준까지 만들 수 있었습니다.
그럼에도 불구하고 저는 만족하지 못했는데, 크게 아래 세 가지 측면에서의 불만이 있었습니다.
- 제 스스로의 개발 능력의 한계
당시에는 컴파일러와 인터프리터의 개념이 없어서 테마 스킨을 처리하기 위한 적절한 자료구조를 생각하지 못했습니다. 그래서 코드와 최적화가 정말 엉망이었습니다.
이를테면 테마를 처리하기 위한 일종의 “명령어"를 “설정 파일"처럼 읽어들이는 덕분에, 키 값이 중복인 설정파일을 위한 자료구조를 개발해야 하게 되었고, 이러다 보니 정말 괴상한 자료구조와 이를 처리하기 위한 괴상한 로직들이 계속해서 만들어지게 되었습니다.
그리고 LR2 테마 특유의 이벤트 핸들러가 있었는데, 이 부분을 처리하기 위한 설계가 충분하지 못해서 실제 게임 실행 시 리소스 소모가 엄청났습니다. 지금 생각해보면 그냥 옵저버 패턴 + 인터프리터 패턴을 사용하면 되는 거였는데, 그런 거 없이 매번 객체를 검색해서 일일이 문자열을 파싱해서 직접 속성을 바꿔주고... -_-;
이대로는 개발을 계속할 수 없었고, 다시 개발하기로 결심하게 되었습니다. 😂 - 오픈소스에서 맞는 두번째 뒤통수
오픈소스는 완벽하지 않고, 믿을 수 없습니다. 실제로도 OpenSSL 관련 결함이 일파만파로 퍼진 적도 있었죠. 이건 직접 당하기 전까지 모릅니다.
앞서도 말했듯이 굉장히 다양한 종류의 리소스를 지원해야 하는데, SDL 또한 여기에서 한계점이 나타났습니다. 4bit wav 지원 못해서 소리 깨져나오는거 고치느라 힘들었고, 특정 이미지 포멧을 읽다가 죽는 문제가 있어서 PR도 날리고 했습니다 -_-;
이 쯤 되니 신뢰할 수 없는 애매한 오픈소스보다는, 필요하다면 직접 만들어서 쓰는게 낫지 않을까? 생각이 들었습니다. - 여전한 프레임워크상의 한계
리소스 로딩 시 멀티코어를 제대로 지원하지 않습니다. 음악이나 리소스 로드 시 단일 코어만 주구장창 갈구죠. 그런데 이건 지금 생각해 보면 리소스 로딩 로직을 아예 별도 개발해서 해결해 볼 수도 있을 듯?
LR2 스킨 처리에 대해서
별개 포스트로 정리하려고 했으나, 이것도 여기에 그냥 퉁쳐서 정리하는게 좋을 것 같아서 써 둡니다 ㅎㅎ...
크게 아래 3가지 정도의 어려움이 있었던 것 같습니다.
- LR2SKIN 자체의 복잡한 문법과 처리 방식 인코딩, 파서, 처리방식 등에 대한 다양한 이슈가 있었습니다. 결국 이건 일종의 인터프리터 언어로 처리하는 쪽이 제일 수월한 것 같더라고요. 명령어에 대한 설명은 lr2csv 및 게임 내 자체 파일에 어느정도 적혀 있어 스펙 파악하는 수고를 덜 수 있었습니다.
- LR2 특유의 리소스 처리 아마 dxa 파일 언패킹 및 lr2font 데이터 역분석이 제일 어려웠던 부분이었을 겁니다. 다행히 dxa 언패킹은 감사하게도 코드가 있어 그걸 빌려 썼고, lr2font는 역분석이 어렵지 않아서 비트맵폰트로 구현하여 렌더링 할 수 있었습니다. 덤으로 비트맵폰트 렌더링 최적화한다고 배치 텍스쳐 렌더링 같은 것도 배웠네요 ㅎㅎ...
- 스킨 오브젝트와 게임 내 이벤트와의 상호작용 위에서 언급했던 부분이긴 한데, 이 부분이 가장 어려웠습니다. 단순히 마구 짜면 솔직히 어렵지는 않은데, 기능 확장이 안 되는게 너무 아쉬워서 그렇게 할 수가 없었습니다. 이 부분에 대해서 생각해 둔 것을 좀 정리해 보려고 합니다.
먼저 LR2 오브젝트의 특징들에 대해서 알아볼 필요가 있습니다. 크게 아래의 특징들이 있는데요,
- 이벤트 핸들러 ID가 있음
- 스타일 ID가 있음 (opcode)
- 인라인 스타일 정보가 있음 (animation)
이벤트 핸들러 ID는, 객체에 클릭 이벤트가 발생할 때 이벤트를 발생시키거나, 혹은 특정 값이 변동하면 이를 객체에 반영해야 하는 작업들을 위해 필요했습니다. 이를 위해 옵저버 패턴 형태로 이벤트를 구독 하는 형태로 구현을 하였습니다.
스타일 ID 같은 경우는 단순히 객체의 show/hide를 위해서만 필요한 요소이긴 한데, 저는 이후의 확장성을 고려하여 단순 show/hide 이외에도 다른 프로퍼티들도 넣을 수 있도록 스타일 형태로 개발을 했습니다. 여기서 스타일 값이 변화할 때 이를 관련 오브젝트들에 반영해주는 것이 관건이었는데, 게임 렌더링 루프 내에서 실행되어야 하기 때문에 신속하게 진행될 필요가 있었습니다. 이를 위해 컴파일 된 스타일 정보 객체를 만들어서, 오브젝트들이 이를 신속하게 처리할 수 있도록 하였습니다. 오브젝트 정보는 이벤트 핸들러에 등록된 객체들을 가져와서 루프를 돌리는 방식으로 정보 중복을 줄이고, 덤으로 디버깅을 위해 각 스타일마다 “계산된 스타일 값"을 기록해 두도록 할 수 있겠네요.
인라인 스타일 정보는 애니메이션밖에 없긴 한데, 이 애니메이션도 게임 도중에 파싱하려면 상당히 시간이 오래 걸립니다. 그래서 애니메이션 정보를 미리 파싱해놓는 방식으로 개발해 두었습니다.
재미있는 점은, 이러한 스킨 형태가 HTML과 상당히 닮았다는 점입니다. 빠른 수행을 위해 명령어를 미리 파싱하고 컴파일 해 놓아야 한다는 점도 그렇고, 명령어를 읽어 객체를 생성하는 것은 DOM과 닮았고, 스타일 ID는 CSS의 그것과 닮았고, 이벤트 핸들러는 자바스크립트 트리거랑 닮은 점이 있죠. (물론, DOM 업데이트도 없고, ECMAScript와는 스펙이 비교도 안되는, 훨씬 간소화된 버전이긴 합니다 ^^;)
계획은 거창한데, 이런저런 이유로 다 구현하지는 못했네요 ^^;
아무튼 이 프로젝트는 나름 어느정도 돌아가고 하니, 이 쯤에서 목표달성 선언을 하고 아예 또 새로 개발해 보기로 합니다.
세 번째 목표?
이번에는 더 낮은 레벨로 갑니다. 멀티코어 풀 지원 + 바닥부터 설계 재시작 이라는 원대한 꿈을 안고...
그래서 사용했던 프레임워크는 아래와 같습니다.
- 코어엔진: GLFW + OpenGL
어느정도 크로스 플랫폼도 지원되고, 어차피 리소스 매니지먼트를 바닥부터 할 거라면 그냥 제가 다 짜는 게 낫겠다 싶었습니다.
인풋기능도 있어서 나름 나쁘지 않은 선택이라고 생각했습니다. - 사운드엔진: PortAudio
기본적인 입출력만 가져다 쓰고, 믹서와 이펙터나 그런 건 직접 구현해서 쓰기로 했습니다
사실 FMOD도 써봄직 했는데, 라이선스가 구려서... 제 목표에 부합하지 못했네요 -_-;
그리고 추가로 게임 엔진에 아래와 같은 목표를 삼았습니다. (아래 220422 내용추가)
멀티스레드 리소스 로딩
최근 코어 개수가 수십개씩 늘어나고 있는 트렌드를 감안하면, 이걸 제대로 다 활용 못하는 건 엄청난 손해잖아요? ㅎㅎ. 그래서 저도 시대의 흐름에 편승하고자 멀티-스레드-리소스-로더를 고안해 내었습니다.
그리고 그럴 만한 명분 또한 있는 것이, BMS는 그 특성상 리소스들이 엄청나게 많습니다. 무비를 bmp 하나하나 키프레임 돌려가면서 만들기 때문에 그것들을 일일이 로드해야 하고, 키사운드도 약 500개쯤 있는 경우가 허다합니다.
그러면 이제 구현을 해야 하는데, 이게 생각만큼 간단하지는 않았습니다. 먼저, 가장 간단하게 떠올릴 수 있는 방법은, 리소스 개수만큼의 스레드를 생성해서 로드시키는 방법이 있습니다. 그런데 이건 오히려 역효과를 가져올 수 있습니다. 시스템 스레드는 비싸거든요. 그리고 리소스 개수만큼의 스레드를 실행시키는 건, 동시에 1000개의 스레드를 돌리겠다는 말인데, 코어 수보다 훨씬 많은 스레드는 오히려 병목현상을 야기할 수 있어 좋은 선택은 아니라고 보여집니다.
그래서 택한 두번째 방법은 리소스 풀을 이용하는 것입니다. 일종의 그린 스레드 방식이지만, 별다른 스케줄러 방식은 없이 단순 FIFO 수준으로 만들었습니다. 하지만 이것 또한 쉽지는 않은게, 게임에서의 리소스 로딩 중 Cancel을 구현하는 것이 엄청나게 까다롭다는 점입니다. 비동기 방식으로 로딩이 되다 보니 취소 또한 비동기 작업으로 수행이 되어야 하고, 그러다보니 타이밍 이슈를 종종 만나곤 했습니다 (가장 힘들었던 게 의사 각성 문제(spurious wakeup), 또 이미 취소된 리소스를 참조하려고 든다던가, 취소된 리소스 클린업이 제대로 안 된다던가...) 이외에도 데드락 상태에 직면하기도 했습니다. 이를테면 스크립트 인터프리터를 비동기로 돌렸는데 해당 작업이 리소스를 비동기로 읽어들이는 작업을 수행하게 되는 것입니다. 그러다가 스레드 풀 cleanup을 수행하게 될 때, 인터프리터 스레드가 먼저 종료되면 리소스 스레드는 callback 을 보낼 객체를 찾지 못하고 프로그램이 터져버리게 되는 문제죠. (사실 이게 제일 골치아팠습니다) 그리고 메모리 누수 등의 문제에서도 멀티스레드 프로그램이 문제를 찾기 훨씬 어려운 점 또한 있었습니다.
- 이건 사실 지금 생각해보면, 리소스 로딩 캔슬이 일어나는 시점이라면 이를 참조하고 있을 객체를 먼저 날리면 되는 문제였을 것 같긴 합니다. Scene 종료시에 리소스 로딩 캔슬을 한다던가, 객체 삭제시에 리소스 로딩 캔슬을 호출한다던가 했으면 될 문제였을 것 같네요. 정책을 확실하게 정하지 못한 아쉬움이 있습니다.
- 부모 스레드가 먼저 종료되어 콜백을 보내지 못해 발생하는 문제도, golang의 channel 같은 것을 만들었다면 훨씬 쉽게 해결 가능했을지도 모르겠습니다.
그리고 또 하나의 문제는, 텍스처는 스레드에서 로딩할 수 없다는 점입니다. 이게 무슨 소리인고 하니, 텍스쳐 업로드는 비트맵을 그래픽카드로 올리는 작업이기 때문에 그래픽카드 콘텍스트를 사용해야 하고, 이에 따라 해당 API는 메인스레드에서만 사용이 가능하게 됩니다. 즉, 이미지 파일을 읽어 비트맵으로 디코딩하는 과정을 멀티스레드로 하고, 메인스레드(렌더링)에서 텍스쳐를 업로드하는 방식으로 구현해야 한다는 이야기가 됩니다. 이에 따라 메인스레드와 워커스레드 사이에서 상당히 복잡한 신호를 주고받아야 하는 상황이 생깁니다. 이를테면 아래처럼 ...
- 사실 위에 쓴 “로딩 중 대상 객체가 사라지면 어떡하지?”가 가장 큰 문제점이기도 했습니다. 사실 이건 delegate를 할 (go의 channel) 같은 걸 하나 만들어서, 둘 중 하나가 먼저 끝나는 걸 파악할 수 있는 객체를 만들어 주었으면 되었을 텐데 하는 아쉬움이 있네요. 저 위 그림을 예를 들면, thread간 정보를 넘겨줄 때 object가 아니라 channel을 넘겨주었으면 아마 되었을 겁니다.
- 어디선가 멀티스레드 환경에서도 스레드에 그래픽 컨텍스트를 생성하여 텍스쳐 사용이 가능하다는 이야기를 본 것 같은데, 설령 가능하다 하더라도 그래픽 API 버전을 타는 문제가 있어 고려 대상에 넣지는 않았습니다. 가능하면 많은 플랫폼에서 구동 가능한 걸 목표로 삼았으니까요.
그런데 그렇게 구현을 하고 나니 실제로 다른 게임 대비 로딩 속도가 월등하게 빠르긴 합니다! 안정성만 잘 잡을 수 있었다면 꽤 마음에 들었을 것 같네요.
로우 레이턴시
몇가지 레이턴시가 몹시 중요한 게임들이 있습니다. 흔히 말하는 “찰나의 순간"이 아주 중요한 게임들이 몇 개가 있는데, 아마 흔히 말하는 “고인물 게임"들이 그러한 게임들이 아닐까 싶습니다.
흔히 말하는 요 세 게임... ^^; 카스같이 몹시 썩은 FPS 게임들도 이 범주에 들어갈 것 같네요!
그 중 리듬게임이 대표적인 예 중 하나일 수 있을텐데, 실제로 판정 단위가 몹시 짭니다. ms 단위로 인풋에 반응해야 되니까요.
출처: 아르파 님 트윗, 지금 작과 판정 정보가 달라졌을 수도 있고 하여 아주 정확한 정보는 아닙니다.
보면 대체로 판정 타이밍이 30ms대, 짜게는 15ms 까지를 허용선으로 잡고 있는 경우가 볼 수 있습니다. 숫자로 보면 커 보이는데, 15ms를 감지해내기 위해서는 약 60FPS 이상의 polling rate가 필요합니다. 33ms도 잘 보면 최소 30FPS를 기준으로 잡은 수치라는 것을 알 수 있는데, 정말이지 프레임 한~두개 사이로 판정이 갈리는 셈입니다. 그렇기 때문에 이러한 게임들에 VSYNC를 적용하는 것은 곤란한 일입니다. 프레임을 강제시키면서 input polling rate도 같이 바뀌면서, 판정도 같이 갈려나가거든요. 일례로 프레임 중간 사이에 음악이 걸치느냐 아니냐에 따라서 8ms 가량의 체감 판정 차이가 날 수 있고, 이게 일정하지도 않습니다.
이러한 경우에 대처하는 방법은 크게 아래의 몇 가지 정도가 있을 것 같습니다.
- 프레임 리미트 풀기 (no Vsync) 렌더링과 폴링 로직이 같이 묶여 있으니, 렌더링을 무제한으로 하면 폴링도 자연스럽게 무제한으로 하게 됩니다. 가장 Naive한 방법이지만, 리소스 소모가 극한으로 치닫는 문제가 있겠죠... 사실 이게 제일 싫었던 점 중 하나입니다. 노트북에서 게임을 돌리면 실제로 부하구간이 아닌데도 배터리랑 발열을 엄청나게 내니까요. (굳이 왜 노트북에서 돌리냐고 태클걸지는 말아주세요 ㅎㅎ; 그리고 노트북이 아니더라도 데스크탑에도 최근 절전기능 관련 기술들 많이 적용된 것을 감안하면 고려해봄직한 요소입니다.)
- 프레임 리미트를 풀되, 렌더링은 리미트를 걸기 게임 로직을 계속 돌리지만, 실제 렌더링 관련 로직은 특정 타이밍이 지났을 때에만 수행되도록 하는 방법입니다. CPU 부하는 크게 달라지지 않겠지만, GPU 부하가 훨씬 적어져 에너지 절약을 기대할 수 있습니다. (이마저도 폴링 로직이 어떻게 되어 있느냐에 따라 훨씬 부하가 적을 수 있습니다!)
- 메인(폴링) 로직에 리미트를 거는 방안도 생각을 안 해본건 아닌데, 폴링 리미트는 생각만큼 잘 작동하지 않더라고요. 일단 sleep를 메인 로직에 거는 순간부터 프로세스는 process queue에서 밀리는 경향이 있어서, 이는 안 될 것 같습니다. 혹시 관련하여 좋은 방법 있다면, 알려주시면 감사합니다!
- 핵심 게임 로직에서만 프레임 리미트를 풀고, 다른 경우에는 Vsync 켜기 잘 생각해보면, 이렇게 빡센 타이밍을 자랑하는 게임들은 항상 로비 등에서 소모하는 시간이 꽤 있습니다. 이를 잘 이용하면, 게임 메인 로직에 들어갈때만 그래픽 세팅을 바꿔주도록 할 수 있겠죠.
렌더링 이외에도, 레이턴시가 크게 영향을 끼치는 부분으로 “사운드 버퍼"가 있습니다. 출력 될 사운드를 버퍼링 해 놓기 때문에, 버퍼가 너무 크면 키 입력 대비 사운드 출력이 부자연스러운 문제가 있고 (+ 판정 타이밍 이슈), 너무 작으면 믹서가 버퍼를 준비하기도 전에 사운드를 가져가기 때문에 스터터링이 일어납니다. 이 중간을 맞추는 게 생각보다 쉽지가 않습니다. 수많은 리듬게임들이 오늘도 판정 관련해서 욕을 먹는 이유 중 하나가 되죠.
그렇게 개발을 했고, 느리지만 천천히 원하던 결과물이 드디어 나오고 있었습니다..! 그제서야 원하던 바를 이루는구나 생각했는데...
아주 긴 개발기간
이 개발을 시작했던게 16년도 즈음인 것 같은데, 6년이 되는 지금까지 마무리를 짓지 못했습니다. 아... 절망스럽다. 이대로는 끝이 보이지 않습니다. 죽을 때까지 만들 수는 있을까?
음... 그런데 왜 그렇게 되었을까요? 곰곰이 생각을 해봅니다.
- 게임 엔진부터의 개발
이게 제일 크지 않나 싶습니다. 모든 걸 직접 개발하겠다는 욕심에 엔진이 해오던 부분까지도 제가 싹 다 다시 개발을 하게 됩니다. 개발이 순탄할 리가 없습니다. 글로 다 적지는 않았지만 정말 많은 버그가 있었습니다. 멀티코어 지원을 하다 보니 동시성 버그도 엄청나게 많이 발생했고, 해결하는데 한 달 가까이 걸린 적도 있습니다. 보통 큐에 리소스를 잔뜩 담아놓은 상태에서 캔슬을 시키거나, 리소스가 다른 리소스를 로드해야 하거나, 비동기로 오브젝트를 읽어들여야 하는 상황에서 문제가 많이 생깁니다. 2D 렌더링을 위해서 OpenGL API및 그래픽스도 다시 복습을 합니다. 또 리소스부터 직접 관리를 하다보니 누수가 발생해서 프로파일러 붙이고 온갖 삽질을 다 합니다.
그러다 보면, 게임은 언제 만들까요... - 현실과의 타협
나름 18년도까지는 그래도 개발 진척이 어느정도 있던걸로 기억하는데, 커밋 내역을 확인해보니 그 시점을 기점으로 속도가 눈에 띄게 둔화됩니다. 18년도에 처음 취직을 했었죠.
역시 일과 취미는 양립하기 어렵습니다...
실패? 성공?
처음에는 실패! 라고 쓰려고 했으나... 나름 얻어간 것도 많고, 한 것도 많네요 ^^; 마냥 실패라고는 쓸 수는 없겠습니다.
그래도 좀 더 디테일하게 정리를 해본다면,
이러한 점에서 성공했다!
- BMS 파서 개발
나름 굉장히 많은 BMS를 인풋 데이터로 사용했는데, 불만없이 잘 쓰고 있습니다!
구글테스트를 붙여놓은 덕에 기능이 잘 깨지지도 않고, 신뢰도도 좋습니다. - 경험: 다양한 리소스 다루어보기 및 로우레벨 게임 엔진 개발해보기
리소스 로드, 리소스 매니징, 사운드 믹서, 렌더링 등 나름 해 볼수 없는 (하기 싫은) 좋은 경험이었습니다 ^^;
로우 레이턴시 설계에 대해서 생각해 볼 수 있었던 것도 좋은 기회였고요. - LR2 스킨 구조 파악 및 리버싱
별로 쓸 데는 없지만 -_-; 해보고 싶었습니다. 결과물도 잘 나왔으니, 목적은 달성한 거겠죠?
이러한 점에서 실패했다!
- 개발기간 준수 실패
사이드 프로젝트는 이렇게 시간을 오래 잡아먹어서는 안 됩니다... - 써먹을 데 없는 경험
나름 6년이라는 시간을 부어서 이러한 경험들을 얻었지만, 이걸 대체 어디 쓰죠? 저는 게임 회사를 갈 것도 아니고, 설령 그러더라도 나름 게임 개발자들 다 다룬다는 유니티를 써본것도 아니고... 그렇다고 게임 엔진 개발쪽으로 갈 것도 아니고... -_-;
앞으로의 계획
일단 지금 하던 프로젝트는 어떻게든 이대로 마무리를 지어야 할 것 같습니다. 첫 목표는 게임의 완성이었지만, 이제는 게임 엔진을 직접 만들었다는 수준에서 의의를 두는 걸로 해야겠네요. 이 작업 속도로는 도저히 계속 진행하기가 어렵네요.
그리고, 생각해보면 첫 작품을 만든때로부터 벌써 8년, 10년 가까운 시간이 지났습니다. 이제는 시대가 많이 바뀌었습니다.
- 고도화된 게임엔진
인디 게임 개발자들은 물론, 거대 게임사들도 이제는 자체 개발된 엔진을 잘 쓰지 않습니다. 엔진을 개발하는 것 자체가 굉장히 비싸기도 하거니와, 엔진 개발 자체가 아웃소싱화 되고 있는 점이 큽니다. 그러다 보니, 이제는 로우 레벨 API를 다룰 줄 아는 개발자 수요보다는, 유니티 프레임워크를 만져본 적 있느냐? 가 훨씬 더 큰 능력으로 취급받는 시대입니다. 저 또한 시대의 흐름을 탈 필요가 있다는 생각을 합니다. 이것저것 요구사항 따져가며 자잘한 프레임워크 선정에 시간을 쓰기보다는, 보다 모던한 프레임워크 좀 써야지...
그리고 앞서 말했듯이 제 자신이 인디개발자라는 점을 감안한다면, 개발의 용이성 또한 감안했어야 했습니다. 그러한 점에서 고도화된 게임엔진이 굉장한 이점으로 작용할 것이라는 건 이견이 없습니다. - 기기 성능의 향상
이제는 더 이상 최적화에 그렇게까지 집착할 필요가 없습니다. 기기 자체가 워낙에 성능이 좋아져서리...
있는 엔진 그대로 가져다 쓰고, 꼭 필요한 부분만 고쳐 쓰는 게 맞을 것 같습니다.
사실 고도화된 게임엔진이 성능 측면에서 그렇게 손해를 보느냐? 하면, 그건 또 아니라고 할 수 있겠습니다. Unity 공식 문서만 봐도 최적화를 위해 온갖 힘을 쓴 게 보이는데...
프로그램 자체가 무겁다고 말할 수는 있어도 최적화에 대해서 부정할 요소는 없어 보입니다.
아무튼, 그래서, 저는 게임 개발을 계속한다면 아래 두 개 정도의 후보를 보고 있습니다.
- GoDot 프레임워크 사용
자유로운 라이선스의 오픈소스이면서 동시에 강력한 확장성과 함께 유니티와 유사한 구조의 컴포넌트 기반 개발을 지향하고 있어, 경험 측면에서나 프로젝트 매니징에서나 큰 도움이 될 것으로 보입니다. 덤으로 크로스 플랫폼 기능까지! - SDL 프레임워크 사용
솔직히 SDL도 우수한 물건이라, 이걸 이용해서 만들더라도 경험상 문제 없는 물건을 만들 수 있을 거라 생각합니다... 일단 이제는 시간 대비 개발의 효용성이 제일 중요하기 때문이니까요.
지금은 새로운 곳에서 일하게 되어 한참 적응하느라(+일도 많어...) 바쁘기도 하고, 본업이 게임 프로그래머가 아니라 어려운 점 또한 있습니다. 그래도 기회가 된다면, 이 삽질의 끝을 보고픈 마음은 여전하네요. 그리고... M1에서도 BMS 좀 돌려보자!