ケーススタディ - Web Audio を使用した HTML5 ゲームの事例

Fieldrunners

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

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

Fieldrunners は効果音を複雑に使用しているわけではありませんが、効果音とのインタラクションについて、いくつかの想定があります。このゲームには 88 個の効果音があり、その多くが同時に再生されることが予想されます。これらのサウンドのほとんどは非常に短く、グラフィック表示とのずれが生じないように、できるだけタイムリーに再生する必要があります。

いくつかの課題が発生

Fieldrunners を HTML5 に移植する際、Audio タグを使用した音声再生で問題が発生したため、早い段階で Web Audio API に切り替えることにしました。WebAudio を使用することで、Fieldrunners で必要とされる、多数の同時エフェクトの再生などの問題を解決できました。それでも、Fieldrunners HTML5 のオーディオ システムを開発する際に、他のデベロッパーが注意すべき微妙な問題がいくつかありました。

AudioBufferSourceNode の性質

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

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

リリース時、Fieldrunners HTML5 サーバーには音楽ファイルに対する大量のリクエストが寄せられました。この結果は、Chrome 15 がファイルをチャンク単位でダウンロードし、キャッシュに保存しなかったために発生しました。これを受けて、音楽ファイルも他の音声ファイルと同様に読み込むことにしました。これは最適ではありませんが、他のブラウザの一部のバージョンでは、まだこの方法が使用されています。

フォーカスが外れたときにミュートする

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

音を一時停止しています

奇妙なことに、この記事へのフィードバックで、音声を一時停止するために使用していた手法が適切ではないことがわかりました。Web Audio の現在の実装のバグを利用して、音声の再生を一時停止していたのです。この問題は将来的に修正される予定であるため、ノードまたはサブグラフを切断して再生を停止するだけでは、音声を一時停止することはできません。

シンプルな Web Audio ノード アーキテクチャ

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

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

Web Audio で上記の機能を実現するために、提供されているノードのうち、DestinationNode、GainNode、AudioBufferSourceNode の 3 つを使用しました。AudioBufferSourceNode が音声を再生します。GainNode は AudioBufferSourceNode を相互に接続します。Web Audio コンテキストによって作成された DestinationNode(destination)は、プレーヤーの音声を再生します。Web Audio には他にも多くの種類のノードがありますが、これらだけでゲーム内のサウンド用の非常にシンプルなグラフを作成できます。

ノードグラフのグラフ

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

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

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 );
}

ほとんどのゲームでは、効果音と音楽を別々にコントロールできます。これは、上記のグラフを使用すると簡単に実現できます。各ゲインノードには、0 ~ 1 の任意の小数値に設定できる「ゲイン」属性があり、これを使用して音量を制御できます。音楽チャンネルと効果音チャンネルの音量を別々に制御したいので、それぞれの音量を制御できるゲインノードを用意しています。

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

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

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

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

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

音を一時停止しています

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

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

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

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

Fieldrunners のリリース後、ノードまたはサブグラフを切断するだけでは AudioBufferSourceNode の再生を一時停止できないことがわかりました。実際には、グラフ内の Destination ノードに接続されていないノードの再生を停止する 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 のノードグラフ(再生をショートアウトするためのノードを作成したため)と、Web Audio が単独では行わない一時停止状態を記録して提供する追加コードの両方に変更を加える必要があります。

フォーカスを失う

この機能では、マスターノードが使用されます。ブラウザのユーザーが別のタブに切り替えると、ゲームは表示されなくなります。視界から消えると、記憶からも消える。音も消えるべき。ゲームのページの特定の公開状態を判断するために使用できるトリックはありますが、Visibility API を使用すると、はるかに簡単になります。

Fieldrunners は、requestAnimationFrame を使用して更新ループを呼び出すため、アクティブなタブとしてのみ再生されます。ただし、ユーザーが別のタブに移動しても、Web Audio コンテキストはループ エフェクトとバックグラウンド トラックを再生し続けます。しかし、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 のオーディオ設計に関する決定です。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 タグを使用して大きな音声ファイルをストリーミングするのが最適ですが、一部のブラウザ バージョンでは、効果音を読み込むのと同じ方法で音楽を読み込むことが必要になる場合があります。

すべての効果音が Web Audio で再生されていたため、BGM の再生も Web 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 回限りのオブジェクトです。作成して、AudioBuffer をアタッチし、ウェブ オーディオ グラフに接続して、noteOn または noteGrainOn で再生します。もう一度音を鳴らす必要がある場合は、次に、別の AudioBufferSourceNode を作成します。