帧同步的初步探究

  欢迎参与讨论,转载请注明出处。

前言

  在阅读这篇文章之前,你需要了解一下何为帧同步。关于帧同步的实现尝试,其实近年来一直都有不间断的尝试,不过大多浅尝辄止,这次总算是一次较为完整的实现了。接下来便对这次实现介绍一二。

详解

  本次项目使用的开发引擎为LÖVE项目地址在此。以下是运行演示(外网联机测试也通过了):1
  由于我并没有什么服务端开发的相关经验,所以只是使用了个简单的UDP网络库——ENet。客户端与服务端共同处于一个项目下,非常的浅薄,以下是服务端与客户端的代码展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
--server.lua
local Enet = require ("enet")
local Lib = require("lib")
local host = Enet.host_create("localhost:6789")
local peerMap = {}
local dataMap = {}
local inputMap = {}
local playList = {}
local playFrame = 0
local frame = 0
local userCount = 0
print("start")
while (true) do
local event = host:service(17)
while (event) do
local type, ip, data = Lib.Recv(event)
if (type == "input") then
inputMap[ip] = data
elseif (type == "connect") then
event.peer:timeout(10, 3000, 5000)
local data = {ip = ip, x = math.random(0, 800), y = math.random(0, 600)}
for k, v in pairs(peerMap) do
Lib.Send(v, "addNewUser", data)
end
peerMap[ip] = event.peer
dataMap[ip] = data
inputMap[ip] = {}
userCount = userCount + 1
Lib.Send(event.peer, "loginSuccess", {ip = ip, users = dataMap, playList = playList})
print("connect", ip)
elseif (type == "disconnect") then
peerMap[ip] = nil
dataMap[ip] = nil
inputMap[ip] = nil
userCount = userCount - 1
for k, v in pairs(peerMap) do
Lib.Send(v, "delUser", ip)
end
event.peer:disconnect(-1)
print("disconnect", ip)
end
event = host:service()
end
if (userCount > 0) then
frame = frame + 1
if (frame % 3 == 0) then
playFrame = playFrame + 1
print(playFrame)
for k, v in pairs(peerMap) do
Lib.Send(v, "play", inputMap)
end
playList[#playList + 1] = Lib.Clone(inputMap)
end
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
--main.lua
local Actor = require("actor")
local Lib = require("lib")
local Enet = require ("enet")
local userList = {}
local userMap = {}
local input = {}
local playList = {}
local playFrame = 0
local perdt = 17
local timer = 0
local frame = 0
local player
local fps = 0
local timer2 = 0
local host = Enet.host_create()
local server = host:connect("localhost:6789")
local function NewActor(x, y, ip)
local actor = Actor.New(x, y, ip)
userMap[ip] = actor
userList[#userList + 1] = actor
return actor
end
local function Update()
if ((frame + 1) % 3 == 0 and #playList == 0) then
return
end
frame = frame + 1
fps = fps + 1
if (frame % 3 == 0) then
for k, v in pairs(playList[1]) do
if (userMap[k]) then
userMap[k].input = v
end
end
playFrame = playFrame + 1
table.remove(playList, 1)
end
for n=1, #userList do
userList[n]:Update()
end
end
function love.load()
local event, type, ip, data
repeat
event = host:service()
if (event) then
type, ip, data = Lib.Recv(event)
end
until event ~= nil and type == "loginSuccess"
for k, v in pairs(data.users) do
local actor = NewActor(v.x, v.y, v.ip)
if (v.ip == data.ip) then
player = actor
print("loginSuccess", v.ip)
end
end
playList = data.playList
while (#playList > 0) do
Update()
end
end
function love.update(dt)
local event = host:service()
while event do
local type, ip, data = Lib.Recv(event)
if (type == "play") then
playList[#playList + 1] = data
elseif (type == "addNewUser") then
NewActor(data.x, data.y, data.ip)
elseif (type == "delUser") then
userMap[data] = nil
for n=#userList, 1, -1 do
if (userList[n].ip == data) then
table.remove(userList, n)
end
end
end
event = host:service()
end
dt = math.floor(dt * 1000)
timer = timer + dt
while (timer >= perdt) do
--print(#playList)
if ((frame + 1) % 3 == 0 and #playList > 1) then
while (#playList > 0) do
Update()
end
else
Update()
end
timer = timer - perdt
end
timer2 = timer2 + dt
if (timer2 >= 1000) then
--print(timer2, fps)
fps = 0
timer2 = timer2 - 1000
end
end
function love.draw()
for n=1, #userList do
userList[n]:Draw()
end
love.graphics.print(playFrame, 5, 5)
end
function love.keypressed(key)
if (key == "up" or key == "down" or key == "left" or key == "right") then
input[key] = 1
Lib.Send(server, "input", input)
end
end
function love.keyreleased(key)
if (key == "up" or key == "down" or key == "left" or key == "right") then
input[key] = 0
Lib.Send(server, "input", input)
end
end

  从代码中可以看出,以上实现便是Skywind所说的乐观帧锁定。事实上我认为传统的帧同步(Lockstep)并不适合网络游戏,甚至只是一种早期的理论模型。在实际应用还是要以乐观帧锁定为准。关于乐观帧锁定的实现原理我便不再复述,只说说实际开发中遇到的一些问题。

Fixed Update

  很抱歉我找不到这个词对应的中文词汇,直译过来的意思便是“固定的更新”。在实际应用的含义为,每隔一段时间便会Update一次,如果遇到卡住之类导致积累了很长时间的行为,则会根据时间一次性进行多次Update。也就是这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--Fixed Update(main.lua)
timer = timer + dt
while (timer >= perdt) do
if ((frame + 1) % 3 == 0 and #playList > 1) then
while (#playList > 0) do
Update()
end
else
Update()
end
timer = timer - perdt
end

  使用了Fixed Update后,你的游戏进度便由时间牢牢把控住,这样便能摆脱帧率的影响。在业务层也不再需要使用DT(Delta Time)了,而是采用一个固定的值(譬如1 / 60)。这个概念很重要,因为过快与过慢的帧率都会与同步不那么搭调。

收发频率

  按照Skywind的说法是服务端每秒20-50次向所有客户端发送同步包。这里我们需要理清乐观帧锁定的本质:就是服务端每隔一段时间发送同步包,然后客户端每隔一段时间接收并应用之,如果在那段时间内没有收到,就持续等待。所以我们必须设置好合适的时间,令服务端和客户端之间配合无间。
  在我的设计里,为服务端和客户端都设置了帧(Frame)的概念,每Update一次即是一帧,每次Update的间隔时间为17毫秒,这个数字是根据(1/60)秒取整得出。每隔三帧服务端便会发送同步包,而客户端则是每帧都会接收,每隔三帧便会应用之。我称这种每隔三帧的帧为同步帧
  衡量设置是否良好,主要看每帧接收的同步包的数量,以及每秒帧数的多少。正常情况下,每帧接收的同步包的数量应是0-1,如果超过这个数值,证明服务端或客户端其中一方的频率太快或太慢。至于每秒帧数的多少,则能看出发送频率是否过剩,正常情况下在60左右即可,这和FPS的要求是类似的。

1
local event = host:service(17) --服务端每隔17毫秒接收一次封包

收包Q&A

  • 关于收包的问题,有个最明显的问题便是为何不是在同步帧时进行收包,而是每帧都尝试收包?
    • 这是因为收包的内容不仅仅是关于帧同步,还可能有其他东西。其次便是先收后收并不影响什么,以及每隔三帧才进行一次收包恐怕会卡。
  • 关于一次性收到多个同步包的情况时,该怎么办?
    • 遇到收到多个同步包的情况,说明客户端失联了一段时间,这时候便需要一次性进行多次Update以迅速追回进度。
  • 既然确定遇到多个同步包时需要一次性追回进度,那为何不选择在接收后立即执行,而要等到同步帧?
    • 因为基本上不会遇到这种情况,能遇到多个同步包的情况,一般客户端已经在等待了。换做客户端卡住这种情况的话,根据Fixed Update的规则,本来就会立即赶到同步帧的。
  • 为何等待同步包的代码并非是阻塞式的,而是每帧去判定一下?
    • 因为如果是阻塞式的话,会使得DT变得很大,影响Fixed Update。

输入应用

  关于输入应用方面,首先要明确一点:客户端每次按下/释放按键,就会改写输入表然后发送之。服务端收到后便会改写在服务端的对应输入表。这种模式在高延迟的表现便是呈现出某些按键因为一直按下而导致一些鬼畜操作,这点算是可以接受的。
  另外注意不要贪图方便每次只发送当前修改的按键,这样会失去输入的稳定性,一旦发生丢包之类的,会产生键盘失灵的感觉。
  在每次同步帧时便会根据同步包的内容更新所有玩家的输入表,从而改变每个联机角色的操作。这个输入表会一直应用到下次同步帧之前,从这点来看帧同步也是一种回合制。

1
2
3
4
5
6
7
8
9
10
11
12
--根据同步包的内容更新所有玩家的输入表(main.lua)
if (frame % 3 == 0) then
for k, v in pairs(playList[1]) do
if (userMap[k]) then
userMap[k].input = v
end
end
playFrame = playFrame + 1
table.remove(playList, 1)
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--客户端每次按下/释放按键,就会改写输入表然后发送之(main.lua)
function love.keypressed(key)
if (key == "up" or key == "down" or key == "left" or key == "right") then
input[key] = 1
Lib.Send(server, "input", input)
end
end
function love.keyreleased(key)
if (key == "up" or key == "down" or key == "left" or key == "right") then
input[key] = 0
Lib.Send(server, "input", input)
end
end

断线重连/中途加入

  想要做到这两点,便需要做到在服务端保存每一份同步包,这样服务端只需要记录每个玩家的初始数据,在新玩家加入游戏时,首先发送每个玩家的初始数据给新玩家同步,然后再把所有同步包打包发送给新玩家,让新玩家一次性Update,即可完成中途加入。当然不要忘记给现有玩家发送新玩家的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
--新玩家同步(main.lua)
local event, type, ip, data
repeat
event = host:service()
if (event) then
type, ip, data = Lib.Recv(event)
end
until event ~= nil and type == "loginSuccess"
for k, v in pairs(data.users) do
local actor = NewActor(v.x, v.y, v.ip)
if (v.ip == data.ip) then
player = actor
print("loginSuccess", v.ip)
end
end
playList = data.playList
while (#playList > 0) do
Update()
end

浮点数

  根据网上的信息看来,浮点数因为在不同环境的实现有所偏差,所以可能会导致不一致的问题,这样便会产生蝴蝶效应,最终导致同步失效。目前我使用LuaJIT在Windows(x64)、Ubuntu(x64)、macOS、Android(红米Note4)、iOS测试来看,浮点数在「输出」的场合下并无不同。当然只是输出,并不能代表真实数据的情况。以及我的测试范本并不算多,对于浮点数的问题还不敢保证。所以我选择在业务层放弃使用浮点数,相关数据都事先进行转换,到最后需要浮点数的对接场合再进行转换。当然我并不敢保证这种做法的可行性,真正成熟的做法应该是使用定点数,但我暂时并未这么做。

1
dt = math.floor(dt * 1000) -- 浮点数转换为整数(毫秒)

后记

  以上便是本次我对帧同步的初步探究,它注定是不成熟的,需要经过实践的检验,接下来我会考虑将其接入到一些项目中。有相关经验的朋友欢迎前来讨论。