时间驱动和事件驱动
模拟器依据其驱动条件的不同,大体可以分为两种:时间驱动型,和事件驱动型。
时间驱动,由时间推动事件的发生。模拟器通过模拟时间的流逝来推动模拟过程。
事件驱动,由事件推动时间的流逝。模拟器模拟事件之间的相互关联、先后顺序和触发链,让时间被动地在其中跳转。
SimC属于时间驱动的模拟。那么我们先来以SimC为例看看时间是如何驱动模拟的。
时间驱动
SimC有一个“时间轮”的概念,每个时间轮默认长为1024秒。
SimC默认将每一秒切为32个“时间碎片”。那么每一片的长度大约为31毫秒。
“时间碎片”是SimC中最小的、不可再分的时间单位。
想象一下,一个有1024个大刻度,每个大刻度里面有32个小刻度的幸运大转轮。
图:幸运大转轮……真幸运!
当你需要注册一个事件,让其在时刻T发生的时候,SimC会将这个事件存放在T所处的时间碎片之中。
随着模拟进行,SimC会缓慢转动时间轮,指针依次扫过各个时间碎片……当指针指向一个时间碎片的时候,其中所包含的事件就将被执行。
另外,每次到达一个新的时间碎片,SimC将检查一次动作优先级列表,看看有没有玩家现在能够执行的动作。
大转轮的周长1024秒足够长,它超过了17分钟,所以在大多数模拟中,大转轮不会转动超过一周。
然而如果超过了一周,SimC显然也有办法应对。时间碎片是循环连续使用的,第一片碎片在被扫描过之后,立即就变成了最后一片。
比如上图中,高博的大转轮刚刚扫过了“时间碎片:5”,现在高博不需要碰它,碎片5就自动成为了离指针最远的碎片。
只要计划任务的延迟时间不超过大转轮的周长,SimC就可以一直维持运转下去。模拟中最长的延迟注册事件是“筋疲力尽/心满意足”的结束事件,需要延迟10分钟,也就是600秒。
那些长达半小时或一小时的Buff,在SimC中,都被当成了不会发生变化的静态效果,像游戏中持续时间为“N/A”的那些光环一样。
使用关键字wheel_granularity指定每一秒包含的时间碎片数,使用关键字wheel_seconds指定时间轮的长度。
例如:
Code (c):
#将时间碎片的粒度缩小一倍。
wheel_granularity=64
#时间轮长度1024秒。
wheel_seconds=1024
“时间轮”的长度必须超过600秒,否则SimC会拒绝使用你提供的时间轮长度,而使用默认的1024秒。
另外,这两个值都必须是2的整数次幂。如果不是,SimC会将它取整到2的整数次幂。
例如
Code (c):
#这样写的实际效果和上面的语句是一样的。
wheel_granularity=65
wheel_seconds=601
#65会被近似到64。而601会被近似到512,然后因为小于600而被拒绝。
依据这个到2的整数次幂的取整,有经验的程序员立刻就能明白SimC时间片的数据结构。
32个碎片需要5比特的编号,1024秒需要10比特的编号。所以在默认设置下,时间轮上的所有碎片可以被编号为:
0111 1111 1111 1111
蓝色部分代表了秒号,红色部分代表了碎片号。黑色的零是未被使用的比特。
这个编号是连续无缝隙的,可以用来内存寻址。这也是SimC要求必须向2的幂取整的原因。
时间驱动的局限性
时间驱动有它的优势:它符合人类的思维习惯,人们日常生活中更容易感受到时间的流逝,而人们对事件的触发往往感受不深。
所以这种模式的模拟器易于编写,易于维护。
但它也有非常明显的局限性。
引入系统误差。
由于这种架构的模拟器,引入了原子时间单位“时间碎片”,使得所有事件的发生时间都发生了细微的改变。
以SimC的默认设置为例。假如实际游戏中,我们在第0毫秒、第12毫秒、第30毫秒和第31毫秒这四个时刻上,分别发生了事件A、B、C、D。
在模拟中,它们将分别落入第1、1、1、2片时间碎片中。
实际事件发生时间与模拟时间的对比如下:
A | B | C | D | |
实际 | 0 | 12 | 30 | 31 |
模拟 | 30 | 30 | 30 | 61 |
大多数事件的模拟发生时刻都被略微延后了,而且ABC三事件本来发生于不同时刻,在模拟中却同时发生。
这将导致模拟器对输入“急速”属性的细微变化,产生不可预期的行为,对急速价值的评价将在一个较大范围内剧烈震荡。
图:急速收益的不稳定现象。
SimC对此作了许多应对措施,例如加入了延迟系统。
加入随机浮动的延迟值,可以缓解急速震荡效应。
由于急速震荡效应,在进行急速权值计算时,世界延迟应该略微取大一些,差商区间应该略微取大一些,时间碎片粒度也应该尽可能地取细一些。
前两者会影响权值的准确度,后者会降低模拟速度。
放慢模拟速度。
时间碎片粒度越细,准确度越高,但是对大量空白时间碎片的扫描浪费了很多CPU资源,大幅拖慢模拟速度。
时间碎片粒度越粗,准确度就越差。
这是传统时间轴架构一对不可调和的矛盾。
事件驱动
在DPS模拟器上,由于SimC采用的是时间驱动架构,所以事件驱动这一块资料并不充足,还处于待开发阶段。
灰谷本人正在尝试评估事件驱动DPS模拟器的可行性及其性能。
举一例说明事件驱动与时间驱动的区别,以及事件驱动架构可能的优势。
灵魂收割,6秒后爆炸。典型的计划任务。
在起始时间点,执行了施加灵魂收割的动作,然后将灵魂收割Debuff结束和爆炸注册在了第193号时间碎片中。
这里假设时间碎片长度是SimC默认的31毫秒,则模拟过程会是这样:
起始时刻:施加
0.030:不变
0.061:不变
0.092:不变
0.123:不变
0.154:不变
……
……
……
5.992:不变
6.030:结束,爆炸
一共扫描了193个时间碎片,其中有192个时间碎片都是空的,效率非常的低。
如果采用事件驱动的模拟,我将有一个事件队列,为即将发生的事件按发生时刻先后排队。
例如我开始模拟时,事件队列中只有一个事件:
于0.000时刻,施加灵魂收割。
每次我都从事件队列中取发生时刻最早的事件出来执行。
由于队列中只有一个事件,所以模拟一开始执行的就是它。
执行“施加灵魂收割”后,这个事件会向事件队列中注册一个新事件“灵魂收割结束”。
现在事件队列是这样的:
于6.000时刻,灵魂收割结束。
每次我都从事件队列中取发生时刻最早的事件出来执行。所以我这次执行的是“灵魂收割结束”,而且当前时间立刻跳转到了6.000。
“灵魂收割结束”这个事件又会向事件队列中注册一个新事件“灵魂收割爆炸”。
现在事件队列是这样的:
于6.000时刻,灵魂收割爆炸。
每次我都从事件队列中取发生时刻最早的事件出来执行。所以我这次执行的是“灵魂收割爆炸”,而且当前时间仍然处于6.000不变。
这样我就完成了整个模拟。我只执行了三个事件,时间点跳转了一次。
由于任何事件都无法影响过去,只能影响现在和未来,所以这种基于事件链式触发的架构无需对时间进行步进操作,也能保证时间单调流逝,不会回溯。