九:Unity 幀同步補遺(性能優化)
來自專欄 Unity 遊戲開發總結39 人贊了文章
在之前介紹幀同步的文章中,說了主體的思路,就是邏輯預測,快照,回滾。今天談談幀同步的性能優化。
性能優化,大概有以下幾點:
- 網路收發的效率,GC問題
- 快照讀寫的效率,GC問題
- 隨機數回滾方式
上面幾點算是簡單的總結,在下面的部分,我會嘗試詳細說明一下。
- 網路收發的效率,GC問題
幀同步,或是即時戰鬥類的遊戲,網路的上行下行,或者說數據包的收發,是相對頻繁的。如果處理不好,光是網路的收發,就會帶來性能的消耗和不小的GC。
在之前的文章中,我們提到,我們的消息,是以protobuf為主,但是戰鬥內的同步數據,我們要自己去讀寫二進位。一方面是protobuf是基於反射來處理,有GC,另一方面,因為消息本身內容不多,用protobuf來序列化,它內部邏輯還是不少,多少會有點性能損失。
自己讀寫BinaryReader 和BinaryWriter的方式,通常做法會這樣寫:
每次發消息,會new 一個stream,然後用BinaryWriter寫入數據,然後從stream中獲得byte[],將byte[] 通過socket發出。
而當我們調用ToArray的時候,實際上是copy了一份數據,這樣就會在每次數據發送的時候,都出現了一次copy byte[]的操作,實際在profile中,我們就能看到GC的存在。
為了減少這部分GC,或者說減少額外copy一次的操作,我們可以重複利用同一個stream的buff。
也就是:
1,只創建一個MemoryStream和BinaryWriter。
2,每次發送數據的時候,將stream的Position設置為0,然後BinaryWriter寫入需要的數據,寫入結束後,執行writer.Flush()
3,用stream的GetBuffer方法獲取數據,而不是ToArray方法copy一份數據
通過這種方式,我們可以反覆重用同一個memorysteam,並且不會產生額外的內存分配。需要注意的是,使用memorysteam的GetBuffer數據的時候,要注意配合Position。(因為比如第一個包發送了1024 byte的數據,第二個包,只有100 byte,那麼GetBuffer出來的byte[]長度,通常是>=1024的,所以這裡要配合Position。)
在改了這種方式以後,我們在消息的收發效率上提高了不少,並且杜絕了每次發送產生的額外內存分配。
這裡再提一句消息的壓縮,我們通常用到的byte[]壓縮第三方代碼,都是通過ToArray的方式來做的,頻繁使用可能同樣有內存問題。幀同步戰鬥消息比較簡單,通常可以通過邏輯上避免一些重複即可,個人認為沒有必要再專門做壓縮。
- 快照讀寫的效率,GC問題
快照讀寫的效率,在於需要讀寫的數據的多少,以及讀寫的速度。
在優化需要快照回滾的的過程中,減少需要讀寫的數據,也就是要讓邏輯盡量依賴每幀的計算,而不是跨幀的臨時數據。這塊就不細說了,需要大家在實踐中具體體會。
而讀寫的速度,是可以有一個通用些的優化方案的。首先,快照,我們是保存成二進位數據,而不是保存為一個數據結構,二進位的讀寫,和之前提到的消息發送類似,我們需要注意GC的問題。
但是快照是每幀保存的,不可能只創建一個memorysteam,我們通過創建一個memorysteam pool的方式,每次保存快照,從pool中獲取一個memorysteam。當前面某一幀的數據,通過伺服器消息確認後(預測和實際操作一致),就可以丟棄這份快照(因為預測正確,不需要回滾到這份快照了),將快照對應的memorysteam放到pool里重複使用。這樣,通常,根據延遲自動調整的邏輯,我們通常只保存5~10份快照。然後根據後續戰鬥結束比對快照的需求(防外掛的部分考慮),每隔一段時間(比如30秒)保存一份快照。
通過上面的截圖,大體可以看出快照保存的工作方式。
進一步優化,減少快照大小的方法,就是在二進位的讀寫中,做一些優化,比如一個long數值,有時候經常是0,那麼,我們用64 bit來寫,有些浪費。這裡,我們參考了protobuf對二進位的讀寫,在它的基礎上,做了一些剔除。並且,通過這篇文章Fastest way of reading and writing binary 和這篇File I/O Performance Tips提到的優化方案(use chunk),我們也作出了對應的優化,獲得了我們的FastBinnayReader和FastBinnayWriter,這兩個腳本,可以分享給大家。
FastBinnayReader:
using System;using System.IO;using System.Text;/// <summary>/// 速度更快的二進位讀寫,並減少使用的byte空間,參考protobuf改來/// 參考文檔 https://stackoverflow.com/questions/2036718/fastest-way-of-reading-and-writing-binary/// https://jacksondunstan.com/articles/3318/// </summary>public sealed class FastBinnayReader{ readonly UTF8Encoding encoding = new UTF8Encoding(); private Stream _source; private byte[] _ioBuffer; private int _ioIndex; private int _position; private int _available; public FastBinnayReader() { _ioBuffer = new byte[256]; } public void Init(Stream s) { _source = s; _ioIndex = 0; _available = 0; _position = 0; } public uint ReadUInt32() { return ReadUInt32Variant(false); } private const int Int32Msb = ((int)1) << 31; private int Zag(uint ziggedValue) { int value = (int)ziggedValue; return (-(value & 0x01)) ^ ((value >> 1) & ~Int32Msb); } public int ReadInt32() { return Zag(ReadUInt32Variant(true)); } public ulong ReadUInt64() { return ReadUInt64Variant(); } private const long Int64Msb = ((long)1) << 63; private long Zag(ulong ziggedValue) { long value = (long)ziggedValue; return (-(value & 0x01L)) ^ ((value >> 1) & ~Int64Msb); } public long ReadInt64() { return Zag(ReadUInt64Variant()); } public string ReadString() { int bytes = (int)ReadUInt32Variant(false); if (bytes == 0) return ""; if (_available < bytes) Ensure(bytes, true); string s = encoding.GetString(_ioBuffer, _ioIndex, bytes); _available -= bytes; _position += bytes; _ioIndex += bytes; return s; } public bool ReadBoolean() { switch (ReadUInt32()) { case 0: return false; case 1: return true; default: throw CreateException("Unexpected boolean value"); } } internal int TryReadUInt32VariantWithoutMoving(bool trimNegative, out uint value) { if (_available < 10) Ensure(10, false); if (_available == 0) { value = 0; return 0; } int readPos = _ioIndex; value = _ioBuffer[readPos++]; if ((value & 0x80) == 0) return 1; value &= 0x7F; if (_available == 1) throw EoF(); uint chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 7; if ((chunk & 0x80) == 0) return 2; if (_available == 2) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 14; if ((chunk & 0x80) == 0) return 3; if (_available == 3) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 21; if ((chunk & 0x80) == 0) return 4; if (_available == 4) throw EoF(); chunk = _ioBuffer[readPos]; value |= chunk << 28; // can only use 4 bits from this chunk if ((chunk & 0xF0) == 0) return 5; if (trimNegative // allow for -ve values && (chunk & 0xF0) == 0xF0 && _available >= 10 && _ioBuffer[++readPos] == 0xFF && _ioBuffer[++readPos] == 0xFF && _ioBuffer[++readPos] == 0xFF && _ioBuffer[++readPos] == 0xFF && _ioBuffer[++readPos] == 0x01) { return 10; } throw CreateException("OverflowException"); } private uint ReadUInt32Variant(bool trimNegative) { uint value; int read = TryReadUInt32VariantWithoutMoving(trimNegative, out value); if (read > 0) { _ioIndex += read; _available -= read; _position += read; return value; } throw EoF(); } private int TryReadUInt64VariantWithoutMoving(out ulong value) { if (_available < 10) Ensure(10, false); if (_available == 0) { value = 0; return 0; } int readPos = _ioIndex; value = _ioBuffer[readPos++]; if ((value & 0x80) == 0) return 1; value &= 0x7F; if (_available == 1) throw EoF(); ulong chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 7; if ((chunk & 0x80) == 0) return 2; if (_available == 2) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 14; if ((chunk & 0x80) == 0) return 3; if (_available == 3) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 21; if ((chunk & 0x80) == 0) return 4; if (_available == 4) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 28; if ((chunk & 0x80) == 0) return 5; if (_available == 5) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 35; if ((chunk & 0x80) == 0) return 6; if (_available == 6) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 42; if ((chunk & 0x80) == 0) return 7; if (_available == 7) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 49; if ((chunk & 0x80) == 0) return 8; if (_available == 8) throw EoF(); chunk = _ioBuffer[readPos++]; value |= (chunk & 0x7F) << 56; if ((chunk & 0x80) == 0) return 9; if (_available == 9) throw EoF(); chunk = _ioBuffer[readPos]; value |= chunk << 63; // can only use 1 bit from this chunk if ((chunk & ~(ulong)0x01) != 0) throw CreateException("OverflowException"); return 10; } private ulong ReadUInt64Variant() { ulong value; int read = TryReadUInt64VariantWithoutMoving(out value); if (read > 0) { _ioIndex += read; _available -= read; _position += read; return value; } throw EoF(); } internal void Ensure(int count, bool strict) { if (count > _ioBuffer.Length) { throw new Exception("too big byte block needs"); } if (_ioIndex + count >= _ioBuffer.Length) { Buffer.BlockCopy(_ioBuffer, _ioIndex, _ioBuffer, 0, _available); _ioIndex = 0; } count -= _available; int writePos = _ioIndex + _available, bytesRead; int canRead = _ioBuffer.Length - writePos; while (count > 0 && canRead > 0 && (bytesRead = _source.Read(_ioBuffer, writePos, canRead)) > 0) { _available += bytesRead; count -= bytesRead; canRead -= bytesRead; writePos += bytesRead; } if (strict && count > 0) { throw EoF(); } } private Exception CreateException(string message) { return new Exception(message); } private Exception EoF() { return new EndOfStreamException(); } public void clear() { _source = null; }}
FastBinnayWriter
using System.IO;using System.Text;public sealed class FastBinnayWriter{ readonly UTF8Encoding _encoding = new UTF8Encoding(); private Stream _stream; private byte[] _ioBuffer; private int _ioIndex; private int _position; public FastBinnayWriter() { _ioBuffer = new byte[256]; } public void Init(Stream s) { _stream = s; _ioIndex = 0; _position = 0; } private void DemandSpace(int required) { if ((_ioBuffer.Length - _ioIndex) < required) { Flush(); // try emptying the buffer if ((_ioBuffer.Length - _ioIndex) >= required) { return; } } } public void Flush() { if (_ioIndex != 0) { _stream.Write(_ioBuffer, 0, _ioIndex); _ioIndex = 0; } } internal uint Zig(int value) { return (uint)((value << 1) ^ (value >> 31)); } public void Write(int value) { Write(Zig(value)); } public void Write(uint value) { DemandSpace(5); int count = 0; do { _ioBuffer[_ioIndex++] = (byte)((value & 0x7F) | 0x80); count++; } while ((value >>= 7) != 0); _ioBuffer[_ioIndex - 1] &= 0x7F; _position += count; } internal ulong Zig(long value) { return (ulong)((value << 1) ^ (value >> 63)); } public void Write(long value) { Write(Zig(value)); } private void Write(ulong value) { DemandSpace(10); int count = 0; do { _ioBuffer[_ioIndex++] = (byte)((value & 0x7F) | 0x80); count++; } while ((value >>= 7) != 0); _ioBuffer[_ioIndex - 1] &= 0x7F; _position += count; } public void Write(string value) { int len = value.Length; if (len == 0) { Write(0); return; // just a header } int predicted = _encoding.GetByteCount(value); Write((uint)predicted); DemandSpace(predicted); int actual = _encoding.GetBytes(value, 0, value.Length, _ioBuffer, _ioIndex); _ioIndex += actual; _position += actual; } public void Write(bool value) { Write(value ? (uint)1 : (uint)0); } public void clear() { _stream = null; }}
我剔除了一些我們不需要的數據類型的讀寫,大家可以參考protobuf的代碼自行添加。
根據File I/O Performance Tips的測試,結合我自己的測試,chunk 模式和 byte模式,性能還是提升很多的。
這裡可以優化的東西很多,大的點就是這兩個:
- 做好快照memorystream pool
- 優化的chunk 讀寫二進位方式
- 隨機數回滾方式
幀同步需要隨機種子來保證隨機數的一致,當我們需要隨機數回滾的時候,就需要自己來做了。這裡就簡單說明下,就是簡單地獲取一定量的隨機數,然後獲取次數index來獲取真實隨機數
快照和回滾也 簡單了,就是處理index即可
- 網路延遲,丟包,斷線模擬工具
上次說過聯網測試,自動測試,需要測試不同的網路環境。我們只需要工具Network-Emulator-Toolkit即可。有介紹文章參考:弱網測試-Network-Emulator-Toolkit(一) - CSDN博客
我們用這個工具,在電腦上用安卓模擬器裝上遊戲,就可以測試。再配合做了自動測試的話,晚上多開幾個客戶端,工具調好浮動延遲掉包等。就可以在第二天早上來看同步比對結果了。
比如昨天我改了個同步bug,晚上下班前開三個端自動聯機測試,100~200ms浮動延遲下,到今天早上戰鬥了800多次,比如戰鬥結果(比對snapshot)都一致。就可以大概率確定bug 已經fix。
最後, 希望這篇文章對大家有些幫助。
推薦閱讀: