参考記事
ZenjectとUniRx.MessageBrokerの相性が抜群
C#6.0時代のUnity
環境
Unity 2017.3.1
.NET 4.6(c#6.0)
経緯
かなり短めに伝えると、
起:Code: Transcendence by reminisce
こういうものが作りたい。
MessageBrokerを使って、一定周期毎に発行するストリームを用いて音楽の同期を取れば良いのではないか?
承:フレーム単位よりも細かく、ms単位の精度でMessageBrokerを一定間隔でPublishしたい
転:MessageBrokerは良くないらしいのでSubjectで作成・ms単位は無理っぽいのでframe単位で安定性を重視
↑いまここ
結:完成してGitHubにあげる。
ということで、ms単位の精度を求めること自体が間違いなのではないかという結論に達するまでの試行錯誤の記事です。
失敗集
Observable.Interval
今回はUniRxとZenjectを用いることが前提であり、まずObservable.Intervalでmsを指定してやれば簡単に行けるんじゃないかなと思ってました。
void Start()
{
float start_time, previous_time = Time.realtimeSinceStartup;
Observable.Interval(TimeSpan.FromMilliseconds(10))
.Subscribe(_ =>
{
start_time = Time.realtimeSinceStartup;
//10msではなく、1frame待つことになる。
Debug.Log($"Interval time:{start_time - previous_time}");
previous_time = start_time;
}).AddTo(this);
}
これでいけると思っていたのですが、実際に動かしてみると

10ms毎に発行するのではなく、frame単位(1/60秒)で動作していました。
その他色々な方法・Coroutine・Taskについても実施しましたが、これらもframe単位の動作であり、msレベルの待機や発行が実施できるものではありませんでした。
Coroutine
bool isDestroyed = true;
void Start()
{
StartCoroutine("MsCoroutine");
}
private IEnumerator MsCoroutine()
{
float start_time, previous_time = Time.realtimeSinceStartup;
while (true)
{
//10msではなく、1frame待つことになる。
yield return new WaitForSeconds(0.01f);
start_time = Time.realtimeSinceStartup;
Debug.Log($"Interval time:{start_time - previous_time}");
previous_time = start_time;
}
}
private void OnDestroy()
{
isDestroyed = true;
}
Task
bool isDestroyed = true;
void Start()
{
MsTask();
}
async void MsTask()
{
float start_time, previous_time = Time.realtimeSinceStartup;
while (!isDestroyed)
{
//1frame + α待つことになる。
await Task.Delay(10);
start_time = Time.realtimeSinceStartup;
Debug.Log($"Interval time:{start_time - previous_time}");
previous_time = start_time;
}
}

Taskだと基本1frameで、貯まると2frame待つ感じになるのかな。
5frameで16.6ms分貯まるってのが良く分からないけれども。
結論
FixedUpdate
結局の所、ms単位で実施は無理っぽいので、だったら確からしさを重視して作成……となるとFixedUpdateが一番良いのかなというのが今の所の結論です。
MonoBehaviour.FixedUpdate()
MonoBehaviour が有効の場合、この関数は毎回、固定フレームレートで呼び出されます。
固定フレームレートで呼ばれるという点ですが、これは少し注意が必要です。
固定フレームレートというのは、例えばFixedUpdateが1秒に50回実施する場合はラグ等で1秒48回になったとしても次の1秒を52回実施することで補填するというぐらいの意味で認識しておいてください。
また、このFixedupdateの実施タイミングはUpdateの方の実行タイミングに依存します。
簡単に言うと、例えばfixedUpdateが50fpsでUpdateが60fpsだったら、fixedUpdateの実行タイミングは下記画像のようなIntervalになります。

今回はFixedUpdateを使用していますが、より精度を高めるためにFixedTimestepをデフォルトの0.02ではなく0.01で実施しています。
float _startTime, _nextTickTime;
void Start()
{
_startTime = 0;
//1Tickあたりの時間を代入する。
_nextTickTime = (float)_tempo.SecondsPerTick();
_tickReactive
//始めの1小節は不安定なので発行しない。
.Skip(_tempo.TickPerTuplet * 8)
.Subscribe(_ =>
{
_Timing.Tick++;
if (_Timing.Tick % _tempo.TickPerTuplet == 0)
{
_Timing.Tuplet++;
if (_Timing.Tuplet % _tempo.TupletPerBeat() == 0)
{
_Timing.Tick = 0;
_Timing.Beat++;
if (_Timing.Beat % _tempo.BeatPerBar() == 0)
{
_Timing.Bar++;
_Timing.Tuplet = 0;
_Timing.Beat = 0;
if (_Timing.Bar % 4 == 0)
{
if (_Timing.Bar == 0)
{
_nextTickTime -= _startTime;
_startTime = 0;
}
//4小節毎に発行する。
_rhythmObserver.FourBars.OnNext(new TimingForFourBars(_Timing));
}
//1小節毎に発行する。
_rhythmObserver.Bar.OnNext(new TimingForBar(_Timing));
}
//1beat毎に発行する。
_rhythmObserver.Beat.OnNext(new TimingForBeat(_Timing));
}
//連符毎に発行する。
_rhythmObserver.Tuplet.OnNext(new TimingForTuplet(_Timing));
}
//Tick毎に発行する。
_rhythmObserver.Tick.OnNext(new TimingForTick(_Timing));
}).AddTo(this);
}
private void FixedUpdate()
{
_startTime += Time.deltaTime;
if(_startTime > _nextTickTime - (Time.deltaTime / 2))
{
_tickReactive.OnNext(Unit.Default);
//1Tickあたりの時間を追加する。
_nextTickTime += (float)_tempo.SecondsPerTick();
}
}
private void Start()
{
float first_time = Time.realtimeSinceStartup;
//1小節毎に購読する。
_rhythmReceiver.Bar
.Subscribe(_ =>
{
Debug.Log($"Bar:{_.Bar},TotalTime:{Time.realtimeSinceStartup - first_time}");
}).AddTo(this);
}

BPM120で4分の4拍子なので、1小節あたり2秒間隔で購読できているとOK。

これでOKだと考えています。
