🎮

UnityのTimelineでオーディオの再生終了をループで待つ

に公開

はじめに

これは『Unityゆるふわサマーアドベントカレンダー2019』の初日の記事です。今回はUnityのTimelineに関するちょっと変わった使い方の紹介をしたいと思います。

UnityのTimelineはマルチトラックに演出等を作成するための標準機能で、主にゲーム中のムービーシーン・カットシーンの作成に役立つものです。標準のAnimationと異なり、復数のアセットを同期的に動かすことが出来るため、複雑な演出をほとんどコードを書かずに実現することが出来ます。

そんな優れたTimelineですが、基本的にはその名の通り時系列に沿った演出が主になります。しかし、コンテンツによってクリップの長さを可変にすることで出来る表現があるのではないかと思いました。そこで今回はオーディオの長さに応じて自動的に再生位置を調整するTrackを実装してみました。

セリフを読み終わるまでループで待つTrack

こちらのリポジトリを改変する形で作りました。
参考: tsubaki/Timeline-Loop

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">これはブログ埋め込み用の動画です <a href="https://t.co/mgtmNskpSW">pic.twitter.com/mgtmNskpSW</a></p>— Nakaji Kohki / リリカちゃん (@nkjzm) <a href="https://twitter.com/nkjzm/status/1156552125364617217?ref_src=twsrc^tfw">July 31, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
© UTJ/UCL

音声が再生し終わるまで、LoopClipの上を繰り返しシークバーが移動している様子が確認出来ると思います。

実装

ほぼほぼtsubakiさんのままですが、折角なので解説込みで紹介します。

まずTimelineの基本的な構成の話です。イメージとしてはTrackAssetという枠を用意して、その中にPlayableAssetというクリップを配置します。そしてPlayableAssetの位置に合わせてPlayableBehaviourのライフサイクルが回りだす感じです。PlayableBehaviourは実行時に生成されるため、Hierarchy上の参照等はPlayableAssetから受け渡してもらう必要があります。
<img width="849" alt="afsadfas.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55365/09fb7ff0-04e7-5c36-a02c-af6dae8e7812.png">

主に3種類のスクリプトに分かれているため一見複雑ですが、PlayableBehaviourを動かすためにゴニョゴニョ準備をしている、というイメージがわかりやすいと思います。
参考: そろそろUnity2017のTimelineの基礎を押さえておこう - 渋谷ほととぎす通信

ではPlayableBehaviourを継承したこのクラスから紹介していきます。

LoopBehaviour.cs
using System;
using UnityEngine;
using UnityEngine.Playables;

[Serializable]
public class LoopBehaviour : PlayableBehaviour
{
    public PlayableDirector director { get; set; }
    public WaitTimeline waitTimeline { get; set; }
    public AudioSource audioSource { get; set; }
    public AudioClip audioClip { get; set; }
    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        if (!audioSource.isPlaying)
        {
            audioSource.PlayOneShot(audioClip, 1);
        }
    }
    float timer = 0f;
    public override void PrepareFrame(Playable playable, FrameData info)
    {
        timer += Time.deltaTime;
        if (timer < audioClip.length)
        {
            return;
        }
        waitTimeline.trigger = true;
    }
    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        if (waitTimeline.trigger == true)
        {
            waitTimeline.trigger = false;
            return;
        }
        director.time -= playable.GetDuration();
    }
}

PlayableBehaviourはタイミングに応じていくつかの関数が呼び出されます。今回のイメージとしては以下の様になっています。

<img width="542" alt="fsdfasggg.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55365/da951ff0-2545-49b5-1347-72ab818c49bf.png">

まずはPlayableAssetの再生時に実行されるOnBehaviourPlay()では音声の再生をしています。

public override void OnBehaviourPlay(Playable playable, FrameData info)
{
    if (!audioSource.isPlaying)
    {
        audioSource.PlayOneShot(audioClip, 1);
    }
}

続くPrepareFrame()は毎フレーム呼ばれる関数で、経過時間の計測を行なっています。また、経過時間がaudioClipの長さを超えた時にtriggerをTrueにしています。

public override void PrepareFrame(Playable playable, FrameData info)
{
    timer += Time.deltaTime;
    if (timer < audioClip.length)
    {
        return;
    }
    waitTimeline.trigger = true;
}

最後のOnBehaviourPause()は名前からは想像しづらいのですが、ポーズした時だけでなくPlayableAssetの最後のフレームでも呼び出される関数です。この時にtriggerがTrue、つまり再生が終わっていたらそのままにし、終わっていなければTimelineの時間を巻き戻しています。

public override void OnBehaviourPause(Playable playable, FrameData info)
{
    if (waitTimeline.trigger == true)
    {
        waitTimeline.trigger = false;
        return;
    }
    director.time -= playable.GetDuration();
}

LoopBehaviourを実行するための参照はPlayableAssetを継承したクラスで行なっています。Hierarchy上の参照に関してはCreatePlayable(PlayableGraph graph, GameObject owner)owner、つまりPlayableDirectorコンポーネントを持つGameObjectを経由して取得しています。

LoopClip.cs
using System;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[Serializable]
public class LoopClip : PlayableAsset, ITimelineClipAsset
{
    public ClipCaps clipCaps { get { return ClipCaps.None; } }

    [SerializeField]
    AudioClip audioClip = null;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        var playable = ScriptPlayable<LoopBehaviour>.Create(graph);
        LoopBehaviour beheviour = playable.GetBehaviour();
        beheviour.director = owner.GetComponent<PlayableDirector>();
        beheviour.waitTimeline = owner.GetComponent<WaitTimeline>();
        beheviour.audioSource = owner.GetComponent<AudioSource>();
        beheviour.audioClip = audioClip;
        return playable;
    }
}

再生するAudioClipだけはPlayableAsset毎に異なるため、PlayableAssetのInspector上から指定する形式にしています。

<img width="559" alt="スクリーンショット 2019-07-31 22.50.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55365/8f8f4e0d-fe5c-e54d-1bc7-3862965336ff.png">

最後はPlayableAssetを配置するためのTrackAssetの実装です。これは単に継承してTrackClipTypeで紐付けをするだけでした。

LoopTrack.cs
using UnityEngine.Timeline;

[TrackColor(1f, 0.2794118f, 0.7117646f)]
[TrackClipType(typeof(LoopClip))]
public class LoopTrack : TrackAsset
{
}

LoopClip配置時のコツ

ループ時には再生位置が巻き戻るため、再生中のアニメーションが不自然に切り替わってしまう発生しました。

今回は再生するAudioClipの長さの等倍の感覚で再生することで、切り替わり時の違和感を軽減させられました。

最後に

元々はTimelineでノベルエディタのようなようなものを作りたくて検証しはじめました。Timelineのダイナミックな演出と長さが異なるセリフの効率的な管理を両立できると思ったのですが、ループ中の制約がかなり厳しいことに気が付きました。動的に出来るメリットとしては他にも、音声を外部から参照できる形にして、ユーザーがセリフを差し替えられるツールなども作れると思います。Default Playablesを使えば字幕なども出すことが出来るので、もしやる気があったら作ったみたいと思います。

そんな感じのゆるゆわ記事でした。明日の『Unityゆるふわサマーアドベントカレンダー2019』@pCYSl5EDgoさんによる「LINQ to NativeArrayについてなんか書く」です。お楽しみに。

Discussion