ViewFinder时间回溯

复刻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类就可以用循环数组存储数据。
    • 不限制可回溯时间的话就需要对记录的数据进行压缩,类似视频压缩相同帧,如果检测到某个记录对象的某些帧与前面相似,就可以记录相同帧的索引以及持续帧数,实现数据量的压缩。

完结撒花~

pid:128892342


ViewFinder时间回溯
https://baifabaiquan.cn/2025/04/03/TimeRewind_ViewFinder/
作者
白发败犬
发布于
2025年4月3日
许可协议