Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Unity の Particle System を音声情報を元に制御してみたい

この記事は、モバイルファクトリー Advent Calendar 2020 20日目の記事です。
こんにちは、最近眠りが浅いことで悩んでいる Yunagi_N です。
一昨年に続いて私はマイペースに、今年も趣味全開のお話をします。

はじめに

今年4月くらいから某 VR SNS にはまって Unity に興味を持ち、いろいろなことをやっているのですが、
VR 世界で VR ならではのパフォーマンスをやっている人たちを見かけて、憧れてパーティクルをいじってみました。
ここでは、一般にパーティクルライブや VRMV と呼ばれているパフォーマンスを指しますが、
それぞれの説明については実際に体験してもらうのが一番良いので、各種 VR プラットフォームにおでかけしてみてください。

今回、普通にパーティクルをいじってみるだけでは特に面白みが無いと感じたので、
再生中の音楽に合わせて動的に変化する効果をスクリプトとシェーダーで作ってみました。
なお、会社で開発しているアプリ・プロジェクトとは一切関係ありません。

前提

以下の環境で開発、動作確認を行っています:

  • Windows 10
  • Unity 2018.4.20f1

また、本記事の実装は VR SNS 内部で動くように作られており、
セキュリティ上の理由などから、独自実装の VM 上で動くため以下の制限があります:

  • C# の一部の構文のみをサポート (C# 7 相当の機能にさらに制限をかけたもの)
    • 本来は C# そのものではなくノード形式のプログラミング言語で行うのですが、有志が C# で作成できるアセットを公開しています
  • Unity で使えるすべての API が許容されているわけではない
    • 例えば、 Job System や ECS (2020.2 の時点でロードマップから消えたようですが)、また List さえ API が公開されていません

なお、この記事では以下の事については解説しません:

  • Unity の基礎
  • パーティクル (Particle System) の基礎
  • シェーダー (ShaderLab, HLSL) の基礎

また、本記事では using を省いていることがあります。ご了承ください。
また、私は音声周りのプロではないので、記事中に間違いなどがある場合があります、ごめんなさい。

実装

まずは音声情報を取得します。
パーティクルなどの動きに応用できるような情報は基本的には以下の2つだと思います。

  1. オーディオレベル (dB 単位)
  2. 周波数スペクトル情報

それぞれの取得は、以下のコードで簡単に行えます。
まずオーディオレベル (dB) は GetOutputData から計算できます。
なお、ここでのオーディオレベル (dB) は dBFS と呼ばれるもので、以下の計算式で求められます。

// MaxValue は RMS (Root Mean Square) が取り得る値の最大値
var dbfs = 20.0f * Mathf.Log10(RMS / MaxValue);

通常、 GetOutputData で得られる値の範囲は -11 であるため、下記のコードにて dBFS が求められます。

private const int SampleCount = 1024; // 64 ~ 8192 の範囲の 2 のべき乗の値を指定する必要があります。

[SerializeField]
private AudioSource audioSource;

private float[] _samples = new float[SampleCount];

private void Update()
{
    var db = CalcDecibel();
}

private float CalcDecibel()
{
    audioSource.GetOutputData(_samples, 0);
    
    var sum = 0.0f;
    foreach (var sample in _samples)
        sum += sample * sample;
    
    var rmsValue = Mathf.Sqrt(sum / SampleCount);
    var dbValue = 20.0f * Mathf.Log10(rmsValue);
    if (dbValue < -80.0f)
        dbValue = -80.0f;
    
    return dbValue;
}

次に、周波数ごとのスペクトル情報は GetSpectrumData を使います。
特に難しいことはないですね。

// 各種インスタンス変数は上記のものを使い回します。

private void Update()
{
    audioSource.GetSpectrumData(_samples, 0, FFTWindow.Hanning);
}

このとき、第3引数に設定する FFTWindow は、求めている精度に応じて適切なものを使用します。
今回、 GetSpectrumData で取得したいデータはそこそこの精度で得られれば良いので、
FFTWindow.Hanning を設定しました。
また、配列に入れられた値は、以下のように計算することで、インデックスと Hz を変換できます。

var i = /* 配列の index */;
var hz = AudioSettings.outputSampleRate * 0.5f * i / SampleCount;

例えば、 AudioSettings.outputSampleRate が 44100Hz である場合、配列の1番目の周波数は、

var hz = 44100 * 0.5 * 1 / 1024; // 21.53Hz

といった具合で、以降は 21.53Hz ごとにデータがサンプルされています。

これで、再生されている音声から各種情報が取得できました。
ただし、オーディオレベルはまだしもスペクトルは生データのままでは使いづらいので、
これらのデータを加工したうえでパーティクル (Particle System) やシェーダーなどに渡しやすくします。

データの加工形式はいくつかあると思いますが、今回は最終的に以下のデータを渡してパーティクルを制御することにしました。

  • オーディオレベル (dBFS)
  • ピッチ情報
  • 音域ごとのオーディオレベル (dBFS)
    • 個人の好みで分類
  • Peak Hold Fall Down
    • VU メーターで、最大値が更新されたらそこに点が移動し、徐々に低下していくアレです
    • ここでは、最大値 (max) + r Hz を範囲に取り、以下の演算の結果を渡します
      • (Mathf.Clamp(n, max - r, max) - (max - r)) / r

まずピッチ情報ですが、これはスペクトル情報から一番大きい値を取り出し、
良い感じに補正してあげれば、それらしい値が得られるようです。
(ただし、通常の音楽においては正確な値は取れないそう。)

コードは以下の通り。簡単ですね。

// 各種インスタンス変数は上記のものを使い回します。

private void Update()
{
    audioSource.GetSpectrumData(_samples, 0, FFTWindow.Hanning);

    var pitch = CalcPitch(_samples);
}

private float CalcPitch(float[] samples)
{
    var maxValue = 0.0f;
    var maxIndex = 0;
    
    for (var i = 0; i < SampleCount; i++)
    {
        var spectrum = samples[i];
        if (maxValue > spectrum)
            continue;
            
        maxValue = spectrum;
        maxIndex = i;
    }
    
    var l = samples[maxIndex - 1] / samples[maxIndex];
    var r = samples[maxIndex + 1] / samples[maxIndex];
    var f = maxIndex + 0.5f * (r * r - l * l);
    
    return f * AudioSettings.outputSampleRate * 0.5f * maxIndex / SampleCount;
}

次は、音域ごとに周波数帯を分類し、各音域のオーディオレベルを計算します。
これは、カヤックさんのオーディオスペクトルアナライザーのコードを元に、
bin を128個に分類したものから、特定周波数区域の値の平均値を取りました。
詳しくは、記事末尾に記載している参考リンクを参照ください。

最後は Peak Hold Fall Down の実装ですが、これは下のコードで実装しました。

private const float FalldownPerTick = 0.1f;
private const float LevelRange = 5.0f;

private float _peak;

// 各種インスタンス変数は上記のものを使い回します。
private void Update()
{
    var db = ...; // 初めに計算した dBFS
    var value = CalcPeakFallDownValue()
}

// 雑だけど
private float CalcPeakFallDownValue(float db)
{
    var delta = Time.deltaTime;
    
    _peak = Mathf.Max(_peak - FalldownPerTick * delta, -80.0f);
    _peak = Mathf.Clamp(db, _peak, 0.0f);

    var minValue = _peak - LevelRange;
    return (Mathf.Clamp(db, minValue, _peak) - minValue) / LevelRange;
}

ここまでで、ようやく必要なデータがそろいました。長かったです。
今度は、これらのデータを Particle System に渡してあげます。

その前に、今回使うシェーダーのコードを張っておきます (ShaderLab は Transparent で良い感じに)。
ポイントはテクスチャを透明度に変換しているのと、頂点カラーを使っていることくらいです。
テクスチャを透明度に変換しているのは用意したテクスチャの都合から、
頂点カラーを使っているのは、 Material 数を減らしたいというプラットフォーム上の都合からです。

なお、 Particle System からシェーダーにデータを渡すには、 Renderer モジュールのうち、
Custom Vertex Streams を有効にした上で、何をどのセマンティクスに渡すか設定する必要があります。

// 各エントリポイントは、以下の設定 (ShaderLab)
//
// #pragma vertex   vs
// #pragma fragment fs
//

#include "UnityCG.cginc"

struct appdata
{
    float4 vertex : POSITION;
    float2 uv     : TEXCOORD;
    float4 color  : COLOR;
    
    // 他はご自由に...
}

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv     : TEXCOORD;
    float4 color  : COLOR0;
    
    // 他はご自由に...
}

v2f vs(appdata v)
{
    v2f o = (v2f) 0;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv     = TRANSFORM_TEX(v.uv, _MainTex);
    o.color  = v.color;
    
    // 渡したいものや変換したいものをお好きに
    
    return o;
}

fixed4 convertMonochromeToTransparent(const float3 color)
{
    const float transparent = color.r + color.g + color.b;
    return fixed4(0, 0, 0, transparent / 3);
}

fixed4 fs(v2f i) : SV_TARGET
{
    fixed4 color = convertMonochromeToTransparent(tex2D(_MainTex, i.uv));
    color.rgb = i.color.rgb;
    color.a  *= i.color.a;
    
    // Emission
    color.rgb *= pow(2, _Emission);
    
    // あとは付けたい効果をどしどしと
    
    return color;
}

最後に、 Particle System でデータを渡してあげます。
上記で変換したデータを元に、 Particle System や Particle を操作するコンポーネントをつくります。
それぞれの操作は単純なものなので、コードは省略しますが、私は以下のようなものを作成しました:

  • 特定のデータの値を増幅・減衰させてさらにデータを扱いやすくするコンポーネント
  • 特定のデータが条件を満たした場合、パーティクルを Emit
  • 特定のデータが条件を満たした場合、 Particle System のプロパティを変更
    • これはインスペクターを作るのが面倒なので、非 Active な Particle System から値を引っ張ってくるように実装しました
  • 特定のデータの値や変化量を Particle そのものに渡す
    • 元データの値を良い感じにして、 velocityrotation に渡すと良いです

ちなみに、 Particle そのものの操作は下記のようにすれば行えます。

// private ParticleSystem ps;

var particles = new ParticleSystem.Particle[ps.particleCount];
ps.GetParticles(particles);

for (var i = 0; i < ps.particleCount; i++)
{
    var particle = particles[i];
    
    // お好きな操作をここで

    paritcles[i] = particle;
}

ps.SetParticles(particles);

と、こんな感じで、音声情報を元に Particle System を操作できるコンポーネント群が完成しました。
あとは、一緒に再生したい音楽や BGM に合わせて、ひたすら数値を調整していけば、完成です。
正直な話、コードを書くよりもひたすら数値調整するのが厳しい気がしますが、そこは根気よく頑張りましょう。
では、お疲れさまでした。また来年会いましょう。

次の記事は id:i1derful さんです。


参考: