帧同步的初步探究

来源:互联网 发布:现在还能做淘宝吗 编辑:程序博客网 时间:2024/05/16 10:06

  欢迎参与讨论,转载请注明出处。
  本文转载自https://musoucrow.github.io/2017/08/26/frame_sync/

前言

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

详解

  本次项目使用的开发引擎为LÖVE,项目地址在此。以下是运行演示(外网联机测试也通过了):1
  由于我并没有什么服务端开发的相关经验,所以只是使用了个简单的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) -- 浮点数转换为整数(毫秒)

后记

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