事例紹介 - ウェブ音声を使用した HTML5 ゲームの話

フィールドランナー

Fieldrunners のスクリーンショット
Fieldrunners のスクリーンショット

Fieldrunners は、受賞歴のあるタワーディフェンス スタイルのゲームで、2008 年に iPhone 向けにリリースされました。それ以来、他の多くのプラットフォームに移植されています。最新のプラットフォームの一つは、2011 年 10 月にリリースされた Chrome ブラウザです。Fieldrunners を HTML5 プラットフォームに移植する際の課題の一つは、音声の再生方法でした。

Fieldrunners では、効果音を複雑に使用していませんが、効果音とどう相互作用するかについてある程度の期待が伴います。このゲームには 88 種類の効果音があり、そのうちの多数の効果音を一度に再生できます。これらのサウンドのほとんどは非常に短いため、できるだけ適切なタイミングで再生して、グラフィカルな表示との間にずれが生じないようにする必要があります。

直面した課題

Fieldrunners を HTML5 に移植するにあたり、Audio タグを使用した音声再生で問題が発生するため、早い段階で Web Audio API に注力することにしました。WebAudio を使用することで、Fieldrunners に必要な大量の同時エフェクト再生などの問題を解決できました。しかし、Fieldrunners HTML5 用のオーディオ システムの開発中に、他のデベロッパーが知っておくべきと思われる微妙な問題に直面しました。

AudioBufferSourceNode の性質

AudioBufferSourceNode は、WebAudio で音声を再生する主要な手段です。これらは 1 回限りのオブジェクトであることを理解しておくことが非常に重要です。AudioBufferSourceNode を作成してバッファを割り当て、グラフに接続し、noteOn または noteGrainOn で再生します。その後、noteOff を呼び出して再生を停止できますが、noteOn または noteGrainOn を呼び出してソースを再度再生することはできません。別の AudioBufferSourceNode を作成する必要があります。ただし、基盤となる同じ AudioBuffer オブジェクトを再利用することはできます(実際、同じ AudioBuffer インスタンスを指す複数のアクティブな AudioBufferSourceNode を使用することもできます)。Fieldrunners の再生スニペットは「Give Me a Beat」で確認できます。

キャッシュされないコンテンツ

リリース時点では、Fieldrunners HTML5 サーバーは音楽ファイルに対する大量のリクエストを示しました。これは、Chrome 15 でファイルをチャンク形式でダウンロードし、その後キャッシュに保存しないことに起因しています。これに応えて、私たちは他の音声ファイルと同じように音楽ファイルを読み込むことにしました。この対応は最適とは言えませんが、他のブラウザの一部のバージョンでは依然としてこの方法で実現されています。

フォーカスが合っていないときに消音にする

以前は、ゲームのタブがフォーカスされていないときに検出するのは困難でした。Fieldrunners は Chrome 13 より前から移植を開始しました。これにより、タブのぼかしを検出するために複雑なコードを使用する必要がなくなり、Page Visibility API が導入されました。すべてのゲームで、Visibility API を使用して、ゲーム全体を一時停止しない場合にサウンドをミュートまたは一時停止するための小さなスニペットを作成する必要があります。Fieldrunners は requestAnimationFrame API を使用していたため、ゲームの一時停止は暗黙的に処理されましたが、サウンドの一時停止は行われませんでした。

サウンドを一時停止する

奇妙なことに、この記事に対するフィードバックから、サウンドの一時停止に使用している手法が不適切であるという通知がありました。Web Audio の現在の実装のバグを利用して、サウンドの再生を一時停止していました。この問題は今後修正されるため、ノードやサブグラフを切断して再生を停止するだけでは、音声を一時停止することはできません。

シンプルなウェブ オーディオ ノード アーキテクチャ

Fieldrunners のオーディオ モデルは非常にシンプルなものです。このモデルは、次の機能セットをサポートできます。

  • 効果音の音量を調節する。
  • BGM トラックの音量を調節する。
  • すべての音声をミュートする。
  • ゲームを一時停止したときにサウンド再生をオフにします。
  • ゲームを再開したら、同じサウンドを再びオンにします。
  • ゲームのタブがフォーカスを喪失したときにすべての音声をオフにします。
  • 必要に応じて、音声が再生されたら再生を再開します。

ウェブ オーディオで上記の機能を実現するには、使用可能なノードのうち DestinationNode、GainNode、AudioBufferSourceNode のうちの 3 つを使用しました。AudioBufferSourceNode がサウンドを再生します。GainNode は AudioBufferSourceNode をつなぎます。デスティネーションと呼ばれるウェブ オーディオ コンテキストによって作成された DestinationNode は、プレーヤーのサウンドを再生します。ウェブ オーディオには他にも多くの種類のノードがありますが、これらのノードだけでも、ゲーム内の音声の非常にシンプルなグラフを作成できます。

ノードグラフのグラフ

リーフノードからデスティネーション ノードへと続くウェブ オーディオ ノードグラフ。Fieldrunners は 6 つの永続的なゲインノードを使用していましたが、ボリュームを簡単に制御でき、バッファを再生する多数の一時ノードを接続するには 3 つあれば十分です。まず、すべての子ノードをデスティネーションにアタッチするマスター ゲイン ノード。マスター ゲイン ノードにすぐに接続されているのは 2 つのゲインノードです。1 つは音楽チャンネル用、もう 1 つはすべての効果音をリンクします。

Fieldrunners には、バグを機能として誤って使用したため、3 つのゲインノードがありました。これらのノードを使用して、グラフからサウンド再生のグループを切り取って、進行を止めました。これは、サウンドを一時停止するためです。正しくないため、上記のように合計 3 つのゲインノードのみを使用することになります。以下のスニペットの多くには間違ったノードが含まれ、実行内容と短期的な修正方法が示されています。しかし長期的には、coreEffectsGain ノードの後にこのノードを使用しない方がよいでしょう。

function AudioManager() {
  // map for loaded sounds
  this.sounds = {};

  // create our permanent nodes
  this.nodes = {
    destination: this.audioContext.destination,
    masterGain: this.audioContext.createGain(),

    backgroundMusicGain: this.audioContext.createGain(),

    coreEffectsGain: this.audioContext.createGain(),
    effectsGain: this.audioContext.createGain(),
    pausedEffectsGain: this.audioContext.createGain()
  };

  // and setup the graph
  this.nodes.masterGain.connect( this.nodes.destination );

  this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );

  this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
  this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}

ほとんどのゲームでは、効果音と音楽を別々に制御できます。これは、上のグラフで簡単に実現できます。各ゲインノードには「gain」属性があり、0 ~ 1 の 10 進値に設定でき、基本的に音量の制御に使用できます。音楽チャンネルと効果音チャンネルの音量を個別に制御するには、音量を制御できる各チャンネルにゲイン ノードがあります。

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

同じ機能を使用して、効果音や音楽のあらゆる音量を調整できます。マスターノードのゲインを設定すると、ゲームのすべてのサウンドに影響します。ゲイン値を 0 に設定すると、音声と音楽がミュートされます。 AudioBufferSourceNode にもゲイン パラメータがあります。再生中のすべてのサウンドのリストを追跡し、全体的な音量のゲイン値を個別に調整できます。音声タグを使用して効果音を作成している場合は、これを行います。代わりに、ウェブ オーディオのノードグラフを使用すると、無数のサウンドの音量を簡単に調整できます。 この方法で音量を調整することで、複雑な操作を行わずに音量を調整できます。AudioBufferSourceNode をマスターノードに直接接続して音楽を再生し、自身のゲインを制御するだけで済みます。ただし、音楽を再生するために AudioBufferSourceNode を作成するたびに、この値を設定する必要があります。代わりに、プレーヤーが音楽の音量を変更したときと起動時にのみ、1 つのノードを変更します。これで、他の処理を行うためのバッファソースのゲイン値を取得できました。音楽の場合は、ある音声トラックから別の音声トラックへのクロスフェードを、1 つの音声トラックがリーブされ、別のオーディオ トラックが入ってくるときに作成することが一般的です。ウェブ オーディオは、これを簡単に行うための優れた方法となります。

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

Fieldrunners では、特にクロスフェードを利用していませんでした。サウンド システムの初期パス時に WebAudio の価値設定機能について知っていたら、おそらく実現したでしょう。

サウンドの一時停止

プレーヤーがゲームを一時停止しても、一部のサウンドが引き続き再生されることを想定できる。音声は、ゲームメニューのユーザー インターフェース要素を操作する際のフィードバックの重要な要素です。Fieldrunners には、ゲームが一時停止している間にユーザーが操作できるインターフェースが多数あるため、引き続きプレイする必要があります。ただし、長いサウンドやループするサウンドの再生は望ましくありません。ウェブ オーディオを使用すれば、簡単に音を止めることができます。少なくとも、私たちはそう考えていました。

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();
}

一時停止中のエフェクトのノードはまだ接続されています。ゲームの一時停止状態を無視できるサウンドは、一時停止状態でも引き続き再生されます。ゲームの一時停止が解除されると、これらのノードに再接続でき、すべてのサウンドがすぐに再生されるようになります。

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

Fieldrunners のリリース後、ノードやサブグラフを切断するだけでは AudioBufferSourceNode の再生は一時停止しないことがわかりました。実際に利用したのは WebAudio のバグで、宛先ノードに接続していないノードの再生がグラフで停止するようになっています。そのため、今後の修正に備えるために、次のようなコードが必要になります。

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();

  var now = Date.now();
  for ( var name in this.sounds ) {
    var sound = this.sounds[ name ];

    if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
      sound.pausedAt = now - sound.source.noteOnAt;
      sound.source.noteOff();
    }
  }
}

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );

  var now = Date.now();
  for ( var name in this.sounds ) {
    if ( sound.pausedAt ) {
      this.play( sound.name );
      delete sound.pausedAt;
    }
  }
};

バグを悪用していたことを先ほどわかっていれば、オーディオ コードの構造はまったく異なるものになっていたでしょう。そのため、この記事の多くのセクションに影響が生じています。これは直接的な効果があるだけでなく、「集中力の喪失」や「ビートを起こす」のコード スニペットにも直接的な影響をもたらしています。実際にこれがどのように機能するかを知るには、Fieldrunners ノードグラフ(再生をショートするためのノードを作成したため)と、ウェブ オーディオ自体では行わない一時停止状態を記録して提供する追加のコードの両方を変更する必要があります。

集中力の低下

そのためには、マスターノードが役立ちます。ブラウザのユーザーが別のタブに切り替えると、ゲームが表示されなくなります。目に見えないし、心から離れて、そして音も消えるべきなのです。ゲームのページの特定の公開状態を判断するためのコツもありますが、Visibility API を使用すると、はるかに簡単に公開できます。

更新ループの呼び出しに requestAnimationFrame を使用するため、Fieldrunners はアクティブなタブとしてのみ再生されます。ただし、ユーザーが別のタブを開いている間も、ウェブ オーディオのコンテキストではループ エフェクトとバックグラウンド トラックが再生されます。しかし、非常に小さな Visibility API 対応スニペットで阻止できます。

function AudioManager() {
  // map and node setup
  // ...

  // disable all sound when on other tabs
  var self = this;
  window.addEventListener( 'webkitvisibilitychange', function( e ) {
    if ( document.webkitHidden ) {
      self.nodes.masterGain.disconnect();

      // As noted in Pausing Sounds disconnecting isn't enough.
      // For Fieldrunners calling our new pauseEffects method would be
      // enough to accomplish that, though we may still need some logic
      // to not resume if already paused.
      self.pauseEffects();
    } else {
      self.nodes.masterGain.connect( this.nodes.destination );
      self.resumeEffects();
    }
  });
}

この記事を書く前に、すべての音声をミュートするのではなく、マスターの接続を解除するだけですべての音声を一時停止できると考えていました。その時点でノードを切断することで、ノードとその子ノードの処理と再生を停止しました。再接続されると、ゲームが中断したところから再生が続行されるのと同様に、すべてのサウンドと音楽が中断したところから再生されるようになりました。これは予期しない動作です。単に接続を解除して再生を停止するだけでは不十分です。

Page Visibility API を使用すると、タブがフォーカスされなくなったことが簡単にわかります。サウンドを一時停止する効果的なコードがすでにある場合は、ゲームタブが非表示のときにサウンドの一時停止機能を記述するために数行を使用するだけです。

ビートを刻もう

いくつかの設定が完了しました。ここにはノードのグラフがありますプレーヤーがゲームを一時停止したときにサウンドを一時停止したり、ゲームメニューなどの要素の新しいサウンドを再生したりできます。ユーザーが新しいタブに切り替えたときに、すべてのサウンドと音楽を一時停止できます。次に、実際に音を鳴らす必要があります。

Fieldrunners では、キャラクターの死など、ゲーム エンティティの複数のインスタンスに対してサウンドのコピーを複数再生するのではなく、一定期間に 1 つのサウンドを 1 回だけ再生します。再生終了後に音声が必要な場合は、再生を再開することはできますが、すでに再生している間は再開できません。これは Fieldrunners のオーディオ デザインに関する決定です。すぐに再生するようにリクエストされるサウンドがあるため、再起動を許可した場合にスタッタリングしたり、複数のインスタンスを再生できると楽しめないカコフォニーが発生したりする可能性があります。AudioBufferSourceNode はワンショットとして使用されることが想定されています。ノードを作成してバッファをアタッチし、必要に応じてループのブール値を設定し、デスティネーションにつながるグラフ上のノードに接続し、noteOn または noteGrainOn を呼び出し、必要に応じて noteOff を呼び出します。

Fieldrunners では、次のようになります。

AudioManager.prototype.play = function( options ) {
  var now = Date.now(),
    // pull from a map of loaded audio buffers
    sound = this.sounds[ options.name ],
    channel,
    source,
    resumeSource;

  if ( !sound ) {
    return;
  }

  if ( sound.source ) {
    var source = sound.source;
    if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
      // discard the previous source node
      source.stop( 0 );
      source.disconnect();
    } else {
      return;
    }
  }

  source = this.audioContext.createBufferSource();
  sound.source = source;
  // track when the source is started to know if it should still be playing
  source.noteOnAt = now;

  // help with pausing
  sound.ignorePause = !!options.ignorePause;

  if ( options.ignorePause ) {
    channel = this.nodes.pausedEffectsGain;
  } else {
    channel = this.nodes.effectsGain;
  }

  source.buffer = sound.buffer;
  source.connect( channel );
  source.loop = options.loop || false;

  // Fieldrunners' current code doesn't consider sound.pausedAt.
  // This is an added section to assist the new pausing code.
  if ( sound.pausedAt ) {
    source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
    source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;

    // if you needed to precisely stop sounds, you'd want to store this
    resumeSource = this.audioContext.createBufferSource();
    resumeSource.buffer = sound.buffer;
    resumeSource.connect( channel );
    resumeSource.start(
      0,
      sound.pausedAt,
      sound.buffer.duration - sound.pausedAt / 1000
    );
  } else {
    // start play immediately with a value of 0 or less
    source.start( 0 );
  }
}

ストリーミングが多すぎる

Fieldrunners は元々、Audio タグで再生されるバックグラウンド ミュージックでリリースされていました。リリース時に、音楽ファイルのリクエスト回数が、ゲームの他のコンテンツのリクエスト回数に対して不釣り合いだったことが判明しました。調査の結果、その時点で Chrome ブラウザが音楽ファイルのストリーミング チャンクをキャッシュしていなかったことが判明しました。その結果、終了後に数分ごとに再生トラックがリクエストされることになります。最近のテストでは、Chrome はストリーミング トラックをキャッシュに保存しましたが、他のブラウザではまだキャッシュされていない可能性があります。音楽の再生などの機能には、Audio タグを使用してサイズの大きい音声ファイルをストリーミングすることをおすすめします。ただし、ブラウザ バージョンによっては、効果音を読み込むのと同じ方法で音楽を読み込むこともできます。

すべての効果音がウェブ オーディオで再生されていたため、バックグラウンド ミュージックの再生もウェブ オーディオに移行しました。つまり、XMLHttpRequests と arraybuffer レスポンス タイプですべてのエフェクトを読み込んだのと同じ方法で、トラックを読み込みます。

AudioManager.prototype.load = function( options ) {
  var xhr,
      // pull from a map of name, object pairs
      sound = this.sounds[ options.name ];

  if ( sound ) {
    // this is a great spot to add success methods to a list or use promises
    // for handling the load event or call success if already loaded
    if ( sound.buffer && options.success ) {
      options.success( options.name );
    } else if ( options.success ) {
      sound.success.push( options.success );
    }

    // one buffer is enough so shortcut here
    return;
  }

  sound = {
    name: options.name,
    buffer: null,
    source: null,
    success: ( options.success ? [ options.success ] : [] )
  };
  this.sounds[ options.name ] = sound;

  xhr = new XMLHttpRequest();
  xhr.open( 'GET', options.path, true );
  xhr.responseType = 'arraybuffer';
  xhr.onload = function( e ) {
    sound.buffer = self._context.createBuffer( xhr.response, false );

    // call all waiting handlers
    sound.success.forEach( function( success ) {
      success( sound.name );
    });
    delete sound.success;
  };
  xhr.onerror = function( e ) {

    // failures are uncommon but you want to do deal with them

  };
  xhr.send();
}

まとめ

Fieldrunners では、Chrome と HTML5 で大活躍しました。何千もの C++ 行を JavaScript に取り込むという膨大な作業のほかにも、HTML5 に固有の興味深いジレンマや決定が引き起こされています。AudioBufferSourceNode は 1 回限りのオブジェクトで、他ではどれも繰り返されない場合にこれを繰り返します。オーディオ バッファを作成してオーディオ バッファをアタッチし、ウェブ オーディオ グラフに接続して、noteOn または noteGrainOn で再生します。その音をもう一度再生しますか?次に、別の AudioBufferSourceNode を作成します。