Contents

游戏架构理论

TimeLine结构

本文,主要总结我在写游戏代码过程中对于时间轴概念的理解和使用。

在游戏开发过程中,不管是客户端还是服务器开发,不可避免的会遇到定时器的概念,即经过一段时间之后再执行某段逻辑。甚至对于电子游戏来说,其本身就是不断Tick进行逻辑刷新来推进的,时间概念在游戏中必不可少。

而在此之前已经描述过状态的抽象概念,在客户端表现中,与时间相关的状态随处可见。例如,一段动画,需要Tick来不断计算骨骼位置,同时又可以随时打断动画,这就说明这个动画播放的时候存在一个状态对象概念,打断之时,有对应操作来切换动画。

本文主要记录我对抽象时间轴的理解,封装和使用。

全局Timer

首先,对于定时器来说,实际上定时触发与要触发逻辑是一个完全无关的结构。例如0.5s之后触发一个弹出UI逻辑,这个弹出UI逻辑可以替换为任意一个逻辑结构。即定时触发这一个功能概念,可以封装成一个稳定结构。该结构可以与任何结构服用组合在一起,所以一般的项目都会提供一个全局的Timer类型,例如如下的一个简单C#代码结构。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class TimerManager
{
    // 单一实例 
    public static TimerManager instance;
    // 存储所有tick的实例计数
    public Dictionary<int, SingleTimer> timer_dict;
    public int timer_index = 0;

    // 添加一个在固定时间回调的函数,C#中Action 相当于一个无参数函数
    public static int AddTimer(float time, Action action)
    {
        instance.timer_index += 1;
        instance.timer_dict.Add(instance.timer_index, new SingleTimer(time, action));
        return instance.timer_index;
    }
}
// 记录经过多少时间的数据结构类
public class SingleTimer
{
    public float total_time;
    public float pass_time;
    public Action action; 
}

上面结构也可以通过下图来表述:

如代码结构所示,调用静态函数接口Timer.AddTimer即可实现在某个时间之后固定调用某个函数功能,而通过该函数回调就可以实现意逻辑的触发结构。但也可以注意到TimerManager是一个静态单例类,存在一个Dictionary结构来存放所有的Timer结构。

  • 有一个全局的内存字典。
  • 字典每个索引存放了一个累计记录时间结构。
  • 每一帧更新的时候,去给pass_time添加delta_time来完成累计时间功能。
  • pass_time跨过total_time的时刻,触发回调。

这样一个Dictionary存放结构,是因为对于每一个定时回调,都是一个不同的触发逻辑对象。对于同一个函数,两次0.5s后的延时调用,也要触发两次。同时我们又要可以取消延时调用。这说明每一次记时都是不同实例数据结构,以及应该有标记来记录对应的记时操作。

这里面有一个很重要的地方就是,实际上对于时间来说,一定要有一个状态,一个数据区块,来记录究竟过了多少时间。下面可以看到不论是时间轮,还是时间累计方式,而这个时间记录结构,是不可避免的。这与前面[02状态与函数]中所述对应:

对于状态来说,时间是一个本质问题

这里我们看到了

  • 延时与回调无关–封装记录时间结构,通过回调来触发任意逻辑。
  • 每次延时不相关–每次延时,创建不同实例数据结构记录。存放Dictionary。
  • 记录时间结构–需要一个地方来记录经过多少时间,在Tick中更新。

时间轮与时间累计

一般来讲,对于这样一个计时操作,一般首先会想到将时间累计的方式,恰如上面所述。但是我们可以仔细观察,会发现:

  • 对于绝大部分情况来说,每次Tick只是在更新数据,真正生效的只有pass_time跨过total_time的时刻。

但是累计时间似乎又是计算pass_time跨过total_time的必要部分。这相当于去计算$\sum_i^n{}t_i>T$这件事情,但是可以简单发现一件事情,如果每次更新时间是一个固定数值即$t_i=t_0$那么可以简单计算出$n=T/t_0$。这相当于计算出第$n$次Tick的时候触发该事件,省略了之前累计时间的开销。这个结构可以用下图表示:

  • 每次Tick前进一格,查询该槽位中对象数量。

而这个结构实际就是时间轮模型。

那么时间轮显然省略了累计时间这件事情,但是有省略记录时间这件事情么?实际上并没有,因为这个时间轮的槽位依旧是时间状态的记录者。我们可以简单想一件事情,如果中途发生了触发事件修改,两个模型将如何操作:

  • 累计时间模型–获取目标ID时间记录结构,修改其上的目标时间$T$。
  • 时间论模型–将目标ID回调结构,移出当前槽位,放到新的槽位中去。

实际上可以认为,一个是每次去累计时间,另一个则是一开始算好tick次数来进行延时调用。

时间轮是Linux上经典的算法,因为其效率对比累计时间方式要高太多了。其原因在于每次Tick不用大批量更新时间记录结构,而是查询一下目标槽位即可知道要触发回调的时间。而这种方式实际上可以认为是通过提前除法计算来建立的,即提前计算,来节约更新时间操作形成。而落到高层想法细节,其实就是不去遍历没必要的时间记录结构。或者换句话说,如果一个回调要很久以后触发,那么这个回调在最近的Tick中是完全没必要考虑的

通常来讲时间轮,会形成一个轮的形式,而不是一个线的形式。这是因为,如果是单纯一个线,因为每次tick的$t_0$很小。那么其槽位将会非常多,假设$t_0=1s$,那么即时1天小时,也需要86400个槽位。更别说,对于游戏来说定时要求会更精细。那么槽位会非常多,而这些槽位实际上也是要内存记录的。 怎么解决这个问题?其实就跟建立时间轮的思路一样,也跟现代记录时间的方法对应。对于很久以后的回调,我们不用计算的非常细致,只有在接近的时候,才有仔细划分的必要。结构可以用下图标识:

所以其实思想非常简单。就是提前计算做好映射来减少Tick累计记时,而HighLevel的想法就是对于很久以后的事情,现在并不需要考虑特别细致。

全局还是局部

这里我们思考一个问题,这个TimerManager是否必须是全局静态单例。我们思考一下全局静态单例结构带来了什么?

  • 全局单例,相当于把整个延时调用结构封装成一个单纯功能,调用接口即可实现延时调用。任何地方都可以方便使用。
  • 但是也因为其是全局的,单纯功能封装。所以不知道什么时候该取消回调。而每个回调结构中,实际存有一个跟逻辑回调关联的实例对象。这就导致一个问题,实例对象的生命周期,不由TimerManager管理,但是TimerManager会引用该对象。进而需要在对象生命结束时,手动移除TimerManager中的Timer,避免回调时触发错误逻辑,如下图。
  • TimerManager中关联的实例对象,遍布各个模块,这导致其基本不可能是内存连续的。因为每个模块有自己的创建时机,创建流程,这些跟其他模块耦合肯定不大。这说明这些内存分配基本无法统一,这导致内存必定是散乱的。这

从上面可以看到,这样一个全局结构,注定的会导致每个使用TimerManager的模块,还要自己去移除相关的回调结构。这导致每个模块必须记得去Timer.RemoveTimer。如果不移除就会有潜在的trace风险。而根据经验来看,这种事情是经常发生的。

内存方面问题,看上去并不重要,对于架构来说,其并不影响代码结构。但是如果考虑到内存连续特性,就会发现其又不适合的特点。例如ECS,其效率高效来源之一就是内存连续排布,如果这样做时间管理那必然难以实现ECS。所以ECS是不会有TimerManager结构的,正如其所说。

另一方面是,这样一个结构可以显然的变成局部结构。即每个相关模块持有一个这样的TimerManager结构,而不是全局TimerManager。如下图的结构也是一样可行的。

这样结构可以看到:

  • 每次调用不再是一个全局方法直接进行,而是要查找关联对象。然后添加关联回调。这导致调用TimerManager的方式变复杂了,而且要知道自己回调关联什么对象。
  • 但是这也带来了好处,Tick接口也是通过查找关联对象来走,例如上图中的角色对象。如果角色对象销毁,自然Tick找不到角色对象,角色对象上的Timer也不再更新,自动销毁,我们添加Timer非常直接而无后顾之忧。
  • 实际上也可以发现,局部TimerManager方式,本质相当于确定对象,然后调用对象上直接轮询的固定Tick接口。例如这里,角色对象一定有一个Tick函数接口,然后去更新里面的Timer,Timer就存放在角色对象结构上。

这样一个方式可以很好的解决全局TimerManager的一些弊端,例如忘记移除Timer情况。但是很可惜的是,这种划分方式一般很难划分清楚。实际上这个结构如果分到非常细节,那么会变成类似ECS的结构。因为这个结构中,同一类对象都会有一个查找过程,那么这些查找对象可以存放在响相邻的内存块中去遍历。这就是ECS内存块相邻结构的来源,进而也提升了内存命中率。

其实两种方式都可以,针对目前的项目状况,选取最恰当的实现方式即可。但是要对每个结构有着清楚的认知。

全局与协程

有些语言提供了一中协程的方式,例如lua,unity中C#都提供了协程概念。其似乎是一种底层提供的可以延时调用的快捷方式,例如如下C#代码

 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
public class GM : MonoBehaviour
{
    void Start()
    {
        // 启动一个协程
        StartCoroutine(this.PrintInfo());
    }
    public IEnumerator PrintInfo()
    {
        // yield返回 3s之后再继续往下走
        yield return new WaitForSeconds(3);
        Debug.Log("PRINT INFO 1");

        float pass_time = 0f;
        while (true)
        {
            yield return new WaitForSeconds(0.1f);
            if (pass_time > 1)
            {
                break;
            }
            pass_time += 0.1f;
        }
    }
}

协程跟我们的Timer结构看上去相差很大,可以在代码运行块层级,直接实现一个暂停延迟的效果,甚至可以结合While语句直接构成一个持续更新的效果。 但实际上其跟全局Timer有异曲同工之妙,只是时间记录的方式,被语言特性所取代。一般来讲,语句都是按顺序同步执行,但是协程过程可以这么理解。

  • 当运行到yield return 时候返回一个记录固定时间的时间记录结构。
  • 然后存在一个Tick结构,在该时间结构上累计时间,完成目标计时之后继续往下走。

所以其本质也会拥有全局Timer的利弊,但是因为其语言特性,会拥有闭包上下文环境,即可方便的访问整个块环境内的任意变量,所以写代码更方便,更快捷。但是也带来了对应的坏处:

  • 可以方便的访问局部环境内的任意变量。
  • 不好取消,例如上面代码中,如果涉及对象,那么对象销毁时,根本无法涉入到这个代码块内部中去。只能在代码块中加判空来处理。这相当于Tick结构要知道Tick逻辑的各种状况,并且去处理。

一次触发与持续修改

在前面的讨论中,我们都是针对延时触发效果来进行叙述的。即延迟一段时间后触发一个逻辑。 但是在游戏中,尤其是客户端逻辑中,存在大量的持续更新逻辑,最主要的就是,插值效果!对于插值来说,其往往需要在$t\in[0,T]$时间段内持续计算一个$f(t)$的值。而过了该时间段之后,该计算便不再进行。

显而易见的,这种效果需要$t\in[0,T]$时间段内的值来计算,即需要累计值。所以很简单的问题出现了,时间轮不能很好的完成这个功能!因为这个Timer在时间论上横跨一段时间,这表示:

  • 如果横跨一段时间轮,那么其添加,移除都很麻烦。如果横跨60个槽位,那么60个槽位都要压入。这操作并不见得省力。
  • 对于时间轮来说,其掩盖时间间隔,即每个间隔为$t_0$,但是Timer开始到目前所经过的时长不会传给逻辑结构,逻辑还需要自己去算一下时间间隔来计算$t$。而这就显得很没必要了。

所以简单一想,可以知道累计时间的方式更适合。实际上这说明Timer结构实际可以分成两部分结构来运行,如下图。

其实可以注意到这一步实际上可以理解为最早的TimerManager扩展版:原来的Timer中只包含一个结束时回调逻辑,把这种Timer理解为某一类timer。其结构恰好非常固定,而我们可以添加跟多种类的Timer,例如每次累计时间,都会调用回调函数并固定传入经过时间的Timer,此即可以实现时间轴结构。

不过通常来讲,这种包含一段持续时间计算的逻辑结构,我都会包装在新Timer之中。即建立新的Timer类型,在其Tick逻辑中,更新完pass_time后,直接计算逻辑。这时候会发现,这种新Timer类型,就彷佛对游戏中各种动画等,时间Tick结构的一种对应封装。

时间膨胀

对于很多客户端来说,都涉及到时间膨胀的概念。即时间流逝并不是固定的,对于很多游戏表现需求,这基本时必须的。

正常状况下,游戏中流逝时间跟现实一样,即游戏中的1s相当于现实世界中的1s。但是有一些情况,我们需要加快或放慢游戏中的时间速率。例如,倍速–游戏中ns相当于现实中1s,游戏中所有流速加快。子弹时间–游戏中0.1s相当于现实中1s,游戏中速度变慢。

也就是对于Tick的时间间隔不再固定,而是一个受到倍率参数控制缩放后的值。

对于累计计时来说,处理就很简单了,对于每次累加的$\Delta{}t$,让其收到倍率参数控制即可,然后所有Timer就都受到了时间膨胀影响。但是时间轮呢?通过简单思考,如果对于加速Tick来说,似乎还可以处理,只需要每次跨越的槽位变多即可。但是如果时间变慢,慢到一帧流逝的时间小于槽位宽度$\Delta{}t

  • 细化最小单位槽位,即最小槽位标识的时间间隔更小。但是这会加大时间轮长度,而且实际限定了最小时间膨胀率。
  • 针对每个槽位Timer对象,添加一个槽位偏移值。然后记录一个累计值,对比累计值与列表中的值来完成回调。如下图:
  • 我们会累计每次Tick时间间隔,记录为$t$,当$t$超过槽位边界的时候减少该槽位值。形成槽位上的余数值$t'$,这相当于在该槽位中有一段。
  • 如果跨过槽位,显然槽位中所有Timer触发,跟原始逻辑一样。当有余数的时候,可以跟槽位中的Timer对比残留时间间隔,如果大于则触发该Timer。
  • 当时间间隔变为$t_2

实际上可以认为,其实累计时间方式也是有个最小间隔,这个最小间隔就是浮点数能表示的最小数值。也就是说,时间膨胀最慢是每帧更新浮点数的最小间隔,这也是因为每次更新的$\Delta{}t$也是一个浮点数。但是用这个数值来直接划分槽位并不合适,于是在给的槽位宽度中才有一个局部的累计计时的方式来筛选触发的回调。不过浮点数的最小间隔并不是一个等距结构,是一个指数分布结构,这里面也不可否认的存在着累计浮点误差的问题。

此外对于游戏更新来说,也有渲染帧,固定帧区分。对于渲染帧来说,其更新时间往往有一定浮动,例如卡顿,就会导致某一帧的时间间隔大于槽位间隔。此时时长也不可能精确按照槽位来走,也需要一种补充计算操作。 实际上对于很多计算几何,物理更新部分,当Tick函数更新的时候,也会根据更新时间间隔$\Delta{}t$与最小计算间隔$t_0$来弥补计算次数,因为计算时间间隔$\Delta{}t$过大,回导致jitter现象。所以也去按照一个小间隔去划分更新。

持续概念的封装

上面我们深入的讨论了,对于时间相关概念的思考。其实我认为这些都可以汇总成一句话:对于时间封装的思考。


»游戏架构哲学理论