사례 연구 - 웹 오디오를 사용하는 HTML5 게임 이야기

필드러너

Fieldrunners 스크린샷
Fieldrunners 스크린샷

Fieldrunners는 원래 2008년에 iPhone용으로 출시된 타워 디펜스 스타일의 게임으로 수상 경력을 자랑합니다. 이후 다른 많은 플랫폼으로 이전되었습니다. 최신 플랫폼 중 하나는 2011년 10월에 Chrome 브라우저였습니다. Fieldrunners를 HTML5 플랫폼으로 포팅할 때 직면하는 문제 중 하나는 사운드를 재생하는 것이었습니다.

Fieldrunners는 음향 효과를 복잡하게 사용하지 않지만 음향 효과와 상호작용할 수 있는 방식에 대한 기대가 수반됩니다. 게임에는 88개의 음향 효과가 있으며 한 번에 많은 사람이 플레이할 것으로 기대할 수 있습니다. 이러한 사운드는 대부분 매우 짧으며 그래픽 표현과 연결이 끊기지 않도록 가능한 한 적시에 재생해야 합니다.

일부 발견된 문제

Fieldrunners를 HTML5로 포팅하는 동안 오디오 태그를 사용한 오디오 재생에 문제가 발생해 초기에 Web Audio API에 초점을 맞추기로 했습니다. WebAudio를 사용하면서 Fieldrunners에 필요한 많은 수의 동시 이펙트를 재생하는 등의 문제를 해결할 수 있었습니다. 하지만 Fieldrunners HTML5용 오디오 시스템을 개발하는 과정에서 다른 개발자들이 알고 싶어 하는 몇 가지 미묘한 문제가 발생했습니다.

AudioBufferSourceNode의 특성

AudioBufferSourceNode는 WebAudio로 사운드를 재생하는 기본 메서드입니다. 이러한 객체는 일회용 객체라는 점을 이해하는 것이 매우 중요합니다. AudioBufferSourceNode를 만들고, 버퍼를 할당하고, 그래프에 연결하고, noteOn 또는 noteGrainOn을 사용하여 이를 재생합니다. 그런 다음 noteOff를 호출하여 재생을 중지할 수 있지만, noteOn 또는 noteGrainOn을 호출하여 소스를 다시 재생할 수 없습니다. 다른 AudioBufferSourceNode를 만들어야 합니다. 동일한 기본 AudioBuffer 객체를 재사용할 수 있으며, 이것이 핵심입니다. 그러나 사실 동일한 AudioBuffer 인스턴스를 가리키는 여러 활성 AudioBufferSourceNode를 보유할 수도 있습니다. Choose Me a Beat에서 Fieldrunners의 재생 스니펫을 찾을 수 있습니다.

캐싱하지 않는 콘텐츠

출시 당시 Fieldrunners HTML5 서버는 음악 파일에 대한 엄청난 수의 요청을 확인했습니다. 이 결과는 Chrome 15에서 파일을 청크 단위로 다운로드한 다음 캐시하지 않고 계속 진행하면서 발생했습니다. 이에 따라 Google은 나머지 오디오 파일과 마찬가지로 음악 파일을 로드하기로 결정했습니다. 이렇게 하는 것은 최적화되지는 않지만 다른 브라우저 일부 버전에서는 여전히 이 작업을 수행합니다.

초점이 맞지 않을 때 음소거

이전에는 게임 탭의 초점이 맞지 않는 경우를 감지하는 것이 어려웠습니다. Chrome 13 이전의 Fieldrunners 포팅은 Page Visibility API 덕분에 탭 블러를 감지하는 복잡한 코드가 필요 없게 되었습니다. 모든 게임은 전체 게임을 일시중지하지 않는 경우 소리를 음소거하거나 일시중지하는 작은 스니펫을 작성하기 위해 Visibility API를 사용해야 합니다. Fieldrunners에서 requestAnimationFrame API를 사용했으므로 게임 일시중지가 암시적으로 처리되었지만 사운드 일시중지는 처리되지 않았습니다.

알림음 일시중지

이상하게도 이 기사에 대한 의견을 받는 동안 Google에서 사운드 일시중지에 사용한 기술이 적절하지 않다는 사실을 알게 되었습니다. 사운드 재생을 일시중지하기 위해 웹 오디오의 현재 구현에서 버그를 활용하고 있었습니다. 이 문제는 향후 수정될 예정이므로 재생을 중단하기 위해 노드 또는 하위 그래프의 연결을 해제하여 사운드를 일시 중지할 수는 없습니다.

간단한 웹 오디오 노드 아키텍처

Fieldrunners에는 매우 간단한 오디오 모델이 있습니다. 이 모델은 다음 특성 세트를 지원할 수 있습니다.

  • 음향 효과의 볼륨을 조절합니다.
  • 배경 음악 트랙의 볼륨을 조절합니다.
  • 모든 오디오를 음소거합니다.
  • 게임이 일시중지될 때 소리 재생 기능을 끕니다.
  • 게임이 다시 시작되면 동일한 사운드를 다시 켤 수 있습니다.
  • 게임 탭에서 포커스가 사라지면 모든 오디오를 끕니다.
  • 필요에 따라 소리가 재생된 후 재생을 다시 시작합니다.

웹 오디오로 위 기능을 구현하기 위해 사용 가능한 노드 중 3개(DestinationNode, GainNode, AudioBufferSourceNode)를 사용했습니다. AudioBufferSourceNode가 사운드를 재생합니다. GainNode는 AudioBufferSourceNode를 함께 연결합니다. 대상이라고 하는 웹 오디오 컨텍스트에 의해 생성된 DestinationNode는 플레이어를 위해 사운드를 재생합니다. 웹 오디오에는 더 많은 유형의 노드가 있지만 이 노드만 있으면 게임의 사운드에 대한 매우 간단한 그래프를 만들 수 있습니다.

노드 그래프 차트

웹 오디오 노드 그래프는 리프 노드에서 대상 노드로 연결됩니다. Fieldrunners는 6개의 영구 게인 노드를 사용했지만, 3개로도 볼륨을 쉽게 제어하고 재생을 버퍼링할 더 많은 임시 노드를 연결할 수 있습니다. 먼저 마스터 게인 노드가 모든 하위 노드를 대상에 연결합니다. 마스터 게인 노드에는 즉시 연결된 두 개의 게인 노드, 즉 음악 채널용 노드와 모든 음향 효과를 연결하는 노드입니다.

버그를 기능으로 잘못 사용하여 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 );
}

대부분의 게임에서 음향 효과와 음악을 별도로 제어할 수 있습니다. 위의 그래프로 이를 쉽게 파악할 수 있습니다. 각 게인 노드에는 0과 1 사이의 십진수로 설정할 수 있는 "게인" 속성이 있으며, 이 속성은 본질적으로 볼륨을 제어하는 데 사용할 수 있습니다. 음악과 음향 효과 채널의 볼륨을 개별적으로 제어하려고 하므로 볼륨을 제어할 수 있는 각 채널의 게인 노드가 있습니다.

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

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

이 기능을 사용하여 음향 효과와 음악의 모든 볼륨을 제어할 수 있습니다. 마스터 노드의 게인을 설정하면 게임의 모든 사운드에 영향을 미칩니다. 게인 값을 0으로 설정하면 사운드와 음악이 음소거됩니다. AudioBufferSourceNode에는 게인 매개변수도 있습니다. 재생 중인 모든 사운드의 목록을 추적하고 전체 볼륨에 맞게 게인 값을 개별적으로 조정할 수 있습니다. 오디오 태그로 음향 효과를 만들고 있다면 이렇게 해야 합니다. 대신 웹 오디오의 노드 그래프를 사용하면 수많은 소리의 볼륨을 훨씬 쉽게 수정할 수 있습니다. 이런 식으로 볼륨을 제어하면 정보 표시 없이 추가적인 전원을 켤 수 있습니다. 음악을 재생하기 위해 AudioBufferSourceNode를 마스터 노드에 직접 연결하고 자체 게인을 제어하기만 하면 됩니다. 그러나 음악을 재생하기 위해 AudioBufferSourceNode를 만들 때마다 이 값을 설정해야 합니다. 대신 플레이어가 음악 볼륨을 변경할 때와 실행할 때에만 노드를 변경합니다. 이제 다른 작업을 할 수 있도록 버퍼 소스에 게인 값이 있습니다. 음악의 경우 일반적으로 한 오디오 트랙에서 다른 오디오 트랙이 나갈 때 다른 오디오 트랙으로 크로스 페이드를 만드는 데 사용할 수 있습니다. 웹 오디오는 이를 쉽게 수행할 수 있는 좋은 방법을 제공합니다.

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

이 사실을 이전에 알았다면 버그가 악용된다는 사실을 알았다면 오디오 코드의 구조가 매우 달라졌을 것입니다. 따라서 이 도움말의 여러 섹션에 영향을 미쳤습니다. 이는 직접적인 영향을 주지만, Losing Focus and Give Me a Beat의 코드 스니펫에서도 마찬가지입니다. 이것이 실제로 어떻게 작동하는지 이해하려면 (재생을 짧게 하기 위해 노드를 만들었기 때문에) 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는 캐릭터가 죽어가는 등 게임 개체의 여러 인스턴스에서 사운드 사본을 여러 개 재생하는 대신 재생 시간 동안 한 번만 사운드를 재생합니다. 재생이 완료된 후에 소리가 필요한 경우 다시 시작할 수 있지만 이미 재생 중일 때는 다시 시작할 수 없습니다. 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는 원래 오디오 태그로 재생되는 배경 음악으로 출시되었습니다. 출시 시점에 음악 파일이 나머지 게임 콘텐츠가 요청된 횟수에 비해 과도하게 많이 요청되고 있는 것으로 확인되었습니다. 조사 결과, 당시 Chrome 브라우저가 음악 파일의 스트리밍 청크를 캐시하지 않았다는 사실을 발견했습니다. 그 결과 브라우저에서 트랙이 끝날 때마다 몇 분마다 트랙을 요청합니다. 최근 테스트에서 Chrome은 스트리밍된 트랙을 캐시했지만 다른 브라우저에서는 아직 이 작업을 수행하지 않을 수 있습니다. 음악 재생과 같은 기능에 대한 오디오 태그를 사용하여 대용량 오디오 파일을 스트리밍하는 것이 최적이지만 일부 브라우저 버전에서는 음향 효과를 로드하는 것과 동일한 방식으로 음악을 로드하는 것이 좋습니다.

모든 음향 효과가 웹 오디오를 통해 재생되기 때문에 배경 음악 재생도 웹 오디오로 옮겼습니다. 즉, XMLHttpRequests와 배열 버퍼 응답 유형으로 모든 효과를 로드한 것과 같은 방식으로 트랙을 로드합니다.

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++ 줄을 자바스크립트에 가져오는 자체적인 작업 외에도 HTML5와 관련된 몇 가지 흥미로운 딜레마와 결정이 야기됩니다. 둘 중 하나가 없는 경우 이 중 하나를 반복하기 위해 AudioBufferSourceNode는 일회용 객체입니다. 오디오 버퍼를 만들고 연결하여 웹 오디오 그래프에 연결한 다음 noteOn 또는 noteGrainOn을 사용하여 재생합니다. 이 소리를 다시 재생하시겠습니까? 그런 다음 또 다른 AudioBufferSourceNode를 만듭니다.