上一节案例使用的是基于本地文件存储的回放系统,每次播放时都需要重新加载地图。那有没有办法实现类似实况足球的实时精彩回放呢?有的,那就是基于DuplicatedLevelCollection和内存数据流的回放方案。

思考一下,通常射击游戏里的击杀镜头、体育竞技里的精彩时刻对回放的基本需求是什么?这类回放功能往往是在某个时间点可以无感知地立刻切换到回放镜头,并在回放结束后迅速再切换到正常的游戏环境。同时,考虑到联机的情况,我们在回放时要保持游戏世界的正常运转,从而确保不错过任何服务器的同步信息,不影响其他玩家。

简单总结就是:

1. 可以迅速地在真实游戏与回放镜头间切换

2. 回放的时候不会影响真实游戏里面的逻辑变化

4.1 回放场景与真实场景分离

为了实现上述的要求,我们需要将回放的场景和真实的场景进行分离,在不重新加载地图的情况下快速地进行切换。虚幻引擎给出的方案是对游戏世界World进行进一步的拆分,把所有的Level组织到了三个LevelCollection里面,分别是:

- DynamicSourceLevels ,存储真实世界的所有标记为Dynamic的Level(包含里面的所有Actor)

- StaticLevels ,存储了静态的Actor,也就是回放过程中不会发生变化的对象,通常指那些不可破坏建筑(通过关卡编辑器里面的Static选项,可以设置任何一个SubLevel是属于DynamicSourceLevels还是StaticLevels的,PersistLevel永远是Dynamic的)

- DynamicDuplicatedLevels ,回放世界的Level(包含里面的所有Actor),会把DynamicSourceLevels里面的所有Level都复制一遍

要注意的是,由于LevelCollection的引入,原来很多逻辑都变得复杂了。

1. 不同LevelCollection的Tick是有先后顺序的,默认情况下是按照他们在数组的排列顺序DynamicSourceLevels-> StaticLevels-> DynamicDuplicatedLevels,这个顺序可能影响我们的代码逻辑或者摄像机更新时机。

2. 回放世界DynamicDuplicatedLevels里面也会有很多Actor,如果不加处理的话很有可能也被录制到回放系统中,造成嵌套录制。

3. 当一个DynamicDuplicatedLevels执行Tick的时候,会通过FScopedLevelCollectionContextSwitch来切换当前的ActiveCollection,进而修改当前World的GameState等指针,所以在回放时需要注意获取对象的正确性。(比如下图获取PC的迭代器接口,在DuplicatedLevels Tick时只能获取到回放世界的PC)。

4. 用于回放的UDemoNetDriver会绑定一个LevelCollection(通过传入PlayReplay的参数LevelPrefixOverride来决定)。当触发回放逻辑后,即UDemoNetDriver::TickDispatch每帧解析回放数据时,我们也会通过FScopedLevelCollectionContextSwitch主动切换到当前DemoNetDriver绑定的LevelCollection,保证解析回放数据时可以通过Outer找到回放场景(DynamicDuplicatedLevels)


4.2 回放录制与播放分离

考虑到在死亡回放的时候不会影响正常比赛的进行和录制,所以我们通常也需要讲录制逻辑与播放逻辑完全分离。

简单来说,就是创建两个不同的Demonetdriver,一个用于回放的录制,另一个用于回放的播放。在游戏一开始的时候,就创建一个DemonetdriverA来开始录制游戏,当角色死亡触发回放的时候,这时候创建一个新的DemonetdriverB来进行回放数据的读取并播放,整个过程中DemonetdriverA一直在处于录制状态,不会受到任何影响。(需要我们手动重写GameInstance::PlayReplay函数,因为默认的逻辑每次创建一个新的Demonetdriver就会删掉原来的那个。)

4.3 基于内存的回放数据流

当然,想要实现真正的快速切换,只将回放场景与真实世界的分离还不够,我们还需要保证回放数据的加载也能达到毫秒级别。所以这个时候就不能再使用前面提到的LocalFileNetworkReplayStreamer把数据放到磁盘上,正确的方案是采用基于内存数据流的ReplayStreamer来加快回放数据的读取。下面是InMemoryNetworkReplayStreamer对回放数据的组织方式,每帧的数据流会根据时间分段存储在StreamChunks里面,而不同时间点的快照则会存储在Checkpoints数组里面。对于射击游戏,我们通常会在比赛一开始就执行录制,录制的数据会不断写到下面的结构里面并在整场比赛中一直保存着,当玩家被击杀后就可以立刻从这里取出数据来进行回放。

关于死亡回放/精彩镜头其实还有很多细节问题,这里列举一些(最后一节会给出一些建议):

- 引擎编辑器里面默认不支持DynamicDuplicatedLevels的创建,所以在不改源码的情况下无法在编辑器里面实现死亡回放功能。

- 回放世界与真实世界都是存在的,可以通过SetVisible来处理渲染,但是回放世界的物理怎么控制?

- 回放世界默认情况下不会复制Controller(容易和本地的Controller发生冲突),所以很多相关的接口都不能使用。

- 由于不同Collection的Tick更新时机不同,但是Controller只有一个,所以回放的时候要注意Controller的更新时机。

- 默认的录制逻辑都是在本地客户端实现的,可能对客户端有一定的性能影响。

更多细节建议到GitHub参考虚幻竞技场的源码:

https://github.com/EpicGames/UnrealTournament


五、Livematch观战系统

在CSGO、Dota、堡垒之夜等游戏里,都支持玩家观战的功能,即玩家可以通过客户端直接进入到某个正在进行的比赛的场景里进行实时观战。不过一般情况下并不是严格意义上的完全实时,通常根据情况会有一定程度的延迟。

实现该功能的一个简易方案就是让观战的玩家作为一个客户端连接进去,然后实时地接受服务器同步数据来进行观战。这种方式既简单,效果也好,但是问题也非常致命——观战的玩家可能会影响正常服务器性能,无法很好地支持大量的玩家进入。