时间控制在游戏中是一类常见的功能,例如菜单里的暂停、倍速,再如《武士 零》中的慢动作、倒带等时间系能力。最近初步尝试了一款时间控制插件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[Random.Range(0, prefabs.Length)]);
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以节省性能。
具体的性能测试还没有做,大概率鸽了。