STYLY (Unity+Playmaker) でインタラクティブミュージック

## TL; DR（数行まとめ）

- VR作品公開プラットフォーム STYLY 上でインタラクティブミュージックを行う方法（の一例）を解説します．私たちの作品 [Birds of a Scape](https://newview.design/works/birds-of-a-scape/) での実装を具体的に解説します．今回実装したのは以下の2つです．
    - アクションや視線方向に応じた「縦の遷移」
    - いつ鳴らされても BGM と協和する効果音

## はじめに

こんにちは，エンジニアの yos1up です．最近，趣味活動で VR 作品の制作に関わり始めたのですが[^99]，とても楽しいので，技術的なことを記事化しておこうと思いました．

[^99]: [toidcoder](https://toidcoder.fanbox.cc/) というグループで，3人のメンバーで活動をしています．

VR 作品を公開できるプラットフォームの一つに [STYLY](https://styly.cc/) があります．今回私たちは，STYLY 作品を応募できるコンペ [NEWVIEW AWARDS 2020](https://newview.design/awards) に向けて， VR 作品 Birds of a Scape を制作して応募しました[^0]．この作品のコンセプトは「インタラクティブに光と音の景色を作っていく」というもので，私はこの中のインタラクティブミュージックの実装を担当しました．VR 作品およびインタラクティブミュージックの実装は初めてだったのですが，非常に楽しいものでした．

[^0]: ありがたいことにこの作品は非常に高い評価をいただき，GOLD PRIZE を受賞しました．

### STYLY と Unity と Playmaker の関係
VR 作品の制作は初めてでしたが，STYLY 上の VR 作品の制作は Unity で完結できる[^90]ため，Unity に習熟してさえいれば誰でもすぐに制作を始められるようになっています．ただし一つ大きな制約があり，Unity のスクリプト（C# or JavaScript）は STYLY にアップロードすると動作しません．その代わりとして，STYLY は [Playmaker](https://assetstore.unity.com/packages/tools/visual-scripting/playmaker-368) に対応しています．Playmaker は，Unity Editor 上でビジュアルプログラミングを可能にする Unity のパッケージです．Playmaker を用いて実装されたロジックは，STYLY にアップロードしても動作します[^2]．

したがって，STYLY 上の作品でインタラクティブミュージックを実現するには，**Unity+Playmaker でインタラクティブミュージックのロジックを実装する**必要があり，これにはいくらかの試行錯誤や困難がありました．そこでこの記事では，作品 Birds of a Scape の中で実装したインタラクティブミュージックのロジックに触れつつ，Playmaker（の標準機能）でインタラクティブミュージックを行う方法を解説していきます．

[^90]: 具体的には，Unity の Scene として開発します．できあがった Scene は，簡単操作で STYLY にまるっとアップロードできます．

[^2]: Playmaker には，標準で使える機能の他に，有志が開発した拡張機能を追加でダウンロードして使える仕組みが備わっています．しかしそれらは STYLY のサポート外のため，私たちは Playmaker の標準機能のみを用いて実装しました．

## もくじ

- [1. Playmaker の標準機能でインタラクティブミュージックを行うための準備](#1-playmaker-の標準機能でインタラクティブミュージックを行うための準備)

    - 縦の遷移をするために

    - 曲再生位置の取得
    
    - 注意：STYLY では conditional expression アクションは動作しない


- [2. Birds of a Scape で実装した「仕掛け」の紹介](#2-birds-of-a-scape-で実装した仕掛けの紹介)

    - アクションによって曲が盛り上がっていく（アクションに依存した縦の遷移）

    - 空を見上げた時だけ音が響く（視線方向に依存した縦の遷移）

    - プレイヤーのアクション次第で曲の行き着く先が変わる（縦の遷移を利用したマルチエンディング）

    - アクション効果音が音楽と協和する（音楽再生位置に依存した効果音の再生）


##1. Playmaker の標準機能でインタラクティブミュージックを行うための準備

今回メインのシーン用に作成した曲は[こちら](https://soundcloud.com/yos1up/a-special-night)になります．

![スクリーンショット 2021-01-12 15.31.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145122/b80536ee-517b-4e80-f3a0-59b077590696.png)

この曲は多数のトラック（楽器・パート）からなります．これらのトラックは，予めばらして別々の音声ファイルとして用意しておきます（これらの音声ファイルを同時再生すると，もとの曲が聞こえる）．

### 縦の遷移をするために

インタラクティブミュージックの手法の中でも「曲を構成する全トラックのうち，一部のトラックのオン・オフを切り替えたり，トラック間の音量バランスを変更したりする」ことによって聴こえ方を変化させるような手法は，**縦の遷移**と呼ばれます．今回，この縦の遷移を Playmaker で実装するため，以下の方針をとりました．

- 全てのトラックは予め別々の AudioSource に読み込んでおき，曲の再生を開始したいタイミングで，(初めの時点ではオフにしておきたいトラック含め)全ての AudioSource を同時に再生開始します．

    - 全トラックを同時に再生開始するタイミングは，他の処理が混み合うタイミング(例えばシーン読み込み完了直後など)とは意図的にずらした方が良いです．処理が不安定な最中に全トラックを一斉に再生開始すると，全てのトラックが縦に揃わずに，数十 ms 程度ずれて再生されてしまうことがありました．

- トラックのオン・オフは，トラック音量の設定により実現します．

    - 音量を 0 に設定することで，トラックをオフにできます．

後で解説する「トラックをオンにされた直後だけ一時的に音量を大きめにする機能」や「視線のY方向に応じて音量調整する機能」など，トラック自身にいくつかの機能を持たせたかったので，単一のトラックを管理できる Audio GameObject を作成し，その上の Playmaker FSM としてそれらの機能を実装しました．（その上で，この GameObject を Prefab 化しておき，シーン開始時にトラックの数だけこの Prefab をインスタンス化するように実装しました．）トラックの FSM は以下のようになっています．

![スクリーンショット 2021-01-20 16.12.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145122/6fb2c204-56bc-4bb4-5d90-8cbf9834e432.png)

- 初期化：WaitingForStateBGM で待機します．グローバルイベント StartBGM が送られると，PlayingBGM ノードへ遷移し，そこで Audio Play アクションが実行され，その後 ComputeAdjustedVolumeContinuously ノードへ遷移します．

- 音量の状態を毎フレーム管理する：ComputeAdjustedVolumeContinuously ノードの処理は，いずれも毎フレーム実行する設定にしてあります（Every Frame チェックボックスをオンにしています）．毎フレーム，トラックの音量を， (基本の音量) * (視線のY方向に依存した係数) * (オン直後の一時的な音量調整のための係数) により計算します．
    - 基本の音量：ベースとなる音量設定です．外部から値を代入されることで，基本の音量が変化します（この FSM 内のロジックで値が書き換えられることはありません）．
    - 視線のY方向に依存した係数：毎フレーム，カメラオブジェクトの方向ベクトルのY成分の一次関数により計算します（後述）．
    - オン直後の一時的な音量調整のための係数：初期値は 1.0 ですが， 1.0 以外の値となっている時には，毎フレーム 1.0 に指数関数的に近づいていくように計算されます[^12]．このトラックをオンにする時に，外部から 1 より大きな値（例えば 1.5）を代入することで，「オン直後だけ音量がちょっと大きく，少し経つと普通になる」挙動が実現できます．

[^12]: 実際には，毎フレーム `x += (1.0 - x) * 0.02` を実行しています（`x`: 「オン直後の一時的な音量調整のための係数」）．これにより， `x` は 1.0 へと指数関数的に近づいていく挙動となります． 

### 曲再生位置の取得

Playmaker の標準機能には，AudioSource から直接「現在の曲再生位置」を取得するアクションは存在しませんが，それとは別に「シーンスタート時点からの経過時間」を取得するアクションはあるので，これを使って間接的に現在の曲再生位置を求めます．具体的には，上記の全トラック再生開始処理を行う時点で「シーンスタート時点からの経過時間」を取得して変数t0に格納しておき，その後，現在の曲再生位置を取得したくなった時には，改めて「シーンスタート時点からの経過時間」を取得して変数 t1 に格納し，t1 と t0 の差を求めれば良いです．これで，曲再生位置を「秒」の単位で得ることができます．なお，曲再生位置を「秒」の単位ではなく「拍」の単位で知りたい場合は，テンポ (BPM) の値を掛けて 60 で割ることにより「秒数」から「拍数」に変換できます．

### 注意：STYLY では conditional expression アクションは動作しない

Playmaker には標準で conditional expression という便利なアクションが用意されています．任意の条件式を文字列により指定でき（例えば `x / y < z + w` のように），その結果に応じた条件分岐をしてくれます．しかし，STYLY は conditional expression アクションに対応していません[^3]．STYLY 上で正常に動作させるには，conditional expression アクションの使用をやめて，その中の条件式そのものを Int/Float Operator アクションや Int/Float Compare アクションなどで実装し直せば OK です[^4]．

[^3]: 具体的には，conditional expression アクションが実行されようとするタイミングで，その FSM の遷移は停止してしまいます．なお，2020 年 10 月時点の情報です．現在はどうかわかりません．

[^4]: ただし，余りを求める演算 `x % y` は Operator アクションでは実行できないので，代わりに `x - floor(x / y) * y` と計算する必要があります．なお，floor(...) は「...以下の最大の整数」の意味です．これには Set float to int アクションを利用します．


##2. Birds of a Scape で実装した「仕掛け」の紹介

Birds of a Scape は「インタラクティブに景色を作っていく」という体験をコンセプトにした作品です．作中でプレイヤーは，不死鳥とともに夜空を飛行しながら，トリガーアクションにより星空を賑わせ，家々に明かりを灯していき，鳥の仲間を増やしていき，徐々に景色が賑やかになっていきます．（[紹介動画](https://twitter.com/yos1up/status/1338787325464047618)）

### アクションによって曲が盛り上がっていく（アクションに依存した縦の遷移）

本作品では，プレイヤーがコントローラーのトリガーを引くことで，景色が一段階盛り上がります[^5]．この「盛り上がり」が生じる際に，BGM の楽器（トラック）が一種類増えるような演出を実装しました．その際，以下に気をつける必要がありました．

[^5]: 実際にはトリガーにはクールタイムが設定されており，仮にトリガーボタンを連打し続けたとしても，景色が盛り上がる効果が生じるのは数秒に一度となります．


- 盛り上がりが生じた際に，まだオンになっていないトラックのうち，ちょうど 1 つのトラックだけがオンに変化してほしいです．

- 多くのトラックは，曲全体にわたって音符が入力されているわけではなく，曲の一部の区間にしか音符が入力されていません．現在の曲再生位置が，そのトラックの「音符が入力されている区間」の外であるときに，そのトラックをオンにしたとしても，直ちに何かが聴こえ始めることはありません．そのような事態は避けたいです．

そこで，以下のようなロジックを実装しました（今回の曲では，景色の盛り上がりに応じて一つずつオンにすることのできるトラックが 12 個ありました．以下の記述では，それらを「トラック1」「トラック2」...「トラック12」と呼んでいます）．

```
景色の盛り上がりが生じたとき
    もし，トラック1がまだオフであり，かつ現在の再生位置がトラック1の「音符が入力されている区間」の内側であるならば
        トラック1をオンにする
        処理を終える
    もし，トラック2がまだオフであり，かつ現在の再生位置がトラック2の「音符が入力されている区間」の内側であるならば
        トラック2をオンにする
        処理を終える
    もし，トラック3がまだオフであり，かつ現在の再生位置がトラック3の「音符が入力されている区間」の内側であるならば
        トラック3をオンにする
        処理を終える
    ...
    もし，トラック12がまだオフであり，かつ現在の再生位置がトラック12の「音符が入力されている区間」の内側であるならば
        トラック12をオンにする
        処理を終える
```

さらに実際の実装では，以下の工夫を行いました．

- 上に記した実装だと，12 個のトラックが解禁される順番がどのプレイヤーも全く同じになってしまいます．プレイヤーの行動によって音楽体験が多少は変わってほしいと思い，次のような工夫をしました．実は景色には「空」「鳥」「街」の三系統あり，トリガーを弾いたときの照準の向き次第でいずれか一系統だけが盛り上がるのですが，上記の 12 トラックを 4 トラックずつ 3 つのグループに分け，それらを「空のトラックグループ」「鳥のトラックグループ」「街のトラックグループ」としておき，例えば「空」の系統の景色が盛り上がった時は「空のトラックグループ」内のトラックのうち一つをオンにする，「鳥」の系統の景色が盛り上がった時は「鳥のトラックグループ」内のトラックのうち一つをオンにする
（街についても同様）という実装を行いました．これにより，プレイヤーのアクションによってトラックが解禁される順番が前後する余地が生まれました．

- トリガーを引いてトラックが一つオンになったにも関わらず．それにプレイヤーが気づかない，というケースが多くありました．そこで「トラックがオンにされた直後，そのトラックの音量は他のトラックよりも大きな値であり，そこから時間とともに，他のトラックと等しい音量まで減衰していく」という実装を行いました．

- トラックを次々オンにしていくと，次第に音割れが生じてしまいました．そこで，オンになっているトラック数に応じて，全トラックの基本の音量を動的に変更することを考えました．

実際の Playmaker の FSM は，以下のようになっています．

![スクリーンショット 2021-01-12 15.01.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145122/e9391f49-0bdb-625d-93b4-09c5dc56261c.png)

- 初期化： GenerateTracks ノードで，各トラック用の GameObject を，前述の Prefab から生成します．その後，Init ノードで曲の開始まで待機します．

- 曲の開始：StartBGM というグローバルイベントが送られてくると，（この FSM ではなく）各トラックに付随する FSM によって各トラックの再生が開始します．と同時に，（この FSM の）SetBGMStartTime ノードにて，BGM 開始時刻が変数に記録されます．その後，WaitingEvents ノードに遷移します．

- 待機状態： `WaitingEvents` ノードで待機しています．景色の盛り上がりが生じると，SkyEnhanced, BirdEnhanced, TownEnhanced のうちいずれかのグローバルイベントが送られてきますが，それらに応じて，右下エリアのノード群に状態遷移します．右下エリアでは，上述のアルゴリズムに従って，オフからオンに変化するトラックが決定されます．

- 全パートの音量を更新：ControlAllActiveTracksVolume ノードにて，全トラックの基本の音量を調整しています[^10]．オンになっているトラックの一覧が格納されている Array があるので，その要素を Array Get Next アクションを用いてイテレートしています．全要素をイテレートし終わったら，WaitingEvents ノードに戻ります．

[^10]: 元々は，オンになっているトラックの個数に応じて動的に基本の音量を設定しようと思っていたのですが，実際にやってみるとどういうわけかイマイチだったため，最終的には全てのトラックに 0.38 という（定数の）音量をセットしているだけの実装にしました． 0.38 という値は，全トラックをオンにしても音割れしないギリギリの値を探った結果の値です．

### 空を見上げた時だけ音が響く（視線方向に依存した縦の遷移）

星空がとても広い空間であることを演出するために，星空を見上げた時にのみ BGM に残響が生じるようにしました．
これは，ベーストラック(最初から最後までずっとオンのトラック)の残響部分だけのトラックを用意しておき(これも他の全てのトラックと同時に再生開始しておく)，その音量を毎フレーム適切な値に更新する，という方法で実現しました．音量の値は，視線方向ベクトルのy成分(上方向が正)の値についての一次関数にしました[^6]．
この機能は，各トラックの FSM 上に実装しました．上述の一次関数の「1 次の係数」と「0 次の係数」を FSM のパラメータとして外部から設定可能な形で実装しました．前者を 0 としておけば「視線方向に依存しない固定音量のトラック」となります．

[^6]: 一次関数の結果はときに（音量として許される）[0, 1] の範囲の外となることがありますが，実際には [0, 1]の範囲に勝手にクランプされます（たしか）．

なお「視線方向ベクトルの y 成分」は，以下のアクションにより取得できます（変数 `mainCamDirY` に -1 以上 1 以下の値が格納されます．真下が -1，水平方向が 0，真上が 1 となります）．

![スクリーンショット 2021-01-20 16.14.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145122/6b703436-438b-ada2-33c9-d9cd24cdec67.png)


### プレイヤーのアクション次第で曲の行き着く先が変わる（縦の遷移を利用したマルチエンディング）

今回の曲では，終盤のある時点からドラムが鳴り始めますが，その際に「2 種類用意されているドラムトラックのうちどちらがオンになるか」が，それまでのプレイヤーの行動に依存して決まるように実装しました．曲の再生位置が所定の拍数に達するとイベントが生じ，その時点で「プレイヤーのそれまでの行動内容を反映するある変数についての条件」によっていずれか一方のトラックのみがオンに切り替わります．

Playmaker の FSM 上では， 所定の拍数に到達したタイミングで TimeToDetermineDrum イベントが発火し， DetermineDrum ノードへ遷移します．それまでのプレイヤーのアクションに依存して  PreferSky または PreferTown イベントが発火し，遷移先の Switch(Timpani|Drummachine)On ノードにて，いずれかのドラムトラックがオンとなります．なお，TimeToDetermineDrum イベントが繰り返し発火しないように，一度発火した時点で専用のフラグが立ち，そのフラグが立っている場合には TimeToDetermineDrum イベントが発火しないように制御しています．

### アクション効果音が音楽と協和する（音楽再生位置に依存した効果音の再生）

トリガーを引くたびに鳴る効果音が，その時点に流れている曲と協和するような仕組みを実装しました．効果音はピチカートの和音で作っていますが，この和音が，その時点での曲のコードと調和するようにしました．具体的には，現在の再生位置（拍数)から現在の曲のコード[^7]を調べ，その結果に応じて効果音を鳴らし分けました．さらに，コードが変化しない短時間の間に複数回トリガーを引いても色々な音が鳴るようにするため，各コードに協和する効果音音声を（1 通りではなく）4 通り用意し，その中からランダムに 1 つを再生するようにしました[^8]．

[^7]: 今回の曲では D♭, E♭, Fm, Cm, A♭sus4, A♭, B♭m, Csus4, C, G♭, E♭m のうちのいずれかです．

[^8]: Array Get Random アクションを利用しました（連続して同じサンプルを引かないオプションもオンにしました）．例えば D♭ の効果音の AudioClip が 4 つありますが，これらを長さ 4 の配列に（予め静的に）格納しておき，その配列から Array Get Random アクションにより 1 つの AudioClip を取得し，それを Audio Play アクションで再生することで「D♭ 効果音の再生」が実現します．なお，ランダムな音を再生することに特化した Play Random Sound アクションもありますが，このアクションでは，動的に生成される AudioSource のプロパティをこちらで変更できず，（今回の用途ではオフにしたい）ドップラー効果をオフにできなかったため，使用を諦めました．対照的に，Audio Play アクションでは，再生に用いる AudioSource をこちらで自由に選択できるので，予めドップラー効果をオフにした AudioSource の Prefab を静的に用意しておき，それを毎回 Create Object して，生じた GameObject を選択すれば OK です．

![スクリーンショット 2021-01-12 15.01.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145122/02cac856-974c-f0b4-2636-195fc0021c60.png)

- 初期化： Init3 ノードで曲の開始まで待機します[^11]．

[^11]: Init1, Init2 ノードでは，プレイヤーの頭の位置に相当するカメラオブジェクトを取得していますが，実際にはこの FSM ではカメラの位置を特に使用していないので，不要です．

- 曲の開始：StartBGM というグローバルイベントが送られてくると，（この FSM ではなく）各トラックに付随する FSM によって各トラックの再生が開始します．と同時に，（この FSM の）SetBGMStartTime ノードにて，BGM 開始時刻が変数に記録されます．その後，WaitingForSounding ノードに遷移します．

- 待機状態： WaitingForSounding ノードにいます．コントローラーのトリガーが引かれると，まず FindingController(R|L) ノードでコントローラーオブジェクトを取得します．そして ComputingTargetPos ノードで「効果音を発生させる空間位置」を計算します．コントローラーから出る光線エフェクトの先端あたりから効果音を発生させたかったので，（コントローラー座標）+（定数）*（コントローラーの方向ベクトル）という計算を行っています．

- BGM の再生位置を拍数で取得：ComputingBGMPosition ノードにて，現在 BGM 開始から何拍目であるかを求めています．

- 効果音の音量を決定：はじめのうちは効果音の音量を一定にしていたのですが，それだと，まだ曲が大して盛り上がっていない時には効果音がうるさく感じられ，一方で，曲がすっかり盛り上がりきってしまった時には効果音がほとんど聴こえない，という事態になってしまいました．そこで，曲の再生位置に依存して効果音の音量を決定するようにしました．DetermineVolume ノードにて，曲の静かな部分かそうでない部分のいずれであるかを決定し，それに応じて SetVolume(0|1) ノードで定数の音量を設定しています．

- 現在再生中のコードを計算：Sounding ノードで，現在の拍数から再生中のコードを計算します．曲のコード進行をまるごと格納した配列を用意しておいてそれを参照するだけの方法でも良かったのですが，配列を打ち込むのが面倒だったのとコード進行の大部分が繰り返しだったため，モジュロ演算と条件分岐を使って拍数からコードを求めるロジックを組みました．一部，特殊なコード進行をする箇所については SoundingInSpecialChordSeq(1|2) ノードで処理を行っています．

- 所定のコードの効果音を鳴らす：画像右側にある各コード名のノードで，それぞれのコードの効果音を鳴らす処理を行います．Array Get Random アクションで AudioClip を取得し，Audio Play アクションを用いてその AudioClip を再生します（詳細は前述の脚注をご覧ください）．

## おわりに

最後までお読みくださりありがとうございました． STYLY での作品制作に関する情報源の一つとしてお役に立てれば幸いです．