3D-аудио — источники, зоны, слушатель

Звук, размещённый в 3D-мире. Аспект AudioSource делает узел излучателем, за которым движок следует каждый кадр; audio.playAt запускает одиночный звук в точке; аспект AudioZone — объём реверберации; а scene.audio (SceneAudio, который несёт каждая Scene) выбирает слушателя — активную камеру, если ты не выберешь узел. Клипы, голоса и шины — из игрового аудио, как и гейт хоста: без audio.supported аспекты прикрепляются и ничего не делают (play возвращает голос, который уже закончился).

Обзор

TypeScript
const main = async () => {
  const hum = await Sound.load(asset('./sfx/hum.wav'))       // моно: то, что источник размещает в пространстве
  const knock = await Sound.load(asset('./sfx/click.wav'))

  const scene = new Scene()
  scene.camera.position = [0, 1.6, 4]

  const generator = new Node().aspect(AudioSource, { minDistance: 2, maxDistance: 40 })
  generator.position = [0, 1, -6]
  const hall = new Node().aspect(AudioZone, { box: [8, 4, 8], reverb: 'hall', blend: 2 })
  scene.add(generator, hall)
  scene.open()

  const loop = generator.audio.play(hum, { loop: true })   // следует за узлом, слышен от камеры
  loop.volume = 0.8
  audio.playAt(knock, [3, 0, -2], { maxDistance: 25 })     // одиночный звук в мировой точке
}
main()

AudioSource — излучатель на узле

TypeScript
node.aspect(AudioSource, {
  minDistance?: number     // метры полной громкости вокруг узла; по умолчанию 1
  maxDistance?: number     // метры, за которыми он не становится тише; по умолчанию 50
  rolloff?: Rolloff        // 'inverse' (по умолчанию) | 'linear' | 'exponential' | 'none'
  cone?: { inner: number, outer: number, outerGain?: number } | null   // градусы; по умолчанию null (во все стороны)
  doppler?: number         // 0 (выкл, по умолчанию) … 1
  spread?: number          // 0 точечное панорамирование (по умолчанию) … 1 одинаково в обоих ушах
  occlusion?: boolean      // глушить, когда на пути физическое тело; по умолчанию false
  bus?: string             // шина его голосов по умолчанию; по умолчанию 'sfx'
})
node.audio.play(sound: Sound, options?: PlaySoundOptions): Voice   // опции audio.play
node.audio.stopAll(fade?: number)                                  // его голоса; fade в секундах
node.audio.voices                                                  // сколько играет

Каждая опция — ещё и живое свойство (node.audio.maxDistance = 80), которое доходит до уже играющих голосов — кроме bus, который выбирает шину для последующих запусков. Движок читает мировую позу узла каждый кадр, после поздней фазы — подвешенные, анимированные и физически управляемые узлы просто работают; вперёд — −Z.

Note

аксессор — node.audio — не то же самое, что глобальный audio и scene.audio.

Расстояние спадает между minDistance и maxDistance (полная громкость внутри minDistance, никаких изменений за maxDistance):

  • inverse — усиление = minDistance / distance; при умолчаниях 0.02 на 50 м.
  • linear — от 1 на minDistance прямо вниз до тишины на maxDistance.
  • exponential — (distance / minDistance)^-1: с фиксированным фактором спада движка, равным 1, это та же кривая, что inverse.
  • none — без затухания по расстоянию.

cone делает динамик, сирену, голос, отвёрнутый в сторону: inner и outer — полные углы в градусах вокруг −Z узла; внутри inner громкость 1, за outer — outerGain (по умолчанию 0.25), между ними — смешивание.

doppler требует, чтобы узел действительно двигался: его скорость измеряется по его движению каждый кадр (с потолком 60 м/с, так что телепорт не свистит); scene.audio.dopplerFactor масштабирует doppler каждого источника.

occlusion бросает три луча каждый кадр от источника к слушателю (в его центр и на 0.5 м по обе стороны) — до 8 помеченных источников за кадр, по очереди — и глушит голоса по тому, сколько лучей перекрыто, вплоть до low-pass на 800 Гц и −12 дБ, когда перекрыты все три, со сглаживанием около 0.1 с. Перекрывает физическое тело, статическое или движущееся (Shape с Physics) — не Trigger, не Shape сам по себе и никогда не собственное тело узла-источника или его потомков. Нужна 3D-физика хоста (Physics.supported).

Источник на стороне движка существует, пока аспект прикреплён: его отсоединение или уничтожение узла останавливает его голоса (fade 50 мс).

Одиночные звуки в точке — audio.playAt

TypeScript
audio.playAt(sound: Sound, position: Vec3Like, options?: PlaySoundOptions & {
  minDistance?: number     // по умолчанию 1
  maxDistance?: number     // по умолчанию 50
  rolloff?: Rolloff        // по умолчанию 'inverse'
}): Voice

Удары, рикошеты, обломки: собственный источник в фиксированной мировой позиции (метры), освобождаемый, когда голос заканчивается. bus по умолчанию sfx; конуса, доплера и окклюзии нет, а pan ничего не делает.

Зоны реверберации — AudioZone

TypeScript
node.aspect(AudioZone, {
  box?: Vec3Like                          // полуразмеры, в локальных единицах узла
  sphere?: number                         // радиус (установка одного сбрасывает другое)
  reverb?: ReverbPreset | ReverbParams    // по умолчанию 'room'
  blend?: number                          // метры кроссфейда внутри границы; по умолчанию 1, 0 = жёсткий край
  bus?: string                            // шина, на которую идёт её реверберация; по умолчанию 'sfx'
})
node.audioZone.id                         // то, что сообщает audio.stats.listenerZone, пока слушатель внутри

Пока слушатель внутри, реверберация зоны заменяет собственный reverb шины (см. Шины), нарастая на первых blend метрах за границей; вне всех зон снова действует собственный шины. Зоны вкладываются: побеждает самая маленькая, содержащая слушателя. Объём следует за узлом — его позицией, вращением и масштабом.

Без box и sphere зона берёт сферу или бокс Shape узла (читается при прикреплении зоны — прикрепи Shape первым), иначе полуразмеры 1 (куб 2 м).

TypeScript
const corridor = new Node().aspect(Shape, { box: [1, 1.5, 8] }).aspect(AudioZone, { reverb: 'bathroom' })

audio.stats.listenerZone / zoneBlend показывают, в какой зоне слушатель и насколько глубоко (0 … 1).

Слушатель — scene.audio

TypeScript
scene.audio.listener: Node | null   // по умолчанию null: камера, которой рендерится сцена
scene.audio.dopplerFactor: number   // умножает doppler каждого источника; по умолчанию 1, 0 = выкл везде

Слушатель слышит из своей мировой позиции, лицом к −Z, вверх +Y. По умолчанию это камера — follow-риг или переключение камеры ничего не меняют. Задай узел, чтобы слушать из другого места: голова персонажа в игре от третьего лица, чтобы звуки панорамировались вокруг персонажа, а не камеры.

Note

у движка один слушатель. Узел, заданный на одной сцене, остаётся слушателем после открытия другой сцены — чей scene.audio.listener по-прежнему читается как null — пока ты не задашь listener = null или узел не будет уничтожен; тогда снова слушает рендерящая камера.

Наушники — audio.hrtf

TypeScript
audio.hrtf = true          // бинауральный рендеринг; по умолчанию false
audio.hrtfVoices = 16      // сколько ближайших 3D-голосов его получают; по умолчанию 16
audio.stats.hrtfVoices     // сколько сейчас рендерится бинаурально

С включённым hrtf ближайшие 3D-голоса фильтруются на каждое ухо (набор MIT KEMAR), так что у звука появляются настоящие верх, низ и «сзади» — в наушниках; на колонках это только размазывает картину, потому по умолчанию выключено. Каждый такой голос стоит больше CPU; остальные остаются на простом панорамировании, а 2D-голоса никогда не бинауральны. Хост, собранный без фильтров (iOS App Clip), держит каждый голос панорамированным: audio.stats.hrtfVoices там остаётся 0, что бы ни читал audio.hrtf.

Смотрите также

  • Игровое аудио — Sound, Voice, шины, пул голосов, audio.supported
  • Аспекты — node.aspect(Class, opts) и отсоединение
  • Physics — тела, которые перекрывают звук
  • Scene — камера, которая слушает по умолчанию