土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity TimeLineにMarkerを打ち込んでオブジェクトにパラメータを送信する

この記事はUnityアドベントカレンダー2021part1の3日目の記事です。

qiita.com
前日は志麻ひぬこさんでした。
shirokurohitsuji.studio


 この記事ではTimeLineで任意のパラメータをオブジェクトに渡せる機能「Marker」について解説します。

きっかけ

 知り合いのアニメ映像作家さんがunityで映像作品を作ってまして、時折技術的な相談に答えています。土屋はunity/シェーダーについて多少理解がありますが、現場経験は多くは無く、特に映像関係はさっぱりなので「なるほどこういう所でひっかかるのか」という案件が多く、とても勉強になっています。

 ある時、その映像作家さんから「キャラが視線を向ける方向をTimelineで制御したい。でもLookAt()は使いたく無い」という要望がありました。これが今回のテーマです。

前提:LookAt()による視線移動について

 unityのTransformには、指定したオブジェクトの方向に自身を回転させるLookAt()というメソッドがあります。キャラモデルの視線だけを動かしたい場合、通常このメソッドを使います。

 unity使ってる人なら、LookAt()メソッドの相当お世話になってると思いますが、念の為手順をおさらいしておきます。

  1. 空のGameObjectをシーンに配置します。以下これを視点目標オブジェクトと呼びます。
  2. C#スクリプトを作成し、Update()メソッド内に視点目標オブジェクトを対象にしたLookAt()メソッドを記述します。
  3. 2のスクリプトを左右それぞれの眼球のボーンオブジェクトにアタッチします。

 これだけで、モデルの眼球がモーションと独立して視点目標オブジェクトを見つめるようになります*1

 なぜ視点目標オブジェクトに、シーン上に既に配置されたオブジェクトではなく、新規に作った空GameObjectを使うのかと言うと、視線の向きを動かしたい時があるからです。例えば、「キャラAが話し終え、キャラBが話し始める」というようなカットシーンを作る場合、それを眺めているキャラCの視線は「キャラA→キャラB」と移動します。

 この時、キャラC専用の視点目標オブジェクトとして空GameObjectを用意しておくと、Timeline上でこの挙動を実現できます。その空GameObjectをTimeline上で「キャラAの顔」から「キャラBの顔」に移動させれば、LookAt()が自動的に視点目標オブジェクトを追従してくれるので、スクリプトやインスペクタを弄る必要がありません。

課題:映像作家さんがLookAt()を使いたくない理由

 さて、ここからが本題です。LookAt()は手軽で便利ですが、映像作家さんは別のアプローチを欲していました。その理由は明快で「視線目標オブジェクトを動かす手間が惜しい」という物でした。

 映像作家さん曰く「ほとんどの場合、キャラの視線は他の喋ってるキャラの顔に向いている(かつ、たまに空を仰いだり地面を見たりもする)。視線対象となるキャラは喋りながら移動することもあるし、もっと言えばFIXするまでは個々のキャラの立ち位置(=配置座標)自体が変わることだってある。キャラの位置を変えるたびに、それに合わせて視線目標オブジェクトを動かすのは面倒」とのこと。

 確かに、これは微妙に二度手間に思えます。もっと言えば、シーンにキャラが5人いた場合、単純に考えれば視線目標オブジェクトも5個必要です(視線の対象はキャラから別のキャラに移動することがあるのをお忘れなく)。あるキャラが喋っているのを他の4人が見ている時、そのキャラの移動に合わせて4個の視点目標オブジェクトを動かす必要があります。場合によっては二度手間どころではなくなるかもしれません。

 映像に限らず、商業コンテンツ製作は常にクオリティと納期とのせめぎ合いです。可能な限り作業効率を上げて無駄作業を削って、その分クオリティの向上に工数を割きたいのは誰もが考える所。映像作家さんにとって、空GameObjectを動かす手間は可能であればオミットしたいわけです。これには土屋も「現場ならではの要望だ!」と感心しました。

 映像作家さんのオーダーに答えるには、シーン上の始点と終点となるオブジェクトをそれぞれ設定し、その間を任意のウェイト値で補間しながら自身の向きを更新するスクリプトが必要です。そのスクリプトにTimeLineから0.0~1.0のウェイト値を流し込めば必要な視線移動を実現できます。

 とはいえ、これを汎用的に実装するのは実は面倒です。前述したように、視点の対象となるオブジェクトは1個でも2個でもなく任意の数であることが望まれます。仮に個々のオブジェクトは番号(index)で指定出来るとして、その番号をTimeLineからスクリプトにどうやって通知すれば良いのでしょうか。

 実は、Unity2018まではこれをシンプルに実現する方法がありませんでした*2。しかし、Unity2019からTimeLineにマーカー(Marker)という、TimeLineから任意のタイミングでパラメータを通知できる機能が追加されました。

 前置きが長くなりましたが、このマーカーの使い方を、実装したコードを元に解説したいと思います。

Makerとはなにか

 まずマーカーについて説明します。マーカーはTimeLine上の任意のフレームに設定しておくと、再生時にイベントを発火し、特定のオブジェクトのメソッドを実行することができるオブジェクトです。

f:id:t_tutiya:20211202234726p:plain
 上図のTimeLine上に埋め込まれているのがマーカーです*3。マーカーはTimeLine全体に設定するグローバルな物と、特定のトラックに設定するローカルな物があります。今回は眼球ボーンにアタッチしたスクリプトにパラメータを送信するので、ローカル設定にします。

f:id:t_tutiya:20211202234730p:plain
 Timelineにマーカーを埋め込むには、コンテキストメニューから「Add ●●●●」という項目を選択します。上図で表示されている「Add Marker EyeTracker by Tsukasa」が、今回実装したマーカーです。

f:id:t_tutiya:20211202234733p:plain
 マーカーをクリックするとインスペクタが開きます。Eye Start ObjとEye End Objが、それぞれ開始と終了となる視線目標オブジェクトのインデックス番号になります。オブジェクト自体は受信側スクリプトで参照リストを持たせています*4。その他のパラメータはMarker固有オプションで、コード解説時に説明します。

f:id:t_tutiya:20211202234736p:plain
 受信側のインスペクタはこちら。unity標準のConstraints系と見た目を合わせたかったとか、TimeLineからは右目だけにイベントを発生させるようにして、左目は右目を監視するようにしたかったとかで色々パラメータがついてますが、記事の主題から外れるので今回は省略します。マーカーに関係するのは一番下にあるResorucesのリストだけです。ここに視線の目標にするオブジェクトを登録しておき、リストの番号をインデックスとしてマーカーから送信させています。

 TimeLineトラックに配置してこのマーカーを設定すると任意のタイミングでの視線移動が実現します。
 サンプル動画はこちら。キャラモデルはつみ式ミクさん部屋着風味をお借りしています。開発中の物でインスペクタのUIが古いかもしれません。
youtu.be
 モーションをループで流しているミクさんが、シーンに配置された3つのボールを左→右上→中央下の順に視点を動かしています。1個目のマーカーで「左→右上」、2個目のマーカーで「右上→中央下」を指定しています。ウェイト値をカーブで指定することで視線が滑らかに移動しています。

コード解説:イベント送信側(EyeTrackerSignalクラス)

 それではコードを見て行きましょう。まずはイベントを送信するマーカー側のEyeTrackerSignalクラスから。
 余談ですが、TimeLineには、Makerを継承したSignalというクラスが標準で提供されていて、今回のクラス名はそれに合わせてあります。Signalは汎用マーカー実装で、通知先オブジェクトのOnNotifyイベントを発火できます。パラメータを設定出来ず今回の目的には合わないので使いませんでした。

using System.ComponentModel;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[System.Serializable, DisplayName("Marker EyeTracker by Tsukasa")] //①
//②
public class EyeTrackerSignal : Marker, 
                                INotification, 
                                INotificationOptionProvider 
{
    //③
    [SerializeField] public int EyeStartObj = 0; //視点開始オブジェクト
    [SerializeField] public int EyeEndObj = 0; //視点終了オブジェクト
    [SerializeField] public bool emitOnce = false; //イベント再発火有無
    [SerializeField] public bool retroactive = true; //イベント遡及発火有無

    //④
    public PropertyName id
    {
        get
        {
            return new PropertyName("EyeTrackSignal");
        }
    }

    //⑤
    NotificationFlags INotificationOptionProvider.flags
    {
        get
        {
            return  (retroactive ? NotificationFlags.Retroactive : default) |
                    (emitOnce ? NotificationFlags.TriggerOnce : default) |
                    NotificationFlags.TriggerInEditMode;
        }
    }

}

 以下、コードのコメント部に振った番号に対応して解説します。

①DisplayName属性でテキストを設定すると、コンテキストメニューに表示させる「Add ●●●●」を変更できます。設定しない場合はクラス名が使用されます。

②オブジェクトをマーカーとして動作させる場合、以下の1クラス/2インターフェイスを継承する必要があります*5

  • UnityEngine.Timeline.Markerクラス
  • UnityEngine.Timeline.INotificationOptionProviderインターフェイス
    • (INotificationOptionProvider.Flags()メソッド)
  • UnityEngine.Playables.INotificationインターフェイス
    • (idプロパティ)

 TimeLineは、裏で動いているPlayable System(あるいはPlayable API)と呼ばれる仕組みで管理されています。Playable System上でイベントを送信するクラスはINotificationインターフェイスを継承してidプロパティを実装する必要があります。
 一方INotificationOptionProviderはTimeLineパッケージのクラスで、マーカーがイベントを通知する条件を表すINotificationOptionProvider.Flags()メソッドを提供します。

③インスペクタに表示される変数群です。EyeStartObj/EyeEndObjがイベント通知時に渡すパラメータ。emitOnce/retroactiveはマーカーが内部的に使うパラメータです。

④idはINotificationインターフェイスの必須実装プロパティです。通知するイベントを一意に識別するProperyNameを返します。特にルールは無いと思いますが、クラス名を使うのが安全でしょう*6

⑤flags()は、INotificationOptionProviderインターフェイスの必須実装プロパティです。戻り値のNotificationFlags列挙体は以下の様なビットフィールドになります。

[Flags][Serializable]
public enum NotificationFlags : short
{
    TriggerInEditMode = 1 << 0,
    Retroactive = 1 << 1,
    TriggerOnce = 1 << 2,
}

各ビットの意味は以下になります。

TriggerInEditMode EditModeでもイベントを通知する
Retroactive TimeLineの途中から再生した時、遡及してイベントを通知する(後述)
TriggerOnce 一回のみイベントを通知する(TimleLineをループさせる時に使用)

 以下余談。記事を書く時に久し振りに動かしてて気づいたんですが、今の実装だとインスペクタでemitOnce/retroactiveを弄った時、即時に反映されないかもしれません(考えて見ればそれはそうだと言う気がする)。INotificationOptionProvider.flags()メソッドがいつ実行されるのか正しく把握しておいた方が良いでしょう(自分でやりなさい)。

コード解説:イベント送信側(EyeTrackerクラス(抜粋))

 次は受信側のコードです。マーカーの受信に必要な箇所のみを抽出しています。

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

[ExecuteAlways]//①
[RequireComponent(typeof(Animator))] //②
public class EyeTracker : MonoBehaviour, 
                          INotificationReceiver //③
{

    //④
    public int startSourceIndex = 0;
    public int finishSourceIndex = 0;

    [SerializeField, Range(0, 1.0f)]
    public float weight = 0.0f;

    //省略。Update()内でtransform.localRotationを更新している。

    //⑤
    public void OnNotify(Playable origin, 
                         INotification notification, 
                         object context)
    {
        //⑥
        EyeTrackerSignal element = notification as EyeTrackerSignal;
        if (element == null)
            return;

        //⑦
        startSourceIndex = element.EyeStartObj;
        finishSourceIndex = element.EyeEndObj;
    }
}

①通常、MonoBehaviour継承オブジェクトはEditModeではインスタンス化されません。しかしこのスクリプトはEditModeでも動いてもらわないと困るので、ExecuteAlways属性を付与しておきます。

②EyeTrackerオブジェクトはTimeLineに配置して使います。TimeLineに配置するGameObjectはAnimatorコンポーネントのアタッチが必須なので、RequireComponent属性で設定して必ずアタッチされるようにしておきます。

③オブジェクトがマーカーを受信する場合、INotificationReceiverインターフェイスを継承しする必要があります。

④startSourceIndex/finishSourceIndexはマーカーから受信した値を格納します。weightは2つの視線目標を線形補間する割合を表し、TimeLine上で遷移させます。

⑤OnNotify()はINotificationReceiverの必須実装メソッドです。第2引数に、INotificationにアップキャストされたマーカーオブジェクト(ここではEyeTrackerSignalクラスのインスタンス)が格納されています。

⑥notificationをas演算子でEyeTrackerSignal型にダウンキャストしてelementに格納します。as演算子の場合、ダウンキャストに失敗するとnullが返るので、直後にnullチェックしています。

⑦elementの各プロパティにはインスペクタで設定した値が格納されているので、それらをメンバ変数に格納します。これで無事にオブジェクトのインデックスを取得出来たので、Update()内でweightの値を元に線形補間させて毎フレームの向きを更新します。

いくつか注意点

 マーカーはとても便利な機能ですが、いくつか変わった挙動があります。土屋が実装した時には以下の点で戸惑ったので気を付けておくと良いでしょう*7

1:PlayModeでイベントを再発火させたい場合

 PlayMode中にTimeLineのカーソルを左右に直接ドラッグした時*8、カーソルがマーカーを通過してもイベントは再発火しません。再発火させるには、カーソルをマーカーよりも前に移動させてから、再生ボタンをクリックする必要があります。

2:EditModeでイベントを再発火させたい場合(Flagsの設定)

 EditMode中でも1と同じようにイベントを再発火させることが出来ます。ただし、FlagsのTriggerInEditModeビットが有効であることが必要です。今回の実装ではこのビットを常にONにしています*9

3:EditModeでイベントを再発火させたい場合(0フレームに注意)

 これが非常にはまったんですが、TimeLineの0フレームに配置したマーカーは、EditModeでは発火しません。土屋がなにか勘違いしているのでなければ、ライブラリのバグと思われます。今回はマーカーを1フレーム目に配置してごまかしました。

4:RetroActiveフラグを有効にした場合の注意

 NotificationFlags.RetroActiveを設定すると、TimeLineの途中から再生を始めた場合に、それより前にあるマーカーのイベントが遡って発火します*10。恐らくTimeLineの頭まで遡って全て発火するのだと思いますが未確認です。
 ただし、この「遡及」を有効にするには、一度下図のアイコンをクリックしてカーソルを先頭に移動させる必要がありました。再生が終わったTimeLineのカーソルをドラッグして移動しただけでは、遡及は起きません。

終わりに

 マーカーが無くてもなんとかなる場面は多いでしょうが、マーカーを使うことで処理の見通しが良くなる場面も多いかと思います。活用方法を考えてみてください。

宣伝「Unityシェーダープログラミングの教科書シリーズ」

 土屋は同人誌「Unityシェーダープログラミングの教科書」シリーズを書いています。現在以下の4冊が出ています。

  • Unityシェーダープログラミングの教科書 ShaderLab言語解説編
  • Unityシェーダープログラミングの教科書2【反射モデル&テクスチャマップ編】
  • Unityシェーダープログラミングの教科書3 ライティング&GI(大域照明)解説編
  • Unityシェーダープログラミングの教科書4 SRP[1]UniversalRP/Litシェーダー解説編

 これらの同人誌は、現在BOOTHにてPDF版を有料頒布しています。

染井吉野ゲームズ
s-games.booth.pm


 技術書典などの同人誌即売会で紙書籍版も頒布しています。ただし、昨今の情勢を鑑みて、次の頒布がいつになるかは未定です。すみません。

 なお、年明けの技術書典で第5巻の頒布を予定しています(間に合えば)。5巻はURPポストプロセス処理編になる予定です(間に合えば)。今回の記事は5巻に収録される予定です(間に合えば)。お楽しみに。

*1:視点目標オブジェクトがキャラの背後に回ったりするとホラー映像になりますが、これはスクリプト上でチェックするか、運用回避しましょう

*2:トランジションカーブで強引にインデックスを指定する/してたのかな?

*3:Unityのバージョンによって見た目が異なるかもしれません

*4:これはあまり上手い実装とは思えませんが、実用上は問題ないと思います

*5:厳密には、INotificationOptionProviderは継承しなくても動作しますが実用上必要になります。

*6:実運用では名前空間も含めた完全名にすべきだと思います

*7:どれも微妙にバグっぽい気もするので、将来的には改善するかもしれません

*8:この作業をとくに「スクラブ」と呼びます

*9:行儀の良いスクリプトではないかもしれません

*10:RetroActiveは「遡及」の意味