案例研究 - Bouncy Mouse

Eric Karl
Eric Karl

简介

弹跳鼠标

在去年年底在 iOS 和 Android 平台上发布《Bouncy Mouse》后,我学到了一些非常重要的教训。其中最重要的一点是,很难打入成熟的市场。在已经完全饱和的 iPhone 市场,吸引用户非常困难;在竞争相对较少的 Android 市场,取得进展虽然更容易,但也绝非易事。 有了这样的经历,我发现了 Chrome 应用商店中的一个有趣商机。虽然网店中并非没有游戏,但其优质的 HTML5 游戏目录才刚刚开始成熟。对于新手应用开发者来说,这意味着更容易进入排行榜并获得曝光度。考虑到这一机会,我开始将《弹跳鼠》移植到 HTML5,希望能为新用户群体提供最新的游戏体验。 在本案例研究中,我将简要介绍将 Bouncy Mouse 移植到 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 时,我非常谨慎,除非至少要使用某个帮助程序三次,否则绝不编写该帮助程序。在编写辅助类时,我会尽量使其整洁、可移植且可在未来的项目中重复使用。另一方面,在仅为弹跳式鼠标编写代码时,由于重复使用可能性较低,我的重点是尽可能简单快速地完成编码任务,即使这不是编写代码的“最美观”方式。为了缩减代码库的大小,第二个也是更重要的部分是尽可能将工作推到预处理步骤中。如果您能将运行时任务移至预处理任务,不仅游戏的运行速度会更快,您还无需将代码移植到每个新平台。 举个例子,我最初以未经处理的格式存储了关卡几何数据,并在运行时组装实际的 OpenGL/WebGL 顶点缓冲区。这需要进行一些设置,并编写几百行运行时代码。后来,我将此代码移到了预处理步骤,在编译时写出完全打包的 OpenGL/WebGL 顶点缓冲区。实际代码量大致相同,但这几百行代码已移至预处理步骤,这意味着我从未需要将它们移植到任何新平台。 Bouncy Mouse 中有很多这样的示例,具体可行情况因游戏而异,但请留意运行时不需要发生的任何操作。

不要添加不需要的依赖项

Bouncy Mouse 易于移植的另一个原因是,它几乎没有依赖项。下表总结了 Bouncy Mouse 在各个平台上的主要库依赖项:

Android iOS HTML5 WP7
图形 OpenGL ES OpenGL ES WebGL XNA
声音 OpenSL ES OpenAL Web Audio XNA
物理学 Box2D Box2D Box2D.js Box2D.xna

就这些了。除了可跨所有平台移植的 Box2D 之外,我们未使用任何大型第三方库。对于图形,WebGL 和 XNA 与 OpenGL 几乎一一对应,因此这不是一个大问题。只有在音效方面,实际库有所不同。不过,Bouncy Mouse 中的音效代码很少(大约 100 行平台专用代码),因此这并不是一个大问题。让 Bouncy Mouse 不使用大型不可移植库意味着,不同版本的运行时代码逻辑几乎相同(尽管语言会发生变化)。此外,它还可避免我们被锁定在不可移植的工具链中。有人问我,与使用 Cocos2DUnity 等库(也有一些 WebGL 辅助程序)相比,直接针对 OpenGL/WebGL 进行编码是否会增加复杂性。事实上,我认为恰恰相反。大多数手机 / HTML5 游戏(至少像“弹跳鼠”这样的游戏)都非常简单。在大多数情况下,游戏只会绘制一些精灵和一些纹理几何图形。Bouncy Mouse 中特定于 OpenGL 的代码总行数可能不到 1,000 行。如果使用辅助库实际上会减少此数量,我会感到惊讶。即使它能将此数值减半,我也需要花费大量时间学习新的库/工具,只为节省 500 行代码。此外,我还没有找到一个可在所有我感兴趣的平台上移植的辅助库,因此采用此类依赖项会严重影响可移植性。如果我要编写需要光照贴图、动态 LOD、贴图动画等的 3D 游戏,我的回答肯定会有所不同。在这种情况下,我需要重新发明轮子,尝试针对 OpenGL 手动编写整个引擎。我的意思是,大多数移动版/HTML5 游戏目前还不属于此类别,因此没必要在没有必要的情况下使事情复杂化。

不要低估不同语言之间的相似性

最后一个技巧是在将 C++ 代码库移植到新语言时节省了大量时间,那就是发现大多数代码在每种语言中几乎完全相同。虽然某些关键元素可能会发生变化,但不变的元素要多得多。事实上,对于许多函数,从 C++ 转换为 JavaScript 只需对我的 C++ 代码库运行一些正则表达式替换即可。

移植结论

以上就是整个移植流程。在接下来的几个部分中,我将介绍一些 HTML5 特有的挑战,但主要要传达的信息是,如果您保持代码简单,移植将只是一个小问题,而不是一个大问题。

音频

音频是我(以及似乎所有其他人)遇到的一个问题。iOS 和 Android 上提供了许多可靠的音频选项(OpenSL、OpenAL),但在 HTML5 世界中,情况似乎不太乐观。虽然可以使用 HTML5 音频,但我发现在游戏中使用时,它存在一些严重问题。即使在最新的浏览器上,我也经常遇到奇怪的行为。例如,Chrome 似乎对您可以同时创建的音频元素(source)数量有限制。此外,即使声音会播放,有时也会莫名其妙地失真。总的来说,我有点担心。 在网上搜索后,我发现几乎所有人都遇到了同样的问题。我最初找到的解决方案是名为 SoundManager2 的 API。此 API 会在可用时使用 HTML5 音频,在棘手的情况下回退到 Flash。虽然此解决方案可行,但仍存在 bug 且不可预测(与纯 HTML5 音频相比,不太稳定)。发布一周后,我与 Google 的几位乐于助人的员工进行了交流,他们向我介绍了 WebKit 的 Web Audio API。我原本考虑过使用此 API,但由于该 API 似乎存在大量对我来说不必要的复杂性,因此我最终放弃了使用该 API。我只想播放一些音效:使用 HTML5 音频只需几行 JavaScript 代码即可。 不过,在简要浏览 Web Audio 后,我惊讶地发现其规范非常庞大(70 页),网络上提供的示例很少(这对于新 API 来说很常见),并且规范中没有任何“播放”“暂停”或“停止”函数。Google 向我保证我的担心没有根据,于是我又深入研究了该 API。查看了更多示例并进行了一些研究后,我发现 Google 是对的:该 API 确实可以满足我的需求,而且不会出现困扰其他 API 的 bug。Web Audio API 入门一文特别实用,如果您想更深入地了解该 API,不妨参阅该文档。我遇到的真正问题是,即使在了解并使用该 API 后,我仍然认为该 API 并非旨在“仅播放一些音效”。为了解决这个疑虑,我编写了一个小型辅助类,让我能够按照自己的方式使用该 API,即播放、暂停、停止和查询音效的状态。我将此辅助类命名为 AudioClip。完整源代码在 GitHub 上以 Apache 2.0 许可的形式提供,我将在下文中介绍该类的详细信息。不过,首先我们来了解一下 Web Audio API 的背景知识:

Web 音频图表

使 Web Audio API 比 HTML5 Audio 元素更复杂(且更强大)的第一点是,它能够在将音频输出给用户之前处理 / 混音。虽然功能强大,但由于任何音频播放都涉及图表,因此在简单场景中,事情会变得更加复杂。为便于说明 Web Audio API 的强大功能,请考虑以下图表:

基本 Web Audio 图
基本 Web 音频图

虽然上面的示例展示了 Web Audio API 的强大功能,但在我的场景中,我不需要使用其中的大部分功能。我只是想播放一个声音。虽然这仍然需要图表,但图表非常简单。

图表可以简单明了

使 Web Audio API 比 HTML5 Audio 元素更复杂(且更强大)的第一点是,它能够在将音频输出给用户之前处理 / 混音。虽然功能强大,但由于任何音频播放都涉及图表,因此在简单场景中,事情会变得更加复杂。为便于说明 Web Audio API 的强大功能,请考虑以下图表:

简单的 Web 音频图
Trivial Web Audio Graph

上面显示的简单图表可以完成播放、暂停或停止声音所需的一切操作。

但我们根本不用担心图表

虽然了解图表很有用,但我不想每次播放音频时都处理这个问题。因此,我编写了一个简单的封装容器类“AudioClip”。此类在内部管理此图表,但提供的面向用户的 API 要简单得多。

AudioClip
AudioClip

这个类只不过是一个 Web Audio 图表和一些辅助状态,但与必须构建 Web Audio 图表来播放每个音效相比,我可以使用更简单的代码。

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

// Later
sound.play();

实现细节

我们来快速浏览一下辅助类的代码: 构造函数 - 构造函数使用 XHR 处理加载声音数据。虽然此处未显示(为使示例保持简单),但 HTML5 Audio 元素也可以用作源节点。这对于大型样本尤其有用。请注意,Web Audio API 要求我们以“arraybuffer”的形式提取此数据。收到数据后,我们会根据这些数据创建 Web Audio 缓冲区(将其从原始格式解码为运行时 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”版本。一个来源只能播放一次,因此我们每次播放时都必须重新创建来源/图表。 此函数的大部分复杂性来自于恢复暂停的剪辑 (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 端口后,我发现我的四核桌面设备上的所有内容都运行正常。遗憾的是,在上网本或 Chromebook 上,情况不太理想。在这种情况下,Chrome 的性能分析器帮了我大忙,它准确地显示了所有程序的运行时间都花在了哪里。 我的经验表明,在进行任何优化之前,进行性能分析非常重要。我原本以为 Box2D 物理引擎或渲染代码是导致速度变慢的主要原因;但实际上,大部分时间都花在了 Matrix.clone() 函数上。鉴于我的游戏具有大量数学运算的特性,我知道自己进行了大量矩阵创建/克隆操作,但我从未预料到这是瓶颈。最后,我们发现只需进行一项非常简单的更改,就能将游戏的 CPU 使用率降低 3 倍以上,从桌面设备上的 6-7% 降至 2%。 这对 JavaScript 开发者来说可能属于常识,但作为一名 C++ 开发者,我对此问题感到意外,因此我会详细说明一下。基本上,我原来的矩阵类是一个 3x3 矩阵:一个 3 个元素的数组,每个元素包含一个 3 个元素的数组。遗憾的是,这意味着在克隆矩阵时,我必须创建 4 个新数组。我只需进行一项更改,即将这些数据移至单个 9 个元素的数组中,并相应地更新我的数学运算。我发现 CPU 使用量减少了 3 倍,这完全归功于这一项更改。进行这项更改后,所有测试设备上的性能都达到了可接受的水平。

更多优化

虽然我的表现还算可以,但仍会出现一些小问题。经过进一步的性能分析,我发现这是由于 JavaScript 的垃圾回收造成的。我的应用以 60fps 的速度运行,这意味着每帧只有 16 毫秒的时间来绘制。遗憾的是,在速度较慢的机器上启动垃圾回收时,有时会耗费大约 10 毫秒的时间。这导致游戏每隔几秒就会出现卡顿,因为游戏几乎需要完整的 16 毫秒才能绘制完整的帧。为了更好地了解产生如此多垃圾的原因,我使用了 Chrome 的堆性能分析器。令我沮丧的是,事实证明,绝大多数垃圾(超过 70%)是由 Box2D 生成的。在 JavaScript 中清除垃圾是一项棘手的工作,而重写 Box2D 更是不可行,因此我意识到自己陷入了困境。幸运的是,我还有一个老办法可以用:如果无法达到 60fps,就以 30fps 运行。众所周知,以稳定的 30fps 运行远比以不稳定的 60fps 运行要好。事实上,我还没有收到任何关于游戏以 30fps 运行的投诉或评论(除非您并排比较两个版本,否则真的很难看出)。每帧额外增加 16 毫秒意味着,即使发生垃圾回收问题,我仍然有充足的时间来呈现帧。虽然我使用的计时 API(WebKit 出色的 requestAnimationFrame)并未明确启用 30fps 的运行速度,但可以通过非常简单的方式实现这一点。虽然可能不如显式 API 那样优雅,但只要知道 RequestAnimationFrame 的间隔时间与显示器的 VSYNC(通常为 60fps)保持一致,就可以实现 30fps。这意味着,我们只需忽略所有其他回调。基本上,如果您有一个回调“Tick”,每当“RequestAnimationFrame”触发时都会调用该回调,则可以按如下方式实现:

var skip = false;

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

// OTHER CODE
}

如果您想格外谨慎,应检查计算机的 VSYNC 在启动时是否已达到或低于 30fps,并在这种情况下停用跳过功能。不过,在我测试的任何桌面/笔记本电脑配置中,都没有出现过这种情况。

分发和创收

关于 Chrome 版 Bouncy Mouse,最后一个让我感到惊讶的方面是变现。在开始这项项目之前,我将 HTML5 游戏视为一种有趣的实验,旨在学习新兴技术。我没有想到的是,该游戏移植版本将覆盖非常庞大的受众群体,并且具有巨大的创收潜力。

Bouncy Mouse 于 10 月底在 Chrome 应用商店中发布。通过在 Chrome 应用商店中发布,我可以利用现有系统来提升可被发现性、社区互动度、排名等功能,这些功能都是我在移动平台上习以为常的。令我惊讶的是,该商店的覆盖面非常广。发布一个月后,我的应用安装量就达到了近四十万,并且已经从社区互动(bug 报告、反馈)中受益。让我感到惊讶的另一点是,网页应用的创收潜力。

Bouncy Mouse 采用了一种简单的创收方式,即在游戏内容旁边展示横幅广告。不过,鉴于该游戏的广泛覆盖面,我发现这款横幅广告能够带来可观的收入。在该应用的热门时期,其收入与最成功的平台 Android 相当。造成这种情况的一个原因是,与在 Android 设备上展示的较小 AdMob 广告相比,在 HTML5 版本中展示的较大 AdSense 广告每次展示所产生的收入要高得多。不仅如此,HTML5 版本的横幅广告比 Android 版本的横幅广告侵扰性要小得多,从而让玩家获得更清爽的游戏体验。总的来说,我对这个结果感到非常惊喜。

收入随时间的变化(经过标准化处理)。
一段时间内的收入(已归一化)

虽然该游戏的收入远超预期,但值得注意的是,Chrome 应用商店的覆盖面仍不及 Android 市场等成熟平台。虽然《弹跳鼠》能够迅速成为 Chrome 应用商店中第 9 热门游戏,但自首次发布以来,该网站的新用户增长率明显放缓。尽管如此,该游戏仍在稳步增长,我很期待看到该平台的未来发展!

总结

我认为,将 Bouncy Mouse 移植到 Chrome 的过程比我想象的要顺利得多。除了一些小音频和性能问题外,我发现 Chrome 是现有智能手机游戏的绝佳平台。我建议所有一直回避这种体验的开发者都尝试一下。我对移植过程以及通过 HTML5 游戏吸引到的全新游戏玩家群体都非常满意。 如果您有任何问题,欢迎随时给我发送电子邮件。或者,您也可以直接在下方留言,我会尽量定期查看这些留言。