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

Fieldrunners

Fieldrunners 스크린샷
Fieldrunners 스크린샷

Fieldrunners는 수상 경력이 있는 타워 디펜스 스타일 게임으로 2008년에 iPhone용으로 처음 출시되었습니다. 이후 여러 다른 플랫폼으로 포팅되었습니다. 가장 최근 플랫폼 중 하나는 2011년 10월의 Chrome 브라우저였습니다. Fieldrunners를 HTML5 플랫폼으로 포팅할 때의 한 가지 문제는 사운드를 재생하는 방법이었습니다.

Fieldrunners는 음향 효과를 복잡하게 사용하지 않지만 음향 효과와 상호작용하는 방식에 대한 몇 가지 기대치가 있습니다. 이 게임에는 88개의 음향 효과가 있으며 한 번에 많은 수의 음향 효과가 재생될 수 있습니다. 이러한 사운드의 대부분은 매우 짧으며 그래픽 프레젠테이션과의 연결이 끊어지지 않도록 최대한 시의적절하게 재생되어야 합니다.

일부 챌린지가 표시됨

Fieldrunners를 HTML5로 포팅하는 과정에서 Audio 태그를 사용한 오디오 재생 문제가 발생하여 초기에 Web Audio API에 집중하기로 결정했습니다. WebAudio를 사용하면 Fieldrunners에 필요한 다수의 동시 재생 효과를 제공하는 등의 문제를 해결할 수 있었습니다. 하지만 Fieldrunners HTML5용 오디오 시스템을 개발하는 과정에서 다른 개발자가 알아두면 좋을 몇 가지 미묘한 문제가 발생했습니다.

AudioBufferSourceNodes의 특성

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

캐시되지 않는 콘텐츠

출시 시 Fieldrunners HTML5 서버에 음악 파일 요청이 대량으로 표시되었습니다. 이 결과는 Chrome 15에서 파일을 청크 단위로 다운로드한 후 캐시하지 않았기 때문에 발생했습니다. 이에 따라 YouTube는 다른 오디오 파일과 마찬가지로 음악 파일을 로드하기로 결정했습니다. 이렇게 하는 것은 최적의 방법은 아니지만 일부 다른 브라우저 버전에서는 여전히 이렇게 합니다.

초점이 맞지 않으면 음소거

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

소리 일시중지

이상하게도 이 도움말에 대한 의견을 수렴하는 과정에서 소리를 일시중지하는 데 사용하고 있는 기법이 적절하지 않다는 지적을 받았습니다. 소리 재생을 일시중지하기 위해 Web Audio의 현재 구현에서 버그를 활용하고 있었던 것입니다. 이는 향후 수정될 예정이므로 노드나 하위 그래프를 연결 해제하여 재생을 중지하는 방식으로 소리를 일시중지할 수는 없습니다.

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

Fieldrunners에는 매우 간단한 오디오 모델이 있습니다. 이 모델은 다음 기능 집합을 지원할 수 있습니다.

  • 사운드 효과의 볼륨을 제어합니다.
  • 배경음악 트랙의 볼륨을 제어합니다.
  • 모든 오디오를 음소거합니다.
  • 게임이 일시중지되면 소리 재생을 사용 중지합니다.
  • 게임이 재개되면 동일한 사운드를 다시 사용 설정합니다.
  • 게임 탭의 포커스가 사라지면 모든 오디오를 사용 중지합니다.
  • 필요한 경우 소리가 재생된 후 재생을 다시 시작합니다.

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

노드 그래프 차트

웹 오디오 노드 그래프는 리프 노드에서 대상 노드로 이어집니다. Fieldrunners에서는 6개의 영구 이득 노드를 사용했지만 볼륨을 쉽게 제어하고 버퍼를 재생할 임시 노드를 더 많이 연결하기에는 3개면 충분합니다. 먼저 마스터 게인 노드가 모든 하위 노드를 대상에 연결합니다. 마스터 게인 노드에 바로 연결된 게인 노드는 2개로, 하나는 음악 채널용이고 다른 하나는 모든 음향 효과를 연결하기 위한 것입니다.

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으로 설정하면 소리와 음악이 음소거됩니다. AudioBufferSourceNodes에도 이득 매개변수가 있습니다. 재생 중인 모든 사운드의 목록을 추적하고 전체 볼륨에 대해 개별적으로 이득 값을 조정할 수 있습니다. 오디오 태그로 음향 효과를 만들고 있다면 이렇게 해야 합니다. 대신 웹 오디오의 노드 그래프를 사용하면 수많은 소리의 음량을 훨씬 쉽게 수정할 수 있습니다. 이 방법으로 볼륨을 제어하면 복잡하지 않게 더 많은 전력을 사용할 수 있습니다. 음악을 재생하고 자체 이득을 제어하기 위해 AudioBufferSourceNode를 마스터 노드에 직접 연결하면 됩니다. 하지만 음악을 재생하기 위해 AudioBufferSourceNode를 만들 때마다 이 값을 설정해야 합니다. 대신 플레이어가 음악 볼륨을 변경할 때와 시작 시점에만 하나의 노드를 변경합니다. 이제 버퍼 소스에 다른 작업을 실행할 이득 값이 있습니다. 음악의 경우 한 오디오 트랙이 종료되고 다른 오디오 트랙이 시작될 때 한 트랙에서 다른 트랙으로 크로스 페이드를 만드는 데 일반적으로 사용됩니다. 웹 오디오는 이를 쉽게 실행할 수 있는 좋은 메서드를 제공합니다.

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를 출시한 후 노드 또는 하위 그래프를 연결 해제하는 것만으로는 AudioBufferSourceNodes의 재생이 일시중지되지 않는 것으로 확인되었습니다. 실제로는 그래프의 대상 노드에 연결되지 않은 노드의 재생을 중지하는 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;
    }
  }
};

버그를 악용하고 있다는 사실을 더 일찍 알았더라면 오디오 코드의 구조가 매우 달라졌을 것입니다. 이에 따라 이 도움말의 여러 섹션에 영향을 미쳤습니다. 이 변경사항은 여기에 직접적인 영향을 미치지만 '집중 상실' 및 'Give Me a Beat'의 코드 스니펫에도 영향을 미칩니다. 이 방법이 실제로 작동하는 방식을 알아보려면 Fieldrunners 노드 그래프 (재생을 단축하기 위한 노드를 만들었기 때문에)와 Web Audio가 자체적으로 실행하지 않는 일시중지 상태를 기록하고 제공하는 추가 코드를 모두 변경해야 합니다.

집중력 저하

이 기능에는 Google의 마스터 노드가 사용됩니다. 브라우저 사용자가 다른 탭으로 전환하면 게임이 더 이상 표시되지 않습니다. 보이지 않으면 잊혀지듯 소리도 사라져야 합니다. 게임 페이지의 특정 공개 상태를 확인하는 트릭이 있지만 Visibility API를 사용하면 훨씬 더 쉽게 확인할 수 있습니다.

Fieldrunners는 업데이트 루프를 호출하는 데 requestAnimationFrame을 사용하기 때문에 활성 탭으로만 재생됩니다. 하지만 웹 오디오 컨텍스트는 사용자가 다른 탭에 있는 동안 루프된 효과와 배경 트랙을 계속 재생합니다. 하지만 매우 작은 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를 사용하면 탭에 더 이상 포커스가 없을 때를 매우 쉽게 알 수 있습니다. 소리를 일시중지하는 효과적인 코드가 이미 있다면 게임 탭이 숨겨질 때 소리 일시중지를 작성하는 데 몇 줄만 있으면 됩니다.

Give Me a Beat

이제 몇 가지 설정을 진행하겠습니다. 노드 그래프가 있습니다. 플레이어가 게임을 일시중지하면 소리를 일시중지하고 게임 메뉴와 같은 요소에 새 소리를 재생할 수 있습니다. 사용자가 새 탭으로 전환하면 모든 소리와 음악을 일시중지할 수 있습니다. 이제 실제로 소리를 재생해야 합니다.

Fieldrunners는 캐릭터 사망과 같은 게임 항목의 여러 인스턴스에 대해 사운드의 여러 사본을 재생하는 대신 해당 기간 동안 한 번만 사운드를 재생합니다. 재생이 완료된 후 사운드가 필요한 경우 다시 시작할 수 있지만 재생 중에는 재시작할 수 없습니다. 이는 Fieldrunners의 오디오 디자인에 따른 결정입니다. 빠르게 재생하도록 요청된 사운드가 있기 때문에 다시 시작하도록 허용하면 끊김이 발생하거나 여러 인스턴스를 재생하도록 허용하면 즐기기 어려운 불협화음이 발생합니다. AudioBufferSourceNodes는 일회성으로 사용해야 합니다. 노드를 만들고, 버퍼를 연결하고, 필요한 경우 루프 불리언 값을 설정하고, 대상으로 연결되는 그래프의 노드에 연결하고, 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를 통해 재생되었으므로 배경 음악 재생도 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와 관련된 몇 가지 흥미로운 딜레마와 결정이 있습니다. 다시 한번 강조하지만 AudioBufferSourceNodes는 일회용 객체입니다. 이를 만들고, Audio Buffer를 연결하고, Web Audio 그래프에 연결한 다음 noteOn 또는 noteGrainOn으로 재생합니다. 소리를 다시 재생해야 하나요? 그런 다음 다른 AudioBufferSourceNode를 만듭니다.