logo
游戏中的帧同步

游戏中的帧同步

帧同步的概念

这里说明什么是帧同步,熟悉的话可以跳过。

这里以移动操作举例:

  • 状态同步:客户端发送当前位置给服务器,服务器广播到其他客户端更新位置。
  • 帧同步:客户端发送当前操作,例如按下的按钮或者摇杆的角度到服务器,服务器广播玩家操作到其他客户端,客户端根据操作数据模拟玩家手动操作更新位置

帧同步的消息机制

帧同步的客户端和服务器消息收发和状态同步并不一致。

服务器

  1. 规定每秒帧数,例如 30帧/秒,那么客户端的操作延迟为 1000 / 30 = 33.33 毫秒,记录经过帧数,帧数根据项目情况而定
  2. 设置定时器,定时器间隔 <= 帧间隔(1000 / 帧数)毫秒,在定时器内累加时间,每当累加时间 >= 帧间隔,即循环 `Math.floor(累加时间 / 帧间隔)` 次数广播客户端操作信息,循环后累加时间减去(循环次数 * 帧间隔),示例代码如下:
const interval_ms_n = 1000 / 30;
/** 上次广播时间 */
let previous_broadcast_timestamp_n = Date.now();
/** 累计时间 */
let cumulative_time_ms_n = 0;

const game_frame_sync_f = (): void => {
	cumulative_time_ms_n += Date.now() - previous_broadcast_timestamp_n;
	previous_broadcast_timestamp_n = Date.now();

	// 未到下一帧
	if (cumulative_time_ms_n < interval_ms_n) {
		return;
	}

	const loop_n = Math.floor(cumulative_time_ms_n / interval_ms_n);

	for (let k_n = 0, len_n = loop_n; k_n < len_n; ++k_n) {
		// ...广播操作

		frame_n++;
	}

	cumulative_time_ms_n -= loop_n * interval_ms_n;
};

game_frame_sync_f();
game_loop_timer = setInterval(game_frame_sync_f, interval_ms_n * 0.5);
  1. 服务器内获取游戏经过时间应该使用经过帧数 * 帧间隔,而不是直接用时间戳判断

客户端

客户端只需要对相同帧内只能执行一次的玩家操作消息进行处理,例如移动,需要执行以下操作

  • 方式1:不做处理,玩家操作后发送到服务器,服务器采用最新的操作数据替换上次的数据
  • 方式2:玩家操作后发送到服务器,在下一逻辑帧之前不再发送此操作到服务器,可节省流量但玩家的操作不是当前帧内最新的

逻辑帧和渲染帧

逻辑帧

服务器的逻辑帧就是上面说的广播消息的次数,广播一次则+1

客户端需要记录远程逻辑帧和本地逻辑帧,远程逻辑帧收到服务器广播消息更新,本地逻辑帧则由客户端游戏进程中更新,远程逻辑帧数作为本地逻辑帧数的上线(目标帧数)

渲染帧

由于服务器的逻辑帧速率一般间隔太长,例如一秒20次,但是客户端不可能一秒只更新 20 次渲染对象,所以我们需要一个渲染速率, 而渲染次数则是选渲染帧数。

渲染帧速率:需要和游戏中的目标帧率`(cc.game.frameRate)`同步,因为客户端存在非帧同步的渲染对象,例如粒子效果,动态场景修饰物等等... 如果不和引擎渲染同步则会造成割裂感,例如帧同步移动对象一次,而引擎渲染两次会导致看起来移动对象卡顿

客户端完整流程

  1. 收到服务器的帧消息(帧消息内容包含当前服务器逻辑帧数, 所有玩家的操作数据),即使玩家操作数据为空服务器也应该发送帧消息

  2. 客户端更新远程帧数,同时存储玩家操作数据到 Map,Key 是 帧数,Data 则是函数列表,存储服务器下发的需要执行的玩家的操作逻辑函数

  3. 检查是否追帧,如果当前本地逻辑帧 + 1 < 远程逻辑帧,则执行渲染函数直到本地逻辑帧等于远程逻辑帧

  4. 开启渲染,由 `cc.director.getScheduler().scheduleUpdate` 驱动,渲染状态为 true 则调用渲染函数。

    渲染函数逻辑:

    1. 检查渲染帧数 + 1是否超过目标渲染帧数(根据目标逻辑帧数计算),超过则停止渲染
    2. 递增渲染帧数,然后根据渲染帧数更新本地逻辑帧数,例如逻辑帧数30次/秒,渲染 2 次则逻辑帧 + 1
    3. 根据本地逻辑帧数执行之前 Map 中存储的函数然后删除 Map 中当前本地逻辑帧数的数据
    4. 更新游戏对象(例如移动, 碰撞检测)

对应 tool_frame_manage.step

随机函数

客户端需要处理随机函数以做到随机值在每个客户端一致。而且需要两个随机函数

  1. Math.random:引擎和其他不在帧渲染逻辑中的使用,需要做到每次渲染帧调用的结果一致

  2. 自定义 random:用于渲染帧中多次调用需要不同的随机值结果,需要做到调用次数一致,调用结果一致

    对应 tool_frame_manage.random

随机数种子:多局游戏之间需要不同的随机值结果,所以依赖随机数种子,对应 tool_frame_manage.set_random_seed

数学库替换

根据 https://262.ecma-international.org/6.0/#sec-function-properties-of-the-math-object 规则,acos, acosh, asin, asinh, atan, atanh, atan2, cbrt, cos, cosh, exp, expm1, hypot, log, log1p, log2, log10, pow, random, sin, sinh, sqrt, tan, tanh 函数结果根据平台自行实现或参考模板实现,所以可能结果不一致

对应 tool_frame_manage._replace_math

帧日志

在渲染逻辑中添加帧日志用于排查多设备间代码执行顺序不一致的位置,越多越好(建议使用 DEBUG 宏在打包时排除)

对应 tool_frame_manage.log

注意事项

  • 在帧同步流程中不要使用异步,即使是加载资源也需要提前加载完成然后使用同步逻辑使用,否则后续排查错误消耗时间更多
  • 获取游戏时长统一使用逻辑帧数计算获得,不论是服务器还是客服端
  • 如果出现不同步,可以使用帧日志排查,在游戏运行几分钟后用两台设备的帧日志对比,如果帧日志完全相同则代表完全同步
  • 在游戏开始(服务器广播帧消息)前,需要等待所有玩家加载完成,如果有玩家超时则定为离线,看需求是否做断线重连(比状态同步简单)或踢出房间

源码

项目根目录/assets/tool/tool_frame_manage.ts

https://github.com/1226085293/MKFramework