How to implement gun shooting sound playback in three.js

Three.js의 THREE.AudioTHREE.PositionalAudio는 내부적으로 Web Audio API의 AudioBufferSourceNode를 래핑하여 동작합니다. Web Audio API 설계상 하나의 소스 노드는 단 한 번만 재생할 수 있으며, Three.js의 play() 메서드는 이미 오디오가 재생 중일 때(isPlaying === true) 중복 호출하면 경고를 내며 실행되지 않습니다.

따라서 발사할 때마다 소리를 중첩해서 재생하려면 다음 3가지 방법 중 하나를 선택하여 구현할 수 있습니다.


방법 1. 오디오 객체 풀(Audio Pool) 사용하기 (가장 권장)

동시 재생할 수 있는 PositionalAudio 인스턴스를 미리 여러 개 생성하여 풀(Pool)로 관리하고, 발사할 때마다 사용 중이지 않은 인스턴스를 가져와 재생하는 방식입니다. 가비지 컬렉션(GC) 부담이 없고 성능이 가장 안정적입니다.

class AudioPool {
  private pool: THREE.PositionalAudio[] = [];
  private size: number;

  constructor(listener: THREE.AudioListener, buffer: AudioBuffer, size: number = 10) {
    this.size = size;
    for (let i = 0; i < size; i++) {
      const audio = new THREE.PositionalAudio(listener);
      audio.setBuffer(buffer);
      // 추가적인 panner 설정 (예: refDistance, rolloffFactor 등)
      audio.setRefDistance(1);
      audio.setRolloffFactor(1);
      this.pool.push(audio);
    }
  }

  // 발사 시점에 호출할 메서드
  play(parent: THREE.Object3D) {
    // 재생 중이지 않은 오디오 찾기
    const idleAudio = this.pool.find(audio => !audio.isPlaying);

    if (idleAudio) {
      parent.add(idleAudio);
      idleAudio.play();
    } else {
      // 모든 풀이 재생 중인 경우: 가장 오래전에 재생된 오디오를 강제 정지 후 재사용
      const fallbackAudio = this.pool[0];
      fallbackAudio.stop();
      parent.add(fallbackAudio);
      fallbackAudio.play();

      // 순서를 맨 뒤로 보내서 다음 fallback 대상으로 교체
      this.pool.push(this.pool.shift()!);
    }
  }
}

방법 2. 발사 시점에 동적으로 생성하고 소멸시키기

발사 이벤트가 발생할 때마다 새로운 PositionalAudio 인스턴스를 생성하고, 재생이 끝나면 자동으로 부모 객체에서 제거 및 메모리를 해제하는 방식입니다.

function playShootSound(parent: THREE.Object3D, listener: THREE.AudioListener, buffer: AudioBuffer) {
  const audio = new THREE.PositionalAudio(listener);
  audio.setBuffer(buffer);

  parent.add(audio);
  audio.play();

  // 재생 완료 시 정리 작업
  audio.source.onended = () => {
    audio.disconnect();
    parent.remove(audio);
  };
}

[!WARNING]
총을 매우 빠르게 연사(초당 수십 발 등)하는 콘텐츠인 경우, 객체의 빈번한 생성과 소멸로 인해 가비지 컬렉션(GC)이 발생하여 프레임 드랍(렉)이 생길 수 있습니다.


방법 3. Web Audio API 레벨에서 소스 노드 직접 생성 및 연결

Three.js의 PositionalAudio가 가지고 있는 panner 노드에 Web Audio API를 사용하여 직접 AudioBufferSourceNode를 동적으로 생성해 연결하는 로우레벨 방식입니다. Three.js의 isPlaying 제약을 우회할 수 있습니다.

function playDirectBuffer(positionalAudio: THREE.PositionalAudio) {
  const context = positionalAudio.listener.context;
  const buffer = positionalAudio.buffer;

  if (!buffer) return;

  // Web Audio API 소스 노드 직접 생성
  const sourceNode = context.createBufferSource();
  sourceNode.buffer = buffer;

  // Three.js 오디오의 panner(혹은 적용된 filter)에 노드 연결
  const destination = positionalAudio.filters.length > 0 
    ? positionalAudio.filters[0] 
    : positionalAudio.panner;

  sourceNode.connect(destination);
  sourceNode.start(0);

  // 재생이 끝나면 커넥션 해제
  sourceNode.onended = () => {
    sourceNode.disconnect();
  };
}

[!NOTE]
이 방식은 Three.js의 audio.isPlaying 상태 관리 및 audio.stop(), audio.pause() 기능이 동작하지 않으므로, 재생 도중 일시정지나 소리 끄기 등의 기능을 수동으로 관리해야 합니다.

요약 및 추천

일반적인 슈팅 게임 환경에서는 방법 1(Audio Pool) 방식이 성능과 기능 관리 편의성 측면에서 가장 안정적이므로 적용하시는 것을 권장합니다.