对方舟帧数问题的最终解答
前言
本文内容为合作完成,在此特别感谢 @fux2 和 @没有-MeiYou 两位大佬的帮助
本文将会以近期盛传的史尔特尔/燧石/棘刺攻速bug为线索,从程序层面讲解攻速与帧数相关的机制问题
并对困扰玩家一年多的帧数问题进行解答

所谓“帧数”
众所周知,方舟是一个“30帧”的游戏
但这个“30帧”并非常规意义上的每秒30张画面,而是指程序的最大自动更新频率
这个更新通过unity引擎自带的FixedUpdate函数实现,其中更新间隔为变量fixedDeltaTime,这个变量在方舟中为1/30
FixedUpdate函数会在每次被执行时执行battleController以及其包含所有BObject(包含实体,地砖等)的OnTick函数
然后实体的OnTick函数又会执行其包含的技能,能力,状态等等的OnTick函数
这些能力又会执行其包含的计时器的OnTick()函数...
以此类推,最后达到让所有需要更新的类都以固定的频率进行自动更新的目的
画面(包括spine动画,场景,特效等等)不包含在此类中

浮点精度
在BV1qD4y1R7tN中,UP主提到了一个测试结果,即尝试使用OnTick函数输出帧与帧之间的间隔时,发现帧的间隔在128s-256s之间发生了变化(帧的间隔变的更长了)

然而,事实真的如此吗?
首先我们需要了解一下测试中输出帧间隔的方法
游戏中有一个名为get_fixedPlayTime的函数,可用来获取游戏从开局到现在所经历的游戏时间
这个游戏时间为一个float变量,会被FixedUpdate函数所更新,每次更新会使其增加fixedDeltaTime(即1/30)
而输出帧间隔,是在FixedUpdate函数上调用get_fixedPlayTime来输出当前游戏时间
之后通过两次游戏时间相减来获取“帧间隔”(即FixedUpdate函数被调用的间隔)
从数学的角度上来说,这么做没有任何问题
但是从计算机的角度出发,这样做可能会带来严重的误差
其原因在于,float变量所能表示的有效数字(即sig fig)位数有限
在游戏时间为128.03333333s时,小数点后的第5位已经变成了第八位有效数字,这很可能会在减法时造成误差
让我们实际测试一下


可以看到,虽然以上两个运算在数学上不会存在任何结果的差异
但在计算机上,由于浮点数的精度问题,结果被改变了
那么这里可以得出结论,所谓“帧的间隔”的改变,实际上并不存在,这仅仅是测试输出结果时由于浮点精度问题造成的误差
但是,128s-256s时,史尔特尔在游戏中攻击间隔的改变是切实存在的
因此可以推测,鹰角应该在哪里犯了和之前测试类似的错误

攻击后摇
最终我们在攻击后摇的时长计算里找到了可能存在的浮点精度问题
首先需要对攻击的逻辑进行一定的解释
攻击属于一种特殊的能力(ability)
大部分情况下,每播放一次完整的攻击动画(这里不考虑什么首次接敌的抬手动作之类的)视作一次完整的攻击
一次完整的攻击的流程为:攻击开始(CastStart)——前摇(PreDelay)——造成效果(event/SpellOn)——后摇(PostDelay)——攻击结束(CastEnd)
攻击开始时会根据当前攻速,攻击动画长度,攻击动画最大拉伸比例,计算出本次攻击持续时间,然后以此时间计算速度播放攻击动画
前摇开始时会记录下当前的游戏时间(fixedPlayTime),作为开始时间。前摇持续会被化为整帧
攻击依赖于动画时,接收到来源于spine动画的攻击事件时结束前摇,造成效果,随后进入后摇
后摇开始时会获取当前游戏时间,并以 后摇持续时间=攻击持续时间-(当前游戏时间-开始时间),计算出后摇时间。随后后摇时间会被四舍五入至整帧
后摇结束时,本次攻击结束,播放攻击结束动画
计算后摇持续时间时,由于获取了两次游戏时间,因此计算出的后摇持续时间很可能存在浮点精度问题
我们获取了史尔特尔在128s附近计算出的后摇时间(未经过四舍五入)

从图片中可看出,在0-128s时,计算出后摇持续为0.850006s,略大于25.5帧。经过四舍五入后变为26帧
而在128s后,计算出后摇持续为0.849915s,略小于25.5帧。经过四舍五入后变为25帧
这样一来,由于前摇时间不变,后摇时间短了一帧,因此在128s后,史尔特尔的攻击较正常情况会提早一帧结束
即,普通状态下,攻击持续1.26667s(38帧)。128s后,攻击持续1.23333s(37帧)。
等等...明明128s后攻击持续减短了,为什么攻击间隔反而多了一帧???

触发顺序
攻击持续比攻击间隔短的干员,攻击间隔往往莫名其妙多出一帧
这和攻击的触发与冷却机制有关
首先是攻击/主动能力的冷却机制
攻击拥有独立的冷却计时器(使用倒计时),不跟关卡时间挂钩,但是依然是以帧为间隔进行更新
攻击会在开始的瞬间进入冷却。依赖攻速时,冷却会被设置为理论攻击间隔
冷却为0,且攻击未处于发动状态的情况下,才可触发下一次攻击
对于史尔特尔128s后的情况,在1.23333s时,冷却计时器尚未归0,因此无法进行下一次攻击
那么为什么1.266667s时,理论上冷却已经归0,却依然无法触发呢?
因为两个逻辑的共同作用:
第一:在执行FixedUpdate时,攻击的触发判定(即状态机tick),始终位于能力的冷却更新之前
第二:在能力执行的第1帧里,不会进行冷却更新
这么说可能有点迷,所以这里做了张图

由图中可以看出,在鹰角的逻辑下,所有攻击冷却周期在实际应用中会比理论情况多一帧
4帧的冷却周期在鹰角的逻辑下变为了5帧
部分干员(如克洛斯)并没有出现这样的问题
其原因是,方舟有一个特殊机制(Ability.FinishCallbackDelegate):攻击结束后,会立刻尝试触发下一次攻击
因此,攻击持续时间等于攻击间隔的干员并不会出现多一帧的现象
而史尔特尔,本应攻击持续等于攻击间隔。但由于之前所描述的浮点精度问题,导致攻击实际持续较攻击间隔短了0.5帧
从而引起了“多一帧”的问题
类似的还有源石地板上的专三至高之术棘刺

冷却重置
除去以上这些机制外,还有一个被称作冷却重置(resetCDStrategy)的机制
这个机制的大概效果为,在攻击结束时(CastEnd,位于立刻尝试触发下一次攻击之前),会检查剩余的冷却
如果这个冷却小于一个固定值(目前除白金为0外其它干员均为半帧),则立刻重置攻击的冷却
这个机制反应到表层就是所谓的帧对齐,或者是攻速化成帧的四舍五入
但是,由于这个机制只在攻击结束时生效,因此如果攻击持续小于攻击间隔0.5帧或以上,不要考虑四舍五入的事情
最典型的例子是这次的燧石攻速问题
燧石理论攻击间隔0.78s,为23.4帧
由于实际攻击动画持续只有21帧,因此无法触发冷却重置,实际冷却应向上取整,为24帧
又因为动画短于攻击间隔0.5帧以上,受到“多一帧”问题的影响,实际冷却周期为25帧,即0.833s,和实际攻速一致

其它问题
主要有小羊火山的攻击间隔多一帧问题
实际原因为后摇拥有1帧保底。进行15帧前摇,未等待到攻击事件后强制进入1帧后摇,导致实际攻击间隔为16帧。
安洁莉娜2技能的攻击次数损失原理同火山,攻速不稳问题可参考cv6376654
本文原载于NGA:https://ngabbs.com/read.php?tid=23640215
作者为本人
此专栏以CC BY-NC-SA 4.0协议发布