复刻viewfinder中时间回溯效果
Github源码传送门————>TimeRewind_ViewFinder
Bilibili教程传送门————>小祥带你实现Viewfinder时间回溯效果
1.整体思路 时间回溯相关的机制在游戏中真的很酷,不论类似时间幻境这样围绕时间展开的游戏,还是类似守望先锋的猎空英雄联盟的艾克这样角色技能中带有时间回溯设定的游戏,这一机制都会让玩法更有趣更有深度。我们使用TimeRewind 这个词组来表示时间回溯,就可以知道其原理和磁带光盘的倒带类似,就以磁带为例,之所以我们能够倒带一盘磁带,是因为在录制时音频数据不断被写入到磁带上,正常播放时磁头正向读取数播放录制好的音频信息,而倒带时则是磁头反向读取,那么就可以总结这一机制的本质:
记录:磁带实时录制音频信息,游戏则记录玩家位置,动画状态等信息。
回放:磁带倒序播放录制好的信息,游戏则逆向回放记录的各项数据,实现回溯效果。
2.RewindBase 经过前面的分析,我们知道了要实现时间回溯这个功能,最重要的两步就是记录和回放,那么先给出一个包含这两个功能的抽象类:
1 2 3 4 5 public abstract class RewindBase : MonoBehaviour { public abstract void Record () ; public abstract void Rewind () ; }
3.RewindBuffer 磁带的数据存储介质就是磁带,那么我们游戏的数据存储介质则是内存,同时我们要记录各种数据,所以给出一个泛型类,使用数组来保存数据信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class RewindBuffer <T> { private const int defaultCapacity = 3000 ; private T[] buffers = new T[defaultCapacity]; private int position; public void Resize () { int capacity = buffers.Length * 2 ; Array.Resize (ref buffers, capacity); } public void WriteBuffer (T values) { if (position >= buffers.Length) Resize (); buffers[position] = values; position++; } public T ReadBuffer () { position--; if (position < 0 ) return default ; T values = buffers[position]; return values; } }
4.RewindController 当然我们还需要一个集中管理时间记录和时间回溯的控制中心:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 private int rewindBufferLength;private List<RewindBase> rewinds;private void Start () { rewinds = FindObjectsByType<RewindBase>(FindObjectsSortMode.None).ToList(); }private void FixedUpdate () { if (rewindState.Equals(RewindState.None)) { foreach (RewindBase rewind in rewinds) { rewind.Record(); } rewindBufferLength++; } if (rewindState.Equals(RewindState.Fixed)) { foreach (RewindBase rewind in rewinds) { rewind.Rewind(); } rewindBufferLength--; if (rewindBufferLength <= 0 ) StopRewind(); } }private IEnumerator RewindByInterval (float interval ) { while (true ) { foreach (RewindBase rewind in rewinds) { rewind.Rewind(); } rewindBufferLength--; if (rewindBufferLength <= 0 ) { StopRewind(); yield break ; } yield return new WaitForSeconds (interval ) ; } }
为什么记录回放数据要放在FixedUpdate 中呢?
记录的数据会有RigidBody等跟物理相关的,这样做物理同步更精确
固定步长记录回放跟稳定,如果使用Update帧率波动导致会记录不稳定,回放时就不够精准和平滑
相当于构建一个离散的状态序列,每一帧对应一个状态,方便维护时间轴
RewindByInterval 可以让你按照想要的速率进行回放,而不是在FixedUpdate中按固定步长回放
5.Components 做好准备工作我们就需要具体对各个需要记录的数据进行封装了,这里分组件来进行封装,这样我们需要记录和回溯什么组件的数据,就挂载对应组件的封装类即可。Transform和RigidBody这些组件要记录的数据比较简单,这里直接以Animator组件为例来讲如何处理数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 public struct AnimatorValues { public int stateNameHash; public int nextStateNameHash; public float normalizedTime; public float nextNormalizedTime; public bool enterTransition; public override string ToString () { return $"stateNameHash:\n{stateNameHash} \n" + $"nextStateNameHash:\n{nextStateNameHash} \n" + $"normalizedTime:\n{normalizedTime} \n" + $"nextNormalizedTime\n:{nextNormalizedTime} \n" + $"enterTransition\n:{enterTransition} \n" ; } }public struct AnimatorParameterValues { public AnimatorControllerParameterType type; public int nameHash; public float value ; } [RequireComponent(typeof(Animator)) ]public class Rewind_Animator : RewindBase { public TMP_Text logText; private Animator animator; private List<RewindBuffer<AnimatorValues>> animatorBufferList = new List<RewindBuffer<AnimatorValues>>(); private List<RewindBuffer<AnimatorParameterValues>> animatorParameterBufferList = new List<RewindBuffer<AnimatorParameterValues>>(); private AnimatorControllerParameter[] animatorControllerParameters; private void Start () { animator = GetComponent<Animator>(); animatorControllerParameters = animator.parameters; for (int i = 0 ; i < animator.layerCount; i++) { animatorBufferList.Add(new RewindBuffer<AnimatorValues>()); } for (int i = 0 ; i < animatorControllerParameters.Length; i++) { animatorParameterBufferList.Add(new RewindBuffer<AnimatorParameterValues>()); } } public override void Record () { animator.speed = 1f ; for (int i = 0 ; i < animator.layerCount; i++) { AnimatorStateInfo animatorStateInfo = animator.GetCurrentAnimatorStateInfo(i); AnimatorStateInfo nextAnimatorStateInfo = animator.GetNextAnimatorStateInfo(i); AnimatorClipInfo[] animatorClipInfos = animator.GetNextAnimatorClipInfo(i); AnimatorValues animatorValues = new AnimatorValues(); animatorValues.stateNameHash = animatorStateInfo.shortNameHash; animatorValues.nextStateNameHash = nextAnimatorStateInfo.shortNameHash; animatorValues.normalizedTime = animatorStateInfo.normalizedTime; animatorValues.nextNormalizedTime = nextAnimatorStateInfo.normalizedTime; animatorValues.enterTransition = animatorClipInfos.Length > 0 ; animatorBufferList[i].WriteBuffer(animatorValues); logText.text = animatorValues.ToString(); } for (int i = 0 ; i < animatorControllerParameters.Length; i++) { AnimatorControllerParameter animatorControllerParameter = animatorControllerParameters[i]; AnimatorParameterValues parameterValues; parameterValues.type = animatorControllerParameter.type; parameterValues.nameHash = animatorControllerParameter.nameHash; parameterValues.value = GetAnimatorParameterValue(animatorControllerParameter); animatorParameterBufferList[i].WriteBuffer(parameterValues); } } public override void Rewind () { animator.speed = 0f ; for (int i = 0 ; i < animator.layerCount; i++) { AnimatorValues animatorValues = animatorBufferList[i].ReadBuffer(); if (animatorValues.enterTransition) { animator.Play(animatorValues.nextStateNameHash, i, animatorValues.nextNormalizedTime); } else { animator.Play(animatorValues.stateNameHash, i, animatorValues.normalizedTime); } logText.text = animatorValues.ToString(); } for (int i = 0 ; i < animatorControllerParameters.Length; i++) { AnimatorParameterValues parameterValues = animatorParameterBufferList[i].ReadBuffer(); SetAnimatorParameterValue(parameterValues); } } private float GetAnimatorParameterValue (AnimatorControllerParameter parameter ) { float value = 0 ; switch (parameter.type) { case AnimatorControllerParameterType.Float: value = animator.GetFloat(parameter.nameHash); break ; case AnimatorControllerParameterType.Int: value = animator.GetInteger(parameter.nameHash); break ; case AnimatorControllerParameterType.Bool: value = animator.GetBool(parameter.nameHash) ? 1 : 0 ; break ; case AnimatorControllerParameterType.Trigger: break ; } return value ; } private void SetAnimatorParameterValue (AnimatorParameterValues parameter ) { switch (parameter.type) { case AnimatorControllerParameterType.Float: animator.SetFloat(parameter.nameHash, parameter.value ); break ; case AnimatorControllerParameterType.Int: animator.SetInteger(parameter.nameHash, (int )parameter.value ); break ; case AnimatorControllerParameterType.Bool: animator.SetBool(parameter.nameHash, parameter.value .Equals(1 )); break ; case AnimatorControllerParameterType.Trigger: if (parameter.value .Equals(0 )) { animator.ResetTrigger(parameter.nameHash); } else { animator.SetTrigger(parameter.nameHash); } break ; } } }
这里我们将动画信息(AnimatorValues)和动画参数信息(AnimatorParameterValues)拆开分成两轨记录,因为动画状态需要逐层记录,动画参数则是逐参数记录,为了后面回溯和记录数据的代码更好维护,所以我们拆开记录。这也是第一点,我们首先分析这个组件需要记录哪些数据,然后对数据进行分类,将方便一起处理的数据组织在一起形成一个数据体。
动画信息数据我们要维护当前正在播的动画,下一个要播的动画以及过渡状态,因为回放动画的时候我们调用animator.Play()方法,所以要记录的数据就是传入方法的参数。这就是第二点,我们只记录回放时需要用的数据,而不是什么数据统统记录下来。
记录动画参数信息的时候,用一个float字段进行记录,在GetAnimatorParameterValue 方法中可以看到我们将不同动画参数类型的值统一转为float类型。这就是第三点,简化记录的数据。
为什么Trigger没有做任何数值记录呢?因为Trigger 相当于一个事件触发器,让动画状态机知道下一个动画播什么,我们已经记录了前后动画的名称哈希值,所以就不必再记录这个触发器了。
6.可优化方向
动态维护rewind对象列表,比如游戏过程中创建出的对象添加到rewind列表或从列表移除。
维护回溯时间线,可以在时间线添加标记,方便一键回溯到指定时间节点,同时物体的生成与销毁等功能也可以维护在时间线上。
RewindByInterval 目前是使用协程实现的,如果运行帧率比较低的话实际上是无法按照给定的interval进行回溯的,如果想要加快回溯速度的话,我觉得可以在FixedUpdate中通过抽帧来达到目的。
性能方面的话存储的数据量实际上并不大,但也可以针对性进行优化,有两条思路:
限制可回溯时间,比如只允许回溯最后10s,这样的话我们的buffer类就可以用循环数组存储数据。
不限制可回溯时间的话就需要对记录的数据进行压缩,类似视频压缩相同帧,如果检测到某个记录对象的某些帧与前面相似,就可以记录相同帧的索引以及持续帧数,实现数据量的压缩。
完结撒花~