우수사례 - Bouncy Mouse

소개

통통 튀는 마우스

작년 말 iOS와 Android에 Bouncy 마우스를 게시한 후 매우 중요한 몇 가지 교훈을 얻었습니다. 그중에서도 가장 중요한 것은 기존 시장에 진입하기가 어렵다는 점입니다. 완전히 포화 상태가 된 iPhone 시장에서는 관심을 끌기가 매우 어려웠고, 포화 상태가 낮은 Android Marketplace에서는 발전이 더 쉬웠지만 쉽지 않았습니다. 이러한 경험을 하면서 Chrome 웹 스토어에서 흥미로운 기회를 발견했습니다. 웹 스토어가 결코 비어 있는 것은 아니지만, 고품질 HTML5 기반 게임 카탈로그는 이제 막 성숙기에 접어들기 시작했습니다. 이는 신규 앱 개발자의 경우 순위 차트를 만들고 가시성을 확보하는 것이 훨씬 더 쉽다는 것을 의미합니다. 이 기회를 감안하여, 흥미진진한 신규 사용자층에게 최근의 게임플레이 경험을 제공하고자 Bouncy 마우스를 HTML5로 포팅하기로 했습니다. 이 우수사례에서는 Bouncy 마우스를 HTML5로 포팅하는 일반적인 과정에 대해 설명한 다음 흥미로운 세 가지 영역인 오디오, 성능 및 수익 창출에 대해 좀 더 자세히 알아보겠습니다.

C++ 게임을 HTML5로 포팅

Bouncy Mouse는 현재 Android(C++), iOS (C++), Windows Phone 7 (C#) 및 Chrome (JavaScript)에서 사용할 수 있습니다. 이때 '어떻게 하면 여러 플랫폼에 쉽게 포팅할 수 있는 게임을 만들 것인가?'라는 질문이 떠오릅니다. 사람들은 손에 쏙 들어가는 번거로움 없이 이 정도 수준의 이동성을 달성할 수 있는 마법의 총알을 원한다는 느낌이 듭니다. 안타깝게도 아직 이러한 솔루션이 있는지 모르겠습니다. 가장 가까운 것은 아마도 Google의 PlayN 프레임워크Unity 엔진일 것입니다. 하지만 그중 어느 것도 제가 관심 있는 타겟에 부합하지 않습니다. 저의 접근 방식은 사실 핸드 포트였습니다. 먼저 C++로 iOS/Android 버전을 작성한 다음 이 코드를 각각의 새 플랫폼에 포팅했습니다. 많은 작업이 필요한 것처럼 들릴 수 있지만 WP7 및 Chrome 버전은 각각 2주 이상 걸리지 않았습니다. 이제 문제는 코드베이스를 손으로 쉽게 포팅할 수 있도록 하는 작업이 있는지입니다. 제가 수행한 몇 가지 작업은 다음과 같습니다.

코드베이스는 작게 유지

당연한 것 같지만 게임을 이렇게 빠르게 포팅할 수 있었던 가장 큰 이유이기도 합니다. Bouncy Mouse의 클라이언트 코드는 약 7,000줄의 C++에 불과합니다. 7,000줄의 코드는 아무것도 아닙니다. 하지만 관리가 용이할 만큼 작습니다. 클라이언트 코드의 C# 버전과 JavaScript 버전 모두 거의 동일한 크기가 되었습니다. 코드베이스를 작게 유지하는 것은 기본적으로 두 가지 핵심 관행에 해당합니다. 초과 코드를 작성하지 말고, 런타임이 아닌 코드를 사전 처리하는 데 최대한 많은 작업을 수행했습니다. 과도한 코드를 작성하지 않는 것이 당연해 보일 수 있지만, 저는 항상 혼자서 싸우는 한 가지입니다. 도우미로 고려할 수 있는 모든 것을 위해 도우미 클래스/함수를 작성하고 싶은 충동을 자주 느낍니다. 그러나 도우미를 실제로 여러 번 사용할 계획이 없다면 일반적으로 코드가 팽창됩니다. Bouncy Mouse의 경우, 최소 세 번 이상 사용하지 않는다면 도우미는 절대 쓰지 않으려고 했습니다. 도우미 클래스를 작성할 때는 깔끔하고 이식이 가능하며 향후 프로젝트에서 재사용할 수 있도록 하려고 노력했습니다. 반면 Bouncy Mouse만을 위한 코드를 작성할 때는 재사용 가능성이 낮고 코드 작성에 '가장 예쁜' 방법은 아니더라도 최대한 간단하고 빠르게 코딩 작업을 완료하는 데 중점을 두었습니다. 코드베이스를 작게 유지하는 것에서 더 중요한 두 번째는 전처리 단계를 최대한 많이 푸시하는 것이었습니다. 런타임 작업을 가져와 전처리 작업으로 이동할 수 있으면 게임이 더 빠르게 실행될 뿐만 아니라 코드를 각각의 새 플랫폼에 포팅할 필요가 없습니다. 예를 들어, 저는 원래 레벨 기하학 데이터를 상당히 처리되지 않은 형식으로 저장하여 런타임에 실제 OpenGL/WebGL 꼭짓점 버퍼를 어셈블했습니다. 약간의 설정과 수백 줄의 런타임 코드가 필요했습니다. 나중에 이 코드를 사전 처리 단계로 이동하여 컴파일 시간에 완전히 패킹된 OpenGL/WebGL 꼭짓점 버퍼를 작성했습니다. 실제 코드 양은 거의 동일하지만 이 수백 줄은 전처리 단계로 이동되었기 때문에 새로운 플랫폼으로 이식할 필요가 전혀 없었습니다. Bouncy Mouse에는 이에 대한 수많은 예가 있으며, 가능한 작업은 게임마다 다르지만, 런타임에 발생할 필요가 없는 모든 것에 주의하면 됩니다.

필요 없는 종속 항목은 사용하지 마세요

Bouncy Mouse의 포팅이 쉬운 또 다른 이유는 종속 항목이 거의 없기 때문입니다. 다음 차트에는 플랫폼별로 Bouncy Mouse의 주요 라이브러리 종속 항목이 요약되어 있습니다.

Android iOS HTML5 WP7
그래픽 OpenGL ES OpenGL ES WebGL XNA 항공
사운드 OpenSL ES OpenAL 웹 오디오 XNA 항공
물리학 박스2D 박스2D Box2D.js Box2D.xna

여기까지입니다. 모든 플랫폼에서 이동할 수 있는 Box2D 외에는 대규모 서드 파티 라이브러리가 사용되지 않았습니다. 그래픽의 경우 WebGL과 XNA가 모두 OpenGL과 거의 1:1로 매핑되므로 이는 큰 문제가 아니었습니다. 실제로 보관함은 사운드 영역에서만 달랐습니다. 하지만 Bouncy Mouse의 사운드 코드는 작기 때문에 (플랫폼별 코드가 약 100줄 정도) 큰 문제가 되지 않았습니다. Bouncy Mouse에 이식할 수 없는 큰 라이브러리가 없도록 하면 언어 변경에 관계없이 런타임 코드의 로직이 버전 간에 거의 동일할 수 있습니다. 또한 이동식이 불가능한 도구 체인에 종속되지 않도록 해 줍니다. OpenGL/WebGL에 코딩하는 것이 Cocos2D 또는 Unity와 같은 라이브러리를 사용하는 것에 비해 직접적으로 복잡성을 유발하는지 질문을 받았습니다 (WebGL 도우미도 일부 있음). 사실 저는 정반대라고 생각합니다. 대부분의 휴대전화 / HTML5 게임 (최소한 Bouncy Mouse 게임)은 매우 간단합니다. 대부분의 경우 게임은 몇 개의 스프라이트와 텍스처 도형을 그립니다. Bouncy Mouse의 OpenGL 관련 코드의 합계는 1,000줄 미만일 수 있습니다. 도우미 라이브러리를 사용하면 실제로 이 수치를 줄일 수 있을지 놀라울 따름입니다. 이 수치를 절반으로 줄였더라도 500줄의 코드를 절약하려면 새로운 라이브러리/도구를 배우는 데 상당한 시간을 들여야 할 것입니다. 게다가 저는 관심 있는 모든 플랫폼에서 이식 가능한 도우미 라이브러리를 아직 찾지 못했기 때문에 이러한 종속 항목을 사용하면 이식성이 크게 저하될 것입니다. 라이트맵, 동적 LOD, 스킨 적용 애니메이션 등이 필요한 3D 게임을 작성한다면 제 대답은 분명히 바뀔 것입니다. 이 경우 OpenGL을 대상으로 전체 엔진을 직접 코딩하기 위해 시간을 낭비하게 될 것입니다. 여기서 제가 요컨대 대부분의 모바일/HTML5 게임은 (아직) 이 카테고리에 포함되지 않았기 때문에 필요하기 전에 복잡하게 만들 필요가 없다는 점입니다.

언어 간 유사점을 과소평가하지 마세요

C++ 코드베이스를 새 언어로 포팅할 때 많은 시간을 절약할 수 있었던 마지막 요령은 대부분의 코드가 각 언어 간에 거의 동일하다는 것을 깨달았다는 것입니다. 일부 주요 요소는 변경될 수 있지만 이러한 요소는 변경되지 않는 요소보다 훨씬 적습니다. 실제로 많은 함수에서 C++에서 JavaScript로 전환하는 데는 C++ 코드베이스에서 몇 가지 정규 표현식 대체를 실행하기만 하면 됩니다.

결론 포팅

이전과 관련된 내용은 여기까지입니다. 다음 섹션에서 몇 가지 HTML5 관련 문제에 대해 다루겠지만, 중요한 점은 코드를 단순하게 유지한다면 포팅이 악몽이 아닌 사소한 골칫거리가 된다는 것입니다.

오디오

저를 비롯한 모든 사람들처럼 보이는 문제가 오디오 문제였습니다. iOS와 Android에서는 다양한 오디오 옵션 (OpenSL, OpenAL)을 사용할 수 있지만 HTML5의 세계에서는 상황이 어색해 보입니다. HTML5 오디오를 사용할 수 있기는 하지만 게임에서 사용할 때 몇 가지 획기적인 문제가 있다는 것을 발견했습니다. 최신 브라우저에서도 이상한 행동을 자주 보곤 합니다. 예를 들어 Chrome에서는 동시에 만들 수 있는 오디오 요소 (출처)의 수에 제한이 있는 것 같습니다. 게다가 소리가 재생되더라도 때로는 알 수 없는 왜곡이 발생합니다. 전반적으로 다소 걱정스러웠습니다. 온라인으로 검색해 보니 거의 모든 사람이 동일한 문제를 겪고 있습니다. 처음에 사용해 본 솔루션은 SoundManager2라는 API였습니다. 이 API는 가능한 경우 HTML5 오디오를 사용하며 까다로운 상황에서는 플래시로 대체합니다. 이 솔루션은 효과가 있었지만 여전히 버그가 많고 예측할 수 없었습니다 (순수한 HTML5 오디오보다는 작았습니다). 출시 1주일 후 저는 Google의 유용한 담당자들과 이야기를 나누었습니다. Webkit의 Web Audio API를 소개해 주었습니다. 원래 이 API를 사용할 생각이었는데 저에게는 API의 불필요한 복잡성 때문에 사용을 거부했습니다. 몇 가지 소리를 재생하려고 했습니다. HTML5 오디오의 경우 JavaScript 몇 줄이면 됩니다. 하지만 웹 오디오를 간략히 살펴본 결과 큰 사양 (70페이지)과 웹상의 소량 샘플 (새 API의 경우 일반적임), 사양의 어느 곳에서든 '재생', '일시중지' 또는 '중지' 기능이 누락되었다는 사실에 놀랐습니다. Google에서 우려사항의 기반이 되지 않았다는 확신을 가지고 API를 다시 살펴봤습니다. 몇 가지 예시를 더 살펴보고 더 연구해 본 결과, Google이 맞다는 사실을 알았습니다. API가 제 니즈를 확실히 충족시켜 주고, 다른 API를 악화시키는 버그 없이도 이런 방식으로 작동할 수 있거든요. 특히 웹 오디오 API 시작하기 문서가 유용합니다. 이 도움말은 API에 대해 더 자세히 알고 싶은 경우에 유용합니다. 진짜 문제는 API를 이해하고 사용한 후에도 여전히 '몇 가지 소리만 재생'하도록 설계되지 않은 API처럼 보인다는 것입니다. 이 문제를 해결하기 위해 사운드의 상태를 재생, 일시중지, 중지, 쿼리하는 원하는 방식으로 API를 사용할 수 있는 작은 도우미 클래스를 작성했습니다. 도우미 클래스 AudioClip을 호출했습니다. 전체 소스는 GitHub에서 Apache 2.0 라이선스에 따라 이용할 수 있으며, 이 클래스의 세부사항은 아래에서 설명하겠습니다. 먼저 Web Audio API에 대한 배경 지식:

웹 오디오 그래프

Web Audio API를 HTML5 오디오 요소보다 더 복잡하고 강력하게 만드는 첫 번째 요소는 사용자에게 출력하기 전에 오디오를 처리 / 믹싱하는 기능입니다. 오디오 재생이 그래프와 관련되어 있다는 사실은 강력하지만 간단한 시나리오에서는 작업이 좀 더 복잡해집니다. 다음 그래프를 통해 Web Audio API의 강력한 기능을 확인해 보세요.

기본 웹 오디오 그래프
기본 웹 오디오 그래프

위 예는 Web Audio API의 강력한 기능을 보여주지만, 내 시나리오에서는 이러한 기능이 필요하지 않았습니다. 소리를 재생하고 싶었습니다. 이 경우에도 그래프가 필요하지만 그래프는 매우 간단합니다.

그래프는 단순할 수 있습니다.

Web Audio API를 HTML5 오디오 요소보다 더 복잡하고 강력하게 만드는 첫 번째 요소는 사용자에게 출력하기 전에 오디오를 처리 / 믹싱하는 기능입니다. 오디오 재생이 그래프와 관련되어 있다는 사실은 강력하지만 간단한 시나리오에서는 작업이 좀 더 복잡해집니다. 다음 그래프를 통해 Web Audio API의 강력한 기능을 확인해 보세요.

트리비얼 웹 오디오 그래프
Trivial 웹 오디오 그래프

위에 표시된 간단한 그래프는 사운드를 재생, 일시중지 또는 중지하는 데 필요한 모든 작업을 수행할 수 있습니다.

하지만 그래프에 대해서는 걱정하지 않으셔도 됩니다.

그래프를 이해하는 것은 좋지만 사운드를 재생할 때마다 처리할 필요는 없습니다. 따라서 간단한 래퍼 클래스 'AudioClip'을 작성했습니다. 이 클래스는 이 그래프를 내부적으로 관리하지만 훨씬 간단한 사용자 대상 API를 제공합니다.

AudioClip
AudioClip

이 클래스는 웹 오디오 그래프와 도우미 상태에 지나지 않지만 각 사운드를 재생하기 위해 웹 오디오 그래프를 빌드해야 하는 경우보다 훨씬 간단한 코드를 사용할 수 있습니다.

// At startup time
var sound = new AudioClip("ping.wav");

// Later
sound.play();

구현 세부정보

도우미 클래스의 코드를 간단히 살펴보겠습니다. 생성자 – 생성자는 XHR을 사용하여 사운드 데이터 로드를 처리합니다. 이 예에서는 여기에 표시되지 않았지만 HTML5 오디오 요소를 소스 노드로 사용할 수도 있습니다. 이 방법은 특히 큰 샘플에 유용합니다. Web Audio API를 사용하려면 이 데이터를 'arraybuffer'로 가져와야 합니다. 데이터가 수신되면 이 데이터에서 웹 오디오 버퍼를 만듭니다 (원본 형식에서 런타임 PCM 형식으로 디코딩).

/**
* Create a new AudioClip object from a source URL. This object can be played,
* paused, stopped, and resumed, like the HTML5 Audio element.
*
* @constructor
* @param {DOMString} src
* @param {boolean=} opt_autoplay
* @param {boolean=} opt_loop
*/
AudioClip = function(src, opt_autoplay, opt_loop) {
// At construction time, the AudioClip is not playing (stopped),
// and has no offset recorded.
this.playing_ = false;
this.startTime_ = 0;
this.loop_ = opt_loop ? true : false;

// State to handle pause/resume, and some of the intricacies of looping.
this.resetTimout_ = null;
this.pauseTime_ = 0;

// Create an XHR to load the audio data.
var request = new XMLHttpRequest();
request.open("GET", src, true);
request.responseType = "arraybuffer";

var sfx = this;
request.onload = function() {
// When audio data is ready, we create a WebAudio buffer from the data.
// Using decodeAudioData allows for async audio loading, which is useful
// when loading longer audio tracks (music).
AudioClip.context.decodeAudioData(request.response, function(buffer) {
    sfx.buffer_ = buffer;
    
    if (opt_autoplay) {
    sfx.play();
    }
});
}

request.send();
}

재생 – 사운드를 재생하려면 재생 그래프를 설정하고 그래프의 소스에서 'noteOn' 버전을 호출하는 2단계를 거쳐야 합니다. 소스는 한 번만 재생할 수 있으므로 재생할 때마다 소스/그래프를 다시 만들어야 합니다. 이 함수의 복잡성은 대부분 일시중지된 클립 (this.pauseTime_ > 0)을 재개하는 데 필요한 요구사항에서 비롯됩니다. 일시중지된 클립의 재생을 재개하려면 버퍼의 하위 영역을 재생할 수 있는 noteGrainOn를 사용합니다. 안타깝게도 noteGrainOn는 이 시나리오에서 원하는 방식으로 루프와 상호작용하지 않습니다 (전체 버퍼가 아닌 하위 영역을 루프). 따라서 noteGrainOn로 클립의 나머지 부분을 재생한 다음 루프를 사용 설정한 상태에서 클립을 처음부터 다시 시작하여 이 문제를 해결해야 합니다.

/**
* Recreates the audio graph. Each source can only be played once, so
* we must recreate the source each time we want to play.
* @return {BufferSource}
* @param {boolean=} loop
*/
AudioClip.prototype.createGraph = function(loop) {
var source = AudioClip.context.createBufferSource();
source.buffer = this.buffer_;
source.connect(AudioClip.context.destination);

// Looping is handled by the Web Audio API.
source.loop = loop;

return source;
}

/**
* Plays the given AudioClip. Clips played in this manner can be stopped
* or paused/resumed.
*/
AudioClip.prototype.play = function() {
if (this.buffer_ && !this.isPlaying()) {
// Record the start time so we know how long we've been playing.
this.startTime_ = AudioClip.context.currentTime;
this.playing_ = true;
this.resetTimeout_ = null;

// If the clip is paused, we need to resume it.
if (this.pauseTime_ > 0) {
    // We are resuming a clip, so it's current playback time is not correctly
    // indicated by startTime_. Correct this by subtracting pauseTime_.
    this.startTime_ -= this.pauseTime_;
    var remainingTime = this.buffer_.duration - this.pauseTime_;

    if (this.loop_) {
    // If the clip is paused and looping, we need to resume the clip
    // with looping disabled. Once the clip has finished, we will re-start
    // the clip from the beginning with looping enabled
    this.source_ = this.createGraph(false);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime)

    // Handle restarting the playback once the resumed clip has completed.
    // *Note that setTimeout is not the ideal method to use here. A better 
    // option would be to handle timing in a more predictable manner,
    // such as tying the update to the game loop.
    var clip = this;
    this.resetTimeout_ = setTimeout(function() { clip.stop(); clip.play() },
                                    remainingTime * 1000);
    } else {
    // Paused non-looping case, just create the graph and play the sub-
    // region using noteGrainOn.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime);
    }

    this.pauseTime_ = 0;
} else {
    // Normal case, just creat the graph and play.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteOn(0);
}
}
}

음향 효과로 재생 - 위의 재생 기능에서는 오디오 클립을 겹쳐서 여러 번 재생할 수 없습니다. 두 번째 재생은 클립이 종료되거나 중지된 경우에만 가능합니다. 게임에서 코인을 모으는 등 재생이 완료될 때까지 기다리지 않고 여러 번 사운드를 재생하려고 하는 경우도 있습니다. 이를 위해 AudioClip 클래스에 playAsSFX() 메서드가 있습니다. 여러 재생이 동시에 발생할 수 있으므로 playAsSFX()에서의 재생은 AudioClip과 1:1로 바인딩되지 않습니다. 따라서 상태에 대해 재생을 중지, 일시중지 또는 쿼리할 수 없습니다. 또한 이 방식으로 재생되는 반복 사운드를 중지할 방법이 없으므로 반복이 사용 중지됩니다.

/**
* Plays the given AudioClip as a sound effect. Sound Effects cannot be stopped
* or paused/resumed, but can be played multiple times with overlap.
* Additionally, sound effects cannot be looped, as there is no way to stop
* them. This method of playback is best suited to very short, one-off sounds.
*/
AudioClip.prototype.playAsSFX = function() {
if (this.buffer_) {
var source = this.createGraph(false);
source.noteOn(0);
}
}

중지, 일시중지, 쿼리 상태 – 나머지 함수는 매우 간단하므로 많은 설명이 필요하지 않습니다.

/**
* Stops an AudioClip , resetting its seek position to 0.
*/
AudioClip.prototype.stop = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.startTime_ = 0;
this.pauseTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Pauses an AudioClip. The offset into the stream is recorded to allow the
* clip to be resumed later.
*/
AudioClip.prototype.pause = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.pauseTime_ = AudioClip.context.currentTime - this.startTime_;
this.pauseTime_ = this.pauseTime_ % this.buffer_.duration;
this.startTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Indicates whether the sound is playing.
* @return {boolean}
*/
AudioClip.prototype.isPlaying = function() {
var playTime = this.pauseTime_ +
            (AudioClip.context.currentTime - this.startTime_);

return this.playing_ && (this.loop_ || (playTime < this.buffer_.duration));
}

오디오 결론

이 도우미 클래스가 저와 같은 오디오 문제로 고군분투하는 개발자에게 도움이 되길 바랍니다. 또한, 이와 같은 클래스는 Web Audio API의 더 강력한 기능을 몇 가지 추가해야 하는 경우에도 시작하기에 적합한 것으로 보입니다. 어떤 방식으로든 이 솔루션은 Bouncy Mouse의 요구를 충족했으며, 어떠한 문자열도 넣지 않고 진정한 HTML5 게임으로 만들 수 있었습니다.

성능

JavaScript 포트와 관련하여 저를 걱정했던 또 다른 영역은 성능이었습니다. 포트의 v1을 완료한 후 쿼드 코어 데스크톱에서 모든 것이 정상적으로 작동한다는 것을 발견했습니다. 안타깝게도 넷북이나 크롬북의 경우 상황이 양호하지 않았습니다. 이 경우 Chrome 프로파일러 덕분에 모든 프로그램 시간이 어디에 쓰이는지 정확히 알 수 있었습니다. 제 경험상 최적화 전에 프로파일링이 중요하다는 점을 잘 알고 있습니다. Box2D 물리학이나 렌더링 코드가 속도 저하의 주요 원인이 될 것으로 예상했지만 실제로는 Matrix.clone() 함수에 대부분의 시간을 사용했습니다. 수학이 많은 게임이라는 특성을 감안할 때 행렬 생성/복제가 많다는 것은 알고 있었지만 병목 현상이 나타날 줄은 몰랐습니다. 결과적으로, 아주 간단한 변화로 게임에서 CPU 사용량을 3배 이상 줄일 수 있었다는 것이 밝혀졌습니다. 데스크톱 CPU는 6~7% 에서 2%로 줄었습니다. 이는 JavaScript 개발자에게 상식일 수도 있지만 C++ 개발자로서 이 문제에 관해 놀라움을 금치 못하였기 때문에 좀 더 자세히 설명하겠습니다. 기본적으로 원래 행렬 클래스는 3x3 행렬이었습니다. 각 요소는 3개의 요소 배열을 포함하는 3개의 요소 배열입니다. 안타깝게도 이 경우 행렬을 클론할 때 4개의 새 배열을 만들어야 했습니다. 이 데이터를 단일 9 요소 배열로 옮기고 그에 따라 수학을 업데이트하는 것 밖에 없었습니다. CPU 사용량이 3배 감소한 이유가 전적으로 한 가지 변경 사항이었으며, 이 변경 후에 모든 테스트 기기에서 성능이 만족스러웠습니다.

추가 최적화

괜찮은 성적이지만 몇 가지 사소한 실수도 있었습니다. 조금 더 프로파일링해 본 결과, 이것이 JavaScript의 가비지 컬렉션 때문이라는 것을 알았습니다. 앱이 60fps로 실행되었는데, 이는 각 프레임을 그리는 데 걸리는 시간이 16ms 밖에 없다는 것을 의미했습니다. 안타깝게도 더 느린 머신에서 가비지 컬렉션이 시작되면 때때로 최대 10ms를 차지하기도 합니다. 전체 프레임을 그리는 데 거의 16ms가 소요되었기 때문에 몇 초 동안 끊김 현상이 발생했습니다. 왜 그렇게 많은 가비지가 생성되는지 더 잘 파악하기 위해 Chrome의 힙 프로파일러를 사용했습니다. 안타깝게도 대부분의 쓰레기 (70% 이상)가 Box2D에서 생성된 것이었습니다. JavaScript에서 가비지를 제거하는 것은 까다로운 작업이며 Box2D를 재작성하는 건 당연한 일입니다. 그래서 저는 구석에 갇혔음을 깨달았습니다. 다행히도 이 책에서 가장 오래된 기술 중 하나인데요. 60fps를 맞추지 못할 때는 30fps로 달리면 된다는 것입니다. 일관된 30fps로 달리는 것이 불규칙한 60fps로 실행하는 것보다 훨씬 낫다는 점은 상당히 잘 알려져 있습니다. 실제로 게임이 30fps로 실행된다는 불만이나 의견을 아직 받지 못했습니다 (두 버전을 나란히 비교하지 않으면 알 수 없음). 프레임당 16ms가 더 늘어났기 때문에 난해한 가비지 컬렉션의 경우에도 프레임을 렌더링할 시간이 충분했습니다. 30fps로 실행하는 것은 내가 사용한 타이밍 API (WebKit의 뛰어난 requestAnimationFrame)에 의해 명시적으로 사용 설정되지 않았지만, 아주 간단한 방법으로 실행할 수 있습니다. 명시적인 API만큼 우아하지는 않을 수도 있지만 RequestAnimationFrame의 간격이 모니터의 VSYNC (일반적으로 60fps)에 정렬된다는 것을 알고 있으면 30fps를 달성할 수 있습니다. 즉, 모든 다른 콜백을 무시하면 됩니다. 기본적으로 'RequestAnimationFrame'이 실행될 때마다 호출되는 콜백 'Tick'이 있다면 다음과 같이 이를 실행할 수 있습니다.

var skip = false;

function Tick() {
skip = !skip;
if (skip) {
return;
}

// OTHER CODE
}

더욱 신중을 기하려면 시작 시 컴퓨터의 VSYNC가 이미 30fps 이하인지 확인하고 이 경우 건너뛰기를 사용 중지해야 합니다. 하지만 테스트한 데스크톱/노트북 구성에서는 아직 이 현상을 본 적이 없습니다.

배포 및 수익 창출

Bouncy Mouse의 Chrome 포트에 대해 제가 놀란 마지막 영역은 수익 창출이었습니다. 이 프로젝트에서 저는 HTML5 게임을 최신 기술을 배울 수 있는 흥미로운 실험으로 구상했습니다. 이 포트가 매우 많은 시청자층에 도달하고 수익 창출 가능성이 크다는 사실을 몰랐습니다.

Bouncy Mouse는 10월 말 Chrome 웹 스토어에 출시되었습니다. Chrome 웹 스토어에 앱을 출시하면서 기존 시스템을 활용하여 검색 가능성, 커뮤니티 참여, 순위 등 모바일 플랫폼에서 사용하던 여러 기능을 활용할 수 있게 되었습니다. 매장의 도달범위가 넓다는 사실에 놀랐습니다. 출시 후 한 달도 안 돼서 약 40만 건의 설치 수를 기록하고 이미 커뮤니티 참여 (버그 신고, 피드백)의 혜택을 받고 있었습니다. 웹 앱의 수익 창출 가능성도 놀라웠습니다.

Bouncy Mouse의 경우 게임 콘텐츠 옆에 표시되는 배너 광고라는 간단한 수익 창출 방법이 있습니다. 하지만 게임의 도달범위가 광범위하다는 점을 감안할 때 이 배너 광고는 상당한 수익을 창출할 수 있었으며, 성수기 동안 앱에서 가장 성공적인 플랫폼인 Android에 필적할 만한 수입을 창출했습니다. 한 가지 요인은 HTML5 버전에 게재되는 더 큰 애드센스 광고가 Android에 게재되는 작은 AdMob 광고보다 훨씬 더 높은 노출당 수익을 창출하기 때문입니다. 또한 HTML5 버전의 배너 광고는 Android 버전에 비해 방해가 덜 되므로 더욱 깔끔한 게임플레이 환경을 즐길 수 있습니다. 전반적으로 이런 결과에 매우 놀랐습니다.

시간 경과에 따른 정규화된 수입.
시간 경과에 따른 정규화된 수입

게임에서 발생한 수익은 예상보다 훨씬 높았지만 Chrome 웹 스토어의 도달범위는 여전히 Android 마켓과 같은 성숙한 플랫폼의 도달범위보다 작다는 점에 주목할 필요가 있습니다. Bouncy Mouse는 Chrome 웹 스토어에서 가장 인기 있는 게임 9위에 올랐지만 사이트 신규 사용자 비율은 초기 출시 이후 현저하게 감소했습니다. 그럼에도 불구하고 게임은 여전히 꾸준한 성장세를 보이고 있으며 이 플랫폼이 어떻게 발전할지 무척 기대됩니다.

결론

Bouncy 마우스를 Chrome으로 포팅하는 작업이 생각했던 것보다 훨씬 매끄러웠다고 생각합니다. 저는 사소한 오디오 및 성능 문제를 제외하고 Chrome이 기존의 스마트폰 게임에 완벽하게 어울리는 플랫폼이라는 것을 알게 되었습니다. 이번 기회를 꺼려했던 개발자라면 꼭 사용해 보시길 권하고 싶습니다. 저는 포팅 과정뿐 아니라 HTML5 게임을 통해 저를 연결시켜준 새로운 게임 시청자 모두에게 매우 만족했습니다. 궁금한 점이 있으면 언제든지 이메일로 보내주세요. 아니면 아래에 댓글을 남겨주시면 정기적으로 확인해 보겠습니다.