概要
この記事はCyberAgent 26卒内定者によるアドベントカレンダーの15日目です。
はじめに
この記事はゲームサウンドに興味がある筆者がGeminiとお話ししながらプロシージャルなサウンドを実装してみたものです。
現役のサウンドプログラマー・テクニカルサウンドデザイナーさんがご覧になった際は私に色々教えてください...!
なぜプロシージャルなのか
あるブロックが落ちる時速く落ちても、遅く落ちても同じサウンドが再生されていませんか?
ゲームサウンドにおいて、没入感を高めることは非常に重要です。
サウンドを重要視しないプロジェクトでも違和感を持たれてほかの重要視したものに集中できないこともあるかもしれません。
プロシージャルサウンドとは落ちる速度や物体の状態などを伝えリアルタイムにサウンドを計算し、音を生み出す技術です。これによって、無限にバリエーションを生み出しインタラクションへのより自然な反応が可能になります。
この記事のゴール
この記事ではUnity 6.3から追加されたScriptable Audio Pipelineを使用して単なる正弦波を合成する方法より高度な共鳴器を使った本格的なプロシージャルなサウンド表現によってキューブが落ちる衝撃音の作成を行います。
音ではなく構造を作る
通常、音をリアルタイムに計算して再生するときは正弦波を組み合わせる方法が思い浮かびます。
しかしそのアプローチでは自然界に存在する音を作ることはとても大変です。
そこで今回はSource-Filter Modelというものを使います。
Source-Filter Model
Source-Filter Modelは「音の構造」を作ることで音を鳴らす手法のことです。
音源となる音の素材をフィルターを通して出力することで、音を作成する手法です。
今回のレシピ
今回は衝撃波ということでSourceにインパルス信号(一瞬のノイズ)、フィルターにBiquadフィルターを使用します。
ワイングラスをデコピンするイメージです。
デコピンでインパルス信号が発生し、それをワイングラスというフィルターがあのキーンという音成分だけを通して、その音がなるというイメージです。
Biquad Filterとは
過去2つ分の入力と出力を使って、現在の音を決める、2次のIIRフィルタです。え?
IIRとは「無限インパルス応答」の略で、IIRフィルタとは過去の出力を入力として使うフィルタのことを指します。
とても万能なフィルターで使い方次第で様々な音が作れますし、計算負荷も低いです。
詳しくは以下のサイトをご覧ください。
https://www.w3.org/TR/audio-eq-cookbook/#formulae
Unityでの実装
UnityではUnity 6.3から実装されたScriptable Audio Pipeline(以下、SAP)を用いて実装できます。
旧来のAudioFilterでの実装は紹介しません。(というか実装できるのか知らないです...)
Scriptable Audio Pipeline(SAP)とは
Unity 6.3から追加された新たなサウンドの機能です。アウトプット全体と音源ひとつの制御がC#からカスタムしやすくなりました。
詳しくはより詳細に記載している記事がありますので、ご覧ください。(SAPはAPIの更新が頻繁に行われています。以下の記事のAPIは古いものです。基本は同じです。)
SAPの実装
実際に実装を見ていきます。
using System;
using Unity.IntegerTime;
using UnityEngine;
using UnityEngine.Audio;
using Random = Unity.Mathematics.Random;
namespace ProceduralAudio.BiquadFilter
{
[CreateAssetMenu(fileName = "BiquadImpactAudioGenerator", menuName = "Procedural Audio/Biquad Impact Audio Generator")]
public class BiquadImpactAudioGenerator : ScriptableObject, IAudioGenerator
{
public bool isFinite => true;
public bool isRealtime => true;
public DiscreteTime? length => new DiscreteTime(3);
private Processor _processor;
public GeneratorInstance CreateInstance(ControlContext context, AudioFormat? nestedFormat,
ProcessorInstance.CreationParameters creationParameters)
{
_processor = new Processor();
return context.AllocateGenerator(_processor, new Processor.Control());
}
internal struct Processor : GeneratorInstance.IRealtime
{
public bool isFinite => true;
public bool isRealtime => true;
public DiscreteTime? length => new DiscreteTime(3);
private GeneratorInstance.Setup _setup;
private ResonatorData _resonatorData0;
private ResonatorData _resonatorData1;
private ResonatorData _resonatorData2;
private bool _isTriggered;
private ProceduralAudioData _proceduralData;
private Random _random;
private float _gain1;
private float _gain2;
private float _gain3;
public void Update(ProcessorInstance.UpdatedDataContext context, ProcessorInstance.Pipe pipe)
{
if (!_isTriggered)
{
return;
}
foreach (var element in pipe.GetAvailableData(context))
{
if (element.TryGetData(out ProceduralAudioData data))
{
_proceduralData = data;
}
}
SetupResonators();
}
private void SetupResonators()
{
var baseQ = 5;
var intensity = Mathf.Clamp01(_proceduralData.velocityMagnitude / 10.0f);
var baseGain = Mathf.Pow(intensity, 2.0f);
// [Resonator 1: 基音 (Low)]
// どんな衝撃でも比較的鳴る
var freq1 = 150f;
_gain1 = 1.0f * baseGain;
float q1 = baseQ;
// [Resonator 2: 倍音 (Mid)]
var freq2 = 317.0f;
_gain2 = 0.8f * baseGain * (0.5f + intensity * 0.5f); // 少し速度依存
var q2 = baseQ * 1.2f;
// [Resonator 3: 高次倍音 (High)]
// ★ここがポイント: 弱い衝突(intensity小)だと gain3 はほぼゼロになる
// これにより「コトッ(弱)」と「カァァン(強)」の演じ分けができる
var freq3 = 580.0f;
_gain3 = 0.6f * baseGain * intensity;
var q3 = baseQ * 0.5f; // 高音は早く減衰させるのが自然
_resonatorData0.Setup(freq1, q1, _setup.sampleRate);
_resonatorData1.Setup(freq2, q2, _setup.sampleRate);
_resonatorData2.Setup(freq3, q3, _setup.sampleRate);
}
public GeneratorInstance.Result Process(in RealtimeContext context, ProcessorInstance.Pipe pipe, ChannelBuffer buffer, GeneratorInstance.Arguments args)
{
for (var frame = 0; frame < buffer.frameCount; frame++)
{
var input = 0f;
if (_isTriggered)
{
_isTriggered = false;
input = _random.NextFloat(-1, 1);
}
var result1 = _resonatorData0.Process(input, _proceduralData) * _gain1;
var result2 = _resonatorData1.Process(input, _proceduralData) * _gain2;
var result3 = _resonatorData2.Process(input, _proceduralData) * _gain3;
var value = result1 + result2 + result3;
for (var i = 0; i < buffer.channelCount; i++)
{
buffer[frame, i] = value;
}
}
return buffer.frameCount;
}
internal struct Control : GeneratorInstance.IControl<Processor>
{
public void Dispose(ControlContext context, ref Processor processor)
{
}
public void Update(ControlContext context, ProcessorInstance.Pipe pipe)
{
}
public ProcessorInstance.Response OnMessage(ControlContext context, ProcessorInstance.Pipe pipe, ProcessorInstance.Message message)
{
var proceduralData = message.Get<ProceduralAudioData>();
pipe.SendData(context, proceduralData);
return ProcessorInstance.Response.Handled;
}
public void Configure(ControlContext context, ref Processor processor, in AudioFormat configuration, out GeneratorInstance.Setup setup,
ref GeneratorInstance.Properties properties)
{
processor._random = new Random((uint)DateTime.Now.Millisecond);
processor._setup = new GeneratorInstance.Setup(AudioSpeakerMode.Mono, configuration.sampleRate);
processor._isTriggered = true;
setup = processor._setup;
}
}
}
}
}
な、、、長い!!
着目すべきは2点です。
Processの中
public GeneratorInstance.Result Process(in RealtimeContext context, ProcessorInstance.Pipe pipe, ChannelBuffer buffer, GeneratorInstance.Arguments args)
{
for (var frame = 0; frame < buffer.frameCount; frame++)
{
var input = 0f;
if (_isTriggered)
{
_isTriggered = false;
// Whiteノイズの生成
input = _random.NextFloat(-1, 1);
}
var result1 = _resonatorData0.Process(input, _proceduralData) * _gain1;
var result2 = _resonatorData1.Process(input, _proceduralData) * _gain2;
var result3 = _resonatorData2.Process(input, _proceduralData) * _gain3;
var value = result1 + result2 + result3;
for (var i = 0; i < buffer.channelCount; i++)
{
buffer[frame, i] = value;
}
}
return buffer.frameCount;
}
Processの中では入力であるインパルス信号(一瞬のノイズ)を作成し、それを共鳴器(Resonator)に受け渡しています。
また、2回目の計算からはinputの値は0となるようにしています。
SetupResonator
private void SetupResonators()
{
var baseQ = 5;
var intensity = Mathf.Clamp01(_proceduralData.velocityMagnitude / 10.0f);
var baseGain = Mathf.Pow(intensity, 2.0f);
// [Resonator 1: 基音 (Low)]
// どんな衝撃でも比較的鳴る
var freq1 = 150f;
_gain1 = 1.0f * baseGain;
float q1 = baseQ;
// [Resonator 2: 倍音 (Mid)]
var freq2 = 317.0f;
_gain2 = 0.8f * baseGain * (0.5f + intensity * 0.5f); // 少し速度依存
var q2 = baseQ * 1.2f;
// [Resonator 3: 高次倍音 (High)]
// ★ここがポイント: 弱い衝突(intensity小)だと gain3 はほぼゼロになる
// これにより「コトッ(弱)」と「カァァン(強)」の演じ分けができる
var freq3 = 580.0f;
_gain3 = 0.6f * baseGain * intensity;
var q3 = baseQ * 0.5f; // 高音は早く減衰させるのが自然
_resonatorData0.Setup(freq1, q1, _setup.sampleRate);
_resonatorData1.Setup(freq2, q2, _setup.sampleRate);
_resonatorData2.Setup(freq3, q3, _setup.sampleRate);
}
ここでは衝突時のVelocityの強さより構成する音の音量を決定しています。
また、のちに説明する周波数(freq)とQ値の算出も行っています。
Resonator(共鳴器・Filter)の実装
共鳴器の実装です。今回はMonoBehaviourとSAP側に流すデータの2つを作りました。
using UnityEngine;
namespace ProceduralAudio.BiquadFilter
{
public class Resonator : MonoBehaviour
{
public void OnCollisionEnter(Collision other)
{
if (other.gameObject.TryGetComponent(out BiquadProceduralSoundSource source))
{
var data = new ProceduralAudioData();
data.velocityMagnitude = other.relativeVelocity.magnitude;
source.PlayProceduralSound(data);
}
}
}
}
MonoBehaviourではSource側を検知すると自身のデータを渡してPlayをかけています。
地味に接地した時の速度(relativeVelocity.magnitude)初めて使ったかも
using System;
using UnityEngine;
namespace ProceduralAudio.BiquadFilter
{
// 共鳴器
public struct ResonatorData : IEquatable<ResonatorData>
{
/// <summary>
/// 共鳴周波数
/// </summary>
private float _frequency;
/// <summary>
/// 共鳴の強さ
/// </summary>
private float _q;
// 内部計算用バッファ
float b0, b1, b2, a1, a2; // 係数
float x1, x2, y1, y2; // 過去の入力/出力サンプル
float _compensationGain;
public void Setup(float freq, float q, float sampleRate)
{
_frequency = freq;
_q = q;
// --- フィルタ係数計算 (Bandpass Filter) ---
// ※詳しい数式は "Audio EQ Cookbook" などを参照。ここでは概念的な記述です。
var omega = 2.0f * Mathf.PI * _frequency / sampleRate;
var alpha = Mathf.Sin(omega) / (2.0f * _q);
b0 = alpha;
b1 = 0.0f;
b2 = -alpha;
var a0 = 1.0f + alpha;
a1 = -2.0f * Mathf.Cos(omega) / a0;
a2 = (1.0f - alpha) / a0;
// 音量が小さくなりがちなのでx100
_compensationGain = Mathf.Sqrt(_q) * 100f;
// 入力側の係数を正規化
b0 /= a0; b2 /= a0;
}
public float Process(float input, ProceduralAudioData data)
{
// 差分方程式 (Direct Form I)
float output = (b0 * input) + (b1 * x1) + (b2 * x2)
- (a1 * y1) - (a2 * y2);
// バッファ更新
x2 = x1; x1 = input;
y2 = y1; y1 = output;
var lastOutput = output * _compensationGain;
return lastOutput;
}
// IEqualableの実装は省略
}
Data側ではBiquadFilterの本体実装があります。(Dataという命名がおかしいのはご愛嬌。)
数学的には理解が及んでいませんが、前の値を使って処理をしていることがわかると思います。
ソース側の実装
using UnityEngine;
using UnityEngine.Audio;
namespace ProceduralAudio.BiquadFilter
{
public class BiquadProceduralSoundSource : MonoBehaviour
{
[SerializeField] private AudioSource _audioSource;
public void PlayProceduralSound(ProceduralAudioData data)
{
_audioSource.Play();
var handle = _audioSource.generatorInstance;
if (!ControlContext.builtIn.Exists(handle))
{
return;
}
ControlContext.builtIn.SendMessage(handle, ref data);
}
}
}
ソース側では音を鳴らしています。単純に。また、SAP側に情報を伝達しています。
本来、再生する前に情報を送る方が良さそうですが、それだとControlContextにパイプラインが生成される前になり、送れないのでPlay後に送信しています。
パラメータについて
Q値
Q値とは「Quality Factor」と呼ばれるもので、どれだけ音を通すかという値です。
これによって材質を変更でき、今回は木の音になるように設定してあります。
freq
これは音の周波数のパラメータです。
これによっても材質などが変化しますし、叩きつける印象なども変化します。
以上より、Q値とfreqを適切に変更してあげることで多彩な表現ができるのです。
発展
BiquadFilterはとても汎用的です。今回は衝撃波が強い時のみ高音を発生させ音色を変えるテクニックを使いました。加えて複数個Resonatorを利用するモーダルシンセシスと呼ばれる手法のアプローチを使用しました。
また、周波数をランダムに変えて、出力にLowPassFilter(低音のみを通すフィルター)を使うことで風の音が生まれます。
様々な工夫を行うことで色々な音を発生させられるのです。
まとめ
今回はサンプルのため実装に雑な部分がありますが、触ってみて可能性をとても感じる技術でした。
今後はより汎用的にオープンワールドなどでより精密にインタラクションに反応するサウンド基盤の実装をしてみたいですね。
また数学的な理解はできていないので、そこもいずれ記事にしたいと思います。
また、今後もサウンドプログラマーになるべく色々な技術を調査していこうと思います!
宣伝
CA26卒のアドベントカレンダーはUnity以外にも様々な記事があります!
ぜひ他の記事にも目を通していただければ幸いです!

