見出し画像

【Unity】オーディオスペクトラムを作る

 こんにちは。Unityエンジニアのオオブチです。

 前回私が書いた記事ではUnity上でバーチャルライブの演出に使えそうなアセットを作ろうという事でLED風のサービススクリーンを作りました。今回もUnityで何か演出に使えるアセットを1つ作ってみよう、という事で今回はこういうのを作ります。

コレ↑

 皆さんご存じオーディオスペクトラムです。鳴っている音に反応して棒が伸びたり点が飛び跳ねたりするアレです。これが1つ置いてあるだけでいかにも音が鳴っているんだな~~という事が視覚的にも伝わりますし、絵的にも動きが出てくれるので何かと便利そうじゃないですか?私は便利そうだと思います。多分。


1. 使うデータを取り出す

 早速作っていきたいわけですが、絵を作る前にオーディオから欲しいデータを取り出します。スペクトラムというと連続して見えるものの事を言うのが一般的なのかと思いますが、ここでは周波数の事です。AudioSourceから再生時点の音の周波数を取り出して、それを可視化する方向で進めましょう(時系列のサンプル値もデータとして読み取れますが今回はパスします)。
 そういう訳で以下C#のコードです。これを元にするAudioSourceのComponenntにしてください。AudioSourceのAudioClipには好きな音源ファイルを参照させます

using System.Collections.Generic;
using UnityEngine;
using System.Linq;

[RequireComponent(typeof(AudioSource))]
public class GetAudioData : MonoBehaviour
{
    public enum FFT_Resolution
    {
        _8192 = 8192, _4096 = 4096, _2048 = 2048, _1024 = 1024, _512 = 512, _256 = 256, _128 = 128, _64 = 64
    }

    [Space]
    [Tooltip("64-8192の間の2の累乗の数字である必要がある")]
    public FFT_Resolution FFT_res = FFT_Resolution._512;
    [SerializeField] private AudioSource source;
    [SerializeField] private int dataOffset = 0;
    [Tooltip("高速フーリエ変換の窓関数指定")]
    [SerializeField] private FFTWindow FFT_wf = FFTWindow.Triangle;
    [HideInInspector] public float[] spectrumData = null;
    private float[] data;

    private void OnEnable()
    {
        //準備
        var clip = source.clip;
        data = new float[clip.channels * clip.samples];
        source.clip.GetData(data, dataOffset);
        spectrumData = new float[(int)FFT_res];
    }

    public void FixedUpdate()
    {
        Refresh();
    }

    private void Refresh()
    {
        bool cond = source.isPlaying && source.timeSamples < data.Length;

        //周波数成分取得
        if (cond) source.GetSpectrumData(spectrumData, 0, FFT_wf);
        else spectrumData = Enumerable.Repeat<float>(0, (int)FFT_res).ToArray();
    }
}

 このスクリプトでは毎秒所定の回数でspectrumDataの配列に周波数の数字を入れて更新されるようになっています。メインの処理をやっているのはAudioSource.GetSpectrumdataで、長くなるので細かい説明は省きますが、ここではその時点周辺の音源のサンプル値を元に、周波数毎の振幅を計算する高速フーリエ変換(FFT)という処理を行っています。FFTWindowはFFTを掛ける時に元の信号をどういう風に切り出すかを選ぶための関数です。最終的な絵には大きく影響しないので一旦好きなのを選んでいいです。FFTの計算をする都合上、計算して得られる数字は2の累乗個に限られるので、欲しい数だけ選んでFFT_Resolutionで指定してください。
 この辺の詳しい話はデジタル信号処理とかの分野の本とか記事を見れば教えてくれます。

2. どう可視化するか決める

 ほしいデータが手に入ったので、次はどう見せたいかを決めましょう。「オーディオスペクトラム」でGoogle検索を掛けると色んなパターンが出てきます。一続きの線で表示したいならLineRendererが使えるかもしれませんし、壁にそういう模様を投影するのもいいかもしれません。折角3DCGが得意なUnityを使っているんですから、今回は3Dのキューブが伸び縮みするパターンで行きましょう。

3. 可視化部分を実装する

 無難にTransformで個々のキューブを伸縮させてもいいんですが、どうせ同じマテリアルを入れることになりますし前回折角シェーダを書いたので今回もシェーダでやってみましょうか。まずはBlenderでも何でもいいのでキューブが均等に16個横並びになっただけのMeshを準備します。MeshのPivotがMesh全体の中心に来ている事だけ確認しておきます。

こんなの

 続いてシェーダを書きます。今回はvertexシェーダを使います。今回は作ったMeshのキューブ一個一個に対応するパラメータを準備し、ここにC#スクリプトから周波数を放り込んで動かす事にします。

Shader "test/3DAudioSpectrum"
{
    Properties{
        _MainTex("Texture", 2D) = "white" {}
        _Intensity("Emission Intensity", float) = 1
        _Width("width", float)=2
        _Amp("Amp", float) = 1
        _Param0("Param0", range(0,1)) = 0
        _Param1("Param1", range(0,1)) = 0
        _Param2("Param2", range(0,1)) = 0
        _Param3("Param3", range(0,1)) = 0
        _Param4("Param4", range(0,1)) = 0
        _Param5("Param5", range(0,1)) = 0
        _Param6("Param6", range(0,1)) = 0
        _Param7("Param7", range(0,1)) = 0
        _Param8("Param8", range(0,1)) = 0
        _Param9("Param9", range(0,1)) = 0
        _Param10("Param10", range(0,1)) = 0
        _Param11("Param11", range(0,1)) = 0
        _Param12("Param12", range(0,1)) = 0
        _Param13("Param13", range(0,1)) = 0
        _Param14("Param14", range(0,1)) = 0
        _Param15("Param15", range(0,1)) = 0
        }

        SubShader{

            Tags { "RenderType" = "Opaque" }

            CGPROGRAM
            #pragma surface surf Lambert vertex:vert

            struct Input {
                float2 uv_MainTex;
                float4 color;
            };

            sampler2D _MainTex;
            float _Param0, _Param1, _Param2, _Param3, _Param4, _Param5, _Param6, _Param7, _Param8, _Param9, _Param10, _Param11, _Param12, _Param13, _Param14, _Param15;
            float _Amp, _Intensity, _Width;

            void vert(inout appdata_full v) {
                float pos = v.vertex.x / _Width + 0.5;
                float cond01 = step(pos , 1. / 16);
                float cond02 = step(pos , 2. / 16);
                float cond03 = step(pos , 3. / 16);
                float cond04 = step(pos , 4. / 16);
                float cond05 = step(pos , 5. / 16);
                float cond06 = step(pos , 6. / 16);
                float cond07 = step(pos , 7. / 16);
                float cond08 = step(pos , 8. / 16);
                float cond09 = step(pos , 9. / 16);
                float cond10 = step(pos , 10. / 16);
                float cond11 = step(pos , 11. / 16);
                float cond12 = step(pos , 12. / 16);
                float cond13 = step(pos , 13. / 16);
                float cond14 = step(pos , 14. / 16);
                float cond15 = step(pos , 15. / 16);
                v.vertex.y =cond01 * v.vertex.y * _Param15 * _Amp + (1 - cond01) * v.vertex.y;
                v.vertex.y =(1 -cond01) * cond02 * v.vertex.y * _Param14 * _Amp + (1 - (1 - cond01) * cond02) * v.vertex.y;
                v.vertex.y =(1 -cond02) * cond03 * v.vertex.y * _Param13 * _Amp + (1 - (1 - cond02) * cond03) * v.vertex.y;
                v.vertex.y =(1 -cond03) * cond04 * v.vertex.y * _Param12 * _Amp + (1 - (1 - cond03) * cond04) * v.vertex.y;
                v.vertex.y =(1 -cond04) * cond05 * v.vertex.y * _Param11 * _Amp + (1 - (1 - cond04) * cond05) * v.vertex.y;
                v.vertex.y =(1 -cond05) * cond06 * v.vertex.y * _Param10 * _Amp + (1 - (1 - cond05) * cond06) * v.vertex.y;
                v.vertex.y =(1 -cond06) * cond07 * v.vertex.y * _Param9 * _Amp + (1 - (1 - cond06) * cond07) * v.vertex.y;
                v.vertex.y =(1 -cond07) * cond08 * v.vertex.y * _Param8 * _Amp + (1 - (1 - cond07) * cond08) * v.vertex.y;
                v.vertex.y =(1 -cond08) * cond09 * v.vertex.y * _Param7 * _Amp + (1 - (1 - cond08) * cond09) * v.vertex.y;
                v.vertex.y =(1 -cond09) * cond10 * v.vertex.y * _Param6 * _Amp + (1 - (1 - cond09) * cond10) * v.vertex.y;
                v.vertex.y =(1 -cond10) * cond11 * v.vertex.y * _Param5 * _Amp + (1 - (1 - cond10) * cond11) * v.vertex.y;
                v.vertex.y =(1 -cond11) * cond12 * v.vertex.y * _Param4 * _Amp + (1 - (1 - cond11) * cond12) * v.vertex.y;
                v.vertex.y =(1 -cond12) * cond13 * v.vertex.y * _Param3 * _Amp + (1 - (1 - cond12) * cond13) * v.vertex.y;
                v.vertex.y =(1 -cond13) * cond14 * v.vertex.y * _Param2 * _Amp + (1 - (1 - cond13) * cond14) * v.vertex.y;
                v.vertex.y =(1 -cond14) * cond15 * v.vertex.y * _Param1 * _Amp + (1 - (1 - cond14) * cond15) * v.vertex.y;
                v.vertex.y =(1 -cond15) * v.vertex.y * _Param0 * _Amp + cond15 * v.vertex.y;
            }

            void surf(Input IN, inout SurfaceOutput o) {
                o.Albedo = tex2D(_MainTex, IN.uv_MainTex.yx).rgb * _Intensity;
            }
            ENDCG
        }
            Fallback "Diffuse"
    }

 今回はオブジェクトのローカル座標を使ってキューブそれぞれを識別して16個のパラメータに対応させてみました。何でPropertyに可変長の配列って使えないんだ。_Widthには次に作るスクリプトでMeshRendererのBoundsの数字が入るので、メッシュ全体の左右の境界の間を16等分して位置を閾値としている訳ですね。元の頂点位置にパラメータを乗算する処理をしているので棒が伸縮しますが、ココを加算にすると上下にはねるような動きになります。
 シェーダでstep関数を多用しているのはif文を使わないようにするためです、別にif文使ったらダメということは無いですけど。

閑話 シェーダの分岐処理ってどうなん

 話題が逸れますが、シェーダに分岐処理多用すると重くなるよ~みたいな話が有ったり無かったりします。頂点ごと/ピクセルごとに別の分岐に入ると、分岐路両方の処理を通過してそれぞれ処理する/しないを決めることになるので余計な待ちが発生したりするみたいです。一回だけ走る処理ならまだしも大量に並列に走るならそれだけ無駄にもなるということらしいです。どうであれ個人的な感想としては分岐を多用する想定で設計されてない気がします。多重の分岐みたいな構造化した処理は可能なら避けて書いた方がいいっぽい。今回みたいに関数で代用するとか三項演算子使うとかループをunrollするとか。直感的にもSIMDの長所は削がれるだろうなって思いますしね。このへんの有識者求む、本当に。ちなみに私は試しにお遊びで分岐マシマシのシェーダを書いてみたらコンパイル時にパーサースタックをオーバーフローさせたことがあります。この辺は私も調べてる最中なので皆さんも調べてみてください。

 閑話休題。続きを作ります。新規マテリアルを作成し、作ったシェーダを新規マテリアルにD&D、作成したマテリアルを準備したメッシュにD&Dして割り付けます。

 次に求めた周波数をマテリアルのパラメータに渡して更新し続けるスクリプトを書きます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class AudioSpectrumMaterialSetter : MonoBehaviour
{
    [SerializeField] private GetAudioData audioData;
    [SerializeField] private Material mat;
    [Tooltip("バー16本にそれぞれサンプル番号(周波数)を割り当てる、数字は0以上GetSpectrum.FFT_res以下。サンプルごとの実際の周波数はFk = outputSampleRate * 0.5 * sampleNum / FFT_res")]
    [SerializeField] private int[] indices;
    [SerializeField] private float amp = 10;

    private int res;
    public Renderer mesh;
    private void OnEnable()
    {
        res = (int)audioData.FFT_res;
        if(mesh != null) mat.SetFloat("_Width", Mathf.Abs(mesh.bounds.size.x/mesh.transform.lossyScale.x));
    }

    public void FixedUpdate()
    {
        RefreshMaterial();
    }

    private void RefreshMaterial()
    {
        for (var i = 0; i < indices.Length; i++)
        {
            float dat = audioData.spectrumData[Mathf.Clamp(indices[i], 0, res - 1)];
            mat.SetFloat("_Param" + i.ToString(), Mathf.Clamp01(dat * amp));            
        }
    }

    private void Reset()
    {
        for (int i = 0; i < 16; i++) mat.SetFloat("_Param" + i.ToString(), 0);
    }
}

 このスクリプトでは得た周波数の中のどれかをindicesで選んでマテリアルに渡し、FixedUpdateで更新し続けています。必要なものはそろったので、適当に参照を付けて実行してみます。

動いた

 ここで下の画像みたいに綺麗に個々のキューブがきれいに伸縮しない場合は幅が均等じゃないとかBoundsがおかしい可能性があるので確認してみてください。

違う、そうじゃない

おまけ バリエーションのための案

 さあ、一通り動いたので記事としての本題は終わりなんですが、こういうこともできそうっていうアイデアと実装後の絵だけ置いておくので興味がある人は自分で実装してみてください。

①    減衰処理を入れる
 マテリアルに数字を渡す前に何か計算を挟んでみる、減衰じゃなくてもいいけど

②    ローカル位置ではなく頂点カラーで棒を識別する
 メッシュ自体にグラデーションを付けて識別させればモデルを差し換えるだけでローカル座標に左右されず好きに棒を並べる余地ありそう、あとは並べ方によってはCubeの動き方の処理を変えてみるとか

丸く並べてみるとか

③    LineRendererでやる
 棒じゃなくて線で出すパターン

これはこれで良い感じじゃない???

④    音圧に連動してScaleを変える
 切り出したサンプル値とかFFT後の周波数のグラフの下の部分の面積はなんらか音圧に相関ある数字になっているはず

⑤    2Dで模様を出す
 vertexシェーダではなくsurfaceシェーダかfragmentシェーダでなんかやる

一昔前のオーディオプレイヤーの表示こんなんだった気がする
16セグメントの文字とか足したい

⑥    16個以上のパラメータを渡す
 Unityで扱えるHLSLシェーダは配列の計算があまり得意な印象ではないです(異論は認める)。Propertyにも可変長の配列出せないし。そういう時は1DのRenderTextureとかにデータをまとめてしまってテクスチャの形式で渡せば便利かもしれません。テクスチャで読み取るなら順番に読んでいくだけじゃなくて規則的に変化を付けて読み込んだりもできますしね。CustomRenderTextureを使えば前のフレームのデータも参照できるのであれこれ出来そう。頂点シェーダでテクスチャをサンプルする時はtex2Dではなくtex2Dlodを使う事に注意

パラメータ数だけコード行数も増やすなんて事したくないんですよ


 終わりです、好きに実装してみてください。さようなら。



この記事が気に入ったらサポートをしてみませんか?