哈希)
做過幀同步lockstep的都知道這套架構(gòu)最大的噩夢(mèng)不是網(wǎng)絡(luò)是不同步。幀同步的核心假設(shè)是所有客戶端跑同一套邏輯、喂同一批輸入那算出來的結(jié)果必然一模一樣。基于這個(gè)假設(shè)網(wǎng)絡(luò)上只需要同步玩家的輸入指令不用同步任何游戲狀態(tài)帶寬省到離譜。一場(chǎng)幾百個(gè)單位的 RTS每幀傳的數(shù)據(jù)可能就幾十字節(jié)。但這個(gè)假設(shè)有個(gè)前提——你的邏輯真的是完全確定性的。一旦有哪怕一個(gè)單位在某一幀的血量差了 1兩臺(tái)機(jī)器就分道揚(yáng)鑣而且誤差會(huì)像滾雪球一樣越滾越大幾秒鐘后畫面就完全對(duì)不上了。問題是不同步發(fā)生的時(shí)候你往往不知道。玩家 A 看到自己贏了玩家 B 看到自己贏了誰也沒報(bào)錯(cuò)游戲也沒崩。等你意識(shí)到不對(duì)勁早就過了幾百上千幀根本沒法回溯到底哪一幀開始歪的。每幀狀態(tài)哈希就是用來抓這個(gè)的。思路把整個(gè)世界壓成一個(gè)數(shù)原理特別樸素。每一幀邏輯跑完把當(dāng)前游戲世界的所有關(guān)鍵狀態(tài)揉在一起算出一個(gè)哈希值。這個(gè)哈希代表了這一幀結(jié)束時(shí)世界長(zhǎng)什么樣。然后各個(gè)客戶端把自己算出來的哈希報(bào)給服務(wù)器或者互相對(duì)比。同一幀的哈希如果對(duì)不上立刻就知道不同步了而且能精確定位到是哪一幀開始出的問題。幀 100: 客戶端A → hash 0x8F3A... 客戶端B → hash 0x8F3A... ? 一致 幀 101: 客戶端A → hash 0x2C71... 客戶端B → hash 0x2C71... ? 一致 幀 102: 客戶端A → hash 0x9B04... 客戶端B → hash 0xE55D... ? 從這幀開始歪了有了這個(gè)排查范圍一下子從整局游戲縮到第 102 幀的那次邏輯更新事情就好辦太多了。哈希什么不哈希什么這是第一個(gè)要想清楚的問題。不是把內(nèi)存里所有東西都塞進(jìn)哈希——那樣又慢又沒必要。要哈希的一切參與邏輯運(yùn)算、會(huì)影響后續(xù)幀計(jì)算結(jié)果的狀態(tài)。單位的位置、血量、朝向、當(dāng)前狀態(tài)機(jī)、隨機(jī)數(shù)種子、尋路目標(biāo)、技能冷卻……凡是邏輯層的數(shù)據(jù)都得算進(jìn)去。絕對(duì)不能哈希的任何表現(xiàn)層的東西。粒子特效、動(dòng)畫播放進(jìn)度、UI、攝像機(jī)位置、音效——這些每臺(tái)機(jī)器可以完全不一樣本來就不該影響邏輯。你要是不小心把攝像機(jī)坐標(biāo)算進(jìn)哈希了那哈希天天對(duì)不上等于白做。邏輯層和表現(xiàn)層的嚴(yán)格分離是幀同步的地基。狀態(tài)哈希這件事會(huì)逼著你把這條線劃得清清楚楚某種程度上它也是個(gè)架構(gòu)約束的檢驗(yàn)工具。怎么算publicuintComputeFrameHash(GameWorldworld){uinthash2166136261;// FNV-1a 初始值// 隨機(jī)數(shù)種子必須算進(jìn)去它直接決定后續(xù)所有隨機(jī)結(jié)果hashCombine(hash,world.RandomSeed);// 遍歷所有單位——注意順序必須確定foreach(varunitinworld.Units)// Units 得是有序的{hashCombine(hash,unit.Id);hashCombine(hash,unit.PositionX);// 定點(diǎn)數(shù)hashCombine(hash,unit.PositionY);hashCombine(hash,unit.Health);hashCombine(hash,(uint)unit.State);// ... 其他邏輯字段}returnhash;}privateuintCombine(uinthash,uintvalue){hash^value;hash*16777619;// FNV-1a 質(zhì)數(shù)returnhash;}用什么哈希算法其實(shí)不太講究FNV-1a、CRC32 這類簡(jiǎn)單快速的就夠了。我們不需要密碼學(xué)強(qiáng)度只需要不同的狀態(tài)大概率產(chǎn)生不同的哈希而且要快——畢竟每幀都要算一次幾百個(gè)單位每個(gè)好幾個(gè)字段性能不能拉胯。三個(gè)必踩的坑坑一浮點(diǎn)數(shù)如果你的邏輯層還在用float那狀態(tài)哈希基本沒法用因?yàn)閒loat運(yùn)算在不同 CPU、不同編譯器、不同平臺(tái)上結(jié)果可能有細(xì)微差別。這個(gè)差別小到肉眼看不見但足以讓哈希對(duì)不上。這其實(shí)反過來說明了幀同步的邏輯層根本就不該用浮點(diǎn)數(shù)必須用定點(diǎn)數(shù)fixed-point。狀態(tài)哈希只是把這個(gè)隱藏的問題暴露了出來。如果你哈希天天不一致先檢查是不是邏輯里混進(jìn) float 了??佣闅v順序foreach(varunitinworld.Units)這個(gè)Units容器的遍歷順序在所有客戶端上必須完全一致。如果你用的是Dictionary或者HashSet這種無序容器那遍歷順序可能因?yàn)椴迦腠樞?、哈希桶分布不同而不一樣。同樣一批單位A 機(jī)器先遍歷 1 號(hào) B 機(jī)器先遍歷 5 號(hào)算出來的哈希就不同——但這其實(shí)是個(gè)假的不同步兩邊狀態(tài)明明一樣只是遍歷順序坑了你。所以要么用有序容器List并保證增刪順序一致要么遍歷前先按單位 ID 排序。這個(gè)坑很隱蔽因?yàn)樗辉诠?duì)比時(shí)才暴露邏輯本身可能沒錯(cuò)。坑三哈希粒度只在每幀末尾算一個(gè)總哈希能告訴你第 102 幀歪了但歪在哪個(gè)系統(tǒng)、哪個(gè)單位、哪個(gè)字段還是不知道。生產(chǎn)環(huán)境里通常做分級(jí)哈希。除了每幀一個(gè)總哈希還可以按系統(tǒng)分別算移動(dòng)系統(tǒng)一個(gè)、戰(zhàn)斗系統(tǒng)一個(gè)、AI 一個(gè)甚至在開發(fā)調(diào)試時(shí)把每個(gè)單位每個(gè)字段的值都 dump 出來。這樣一旦對(duì)不上把兩臺(tái)機(jī)器第 102 幀的詳細(xì)快照拉出來 diff一眼就能看到是7 號(hào)單位的血量 A 是 100 B 是 99直接定位到出問題的那行邏輯。當(dāng)然這么細(xì)的粒度開銷大一般只在調(diào)試構(gòu)建里開正式版只留每幀總哈希。什么時(shí)候算、算了怎么用哈希要在每幀邏輯完全跑完之后算這時(shí)候世界狀態(tài)是穩(wěn)定的。對(duì)比策略有幾種。最簡(jiǎn)單的是客戶端定期把哈希上報(bào)給服務(wù)器服務(wù)器發(fā)現(xiàn)對(duì)不上就記日志、踢人或者提示重連。也有 P2P 架構(gòu)下客戶端互相廣播哈希對(duì)比的。帶寬上完全不是負(fù)擔(dān)一個(gè) uint 才 4 字節(jié)就算每幀上報(bào)也沒多少。實(shí)際項(xiàng)目里通常不會(huì)每幀都上報(bào)而是每隔若干幀報(bào)一次或者本地緩存一批一起報(bào)進(jìn)一步省流量。反正不同步一旦發(fā)生就會(huì)持續(xù)存在晚幾幀發(fā)現(xiàn)問題不大能定位到大致范圍就夠了。說到底每幀狀態(tài)哈希本身沒多少技術(shù)含量就是遍歷一遍算個(gè)數(shù)。但它是幀同步項(xiàng)目里性價(jià)比最高的一個(gè)基礎(chǔ)設(shè)施——花不了多少代碼卻能在不同步這個(gè)最難查的問題上把你從大海撈針救到按圖索驥。我的建議是幀同步項(xiàng)目一開始就把它加上別等出了不同步再來補(bǔ)。因?yàn)樗还馐桥挪楣ぞ吒且坏莱掷m(xù)運(yùn)行的紅線檢測(cè)只要哈希開始飄就說明你的確定性被破壞了逼著你當(dāng)場(chǎng)就去查而不是等到測(cè)試后期甚至上線才發(fā)現(xiàn)一堆詭異的不同步。下一篇可以聊聊定點(diǎn)數(shù)幀同步確定性的另一塊基石坑同樣不少。