当前位置: 华文问答 > 游戏

为什么大多数游戏的回放功能都不能倒放或者跳跃进度?

2019-01-20游戏

说起这个问题,这背后藏着一个很深的游戏开发学问,只是一直没有人去深究它,因为这个需求本身不是核心需求,而且是一个可以很容易推脱掉的需求,配合「只要做到it just works就行」的心态,外加又不是「最赚钱」的「渲染学」,所以没人乐意研究它。但恰恰是这么一个需求,当年让我的游戏开发水平又升了一级。出于这种特殊情感,我又要发泄一篇带吐槽、带技术的长篇大论了。 从实现原理的角度来看,这个功能为什么大多游戏没有实现

首先当然是吐槽

之所以一直做不到这个, 是因为通常我们面对「重播」这个功能,都 采用小聪明的「笨办法」 去做。最常见的是记录整个游戏过程的输入(input,包含玩家操作以及AI的指令等,如有必要的话)或者把一段时间内显卡渲染的信息记录下来。

记录输入,或者更高级一点是记录当时游戏进程的状态信息(Snapshot) :这个做法,最终实现的是让游戏逻辑快速重新演算到「开始帧」(即玩家希望从这个时间点开始看起),然后继续演算下去,重新依赖整个游戏的逻辑部分不断重新产生数据,然后「重玩」了一局游戏,做到「重播」,是不是我这么说穿了,你就感觉是在凑效果?因为听起来似乎哪儿不对劲,有点不靠谱,但是呢,他又总是能运行(就跟「一本正经的胡说八道」一样,没毛病,没法证伪,只能认为这是对的)。 但事实上这种做法在遭遇「倒推」需求的时候就非常操蛋了,因为你记录的Input都是时间顺序的,假如用户要求倒过来,你根本没法逆转所有的input结果 。事实上,这里暴露出一个致命的问题—— 就是这么做的人,根本没想过「渲染依赖的数据非得是游戏逻辑产生的吗?」这个问题,当年的我也是这样,还自以为做到了数据逻辑分离,虽然看似很接近了,但是失之毫厘,差之千里 ,如果你有耐心听完我吐槽,看下去深入了解这个需求的原理,你就能知道为什么我这么说。

记录显卡渲染数据做法 :事实上是绝对正确的做法,就像很多录制视屏的软件的做法一样,是将一段时间内显卡的数据记录下来,用以重新传回给显卡实现重播效果。包括Switch的分享按钮长按等,很多都是这么实现的,严格来说,这样做是对的,真正的「录像」就应该如此。但是问题在于,且不说数据量之大,我们真实的需求,真的是做一个「录像功能」吗?当然, 只要不在乎一段游戏的视频占用过大的硬盘,那么这样「录像」的效果,倒推、跳着播是绝对没有问题的

这就是为什么你很少能看到一个游戏的「录像」功能,能够完善到除了常见功能,还有如倒过来播放、跳跃进度等「常见功能」。 因为这两种做法,都存在这根本的问题,实现上都有对游戏产品来说不科学的地方,实现的思想上也根本没有重视这个问题

接下来是讨论和思考时间,探索一下这个功能的原理

开始思考这个问题之前,你可能有必要先了解一下ECS是什么,我用最通俗的方式表达过一次:

我们重新从程序角度思考一下策划的这个「重播需求」,用Pascal一言以蔽之就是,我们需要一个:

procedure TGameRecord . Render ( frameIndex : integer );

它的作用是是在「重播」模块下,让画面渲染指定逻辑帧(frameIndex)的情况,只要实现了这个功能,并且运行高效(肯定不能是重新演算一下这么低效把),那么真正的「视频录像」功能也就算是完成了(至少是核心功能完成了吧,UI什么的当然还没做)。那么这个函数里到底要做些什么事情呢?

最早接到做一个完整的视频功能,并且甲方高度重视这个功能,是在2014年,当时直播行业刚起步(萌芽阶段),甲方的意图是,如果游戏能够更方便用户去录制各种视频,包括一些好像是叫「鬼畜」的功能,就是玩家可以选定一段然后让这段来回播放什么的。所以需求中,的确存在了倒播、甚至是复制和插播的功能。而那个游戏玩一局的时间,短的差不多5分钟,长一点可能得2小时(甲方设计的确傻逼,但是作为乙方在提议无效的情况下,是只能当做正确的需求来做的,这里顺便吐槽一下很多公司的程序员,其实是「甲方程序员」,甚至可以否决策划设计,这工作也太轻松了点了吧)。 其实一切逻辑你也想得到,就像上面这个问题的答案一样「就是把frameIndex下的数据拿出来渲染」,说这句话非常轻松,但是只要批判一下,你就发现一个很严重的问题 ——那么每一帧要记录的数据是什么?要知道长达2小时的游戏局(如果用户当中挂机一会甚至可以玩6、7个小时一局,算了这就不吐槽了),即使FPS是30,也要14400帧的数据,如果按照每一帧都是一个位图来存,这显然是没法接受的;但是如果我只记录操作,那么倒播是根本无法实现的,这不符合甲方的需求,正如吐槽里说的。 那么当时最先想到的最佳的方案就是,记录每一帧的Snapshot——这是这两个方案的折中方案

但是如果顺着这个Snapshot的思路继续下去,你就会问出下一个问题——那么我要存些什么数据呢?最早进入我大脑的方案是这个:

因为既然客户端可以「重播」一段来自服务器的数据,那么它重播自己的数据,本身没有问题,并且这样一套机制,看起来是通用的,适合于任何其他游戏。当时我也沉迷于造轮子,追求的是一个机制可以用在所有的游戏当中,这也是使用ECS的目的之一。但是在实际动手写代码(Haxe写ECS)的时候,新的问题产生了,让我不得不重新思考这个问题:

是不是我之后开发的每一个游戏,我针对这个游戏产生这些「重播数据」,以及使用这些「重播数据」,都得有一个约定的写法?或者说每开发一个游戏,都得为它的「重播数据」定制一套「重播功能」?

虽然主要工作是游戏策划,但是我对于编程还是有一定的要求的,原则告诉我,ECS需要的不是这个玩意儿,不是一份说明书,不是一个约定,也不是一个应付的玩意儿,它真正需要的是:

一个RecordSystem和一个RecordComponent,来实现一个:不依赖于游戏逻辑产生数据,并且能够重播,并实现TGameRecord.Render()的这么个玩意儿。或者换句话说,如果它本身是一段「录像」,是否能被「重播」?(是不是感觉这个需求说起来有点绕了?)

然后我根据这个真实的需求,仔细思考了一下实现的细节,它应该是……

RecordComponent

在这个Component中的只有2个数据:currentValue:Array<ComponentKeyValue>和modifications: Array<ComponentModification>,这玩意记录了这个RecordComponent的宿主Entity下,所有position和render这两个Component中的数据变化。

ComponentKeyValue的属性包括:

  • component:string,之所以用string,而不是用Component,是因为Component之间是不应该存在互相依赖的关系,所以使用一种类似「密码」的方式来实现自欺欺人的效果,是的,大多程序员都善于自欺欺人,比如C#里的单例模式(当然这就扯远了)。这个的值就是Component的名字,这是在ECS里能正常Get得到的(简单地说就是.toString()而已)。
  • key:string,对应Component中的属性,跟component一样道理。
  • value:dynamic,对应的值,可以吃haxe的dynamic类的糖。
  • 这些值在初始化这个component的时候,可以从对应的Component里读取,实际上这个东西也是为了实现「对比」功能而存在的,当然为了重播,可以再开一个initValue,即创建时候所关心的Component的数据,并且不再改动,用于录像功能更方便的追溯当前帧的数据。

    ComponentModification的属性包括:

  • tick:int,即这次改动所发生的游戏tick,确切的说是RecordSystem运作了的tick数。
  • modification: ComponentKeyValue,即属性变化的样子信息,那个属性发生了变化,变成了什么。
  • 通过这样一个array,我就可以知道这个entity的「有效生命周期」里,它的renderSystem所依赖的数据(Component)发生的变化。

    RecordSystem

    这个系统关注的Component有:

  • RecordComponent:即上面的Component。
  • Render和Position:之所以只关心这两个,是因为大多游戏中RenderSystem关注这两个就足够了,但是可能存在第三个,这个当初没有完成归纳,不幸的是团队解散了,各奔东西了,ECS也被雪藏了,直到很多年后守望先锋再次提起,当然这是另外一个故事了。
  • 这个System的工作核心有2件事情:

    1. 记录每次运行时,所捕捉到的entity,整理后归纳到一个临时文件,这个临时文件中,实际记录的是一个entity被「创建」即首次被捕获的tick,和被「移除」的tick,即不再捕捉到这个entity,当然实现方式很多种,这是一个不咋得的方式,但是当时也没来得及优化,it just works。
    2. 对比RecordComponent中的currentValue和对应的Component中的对应属性,如果发生变化了,则要在RecordComponent中记录,并刷新对应的属性。这样一来,在整个RecordComponent生命周期中,「发生过的变化」都被一一记下了。

    「重播数据」产生及应用

    一局游戏结束后,即这个System被整个游戏世界Shutdown的时候,导出数据,这个数据中主要包的内容是:一个RecordComponent被创建和移除的时间点(tick),以及被移除时的数据。通过这些数据信息,我们就可以实现这个TGameRecord.Render:

    1. 首先获得当前帧所有的entity:这个entity和游戏运行时候的entity不同,ECS的特性也是如此,即我们人理解的一个东西,他未必只是一个entity,他可以是很多个「同步的」entity,当然这是一个ECS的使用方式问题了,这里不细说。从数据中,我们能得出当前帧存在的RecordComponent的数组,有多少个存在的RecordComponent,就有多少个entity。
    2. 为entity创建render和position两个Component,这里你很容易就能从RecordComponent中过滤出当前帧的属性信息来,不论你是用RecordComponent.initValue还是用currentValue,只是一个for循环的方向问题而已。
    3. 通过一个RecordRenderSystem来把他们绘制出来。

    这个「重播功能」的核心部分就算是完成了。

    总结

    耐心看到这里,你可以发现,实际上一个「完善的重播功能」实现方式是这样的,与我们「小聪明产生的笨办法」看起来相似,但实际上差距还是存在的——它既不是存显卡信息,也不是存操作指令;它看起来存的是snapshot(或者说它存的就是snapshot),但是此snapshot非彼snapshot,并且这才是泛用型的「重播功能」,因为我可以把这个RecordSystem和RecordComponent用于任何游戏,而不用单独写什么额外逻辑,唯一可能需要维护的,就是关心的Component而已(但这讲道理也是不该变化的)。

    既然实现方式不同,自然根本就不是一个东西,所以你提的需求,和现在市面上游戏所做的东西,就不是一个东西,他们满足不了你的这些倒播、跳播,自然也是很正常的事情了