自从Unity 2017.1发布Timeline以来,受到了广大开发者的欢迎,创作出不少精彩作品。我们也收到很多反馈,其中一项就是Timeline事件功能的请求,它是Timeline缺少的功能,一些用户通过剪辑实现了事件功能,但剪辑有自身的缺点。此篇将为你介绍在Unity 2019.1中新推出的Timeline Signals信号功能和扩展。

介绍

标记可以在Timeline上和剪辑一样添加和处理,能够使用选取,复制粘贴和编辑模式等功能。就像剪辑一样,标记也有具体类型,例如:剪辑分为动画剪辑、激活剪辑、控制剪辑等类型。

工作原理

  为了在Timeline上正确设置信号,我们需要三个部分:

  Signal Asset信号资源:信号资源是发射器和接收器之间的联系。通常信号资源会用作标识符。

  Signal Emitter信号发射器: 信号发射器会放在Timeline上,它包含对信号资源的引用。运行信号发射器时,如果当前时间比发射器的时间大,发射器会被触发,发射器会把信号资源发送到信号接收器。

  Signal Receiver信号接收器:信号接收器是带有一组反应的组件,每个反应都关联到信号资源。当接收器知道信号已被触发时,它会激活关联到对应信号资源的反应。

如下图所示,当关联到同一信号资源的发射器触发时,相应反应会被激活。

关于基础的timeline signal的使用可以参考这篇文章

扩展

unity自带的signal是不可以参数参数的, 或者说自能在signal receiver的inspector传递一个固定的参数,参数超过一个就不能被识别了,如下图所示:

这显然不能满足我们日常的开发需求,因为我们的参数常常来自于signal, 而不是reveiver。还好Unity给了我们自定义扩展的接口, 这样能实现一些比如参数传递、分支逻辑等功能了。

自定义信号发射器signal emitter

定义一个发射器signal emitter, 它是实现 INotification, INotificationOptionProvider接口的类。我们可以使用id属性来识别唯一标识通知。对于本文的示例,我们并不需要该功能,因此将使用默认的实现。

public class MyNotification : INotification
{
    public PropertyName id
    {
      get { return new PropertyName("MyNotification"); }
    }

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

自定义信号接收器 signal receiver

我们需要一个接收器,它是实现INotificationReceiver接口的类。示例中,接收器会记录接收到通知的时间。

class ReceiverExample : MonoBehaviourINotificationReceiver
{
    public void OnNotify(Playable origin, INotification notification, object context)
    {
        if (notification is JumpSignalEmmiter)
        {
            JumpSignalEmmiter signal = notification as JumpSignalEmmiter;
            director.time = signal.jumpTime; //跳转
        }
        else if (notification is SlowSignalEmitter)
        {
            SlowSignalEmitter signal = notification as SlowSignalEmitter;
            director.playableGraph.GetRootPlayable(0).SetSpeed(signal.slowRate); //改变播放速率
        }
    }
}

这样你就可以接受到来自signnal的传递过来的参数了。 我们这里直接把INotificationReceiver挂载在MonoBehaviour的GameObject上,就直接可以收到信号了。运行效果如下:

另外我们也可以通过代码手动的派发signal, 而不是一定要通过time cursor来触发,通过使用AddNotificationReceiver方法,把ReceiverExample添加给可运行输出。m_Receiver实例现在可以接收发送到该输出的通知。

var output = ScriptPlayableOutput.Create(director.playableGraph, "");
output.AddNotificationReceiver(this); //this 所在的类需要实现 INotificationReceiver
JumpSignalEmmiter signal = new JumpSignalEmmiter(); //实现接口INotification
sign.jumpTime = 0;
output.PushNotification(Playable.Null, signal);

调用PushNotification后不会立即发送通知,它们仅会加入队列排队等候,这表示它们会进行积累,直到视图已经完全处理。

在LateUpdate阶段前,所有队列中的通知都会发送到视图的输出部分。在所有通知都发送后,队列会在新一帧开始前清空。

在视图播放的时候,将在m_Receiverinstance实例上调用OnNotify方法,并将通知作为参数发送。

TimeNotificationBehaviour指定时间点

使用内置类TimeNotificationBehaviour,该类是标准的PlayableBehaviour,所以它可以添加到任意视图,只要使用一些逻辑就可以在准确时间发送通知。

var timeNotificationPlayable = ScriptPlayable<TimeNotificationBehaviour>.Create(m_Graph);
output.SetSourcePlayable(timeNotificationPlayable);

//在时间通知行为添加通知
var notificationBehaviour = timeNotificationPlayable.GetBehaviour();
notificationBehaviour.AddNotification(2.0, new MyNotification());

我们没有在可运行输出上直接调用PushNotification,而是给输出附加TimeNotificationBehaviour,然后给它添加一个通知。该行为会在正确时间自动把通知推送给输出,控制台会显示以下信息:

上述代码并不是在准确的第二秒发送?这是因为AddNotification方法不会确保准确的时间。在Unity开始渲染新一帧时,Playable Graph可运行视图会进行更新。根据游戏的帧率,在通知添加到TimeNotificationBehaviour时,PlayableGraph的估算时间可能不会正好符合指定的时间。

AddNotification方法会确保的是:通知将会在PlayableGraph的时间比通知触发时间大的时候发送。

自定义样式CSS

我们的自定义标记会以常见的“图钉”表示,也可以把该图标改为自己选择的图像。第一步是创建样式表。

样式表可以用来扩展编辑器的可视化外观,我们可以在编辑器文件夹的StyleSheets/Extensions目录下添加common.uss文件。

USS即Unity样式表,使用类似CSS的语法来描述新样式,下面是样式的示例。

JumpSignalEmmiter
{
 width: 18px;
 height: 18px;
 background-image: resource("Assets/Editor/StyleSheets/ico_collapsed.png");
}

JumpSignalEmmiter:checked
{
 background-image: resource("Assets/Editor/StyleSheets/ico_normal.png");
}

JumpSignalEmmiter:focus:checked
{
 background-image: resource("Assets/Editor/StyleSheets/ico_selected.png");
}

更多关于介绍Unity 使用uss创作更多的自定义样式, 可以参考官方的API。改完uss, Unity编辑器并不是立即发生样式改变,当点击运行按钮的时候,样式才发生改变。

注意,这里必须是common.uss文件名, 且目录名必须是Editor下StyleSheets/Extensions的子目录。 对应的SignalEmitter需要指定CumstomStyle。

  [CustomStyle("JumpSignalEmmiter")]
  public class JumpSignalEmmiter : Marker, 
    INotification, 
    INotificationOptionProvider
  {
    //to implement INotification & INotificationOptionProvider
  }

Tips

1. Signal Emitter参数说明

Retroactive

有时我们直接使用API( PlayableDirector.time = 1.4)或者游戏非常卡发生跳帧的时候, 导致标记没有在时序上经过, 是否还触发此signnal。如果勾上了,就算跳过timeline上的maker,只要当前时间在maker之后,就会触发。

EmitOnce

有时我们会循环播放某一段动作,如果此时勾上此选项, 信号量自会触发一次, 否则的话,每一次都会触发。

2. Context 不显示signnal

Unity默认显示Mark Context只在绑定gameobject或者component的track上, 比如说Control Track就不显示。

有时候有些自定义的signal emitter并不想出现在某些track里, 比如说一些用来控制全局的signal并不想出现在Animation Track里。一种最直接的方法针对对应的track添加一个Attribute, 并在中指定对应的TrackType:

public enum TrackType
{
    NONE = 0,
    MARKER = 1,
    ANIMTION = 1 << 1,
    CONTROL = 1 << 2,
    ANCHOR = 1 << 3,
    OTHER = 1 << 7 // put other at last
}


[AttributeUsage(AttributeTargets.Class)]

public class MarkerAttribute : Attribute
{
    public TrackType supportType = default(TrackType);


    public MarkerAttribute(TrackType type)
    {
        supportType = type;
    }

    public void AddType(TrackType type)
    {
        supportType |= type;
    }

    public bool SupportTrackType(TrackAsset track, Type type)
    {
        bool rst = false;
        if (track is MarkerTrack)
        {
            rst = (supportType & TrackType.MARKER) > 0;
        }
        else if (track is AnimationTrack)
        {
            rst = (supportType & TrackType.ANIMTION) > 0;
        }
        else if (track is AnchorTrack)
        {
            rst = (supportType & TrackType.ANCHOR) > 0;
        }
        else if (track is ControlTrack)
        {
            rst = (supportType & TrackType.CONTROL) > 0;
        }
        return rst;
    }

}

针对每个Track设置好对应的track类型, 然后再TimelineContextMenu.cs 调用的地方制定特定的track:

 public static bool DoesTrackSupportMarkerType(TrackAsset track, Type type)
{
    if (track.supportsNotifications)
    {
        bool rst = true;
        var attr = Attribute.GetCustomAttribute(type, typeof(MarkerAttribute)) as MarkerAttribute;
        if (attr != null) rst = attr.SupportTrackType(track,type);
        return rst;
    }
    return !typeof(INotification).IsAssignableFrom(type);
}

比如上述代码,自定义的JumpSignalEmmiter和SlowSignalEmitter, 就只会marker track里显示了。

3. 利用marker生成曲线

自定义的Emitter 继承Marker, 只要不实现INotification, 就不会生成在全局广播。 利用Marker可以标记的性质(即在track里可编辑的关键帧), 生成AnimationCurve, 同时不广播事件,还可以节省消耗。

比如在track里标记一些节点:

[Serializable]
[CustomStyle("TransforSignalmEmitter")]
[Marker(TrackType.ANCHOR | TrackType.MARKER)]
public class AnchorSignalEmitter : Marker
{
    [SerializeField] Vector3 m_Position = Vector3.zero;

    public Vector3 position
    {
        get { return m_Position; }
        set { m_Position = value; }
    }

}

然后再对应的track clip中生成曲线, 然后还可以在Track Clip中画出曲线:

 const int sample_cnt = 20;
Vector3[] sample_vtx = new Vector3[sample_cnt];

private void DrawAnchorAsset(AnchorAsset asset, double start, double end, int idx)
{
    FetchKeys(asset, start, end);
    if (sample_vtx != null)
    {
        Handles.color = gizColors[idx % 3];
        for (int i = 0; i < sample_cnt - 1; i++)
        {
            Handles.DrawLine(sample_vtx[i], sample_vtx[i + 1]);
        }
    }
}

private void FetchKeys(AnchorAsset asset, double start, double end)
{
    double dur = end - start;
    double delta = dur / sample_cnt;
    if (asset.IsValid())
    {
        for (int i = 0; i < sample_cnt; i++)
        {
            float time = (float)(start + delta * i);
            float x = asset.clip_pos[0].Evaluate(time);
            float y = asset.clip_pos[1].Evaluate(time);
            float z = asset.clip_pos[2].Evaluate(time);
            sample_vtx[i] = new Vector3(x, y, z);
        }
    }
}

 private void CreateClips()
{
    if (signals != null && signals.Count > 0)
    {
        AnimationCurve[] m_curves_pos=new AnimationCurve[3];
        for (int i = 0; i < signals.Count; i++)
        {
            AnchorSignalEmitter sign = signals[i];
            float time = (float)sign.time;
            m_curves_pos[0].AddKey(time, sign.position.x);
            m_curves_pos[1].AddKey(time, sign.position.y);
            m_curves_pos[2].AddKey(time, sign.position.z);
        }
    }
}

最后效果如图所示, 不但在track里画出了曲线, 而且在场景里还可以画出轨迹:

对应的代码都上传到github上了, 欢迎点击查阅

参考:

[1] 介绍Unity2019的timeline信号量
[2] 创建自定义Timeline 标记Marker
[3] Unity2019 uss自定义样式介绍