帧同步分离逻辑层和渲染层_帧同步的一些坑
一. 簡述
我們用最精簡的模型來描述一下幀同步。
客戶端檢測服務器的邏輯幀 -> 拿到邏輯幀 -> 進行處理 -> 處理出結果 -> 完成本次邏輯幀處理 -> 表現層將處理結果渲染到屏幕上 -> 結束
客戶端檢測用戶操作 -> 打包成action -> 上報到服務器 -> 結束
在此基礎上,客戶端可以通過緩存幀,平滑幀,預測,插值,等方案優化表現層的效果及手感,但是底層模型是一樣的。
比如緩存幀就是客戶端檢測到新的邏輯幀之后不立即處理,而是先緩存到一個隊列中,等到滿足一定數量之后,才對隊列popFrame進行處理。
而平滑幀,則是在緩存幀的基礎上,將popFrame的操作做成定時操作,從而抵抗網絡波動導致邏輯幀到達的時間間隔不一致問題。
幀同步游戲一定要分為邏輯層和表現層。
表現層怎么折騰都可以,但是邏輯層必須保證確定性。
那么哪一部分屬于邏輯層呢?
拿到邏輯幀 -> 進行處理 -> 處理出結果 -> 完成本次邏輯幀處理
打包成action -> 上報到服務器
所以,平緩幀的實現方案中,才能用普通的setTimeout來直接定時popFrame而沒有問題。
再舉個例子,比如用戶操作搖桿,搖桿獲取到的方向是浮點型,將浮點型直接打包成action是否ok呢?答案是不行的,因為一旦這么處理,服務器的邏輯幀就包含了浮點數。
那么如果將浮點型先轉換為string再打包到action中是否OK呢,這樣就是可以的。但是有個條件,就是客戶端取到邏輯幀中的這個字段需要做數學運算時,需要從string直接轉為定點數,一定不能出現浮點數。
二. 容易導致不一致的坑
注意:以下說的都是邏輯層。
初始化/釋放物理實體的順序
比如之前在start里面將entity加入到物理世界,而每個客戶端的start函數執行順序不確定,導致物理世界的entities的順序不一致。從而在進行碰撞檢測的時候,檢測出來的結果順序會不一致。
我們具體來驗證一下start/onDestroy函數的執行順序問題。
cocos creator對一個對象生命周期主要包含幾個方面:
ctor
onLoad
start
...
onDestroy
而我們可以通過測試代碼來確定他們的執行順序:
cc.log('ins before');
// 使用給定的模板在場景中生成一個新節點
let objStar = cc.instantiate(this.starPrefab);
cc.log('addChild before');
// 將新增的節點添加到 Canvas 節點下面
this.node.addChild(objStar);
cc.log('addChild after');
打印出來的結果為:
ins before
ctor
addChild before
onLoad
addChild after
并沒有直接打印start。可見,start不會在當前立刻執行,而是要等之后才執行。
也即start順序不可控。
那么onDestroy函數呢?測試代碼如下:
cc.log('destroy before');
other.custom.node.destroy();
cc.log('destroy after');
輸出結果為:
destroy before
destroy after
并沒有直接打印onDestroy,可見onDestroy函數也不是立刻執行,而是要等之后才執行。
也即,onDestroy順序不可控。
所以不要依賴于cocos自帶的start/onDestroy回調函數來增加/刪除物理實體,會導致不一致。
字典keys/values的遍歷順序
不同的字典實現的排序方案是不一樣的,這也導致keys/values的順序很可能無法統一。
為了安全,如果一定要遍歷字典,需要先對keys做一次sort,而values需要通過遍歷sorted_keys來進行獲取。
數學運算確定性
有幾個關鍵點:
定點數
定點數的使用務必保證正確,可以使用 string/int/定點數 來創建定點數。但是絕對不能用double/float來創建定點數,拿來中轉也不行。
隨機數
隨機數生成器的種子要一致,并且需要將隨機數生成器實例私有化,避免與其他業務公用。比如js中的Math.random()就絕對不可以使用。
邏輯幀率是否越高越好
并非如此,建議15幀/秒。
邏輯幀率增加帶來的影響如下:
邏輯層計算次數增多,cpu消耗越大。
網絡流量消耗越多
對服務器CPU和帶寬壓力增大
三. 表現層優化方案
I. 插值
在精簡的幀同步模型中,我們提到了
表現層將處理結果渲染到屏幕上
但由于邏輯幀率一般比較低(15左右),遠不能達到表現層的幀率要求,所以看起來就會是卡卡的,實際上是因為物體的位置是離散的。
我們可以使用cocos creator中的緩動動畫很容易的解決這一點。
// 這是最簡單的方案,但是效果比較差
// 因為相當于渲染層的幀率與邏輯層一致了
syncPosWithEntity: function() {
this.node.setPosition(AppUtils.conventToCCV2(
this.entity.getPos(),
));
},
// 嘗試平滑移動到物理層的位置
smoothSyncPosWithEntity: function() {
// 第一次賦值的位置
// 在一個幀間隔內,移動過去
// 說明沒有變化
if (this.moveDstPos != null && this.moveDstPos.equals(this.entity.getPos())) {
// cc.log("here");
return;
}
this.moveDstPos = this.entity.getPos().clone();
if (this.moveTween != null) {
this.moveTween.stop();
}
// 使用設定的邏輯幀間隔
// let duration = this.game.dLogicDt.toNumber();
// 使用客戶端實際接收到邏輯幀的間隔。如果要再復雜一點,就算最近一段時間的平均值。
let duration = this.game.frameRecvDt || 0;
// 限制最小值
duration = Math.max(
duration,
this.game.dLogicDt.toNumber()
);
// 限制最大值
duration = Math.min(
duration,
this.game.dLogicDt.toNumber() * 3
);
// cc.log('duration:', duration);
// 這樣,如果動畫慢了的話,會自然追上
this.moveTween = cc.tween(this.node)
.to(duration, {
position: AppUtils.conventToCCV2(
this.moveDstPos
)
}).start()
},
為什么是moveto動畫呢,這樣當新的邏輯幀處理完產生了新的邏輯位置,而我們表現層還沒有移動到指定位置時,新的moveto動畫會自動加速,不用我們人工干預了。
當然,在物體剛剛創建并指定了位置的時候,需要調用一次syncPosWithEntity(),否則就會出現物體剛出生,表現層就從(0,0)位置往出生位置移動的動畫了。
至于其中的duration值得好好聊一聊。
一開始我們是使用
let duration = this.game.dLogicDt.toNumber();
經過測試后發現,當邏輯幀率越低的時候,這種方式表現越好。比如在邏輯幀為30幀/秒的時候,卡頓的感覺很明顯,但是15幀/秒就比較正常。
但是核心的原因都是:因為邏輯幀率越低,讓動畫中斷的次數越少。
所以我們想盡量減少動畫的中斷。
本能的想到解決方案就是讓動畫的播放時間稍微長一點,即讓動畫能夠盡量看起來是在連續播放的(雖然實際上還是先stop后又創建的)。
this.game.dLogicDt.toNumber() * 1.5
上面的1.5,其實就是給了下個邏輯幀一點緩沖時間,相當于我們多等了0.5個邏輯幀間隔。 只要下個邏輯幀在這個時間內到達,就不會出現表現層的動畫停止。
但是這樣其實還是有問題,因為這個1.5是寫死的,并且寄希望于邏輯幀能夠在這個時間范圍內到達,萬一這個時候網速更差呢?
有沒有更好的方法呢?
有的,就是將客戶端算出的當前邏輯幀與上一個邏輯幀的實際間隔時間傳入進去。或者通過算法,取出一段時間內的平均值,來反映出平均網絡情況。
let nowMs = Date.now();
if (this.frameRecvTimeMs != null) {
this.frameRecvDt = (nowMs - this.frameRecvTimeMs) / 1000;
}
this.frameRecvTimeMs = nowMs;
之后將這個時間與上面那個時間取較大值,但是我們也不能讓這個值無限大,所以還要再取一次較小值。其中的3是可以自己調整的,看業務需要。
// 使用客戶端實際接收到邏輯幀的間隔。如果要再復雜一點,就算最近一段時間的平均值。
let duration = this.game.frameRecvDt || 0;
// 限制最小值
duration = Math.max(
duration,
this.game.dLogicDt.toNumber()
);
// 限制最大值
duration = Math.min(
duration,
this.game.dLogicDt.toNumber() * 3
);
但是即使我們做了上面這一切后,單獨使用插值的效果也不是特別好。
直到我們將插值與下面的緩存幀+平滑幀的方案結合,并將邏輯幀率設置為15幀/秒,效果才特別優秀。
另外有人可能會問,萬一邏輯層正在追幀呢?也就是說雖說在渲染層只看到了一次pos變化,但是邏輯層其實經過了好幾次變化,那用一個邏輯幀間隔時間作為duration會不會有問題呢?。
答案是:沒問題。既然是追幀,表現層當然要保持與現實時間一致,所以快速追上pos是合理的。
II. 緩存幀+平滑幀
一般這兩個方案是要結合在一起使用的,也可以和第三大項中的插值一起使用。
簡單的類比就是:看視頻很卡的時候,我們會先緩存一會,之后以一個恒定的速度來穩定播放視頻。
雖然說延遲了一點,但是體驗會舒服很多。
這里就直接貼出cocos creator的代碼了,比較簡單,大家參考一下就好:
constructor() {
this._frameIntervelMS = 30;
// 初始化為-1,確保第一次一定會啟動
this._smoothFramePopIntervalFactor = -1;
// 軟上限。軟上限的設置與calcSmoothFramePopIntervalFactor中factor=0時的設置一致
this._smoothFrameSoftLimitCount = 5;
}
setPlayInterval() {
gg.intervalManager.setIntervalByKey('loopSmoothFramePop', () => {
let factor = this.calcSmoothFramePopIntervalFactor();
if (factor === this._smoothFramePopIntervalFactor) {
// console.log('equal factor, return');
return;
}
this._smoothFramePopIntervalFactor = factor;
// console.log('not equal factor:', this._usingSmoothFramePopIntervalFactor);
// 如果已經存在,會直接覆蓋
gg.intervalManager.setIntervalByKey('handleSmoothFramePop',
() => {
// 這里只能用臨時函數,用類的內部函數會丟失this
// cc.log("this.popArray.length:", this._popArray.length);
if (this._popArray.length <= 0) {
return;
}
// 總要先執行一次
do {
this.receiveFrameData(this._popArray.shift());
} while (this._popArray.length > this._smoothFrameSoftLimitCount);
gg.gameManager.gameScene.arrayLengthLabel.string = this._popArray.length;
},
this._frameIntervelMS * factor);
}, 10);
}
// 計算出使用的幀間隔系數
calcSmoothFramePopIntervalFactor () {
let framesLen = this._popArray.length;
let factor = 1;
if (framesLen === 0) {
// 說明網絡幀有點慢,pop速度可以慢一點
factor = 1.2;
}
else if (framesLen === 1) {
factor = 1.1;
}
else if (framesLen <= 3) {
// 以同樣的速度首發
factor = 1;
}
else if (framesLen <= 5) {
factor = 1 / framesLen;
}
else {
factor = 0;
}
return factor;
}
緩存幀+平滑幀帶來的效果還是很好的,手感明顯的好了很多。但也會有個小問題。
因為存在追幀的問題,所以有時候會出現隔空碰撞的表現。其實就是因為邏輯層同時處理了多個邏輯幀,而表現層只表現出了碰撞前和碰撞后的畫面,中間過程給跳過了。可以通過調整_smoothFrameSoftLimitCount來調整。
另外代碼中使用的定時器一定要使用cocos creator或者其他引擎內部自帶的timer來實現,不要使用js的SetInterval,性能很低。
當然,引擎內部的timer依賴于渲染幀的幀間隔,也就是精度是無法高于一個幀間隔時間的。
所以我們為什么要把邏輯幀通常設置為15幀/秒而不是30幀/秒,其實也是這個原因,因為擔心表現層的定時器會處理不過來。
當然,設置為30幀/秒也有好處就是表現層即使不做插值看起來也很流暢就是了,總之各有各的好處吧。
四. 架構設計
幀同步的游戲架構還是有很多寫法的,只要保證最關鍵的前提:邏輯層與表現層分離。
我個人總結的幾個比較好的架構是:
方案I(適合大部分游戲)
徹底分離邏輯層與表現層代碼。
在目錄上即完全分開。
邏輯層游戲可以脫離界面運行,你可以理解為是在寫一個命令行程序。
邏輯層與表現層的交互主要通過事件完成。
即邏輯層發送事件,表現層監聽事件。
表現層允許讀取邏輯層,但是不允許修改。邏輯層禁止讀寫表現層。
以位移舉例,邏輯層的物體位置是離散的,每次位置變化的時候,就廣播一個事件出去。
渲染層可以通過一個主類來監聽,之后分發處理;也可以直接讓表現層的類直接監聽自己感興趣的事件。
比如DisplayPlayer收到LogicPlayer發來的位置變化事件后,就可以選擇賦值位置/插值的方式來進行渲染。
方案II(適合物理模擬類游戲)
每個表現層對象都有一個邏輯對象,并且邏輯對象作為一個插件掛載到表現層對象上。
這里千萬要注意邏輯對象的初始化順序不能依賴表現層對象的onLoad/onStart,而是應該在主類里統一初始化。
表現層對象在每一次update函數中,從邏輯對象獲取并更新數據。
還是以位移為例,整個游戲是受物理引擎驅動的,當邏輯層對象的位置發生變化后,表現層對象在update函數中就會檢測到,從而選擇賦值位置/插值的方式來進行渲染。
先這樣,其他的等想到了再補充吧。
總結
以上是生活随笔為你收集整理的帧同步分离逻辑层和渲染层_帧同步的一些坑的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【推荐】javaweb JAVA JSP
- 下一篇: js打印和vue打印