帧同步的初步探究
来源:互联网 发布:现在还能做淘宝吗 编辑:程序博客网 时间:2024/05/16 10:06
欢迎参与讨论,转载请注明出处。
本文转载自https://musoucrow.github.io/2017/08/26/frame_sync/
前言
在阅读这篇文章之前,你需要了解一下何为帧同步。关于帧同步的实现尝试,其实近年来一直都有不间断的尝试,不过大多浅尝辄止,这次总算是一次较为完整的实现了。接下来便对这次实现介绍一二。
详解
本次项目使用的开发引擎为LÖVE,项目地址在此。以下是运行演示(外网联机测试也通过了):
由于我并没有什么服务端开发的相关经验,所以只是使用了个简单的UDP网络库——ENet。客户端与服务端共同处于一个项目下,非常的浅薄,以下是服务端与客户端的代码展示。
--server.lualocal Enet = require ("enet")local Lib = require("lib")local host = Enet.host_create("localhost:6789")local peerMap = {}local dataMap = {}local inputMap = {}local playList = {}local playFrame = 0local frame = 0local userCount = 0print("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 endend
--main.lualocal Actor = require("actor")local Lib = require("lib")local Enet = require ("enet")local userList = {}local userMap = {}local input = {}local playList = {}local playFrame = 0local perdt = 17local timer = 0local frame = 0local playerlocal fps = 0local timer2 = 0local 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 actorendlocal 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() endendfunction 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() endendfunction 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 endendfunction love.draw() for n=1, #userList do userList[n]:Draw() end love.graphics.print(playFrame, 5, 5)endfunction love.keypressed(key) if (key == "up" or key == "down" or key == "left" or key == "right") then input[key] = 1 Lib.Send(server, "input", input) endendfunction love.keyreleased(key) if (key == "up" or key == "down" or key == "left" or key == "right") then input[key] = 0 Lib.Send(server, "input", input) endend
从代码中可以看出,以上实现便是Skywind所说的乐观帧锁定。事实上我认为传统的帧同步(Lockstep)并不适合网络游戏,甚至只是一种早期的理论模型。在实际应用还是要以乐观帧锁定为准。关于乐观帧锁定的实现原理我便不再复述,只说说实际开发中遇到的一些问题。
Fixed Update
很抱歉我找不到这个词对应的中文词汇,直译过来的意思便是“固定的更新”。在实际应用的含义为,每隔一段时间便会Update一次,如果遇到卡住之类导致积累了很长时间的行为,则会根据时间一次性进行多次Update。也就是这段代码:
--Fixed Update(main.lua)timer = timer + dtwhile (timer >= perdt) do if ((frame + 1) % 3 == 0 and #playList > 1) then while (#playList > 0) do Update() end else Update() end timer = timer - perdtend
使用了Fixed Update后,你的游戏进度便由时间牢牢把控住,这样便能摆脱帧率的影响。在业务层也不再需要使用DT(Delta Time)了,而是采用一个固定的值(譬如1 / 60)。这个概念很重要,因为过快与过慢的帧率都会与同步不那么搭调。
收发频率
按照Skywind的说法是服务端每秒20-50次向所有客户端发送同步包。这里我们需要理清乐观帧锁定的本质:就是服务端每隔一段时间发送同步包,然后客户端每隔一段时间接收并应用之,如果在那段时间内没有收到,就持续等待。所以我们必须设置好合适的时间,令服务端和客户端之间配合无间。
在我的设计里,为服务端和客户端都设置了帧(Frame)的概念,每Update一次即是一帧,每次Update的间隔时间为17毫秒,这个数字是根据(1/60)秒取整得出。每隔三帧服务端便会发送同步包,而客户端则是每帧都会接收,每隔三帧便会应用之。我称这种每隔三帧的帧为同步帧。
衡量设置是否良好,主要看每帧接收的同步包的数量,以及每秒帧数的多少。正常情况下,每帧接收的同步包的数量应是0-1,如果超过这个数值,证明服务端或客户端其中一方的频率太快或太慢。至于每秒帧数的多少,则能看出发送频率是否过剩,正常情况下在60左右即可,这和FPS的要求是类似的。
local event = host:service(17) --服务端每隔17毫秒接收一次封包
收包Q&A
- 关于收包的问题,有个最明显的问题便是为何不是在同步帧时进行收包,而是每帧都尝试收包?
- 这是因为收包的内容不仅仅是关于帧同步,还可能有其他东西。其次便是先收后收并不影响什么,以及每隔三帧才进行一次收包恐怕会卡。
- 关于一次性收到多个同步包的情况时,该怎么办?
- 遇到收到多个同步包的情况,说明客户端失联了一段时间,这时候便需要一次性进行多次Update以迅速追回进度。
- 既然确定遇到多个同步包时需要一次性追回进度,那为何不选择在接收后立即执行,而要等到同步帧?
- 因为基本上不会遇到这种情况,能遇到多个同步包的情况,一般客户端已经在等待了。换做客户端卡住这种情况的话,根据Fixed Update的规则,本来就会立即赶到同步帧的。
- 为何等待同步包的代码并非是阻塞式的,而是每帧去判定一下?
- 因为如果是阻塞式的话,会使得DT变得很大,影响Fixed Update。
输入应用
关于输入应用方面,首先要明确一点:客户端每次按下/释放按键,就会改写输入表然后发送之。服务端收到后便会改写在服务端的对应输入表。这种模式在高延迟的表现便是呈现出某些按键因为一直按下而导致一些鬼畜操作,这点算是可以接受的。
另外注意不要贪图方便每次只发送当前修改的按键,这样会失去输入的稳定性,一旦发生丢包之类的,会产生键盘失灵的感觉。
在每次同步帧时便会根据同步包的内容更新所有玩家的输入表,从而改变每个联机角色的操作。这个输入表会一直应用到下次同步帧之前,从这点来看帧同步也是一种回合制。
--根据同步包的内容更新所有玩家的输入表(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
--客户端每次按下/释放按键,就会改写输入表然后发送之(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) endendfunction love.keyreleased(key) if (key == "up" or key == "down" or key == "left" or key == "right") then input[key] = 0 Lib.Send(server, "input", input) endend
断线重连/中途加入
想要做到这两点,便需要做到在服务端保存每一份同步包,这样服务端只需要记录每个玩家的初始数据,在新玩家加入游戏时,首先发送每个玩家的初始数据给新玩家同步,然后再把所有同步包打包发送给新玩家,让新玩家一次性Update,即可完成中途加入。当然不要忘记给现有玩家发送新玩家的数据。
--新玩家同步(main.lua)local event, type, ip, datarepeat event = host:service() if (event) then type, ip, data = Lib.Recv(event) enduntil 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) endendplayList = data.playListwhile (#playList > 0) do Update()end
浮点数
根据网上的信息看来,浮点数因为在不同环境的实现有所偏差,所以可能会导致不一致的问题,这样便会产生蝴蝶效应,最终导致同步失效。目前我使用LuaJIT在Windows(x64)、Ubuntu(x64)、macOS、Android(红米Note4)、iOS测试来看,浮点数在「输出」的场合下并无不同。当然只是输出,并不能代表真实数据的情况。以及我的测试范本并不算多,对于浮点数的问题还不敢保证。所以我选择在业务层放弃使用浮点数,相关数据都事先进行转换,到最后需要浮点数的对接场合再进行转换。当然我并不敢保证这种做法的可行性,真正成熟的做法应该是使用定点数,但我暂时并未这么做。
dt = math.floor(dt * 1000) -- 浮点数转换为整数(毫秒)
后记
以上便是本次我对帧同步的初步探究,它注定是不成熟的,需要经过实践的检验,接下来我会考虑将其接入到一些项目中。有相关经验的朋友欢迎前来讨论。
- 帧同步的初步探究
- JCS的初步探究
- 关于spfa的初步探究
- 关于ExtJs5的初步探究一
- WebService的初步探究与应用-01
- Ngrx、RxJs、Redux的初步探究
- Java多线程同步问题的探究
- JAVA线程同步的探究(1)
- Java 多线程同步问题的探究
- Java多线程同步问题的探究
- jQuery 原理初步探究
- MassTransit 探究初步
- boost bind初步探究
- xvfb 初步探究
- MassTransit 探究初步
- GridLayout 初步探究
- AndroLua, Luajava初步探究
- jQuery 原理初步探究
- docker初识:运行mysql实例
- NYOJ【113】字符串替换【字符串】
- python 获取股票数据
- 排序算法总结
- sed 和grep 统计/etc/init.d/functions文件中每个单词的出现次数,结果不同
- 帧同步的初步探究
- 浅谈在XXX公司的职业经历
- xampp上mysql无法启动的问题
- java 8 接口默认方法
- ArcGIS地形分析--TIN及DEM的生成,TIN的显示
- 架构师的成长之路——知识储备
- ios XCODE url 拼接返回为空
- hadoop之旅(六)
- 防止网页被嵌入框架