|
说起回放这个问题,这背后藏着一个很深的游戏开发学问,只是一直没有人去深究它,因为这个需求本身不是核心需求,而且是一个可以很容易推脱掉的需求,配合“只要做到it just works就行”的心态,外加又不是“最赚钱”的“渲染学”,所以没人乐意研究它。但恰恰是这么一个需求,当年让我的游戏开发水平又升了一级。出于这种特殊情感,我又要发泄一篇带吐槽、带技术的长篇大论了。从实现原理的角度来看,这个功能为什么大多游戏没有实现。
首先当然是吐槽
之所以一直做不到这个,是因为通常我们面对“重播”这个功能,都采用小聪明的“笨办法”去做。最常见的是记录整个游戏过程的输入(input,包含玩家操作以及AI的指令等,如有必要的话)或者把一段时间内显卡渲染的信息记录下来。
记录输入,或者更高级一点是记录当时游戏进程的状态信息(Snapshot):这个做法,最终实现的是让游戏逻辑快速重新演算到“开始帧”(即玩家希望从这个时间点开始看起),然后继续演算下去,重新依赖整个游戏的逻辑部分不断重新产生数据,然后“重玩”了一局游戏,做到“重播”,是不是我这么说穿了,你就感觉是在凑效果?因为听起来似乎哪儿不对劲,有点不靠谱,但是呢,他又总是能运行(就跟“一本正经的胡说八道”一样,没毛病,没法证伪,只能认为这是对的)。但事实上这种做法在遭遇“倒推”需求的时候就非常操蛋了,因为你记录的Input都是时间顺序的,假如用户要求倒过来,你根本没法逆转所有的input结果。事实上,这里暴露出一个致命的问题——就是这么做的人,根本没想过“渲染依赖的数据非得是游戏逻辑产生的吗?”这个问题,当年的我也是这样,还自以为做到了数据逻辑分离,虽然看似很接近了,但是失之毫厘,差之千里,如果你有耐心听完我吐槽,看下去深入了解这个需求的原理,你就能知道为什么我这么说。
记录显卡渲染数据做法:事实上是绝对正确的做法,就像很多录制视屏的软件的做法一样,是将一段时间内显卡的数据记录下来,用以重新传回给显卡实现重播效果。包括Switch的分享按钮长按等,很多都是这么实现的,严格来说,这样做是对的,真正的“录像”就应该如此。但是问题在于,且不说数据量之大,我们真实的需求,真的是做一个“录像功能”吗?当然,只要不在乎一段游戏的视频占用过大的硬盘,那么这样“录像”的效果,倒推、跳着播是绝对没有问题的。
这就是为什么你很少能看到一个游戏的“录像”功能,能够完善到除了常见功能,还有如倒过来播放、跳跃进度等“常见功能”。因为这两种做法,都存在这根本的问题,实现上都有对游戏产品来说不科学的地方,实现的思想上也根本没有重视这个问题。
接下来是讨论和思考时间,探索一下这个功能的原理
我们重新从程序角度思考一下策划的这个“重播需求”,用Pascal一言以蔽之就是,我们需要一个:
- <p>procedure TGameRecord.Render(frameIndex:integer);</p>
复制代码 它的作用是是在“重播”模块下,让画面渲染指定逻辑帧(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件事情:
记录每次运行时,所捕捉到的entity,整理后归纳到一个临时文件,这个临时文件中,实际记录的是一个entity被“创建”即首次被捕获的tick,和被“移除”的tick,即不再捕捉到这个entity,当然实现方式很多种,这是一个不咋得的方式,但是当时也没来得及优化,it just works。
对比RecordComponent中的currentValue和对应的Component中的对应属性,如果发生变化了,则要在RecordComponent中记录,并刷新对应的属性。这样一来,在整个RecordComponent生命周期中,“发生过的变化”都被一一记下了。
“重播数据”产生及应用
一局游戏结束后,即这个System被整个游戏世界Shutdown的时候,导出数据,这个数据中主要包的内容是:一个RecordComponent被创建和移除的时间点(tick),以及被移除时的数据。通过这些数据信息,我们就可以实现这个TGameRecord.Render:
首先获得当前帧所有的entity:这个entity和游戏运行时候的entity不同,ECS的特性也是如此,即我们人理解的一个东西,他未必只是一个entity,他可以是很多个“同步的”entity,当然这是一个ECS的使用方式问题了,这里不细说。从数据中,我们能得出当前帧存在的RecordComponent的数组,有多少个存在的RecordComponent,就有多少个entity。
为entity创建render和position两个Component,这里你很容易就能从RecordComponent中过滤出当前帧的属性信息来,不论你是用RecordComponent.initValue还是用currentValue,只是一个for循环的方向问题而已。
通过一个RecordRenderSystem来把他们绘制出来。
这个“重播功能”的核心部分就算是完成了。
总结
耐心看到这里,你可以发现,实际上一个“完善的重播功能”实现方式是这样的,与我们“小聪明产生的笨办法”看起来相似,但实际上差距还是存在的——它既不是存显卡信息,也不是存操作指令;它看起来存的是snapshot(或者说它存的就是snapshot),但是此snapshot非彼snapshot,并且这才是泛用型的“重播功能”,因为我可以把这个RecordSystem和RecordComponent用于任何游戏,而不用单独写什么额外逻辑,唯一可能需要维护的,就是关心的Component而已(但这讲道理也是不该变化的)。
既然实现方式不同,自然根本就不是一个东西,所以你提的需求,和现在市面上游戏所做的东西,就不是一个东西,他们满足不了你的这些倒播、跳播,自然也是很正常的事情了。
作者:猴与花果山
|
|