一句話說清幀同步(附伺服器Golang關鍵程式碼,客戶端JS關鍵程式碼)。
言歸簡短,書歸正傳。
關於幀同步實際的做法,網上一搜一大把,但是寫這些文章的人並沒有真正的為讀者考慮。
很多人看了之後,似懂非懂。
那為什麼不懂呢?
先不說別的,這裡有幾個在幀同步模型裡的關鍵術語要搞懂。
1.幀。
2.邏輯幀。
3.渲染幀。
什麼是幀?
幀在不同的語境裡有不同的含義:
在動畫裡,幀是動畫影像最小單位即單幅影像畫面。
在網路傳輸中,幀是最小的資料傳輸單位。
在實體記憶體中,最小的儲存單位也叫做幀。
這些幀的含義都是從英文Frame來翻譯過來的,再看Frame的意思。框架,邊框,有木架的,有架構的。
那麼就可以知道了,幀並不是指特定的事物,倒不如說它是一個測量某種東西的最小單位,如斤,千克,釐米,這種某種度量衡的一種測量單位。
而且這種單位是架構的單位。到了這裡回頭看幀同步。
直接替換概念,就是最小單位同步。至於是什麼事物的最小單位,因為動畫和遊戲的連續畫面要在時間維度裡才會存在,
所以這個最小單位其實指的是時間單位。
為什麼說要替換概念,因為做遊戲開發,做過客戶端開發的,都知道FPS這個概念,Frames Per Second,每秒幀數。
如果不替換概念,很容易就把此幀(幀同步)當做彼幀(FPS的Frame幀)。這就是最蛋疼的地方了。為什麼蛋疼?接著看。
渲染幀 在Unity,Cocos,Laya等遊戲客戶端引擎裡都有一個函式叫Update。只要做過客戶端開發的都不陌生。這在每一幀渲染之前會呼叫一次Update,在Update裡面可以增加自己的邏輯處理(自己寫的程式碼),如更新精靈位置、角度等,渲染的時候會去取精靈的這些屬性進行繪製。就是在每一幀渲染顯示到螢幕之前,都會執行這個Update裡的程式碼,這個幀
遊戲的常識裡都知道要買好的顯示卡,這樣遊戲看起來流暢,不會卡成紙片人。為什麼會這樣,其實說白了,遊戲就是玩家可以操控互動的程式,程式是什麼?百度百科裡說計算機程式是一組計算機能識別和執行的指令,運行於電子計算機上,滿足人們某種需求的資訊化工具。這些指令的目的呢?其實是處理資料啊。不管是用支付寶付款,還是打一把dota,不管是資料從硬盤裡載入到記憶體裡,在記憶體處理過後,經過顯示卡顯示在螢幕上,還是玩家用滑鼠操作英雄在遊戲裡廝殺,都是代表資料的電訊號被程式處理的結果,當然這些處理隨著資料的變化和顯示卡的最大處理能力都會有一個極限。
當把資料看做水流,顯示卡看做水管,水太多,水管不夠粗的時候,水就會流的慢,在遊戲程式裡的表現就是卡頓,因為顯示卡處理不過來啊,資料不能快速順暢的顯示在螢幕上。
以上是卡頓的原因。
那渲染幀在其中起的什麼作用?
渲染幀就是引擎處理資料直到處理完成後並渲染到螢幕上的一個最小時間內做的一切事。
打個比方就相當於大部分人平常上班,每天上一天班一樣。
引擎處理一幀(渲染幀),類比在公司幹活一天。
每週五天工作制的話,每天至少幹8個小時,即使你沒事做,在公司發呆也要呆夠一天(8小時)。如果有事沒做完,要加班,那麼一天就不止8小時了。
每秒60渲染幀的話,每幀大概17毫秒,即使沒事幹的話,一幀也要執行跑滿17毫秒。如果17毫秒還沒處理完,渲染幀要延時,那麼一幀就不止17毫秒了。
當然這個類比還可以細化,僅僅在公司呆8小時是不行的,如果在公司裡不幹活,而只是玩,公司也不會發薪水的。所以這裡假定,每幹活一天,就要填寫一個進度管理系統,上報給公司,供上面查詢你都做了什麼。
這個上報自己的工作進度給公司領導看的過程,在遊戲引擎裡就類比把資料渲染到顯示器上顯示給玩家看的過程。
每天下班之前10分鐘總結工作進度,並填寫後臺進度管理系統,上面就知道員工的工作進度了。即使某一天工作量很小,來到公司半小時幹完之後,一天都沒事幹,也要一天一上報。這個就相當於遊戲裡即使什麼操作都不做,每秒也會固定60幀,每幀17毫秒(假設第1-第10秒處理邏輯,第10秒開始渲染,第15秒渲染完成,剩餘兩秒空跑)。
至於為什麼是每天,而不是每兩天,或者每秒60幀,不是120幀,這都是自己根據某些原理定的,比如人眼動畫連續的最少幀數等,比如每天要回家睡覺等等。
邏輯幀 呢?參考之前論述的幀的概念,邏輯幀 就是執行遊戲邏輯的最小時間單位。在這個單位時間內,會有那麼一套結構化的程式碼需要執行。前面說過渲染幀是由遊戲引擎控制的,在渲染上面,玩家是沒有辦法對引擎做出任何控制的。但是怎麼渲染?難道瞎渲染?肯定是要根據遊戲開發者自己制定的邏輯執行後,執行結果所產生的資料來渲染。比如玩家按了一下跳躍按鈕,在空中用搖桿挫了一個<- -> <- ->,並同時按下了 A B 兩個按鍵,釋放除了天罡火的技能。跳躍之後,控制角色的高度位置,在空中的用什麼動畫,釋放天罡火的搖桿搖的對不對,按鈕按的對不對,有沒有能量條,天罡火的釋放動畫,怪物自主移動,等等判斷和資料。在引擎渲染到螢幕上,玩家看到以前,這些都是需要先提前處理的,而這些東西,就是我們所謂的邏輯。最後的最後,【渲染】只是這一幀 (渲染幀)在處理完之前的邏輯之後,最後要做的動作。這裡的這一幀指的是什麼幀?好吧,是渲染幀。可不是邏輯幀。這個圖下面的週一 其實畫的不好 應該是 |工作|上報結果| 這樣就更完美了。
這樣會導致一個什麼問題?比如員工A(使用了3年的IPhone5S)一天工作8小時(一幀17毫秒),18:30就上報自己的工作進度下班回家。於是A給產出定了一個鬧鐘。到了18:30就提醒自己開啟後臺進度管理系統,上報自己的工作進度。每次做完一份工作,才打開進度管理系統,接受新的工作任務。結果工作沒做完,那隻能加班了,加到20:20,終於搞完了,開始在進度管理系統提交自己的任務完成情況,20:30下班。本來一天只要8小時,結果乾了10小時。
【這裡必須要說一個前提,我們的渲染幀之間是不休息的,但是人是要休息的,沒有以人工作24小時為例,是因為不現實,這裡就假設下班晚多久,第二天就晚多久上班。】
A下班越來越晚,甚至按照管理系統的打卡記錄來說,別人在做11月20號的事情時,A才做到11月9號的事,這樣,A是越來越慢。。。
對於正常的其他18:30幹完工作,下班回家的員工(IPhoneXS)來說,每天還是工作8小時。但是對於員工A來說,已經不是8小時了。
類比渲染幀,某一幀A,17毫秒沒渲染完,只好延長A的處理時間,結果A用了25毫秒才渲染完,每幀已經不是17毫秒了。
因為A每天的工作只是一個專案中某個小任務,每個小任務都延期,導致整個專案幾乎陷入了癱瘓,大領導的臉色越來越難看。
這樣A的上層領導有意見了,給了你任務,就卡住,專案都TM停止了。搞的都不知道專案到底啥時候能完成,只看到工作時間越來越長。最後這次更誇張,從上一次寫上班計劃開始到現在已經過去了48個小時了,員工A還沒有上報工作進度。還沒做完啊~~,做完才能上報。
只發了白菜的薪水,也不可能吃出海鮮的味道。想Fire也是不現實的。
這TM搞的我都沒法安排工作了啊,領導上面還有大領導,大領導還以為你這領導上臺以後,工作進度老不不見前進,不想幹了呢。
於是A的領導想了個辦法,不管你下面的A把工作做到什麼程度,做的快慢都好,我該按照我自己的計劃佈置任務,你上報進度也好,不上報進度也罷,我先把任務佈置下去,這樣大領導看我自己的每天任務進度就能看到我的工作情況,完成不了那是自己的下屬不行。
領導找A談話:A啊,你不管完成不完成你手中的任務,你先把任務都接了,對,不要像之前那樣每完成一個任務才接下一個任務,你都接了。
A:o(╥﹏╥)o,做的慢咋辦?
領導:沒事,你慢慢做,做完一個接著做下一個。
這樣大領導一看領導的工作進度,每天一收集下屬的工作情況,每天再下發給下屬新的任務,有條不紊嘛,這個可以的。
中層領導決定不管A每天的任務完成的怎樣,每天按時下達部署自己需要下屬完成的任務。至於A能做到什麼程度,領導心裡也得有點數啊,不可能太趕鴨子上架,為難A。
看到這裡應該有感覺了吧,中層領導做的這個決策對應的就是邏輯幀的處理。
就像伺服器說了,我不管你能執行成什麼樣子,我就發給你第一幀(邏輯幀) 玩家C放了技能1,D放了技能2,資料下發到C和D,C用的2018款的IPhoneXS,D用的四年前買的華為榮耀6Plus,C順暢的天馬行空,D卡的面紅耳赤,自己放完技能2之後,要按技能3,技能3按了N次就是沒反應,因為技能2的特效太炫酷,記憶體一下滿了,卡的不動了。
但是D這裡沒反應,C無所謂啊,D的手機執行不過來渲染,就無法接受輸入,在C的手機上就會到D站在那裡發呆,一動不動,C上去一頓技能幹掉了D。
無所謂啊,伺服器無所謂,C無所謂,D卡你還來玩那是你自己的問題,自己找虐,誰有辦法。
所以邏輯幀就是開啟一個定時器,定時下發自己的需要下發的資料,每一次下發一次邏輯幀執行需要的資料。雖然幀同步需要在客戶端執行邏輯,但邏輯幀的執行頻率是伺服器控制的。
轉回員工A的例子,A接收到任務後,先放在一邊,A想了,領導讓我先沒有完成當前的任務之前,也可以接受新的任務,讓大領導到自己的後臺一看,哇,同時做了這麼多工作,很賣力啊。
既然這麼體諒我,我也不能辜負領導厚愛。更何況領導分配的任務也不重,自己也剛把得吧。儘量在任務截止時間之前完成。
於是定了另一個鬧鐘,每小時(自己設定)都會檢查一下任務管理系統,有什麼任務到做的時間了,如果自己空閒,那麼立刻就動手做。如果還在忙著,
領導熟悉了A的工作能力之後,又調整了一下工作任務量的每日安排,保證A能順利的完成,這樣,大老闆看誰的專案進度都很滿意。
所以A有兩個鬧鐘,一個是定時上報自己工作任務進度的鬧鐘(對應渲染幀),一個是定時監測自己什麼時間該做什麼任務的鬧鐘(即邏輯幀)。
綜上:
渲染幀是我們無法控制的(只能通過在它的函式裡少執行邏輯,減少它在單渲染幀的執行時長);
邏輯幀是我們自己控制的,我們決定每一小段時間就監測一下是不是有伺服器下發的資料,如果有對應時間的幀資料,就立刻執行幀邏輯。
所以在這裡,我們把邏輯提出來,如圖。
所以在伺服器新建定時器,定時下發客戶端發上來的資料。資料帶上幀號。
客戶端新建定時器,定時監測自己該執行哪一幀的資料,直接把資料執行了,並呼叫引擎的介面,設定到遊戲的精靈中去。
這就是幀同步的全部祕密。
根渲染幀沒有半毛錢的關係。
看到這裡應該已經明白幀同步到底怎麼回事了。自己寫程式碼就可以。不過還是附上程式碼。
部分參考程式碼:
伺服器部分:
// 向玩家同步操作
b.syncTimer = skeleton.AfterFunc(time.Duration(b.bLogicInterval), func() {
b.SyncRoutine()
nowMilliSec := time.Now().UnixNano()/int64(time.Millisecond)
// 超過了輪詢時間間隔
b.frameTimeChanged = false
for nowMilliSec >= b.startTime + b.logicTime + b.bLogicInterval {
// 邏輯時間+=邏輯間隔
b.logicTime += b.bLogicInterval
// 當前邏輯時間>當前幀時間+幀間隔
if b.logicTime > b.frameTime + b.bFrameInterval {
// 幀時間+=幀間隔
b.frameTime += b.bFrameInterval
// 幀時間改變=true
b.frameTimeChanged = true
}
}
// 如果幀時間已改變,且人數
if !b.frameTimeChanged {
return
}
//log.Debug("@@SyncRoutine_1")
// 有玩家參與
if b.users.Len() == 0 {
return
}
//log.Debug("@@SyncRoutine_2")
// 有操作
//if len(b.opsCurFrame) == 0 {
// return
//}
//log.Debug("@@SyncRoutine_3")
b.curFrameID++
m := b.buildSyncData_GM2C()
log.Error("TTTT%v", time.Now().UnixNano()-b.diffTime)
b.diffTime = time.Now().UnixNano()
b.BroadCast(m)
// 把當前幀的操作和之前的操作合併後儲存
b.ops = append(b.ops, b.opsCurFrame...)
// 清空當前幀操作
b.opsCurFrame = b.opsCurFrame[:0]
})
}
客戶端邏輯幀:
Laya引擎JS程式碼
Laya.timer.loop(this.logicInterval, this, this.routineLoop);
SoccerPitch.prototype.routineLoop = function ()
{
// diffS = mydate.getTime();
// 在上次執行更新之前------當前幀一共過去的時間
// this.timeSum += Laya.timer.delta * this.i;
var elapsedTicks = new Date().getTime();
if (this.mLastElapsedTicks == elapsedTicks) // 便於時鐘暫停後能立即停下來,哪怕是上次暫停後mLastUpdateTick還遠遠小於elapsedTicks,也會暫停
return;
this.mLastElapsedTicks = elapsedTicks;
// 每
if (elapsedTicks < this.mLastUpdateTick + this.logicInterval) {
return;
}
else
{
this.mLastUpdateTick += this.logicInterval;
}
// 已跑到伺服器同步過來的最新一回合了。
if (this.logicInterval > this.svrFrameTime - this.logicTime) // 不夠邏輯更新間隔,等下一次
{
// if (mWaitFlags == 0 && (elapsedTicks > mWaitStartTime + 400)) // 等待超過400ms,則提示訊號弱效果
// {
// mWaitFlags = 1;
// }
// ++mWaiteLimitCnt;
//Log.Warning("pause1, mLogicTime:" + mLogicTime + " mLimitTime:" + mLimitTime);
return;
}
var loops = 0;
var idealTime = this.svrFrameTime;
// 當前回合時間慢理想時間 2 幀以上時(this.OutdatedLength=1幀邏輯時間*3),本次更新需要多個回合(加速)
var deltaTime = idealTime - this.logicTime;
console.log("PPPPPPPPPPPPPP", this.svrFrameTime, this.logicTime, deltaTime, this.OutdatedLength);
if (deltaTime > this.OutdatedLength) {
loops = (deltaTime - this.OutdatedLength) / this.logicInterval + 1;
}
else {
loops = 1;
}
console.log("==",loops, this.td);
this.ResidueFrame = loops;
for (var i = 0; i < loops*2; i++)
this.onLogicUpdate(this.logicInterval);
}
最後給出一些參考和其他模糊不清的連線:
https://blog.csdn.net/qq_14914623/article/details/81258236
https://blog.csdn.net/a549297336/article/details/79354022
https://blog.csdn.net/su9257/article/details/54894228