JamesB 发表于 2022-1-25 10:23

[Unity]时间控制插件Chronos的基本使用与原理分析


时间控制在游戏中是一类常见的功能,例如菜单里的暂停、倍速,再如《武士 零》中的慢动作、倒带等时间系能力。最近初步尝试了一款时间控制插件Chronos,网上相关的中文资料比较少,不知道会不会踩坑,总之先记录一下使用笔记。

与《武士 零》中可以预知未来、操控时间的药物“柯罗诺斯”一样,这款插件也以古希腊神话中的时间神命名,通过它可以控制游戏中的时间流速,实现倍速、暂停、时光倒流,同时它还提供针对单个物体、一组物体和指定区域内的时间控制。



从插件描述可以看出,虽然不能预知未来,但只要不是太复杂的时间控制功能,它基本都可以实现。

插件从2020年6月开始永久免费,在Asset Store下载并在Unity中导入即可:
https://assetstore.unity.com/packages/tools/particles-effects/chronos-31225
设计理念

在使用之前,先来了解它的设计理念。假设现在要做一个正经的塔防游戏,游戏有如下要求:
游戏时间与用户界面时间互不影响,游戏可以暂停、倍速,而用户界面始终保持正常速度。每类物体(敌人、防御塔、玩家角色)的时间统一受游戏时间影响并且可以单独调整,它们之间互不影响。每个物体的时间也可以单独调整,比如某种环境效果,让区域内的敌我单位加/减速。时间流速改变时,动画、粒子效果等的速度要一同变化。


针对上述需求,Chronos给出了这样的结构:

Timekeeper
作为根节点,管理场景中的所有时钟,每个场景中只需要一个Timekeeper,是单例模式。

Global Clock
管理一组物体的时间,根节点下分为了Root与Interface两个时钟,Root用来管理游戏时间,Interface用来管理用户界面时间。Root时钟下又有Enemies、Turrets和Player时钟,Root时钟的流速改变时,这三个时钟都会随之改变。

Local Clock
与Global Clock类似,Local Clock用来管理单个物体的时间,对于玩家角色只存在一个的情况,Local Clock更为合适。

Timeline
可以理解为时钟的具体实施者,Timeline组件挂在各个游戏物体上,改变它们的时间流速。

Area Clock
改变区域内物体的时间流速,比如宣传图里的时间结界。

以上就是Chronos的核心组件,使用时添加好对应的组件就行了,比如这里的正经塔防游戏引入Chronos的步骤:
创建一个空物体,添加Timekeeper组件。

在同一物体上继续添加Global Clock组件,设置好它们的Key与父子关系。

如果有玩家角色(萝卜之类的),在玩家物体上添加Local Clock组件。给防御塔、敌人、玩家角色物体添加Timeline组件,设置好对应的时钟。

在脚本中获取对应的时钟,改变它的timeScale来调整时间流速。
clock.localTimeScale = value;
运行效果(并不是塔防):



这样就搞定了,是不是很简单,那么本篇笔记就到这里,最后祝您身体健康,再见。
深入使用

上面是官方教程中介绍的使用步骤,在项目中引入这个插件确实很简单,但个人更关心这些问题:
插件的作用范围,它支持哪些组件,如何配合使用,有哪些限制。对于自己的脚本以及不支持的组件,如何进行扩展。插件的实现原理,性能如何。
作用范围


从自带的示例可以看出,Chronos支持的Unity组件有Rigidbody、Animator、Nav Mesh Agent、Particle System、Audio Source等,在源码中可以看到它已适配的组件:

扩展

Timeline


在自己的脚本中引入时间控制,最基础的方法就是使用Timeline代替Unity的Time,比如原来使用Time.deltaTime,改为Timeline.deltaTime:
private void Start(){    timeline = GetComponent<Timeline>();}private void Update(){    if (targetingCounter > 0)    {      // targetingCounter -= Time.deltaTime;      targetingCounter -= timeline.deltaTime;      ...    }}
如果用到了内置组件,同样用Timeline中的对应组件代替,比如rigidbody改为Timeline.rigidbody:
// rb.velocity = targetDirection * targetSpeed;timeline.rigidbody.velocity = targetDirection * targetSpeed;
完整的替换表:

https://ludiq.io/chronos/manual/migration
Occurrence


不远处有个怪物生成点(比如刷怪笼)在不停地刷怪,一大波僵尸正向我袭来,然后我灵机一动将敌方的时间流速改为了负二倍速,刷怪笼和一大波僵尸的时间都被逆转,它们回到了原来的位置——但仅限于此,僵尸并不会随着时光倒流而消失。

为什么呢?Chronos会按设置的时间间隔,不断记录物体的各种信息,在时光倒流时进行回溯,但它并不会记录什么时候物体被生成,什么时候需要被销毁,这部分工作需要我们自己完成。

为此Chronos提供了一个叫Occurrence的工具,通过Timeline调用,结构长这样:
timeline.Do(    true, // 是否可重复执行    delegate() // 前向操作    {      // 生成物体并返回它    },    delegate(object transfer) // 逆向操作    {      // 销毁对应的物体    } );
有点电影《信条》的感觉,这里前向操作与逆向操作是成对的,时间正常流转时执行前向操作,时间倒流时执行对应的逆向操作。

使用示例:
private void Start(){    timeline = GetComponent<Timeline>();    StartCoroutine(Spawn());}private IEnumerator Spawn(){    while (true)    {      timeline.Do(            true, // 允许重复执行            () =>            {                // 前向操作                if (num >= maxNum)                  return null;                var go = Instantiate(prefabs);                go.transform.position = spawnPoint.position;                go.transform.rotation = spawnPoint.rotation;                num++;                return go;            },            (gameObject) =>            {                // 逆向操作                if (gameObject != null)                {                  Destroy(gameObject);                  num--;                }            });      yield return new WaitForSeconds(spawnInterval);    }}
使用Timeline与Occurrence,可以满足一些简单的需求,对于更复杂的情况,比如物体数值与状态的记录与回溯,则可能需要做一套完整的适配。目前个人项目需求比较简单,暂时没到这一步,之后如果有做相关的扩展再来补充。
一些坑


Chronos并不是万能的,官网列出了它的限制:

https://ludiq.io/chronos/manual/limitations

如果粒子系统需要支持时间回溯,其中的限制可能影响较大:
低速(小于0.25倍速)时粒子系统可能会卡顿。粒子系统的模拟空间只能是本地。不支持粒子的碰撞检测。

这些问题主要是由粒子系统的Simulate方法引起的,后面的原理分析中会提到。可以说大部分限制的原因都来自引擎底层,看了下插件的最后更新时间,这些问题多半是不会修复了。

除了上面提到的限制,个人在使用过程中也遇到了一些问题:
粒子系统如果勾选了Play On Awake,运行时会报错:

这是由于Chronos在初始化时会将粒子系统的随机种子改为固定的值,而此时粒子已经开始播放了,Unity不支持这个操作所以报错。可以取消勾选Play On Awake,初始化完后再调用播放,注意是通过timeline的particleSystem来播放,不能直接调用。
timeline.particleSystem.Play();
并不能直接影响Shader中的时间速度,需要另外适配。

如果有时间回溯功能,在倒带时需要注意屏蔽玩家控制,避免引起冲突,比如角色控制脚本中,仅在timeScale为正时开启玩家控制。
原理分析

只是粗略看了一下源码,可能会有一些分析得不对的地方。

Timekeeper、Global Clock的树形结构以及对Timeline的管理比较好理解,这部分就不看了,更值得关注的是Timeline是如何控制组件的时间流速的,这里从Timeline的源码开始阅读。

在Timeline的父类TimelineEffector中,定义了一堆它已适配的组件类,每个类与Unity的内置组件相对应:

这些XXXTimeline均继承自ComponentTimeline类,实现IComponentTimeline接口,虽然这里也命名为组件,但它们不继承Unity的Component,仅持有对应Unity组件的引用,可以看作是Unity组件的一层包装。

在Awake中,调用CacheComponents方法,获取当前物体上挂载的Unity组件,将其包装成对应的Timeline组件,初始化并存入components列表中:

注意这里的注释,如果运行过程中添加或移除了物体上相关的Unity组件,则需要重新调用一次这个方法。

Timeline组件的初始化方法Initialize中只有一个CopyProperties的调用:

CopyProperties的实现因组件而异,通常其中会记录一些与时间相关的参数,例如Animator组件记录播放速度、Audio Source组件记录音高。

初始化中针对一些组件有特殊的处理逻辑,比如Rigidbody与Transform,具体可以看源码,这里就不过多介绍了。

Start或OnEnable中,Timeline将应用对应时钟的时间流速timescale,并调用所有组件的AdjustProperties方法:

AdjustProperties中应用时间流速,例如Animator组件调整播放速度、Nav Mesh Agent组件调整移动速度与转向速度、粒子系统调整simulationSpeed等等。

常用的事件函数中调用所有组件的对应事件函数:

对大部分内置组件来说,光是调整速度还无法做到时间倒流的效果,Chronos对不同的组件采用了不同的解决方法。

Animator
Animator是相对简单的一个,将它的播放速度设置为负数就可以倒放了。在时间正常流转时,调用Animator的StartRecording录制,在时间倒流时,倒放之前的录制结果。

Transform、Rigidbody
对于Transform,Chronos用了一个自定义的RecorderTimeline组件,RigidbodyTimeline组件同样继承于它。时间正常流转时,按设置的录制间隔将物体的位置、旋转等信息(缩放默认被注释了,需要可以自己打开)录制成Snapshot并缓存起来,在时间倒流时逐个应用这些Snapshot。

Particle System
根据是否要支持时间回溯,Chronos将粒子系统组件分为两个,NonRewindableParticleSystemTimeline与RewindableParticleSystemTimeline。

不可回溯的实现很简单,根据Timeline的timeScale调整粒子系统的simulationSpeed即可;
可回溯的粒子系统是通过Simulate方法来实现的,Simulate方法可以让粒子系统立即达到到指定时间点的状态。时间正常流转时,记录粒子系统的播放状态(启用、禁用、播放、暂停),在倒带时还原这些播放状态。

Simulate也带来了上面提到的问题:
低速卡顿,这个问题Unity从2015年到现在都没修复,但个人测试感觉不太明显,处于可接受的范围。不断调用导致模拟空间不断更新,所以粒子的模拟空间仅限本地。不支持粒子的碰撞检测。

总结
从源码中可以得知,Chronos初始化时需要对游戏物体上的Unity组件做一层包装,常用的事件函数(Start、OnEnable、FixedUpdate、Update、OnDisable)中会遍历所有包装组件并调用相关方法,在Timeline的Update中还包含对Occurrence的处理等等。如果要支持时间回溯,在游戏运行时需要对某些组件的状态进行录制,录制按指定的时间间隔执行,不同组件占用的内存空间不同。

如果自己的脚本需要完全接入Chronos,可以像上面哪些组件一样,继承ComponentTimeline,并加入到Timeline的初始化过程中。

对于各组件中的录制功能,Timeline提供了统一的参数配置,可以调整录制间隔与录制的最大时长,并会给出预计消耗的内存:

如果游戏不需要时间回溯功能,那么可以取消勾选Rewindable以节省性能。
具体的性能测试还没有做,大概率鸽了。
页: [1]
查看完整版本: [Unity]时间控制插件Chronos的基本使用与原理分析