Netcode for Entities如何新增自定義序列化,讓GhostField支援任意型別?以int3為例(1.2.3版本)

horeaper發表於2024-07-17

一句話省流:很麻煩也很抽象,能用內建支援的型別就儘量用。

首先看文件。官方文件裡一開頭就列出了所有內建的支援的型別:Ghost Type Templates
其中Entity型別需要特別注意一下:在同步這個型別的時候,如果是剛剛Instantiate的Ghost(也就是GhostId尚未生效,上一篇文章裡說過這個問題),那麼客戶端收到的Entity值會是Entity.Null。之後就算GhostId同步過來了也不會再重新整理。可以說有用,但不那麼好用。
另外實測除了float2/float3/float4以外,double2/double3/double4也是支援的。

對於其他型別想要讓[GhostField]支援它的話,就需要自己寫序列化邏輯了。為了效能和功能,Netcode for Entities的自定義序列化方式搞的特別的複雜,這裡需要仔細閱讀文件和NetcodeSamples裡Translation2d/Rotation2d自定義序列化的做法。這倆分別是對2D物件的位置/旋轉的序列化,前者是兩個int值的座標,後者是一個int值的旋轉。
什麼?官方的Sample專案Unity裡打不開?看這裡
當然直接看肯定會一頭霧水。畢竟Netcode用的方法不那麼常規(或者說,有點復古)。這裡以int3為例寫一份引導:

首先要確定我們拿這個int3來幹什麼。我想讓它功能儘可能豐富,除了Quantization以外(這東西對int型別也沒啥意義),float3支援啥它就支援啥,比方說支援GhostFieldAttribute.Smoothing、Prediction等等。然後我準備拿它當位置座標來用。

1、建立Template檔案

自定義序列化的原理是:提供一個程式碼模板檔案,然後Netcode for Entities就會拿著這個模板透過C#的Source Generator生成它想要的程式碼,最後再編譯。所以我們需要先編寫這個模板檔案。
同時因為程式碼設計上的原因,你自定義的這個模板檔案是透過寫一個partial class新增到Netcode的處理佇列裡面的。從全域性來看,就像是你把一堆程式碼“插入”到了Netcode原來的程式碼裡一樣。
首先隨便找個地方建立一個資料夾,就直接叫Unity.NetCode好了。然後在裡面建立一個Assembly Definition Reference,起名Unity.NetCode.Ref。接著在其Assembly Definition屬性裡選擇Unity.NetCode。
然後在Unity.NetCode資料夾裡建立一個新資料夾,叫Templates。再到Templates資料夾裡建立一個新檔案,叫“IntPosition.NetCodeSourceGenerator.additionalfile”。注意副檔名不要寫錯了。這個檔案在Unity右鍵選單裡找不到的,去檔案目錄裡面自己新建吧。
最後回到Unity.NetCode資料夾,建立一個空C#指令碼檔案:UserDefinedTemplates.cs
(看過我前面文章的會發現這個流程和解決程式碼註釋不在IDE裡顯示的流程非常類似,其實這裡說的才是這個功能本來的用法)

2、編輯Template檔案

回到IDE內,以Visual Studio為例,會發現能在Solution Explorer裡看到Unity.NetCode專案,其中包含Netcode的(部分)原始碼,同時這個專案裡還有剛才建立的UserDefinedTemplates.cs檔案。
另外每個專案底下都多了前面建立的additionalfile檔案。很亂,但沒辦法╮( ̄▽ ̄")╭
從頭開始編寫一個Template很麻煩,有很多“腳手架程式碼”需要搭建,一般都是拿官方的示例程式碼過來,在其基礎上修改。所以我這裡要打破我不喜歡貼大段程式碼的習慣,貼一個大段程式碼進來。先不要嘗試閱讀這段程式碼,先Ctrl+C/Ctrl+V到IntPosition.NetCodeSourceGenerator.additionalfile檔案裡面去,後面來一段一段分析:

#templateid: Custom.IntPositionTemplate

#region __GHOST_IMPORTS__
#endregion

namespace Generated
{
    public struct GhostSnapshotData
    {
        struct Snapshot
        {
        #region __GHOST_FIELD__
            public int __GHOST_FIELD_NAME__X;
            public int __GHOST_FIELD_NAME__Y;
            public int __GHOST_FIELD_NAME__Z;
        #endregion
        }

        public void PredictDelta(uint tick, ref GhostSnapshotData baseline1, ref GhostSnapshotData baseline2)
        {
            var predictor = new GhostDeltaPredictor(tick, this.tick, baseline1.tick, baseline2.tick);
        #region __GHOST_PREDICT__
            snapshot.__GHOST_FIELD_NAME__X = predictor.PredictInt(snapshot.__GHOST_FIELD_NAME__X, baseline1.__GHOST_FIELD_NAME__X, baseline2.__GHOST_FIELD_NAME__X);
            snapshot.__GHOST_FIELD_NAME__Y = predictor.PredictInt(snapshot.__GHOST_FIELD_NAME__Y, baseline1.__GHOST_FIELD_NAME__Y, baseline2.__GHOST_FIELD_NAME__Y);
            snapshot.__GHOST_FIELD_NAME__Z = predictor.PredictInt(snapshot.__GHOST_FIELD_NAME__Z, baseline1.__GHOST_FIELD_NAME__Z, baseline2.__GHOST_FIELD_NAME__Z);
        #endregion
        }

        public void Serialize(int networkId, ref GhostSnapshotData baseline, ref DataStreamWriter writer, StreamCompressionModel compressionModel)
        {
        #region __GHOST_WRITE__
            if ((changeMask & (1 << __GHOST_MASK_INDEX__)) != 0) {
                writer.WritePackedIntDelta(snapshot.__GHOST_FIELD_NAME__X, baseline.__GHOST_FIELD_NAME__X, compressionModel);
                writer.WritePackedIntDelta(snapshot.__GHOST_FIELD_NAME__Y, baseline.__GHOST_FIELD_NAME__Y, compressionModel);
                writer.WritePackedIntDelta(snapshot.__GHOST_FIELD_NAME__Z, baseline.__GHOST_FIELD_NAME__Z, compressionModel);
            }
        #endregion
        }

        public void Deserialize(uint tick, ref GhostSnapshotData baseline, ref DataStreamReader reader, StreamCompressionModel compressionModel)
        {
        #region __GHOST_READ__
            if ((changeMask & (1 << __GHOST_MASK_INDEX__)) != 0) {
                snapshot.__GHOST_FIELD_NAME__X = reader.ReadPackedIntDelta(baseline.__GHOST_FIELD_NAME__X, compressionModel);
                snapshot.__GHOST_FIELD_NAME__Y = reader.ReadPackedIntDelta(baseline.__GHOST_FIELD_NAME__Y, compressionModel);
                snapshot.__GHOST_FIELD_NAME__Z = reader.ReadPackedIntDelta(baseline.__GHOST_FIELD_NAME__Z, compressionModel);
            }
            else {
                snapshot.__GHOST_FIELD_NAME__X = baseline.__GHOST_FIELD_NAME__X;
                snapshot.__GHOST_FIELD_NAME__Y = baseline.__GHOST_FIELD_NAME__Y;
                snapshot.__GHOST_FIELD_NAME__Z = baseline.__GHOST_FIELD_NAME__Z;
            }
        #endregion
        }
        
        public void SerializeCommand(ref DataStreamWriter writer, in IComponentData data, in IComponentData baseline, StreamCompressionModel compressionModel)
        {
        #region __COMMAND_WRITE__
            writer.WriteInt(data.__COMMAND_FIELD_NAME__.x);
            writer.WriteInt(data.__COMMAND_FIELD_NAME__.y);
            writer.WriteInt(data.__COMMAND_FIELD_NAME__.z);
        #endregion

        #region __COMMAND_WRITE_PACKED__
            writer.WritePackedIntDelta(data.__COMMAND_FIELD_NAME__.x, baseline.__COMMAND_FIELD_NAME__.x, compressionModel);
            writer.WritePackedIntDelta(data.__COMMAND_FIELD_NAME__.y, baseline.__COMMAND_FIELD_NAME__.y, compressionModel);
            writer.WritePackedIntDelta(data.__COMMAND_FIELD_NAME__.z, baseline.__COMMAND_FIELD_NAME__.z, compressionModel);
        #endregion
        }

        public void DeserializeCommand(ref DataStreamReader reader, ref IComponentData data, in IComponentData baseline, StreamCompressionModel compressionModel)
        {
        #region __COMMAND_READ__
            data.__COMMAND_FIELD_NAME__.x = reader.ReadInt();
            data.__COMMAND_FIELD_NAME__.y = reader.ReadInt();
            data.__COMMAND_FIELD_NAME__.z = reader.ReadInt();
        #endregion

        #region __COMMAND_READ_PACKED__
            data.__COMMAND_FIELD_NAME__.x = reader.ReadPackedIntDelta(baseline.__COMMAND_FIELD_NAME__.x, compressionModel);
            data.__COMMAND_FIELD_NAME__.y = reader.ReadPackedIntDelta(baseline.__COMMAND_FIELD_NAME__.y, compressionModel);
            data.__COMMAND_FIELD_NAME__.z = reader.ReadPackedIntDelta(baseline.__COMMAND_FIELD_NAME__.z, compressionModel);
        #endregion
        }

        public unsafe void CopyToSnapshot(ref Snapshot snapshot, ref IComponentData component)
        {
            if (true) {
        #region __GHOST_COPY_TO_SNAPSHOT__
                snapshot.__GHOST_FIELD_NAME__X = component.__GHOST_FIELD_REFERENCE__.x;
                snapshot.__GHOST_FIELD_NAME__Y = component.__GHOST_FIELD_REFERENCE__.y;
                snapshot.__GHOST_FIELD_NAME__Z = component.__GHOST_FIELD_REFERENCE__.z;
        #endregion
            }
        }
        
        public unsafe void CopyFromSnapshot(ref Snapshot snapshotBefore, ref Snapshot snapshotAfter, float snapshotInterpolationFactor, ref IComponentData component)
        {
            if (true) {
        #region __GHOST_COPY_FROM_SNAPSHOT__
                component.__GHOST_FIELD_REFERENCE__ = new int3(snapshotBefore.__GHOST_FIELD_NAME__X, snapshotBefore.__GHOST_FIELD_NAME__Y, snapshotBefore.__GHOST_FIELD_NAME__Z));
        #endregion

        #region __GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_SETUP__
                var __GHOST_FIELD_NAME___Before = new int3(snapshotBefore.__GHOST_FIELD_NAME__X, snapshotBefore.__GHOST_FIELD_NAME__Y, snapshotBefore.__GHOST_FIELD_NAME__Z);
                var __GHOST_FIELD_NAME___After = new int3(snapshotAfter.__GHOST_FIELD_NAME__X, snapshotAfter.__GHOST_FIELD_NAME__Y, snapshotAfter.__GHOST_FIELD_NAME__Z);
        #endregion

        #region __GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_DISTSQ__
                var __GHOST_FIELD_NAME___DistSq = UMath.PVector.DistanceSquared(__GHOST_FIELD_NAME___Before, __GHOST_FIELD_NAME___After);
        #endregion

        #region __GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE__
                component.__GHOST_FIELD_REFERENCE__ = UMath.PVector.Lerp(__GHOST_FIELD_NAME___Before, __GHOST_FIELD_NAME___After, snapshotInterpolationFactor);
        #endregion
            }
        }
        
        public unsafe void RestoreFromBackup(ref IComponentData component, in IComponentData backup)
        {
        #region __GHOST_RESTORE_FROM_BACKUP__
            component.__GHOST_FIELD_REFERENCE__ = backup.__GHOST_FIELD_REFERENCE__;
        #endregion
        }
        
        public void CalculateChangeMask(ref Snapshot snapshot, ref Snapshot baseline, uint changeMask)
        {
        #region __GHOST_CALCULATE_INPUT_CHANGE_MASK__
            changeMask |= (snapshot.__COMMAND_FIELD_NAME__.x != baseline.__COMMAND_FIELD_NAME__.x ||
                           snapshot.__COMMAND_FIELD_NAME__.y != baseline.__COMMAND_FIELD_NAME__.y ||
                           snapshot.__COMMAND_FIELD_NAME__.z != baseline.__COMMAND_FIELD_NAME__.z) ? 1u : 0;
        #endregion

        #region __GHOST_CALCULATE_CHANGE_MASK_ZERO__
            changeMask = (snapshot.__GHOST_FIELD_NAME__X != baseline.__GHOST_FIELD_NAME__X  ||
                          snapshot.__GHOST_FIELD_NAME__Y != baseline.__GHOST_FIELD_NAME__Y  ||
                          snapshot.__GHOST_FIELD_NAME__Z != baseline.__GHOST_FIELD_NAME__Z) ? 1u : 0;
        #endregion

        #region __GHOST_CALCULATE_CHANGE_MASK__
            changeMask |= (snapshot.__GHOST_FIELD_NAME__X != baseline.__GHOST_FIELD_NAME__X  ||
                           snapshot.__GHOST_FIELD_NAME__Y != baseline.__GHOST_FIELD_NAME__Y  ||
                           snapshot.__GHOST_FIELD_NAME__Z != baseline.__GHOST_FIELD_NAME__Z) ? (1u << __GHOST_MASK_INDEX__) : 0;
        #endregion
        }
        
#if UNITY_EDITOR || NETCODE_DEBUG
        private static void ReportPredictionErrors(ref IComponentData component, in IComponentData backup, ref UnsafeList<float> errors, ref int errorIndex)
        {
        #region __GHOST_REPORT_PREDICTION_ERROR__
            errors[errorIndex] = math.max(errors[errorIndex], UMath.PVector.Distance(component.__GHOST_FIELD_REFERENCE__, backup.__GHOST_FIELD_REFERENCE__));
            ++errorIndex;
        #endregion
        }
        
        private static int GetPredictionErrorNames(ref FixedString512Bytes names, ref int nameCount)
        {
        #region __GHOST_GET_PREDICTION_ERROR_NAME__
            if (nameCount != 0) {
                names.Append(new FixedString32Bytes(","));
            }
            names.Append(new FixedString64Bytes("__GHOST_FIELD_REFERENCE__"));
            ++nameCount;
        #endregion
        }
#endif
    }
}

3、這一大坨特喵的到底是個啥

第一眼看過去絕對大腦爆炸,畢竟這一堆一堆的下劃線實在是太不C#了。其實這只是為了防止識別符號重複而做的妥協罷了,玩過C++的肯定很熟悉這種做法。
我們一段一段的來看:

#templateid: Custom.IntPositionTemplate

給這個模板一個字串ID。這裡用了“Custom.IntPositionTemplate”這個ID,其實你給它起任何名字都是可以的,只要名字別和其他模板重複就好。比方說起個“MyAwsomeGame.ThisIsJustATemplate”都行。

#region __GHOST_IMPORTS__
#endregion

啊?搞毛?一個空的region?
實際上Netcode就是透過這些region來確定你提供的程式碼在什麼地方的,有些時候也會利用這些region標記程式碼插入的位置。這裡這個空region就是告訴Netcode的Source Generator:把Ghost Imports相關的程式碼插入到這個地方。
瞭解了這個特點之後,後面很多乍一看亂七八糟的程式碼就突然變得有邏輯了。

namespace Generated
{
    public struct GhostSnapshotData
    {

模板硬性規定,照著寫就行。

struct Snapshot
{
#region __GHOST_FIELD__
    public int __GHOST_FIELD_NAME__X;
    public int __GHOST_FIELD_NAME__Y;
    public int __GHOST_FIELD_NAME__Z;
#endregion
}

這裡定義儲存在Snapshot裡的資料格式,int3有三個int欄位,所以這裡也準備三個int。__GHOST_FIELD_NAME__X這些名字其實可以自己隨便改。但注意#region __GHOST_FIELD__這一行不要改它。就像前面說的那樣,這裡是給Source Generator的標記,改了它就不認識了。

public void PredictDelta(uint tick, ref GhostSnapshotData baseline1, ref GhostSnapshotData baseline2)
{
    var predictor = new GhostDeltaPredictor(tick, this.tick, baseline1.tick, baseline2.tick);
#region __GHOST_PREDICT__
    snapshot.__GHOST_FIELD_NAME__X = predictor.PredictInt(snapshot.__GHOST_FIELD_NAME__X, baseline1.__GHOST_FIELD_NAME__X, baseline2.__GHOST_FIELD_NAME__X);
    snapshot.__GHOST_FIELD_NAME__Y = predictor.PredictInt(snapshot.__GHOST_FIELD_NAME__Y, baseline1.__GHOST_FIELD_NAME__Y, baseline2.__GHOST_FIELD_NAME__Y);
    snapshot.__GHOST_FIELD_NAME__Z = predictor.PredictInt(snapshot.__GHOST_FIELD_NAME__Z, baseline1.__GHOST_FIELD_NAME__Z, baseline2.__GHOST_FIELD_NAME__Z);
#endregion
}

給Predict系統提供的程式碼。
GhostSnapshotData這個型別並不存在,是個佔位符,最後會被Source Generator替換成其他的型別。
GhostDeltaPredictor這個型別的原始碼就在GhostDeltaPredictor.cs裡,直接就可以在專案中找到。可以去看一下里面PredictInt的實現,瞭解一下Prediction系統背後的數學演算法。

你可能想問:GhostDeltaPredictor裡沒有float和double相關的實現啊!我要是float型別這裡應該怎麼寫?
答案是:不用寫。
更進一步的,如果你的型別裡所有資料都是float或者double,只要留一個空的#region __GHOST_PREDICT__即可。

public void Serialize(int networkId, ref GhostSnapshotData baseline, ref DataStreamWriter writer, StreamCompressionModel compressionModel)
{
#region __GHOST_WRITE__
    if ((changeMask & (1 << __GHOST_MASK_INDEX__)) != 0) {
        writer.WritePackedIntDelta(snapshot.__GHOST_FIELD_NAME__X, baseline.__GHOST_FIELD_NAME__X, compressionModel);
        writer.WritePackedIntDelta(snapshot.__GHOST_FIELD_NAME__Y, baseline.__GHOST_FIELD_NAME__Y, compressionModel);
        writer.WritePackedIntDelta(snapshot.__GHOST_FIELD_NAME__Z, baseline.__GHOST_FIELD_NAME__Z, compressionModel);
    }
#endregion
}

public void Deserialize(uint tick, ref GhostSnapshotData baseline, ref DataStreamReader reader, StreamCompressionModel compressionModel)
{
#region __GHOST_READ__
    if ((changeMask & (1 << __GHOST_MASK_INDEX__)) != 0) {
        snapshot.__GHOST_FIELD_NAME__X = reader.ReadPackedIntDelta(baseline.__GHOST_FIELD_NAME__X, compressionModel);
        snapshot.__GHOST_FIELD_NAME__Y = reader.ReadPackedIntDelta(baseline.__GHOST_FIELD_NAME__Y, compressionModel);
        snapshot.__GHOST_FIELD_NAME__Z = reader.ReadPackedIntDelta(baseline.__GHOST_FIELD_NAME__Z, compressionModel);
    }
    else {
        snapshot.__GHOST_FIELD_NAME__X = baseline.__GHOST_FIELD_NAME__X;
        snapshot.__GHOST_FIELD_NAME__Y = baseline.__GHOST_FIELD_NAME__Y;
        snapshot.__GHOST_FIELD_NAME__Z = baseline.__GHOST_FIELD_NAME__Z;
    }
#endregion
}

向網路資料裡序列化,和從網路資料裡反序列化的程式碼。
if什麼什麼mask的那一堆直接照抄,這些都是和Netcode內部序列化實現細節有關的玩意兒,不必深究。
DataStreamWriterDataStreamReader都是實際存在的型別,裡面有一堆WriteXXX()/ReadXXX()這樣的方法。你用哪個型別就呼叫哪個方法。注意這裡用的不是常見的WriteInt()ReadInt(),而是WritePackedIntDelta()ReadPackedIntDelta(),也就是說寫到網路資料裡的並不是絕對值,而是相對於上一個Snapshot的變化量。這樣有助於資料壓縮,減少最終網路資料的位元組數。
後面的StreamCompressionModel顧名思義就是個流壓縮演算法,在意實現的可以自己去翻原始碼。

public void SerializeCommand(ref DataStreamWriter writer, in IComponentData data, in IComponentData baseline, StreamCompressionModel compressionModel)
{
#region __COMMAND_WRITE__
    writer.WriteInt(data.__COMMAND_FIELD_NAME__.x);
    writer.WriteInt(data.__COMMAND_FIELD_NAME__.y);
    writer.WriteInt(data.__COMMAND_FIELD_NAME__.z);
#endregion

#region __COMMAND_WRITE_PACKED__
    writer.WritePackedIntDelta(data.__COMMAND_FIELD_NAME__.x, baseline.__COMMAND_FIELD_NAME__.x, compressionModel);
    writer.WritePackedIntDelta(data.__COMMAND_FIELD_NAME__.y, baseline.__COMMAND_FIELD_NAME__.y, compressionModel);
    writer.WritePackedIntDelta(data.__COMMAND_FIELD_NAME__.z, baseline.__COMMAND_FIELD_NAME__.z, compressionModel);
#endregion
}

public void DeserializeCommand(ref DataStreamReader reader, ref IComponentData data, in IComponentData baseline, StreamCompressionModel compressionModel)
{
#region __COMMAND_READ__
    data.__COMMAND_FIELD_NAME__.x = reader.ReadInt();
    data.__COMMAND_FIELD_NAME__.y = reader.ReadInt();
    data.__COMMAND_FIELD_NAME__.z = reader.ReadInt();
#endregion

#region __COMMAND_READ_PACKED__
    data.__COMMAND_FIELD_NAME__.x = reader.ReadPackedIntDelta(baseline.__COMMAND_FIELD_NAME__.x, compressionModel);
    data.__COMMAND_FIELD_NAME__.y = reader.ReadPackedIntDelta(baseline.__COMMAND_FIELD_NAME__.y, compressionModel);
    data.__COMMAND_FIELD_NAME__.z = reader.ReadPackedIntDelta(baseline.__COMMAND_FIELD_NAME__.z, compressionModel);
#endregion
}

我打算讓int3型別支援在ICommandData裡面使用,所以有了這麼一堆程式碼。
注意看data.__COMMAND_FIELD_NAME__.x這裡,為什麼後面跟了個小寫的x?實際上你把__COMMAND_FIELD_NAME__看做是int3型別的一個變數,是不是就懂了?__COMMAND_FIELD_NAME__也不過是Netcode的Source Generator預留的佔位符,最後會替換成你想序列化的型別。
瞭解了region是拿來進行程式碼塊標記的,這幾坨程式碼的含義也就很清晰了,它們分別定義了四坨程式碼:直接的寫入;將資料變化量壓縮後寫入;普通的讀取;壓縮後的變化量資料的讀取。

public unsafe void CopyToSnapshot(ref Snapshot snapshot, ref IComponentData component)
{
    if (true) {
#region __GHOST_COPY_TO_SNAPSHOT__
        snapshot.__GHOST_FIELD_NAME__X = component.__GHOST_FIELD_REFERENCE__.x;
        snapshot.__GHOST_FIELD_NAME__Y = component.__GHOST_FIELD_REFERENCE__.y;
        snapshot.__GHOST_FIELD_NAME__Z = component.__GHOST_FIELD_REFERENCE__.z;
#endregion
    }
}

看過前面的程式碼之後,這堆玩意兒也就顯得親切了不少,__GHOST_FIELD_REFERENCE__很明顯也是int3型別的。這些程式碼就是把“外面的”int3資料複製到“裡面的”Snapshot資料的過程。至於為啥有個if (true),別問我,我也沒搞懂╮( ̄▽ ̄")╭

public unsafe void CopyFromSnapshot(ref Snapshot snapshotBefore, ref Snapshot snapshotAfter, float snapshotInterpolationFactor, ref IComponentData component)
{
    if (true) {
#region __GHOST_COPY_FROM_SNAPSHOT__
        component.__GHOST_FIELD_REFERENCE__ = new int3(snapshotBefore.__GHOST_FIELD_NAME__X, snapshotBefore.__GHOST_FIELD_NAME__Y, snapshotBefore.__GHOST_FIELD_NAME__Z));
#endregion

#region __GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_SETUP__
        var __GHOST_FIELD_NAME___Before = new int3(snapshotBefore.__GHOST_FIELD_NAME__X, snapshotBefore.__GHOST_FIELD_NAME__Y, snapshotBefore.__GHOST_FIELD_NAME__Z);
        var __GHOST_FIELD_NAME___After = new int3(snapshotAfter.__GHOST_FIELD_NAME__X, snapshotAfter.__GHOST_FIELD_NAME__Y, snapshotAfter.__GHOST_FIELD_NAME__Z);
#endregion

#region __GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_DISTSQ__
        var __GHOST_FIELD_NAME___DistSq = UMath.PVector.DistanceSquared(__GHOST_FIELD_NAME___Before, __GHOST_FIELD_NAME___After);
#endregion

#region __GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE__
        component.__GHOST_FIELD_REFERENCE__ = UMath.PVector.Lerp(__GHOST_FIELD_NAME___Before, __GHOST_FIELD_NAME___After, snapshotInterpolationFactor);
#endregion
    }
}

哦豁,還有高手?我們一塊一塊來分析。
__GHOST_COPY_FROM_SNAPSHOT__程式碼塊:顧名思義是從“裡面的”Snapshot將資料傳遞迴“外面的”int3的。
__GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_SETUP__程式碼塊:又是兩行往外傳遞程式碼的。
__GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE_DISTSQ__程式碼塊:拿前一塊程式碼“提取”出來的“什麼什麼Before”和“什麼什麼After”計算了一下距離的平方,UMath.PVector.DistanceSquared是我自己的程式碼,初中數學課本上的距離的平方的演算法:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long DistanceSquared(in int3 left, in int3 right)
{
    long x = left.x - right.x;
    long y = left.y - right.y;
    long z = left.z - right.z;
    return x * x + y * y + z * z;
}

別問我UMath是啥意思……歷史遺留產物……PVector的意思就是Position Vector。
啊咧?最後計算出來的__GHOST_FIELD_NAME___DistSq好像沒有用到?嘛,也只是咱們用不到罷了,Netcode會把這段程式碼插入到它自己想用的地方去的。
最後__GHOST_COPY_FROM_SNAPSHOT_INTERPOLATE__程式碼塊,顧名思義就是做線性插值,UMath.PVector.Lerp程式碼如下:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int3 Lerp(in int3 value1, in int3 value2, float amount)
{
    return new int3(
        Lerp(value1.x, value2.x, amount),
        Lerp(value1.y, value2.y, amount),
        Lerp(value1.z, value2.z, amount)
    );
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int Lerp(int value1, int value2, float amount)
{
    return LerpUnchecked(value1, value2, Clamp01(amount));
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int LerpUnchecked(int value1, int value2, float amount)
{
    return value1 + (int)((value2 - value1) * (double)amount);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float Clamp01(float value)
{
    if (value > 1) {
        return 1;
    }
    else if (value < 0) {
        return 0;
    }
    else {
        return value;
    }
}

就是把常見的基於float的Lerp演算法改成了int的,中間其實是用double進行的運算,為了儘可能的儲存精度。

public unsafe void RestoreFromBackup(ref IComponentData component, in IComponentData backup)
{
#region __GHOST_RESTORE_FROM_BACKUP__
    component.__GHOST_FIELD_REFERENCE__ = backup.__GHOST_FIELD_REFERENCE__;
#endregion
}

__GHOST_FIELD_REFERENCE__這個識別符號前面已經見過了,這行程式碼是幹什麼的也就很清楚了。

public void CalculateChangeMask(ref Snapshot snapshot, ref Snapshot baseline, uint changeMask)
{
#region __GHOST_CALCULATE_INPUT_CHANGE_MASK__
    changeMask |= (snapshot.__COMMAND_FIELD_NAME__.x != baseline.__COMMAND_FIELD_NAME__.x ||
                   snapshot.__COMMAND_FIELD_NAME__.y != baseline.__COMMAND_FIELD_NAME__.y ||
                   snapshot.__COMMAND_FIELD_NAME__.z != baseline.__COMMAND_FIELD_NAME__.z) ? 1u : 0;
#endregion

#region __GHOST_CALCULATE_CHANGE_MASK_ZERO__
    changeMask = (snapshot.__GHOST_FIELD_NAME__X != baseline.__GHOST_FIELD_NAME__X  ||
                  snapshot.__GHOST_FIELD_NAME__Y != baseline.__GHOST_FIELD_NAME__Y  ||
                  snapshot.__GHOST_FIELD_NAME__Z != baseline.__GHOST_FIELD_NAME__Z) ? 1u : 0;
#endregion

#region __GHOST_CALCULATE_CHANGE_MASK__
    changeMask |= (snapshot.__GHOST_FIELD_NAME__X != baseline.__GHOST_FIELD_NAME__X  ||
                   snapshot.__GHOST_FIELD_NAME__Y != baseline.__GHOST_FIELD_NAME__Y  ||
                   snapshot.__GHOST_FIELD_NAME__Z != baseline.__GHOST_FIELD_NAME__Z) ? (1u << __GHOST_MASK_INDEX__) : 0;
#endregion
}

還記得前面見過的那個“if什麼什麼mask”嗎?這裡就是mask的生成過程,__COMMAND_FIELD_NAME____GHOST_FIELD_NAME__X/Y/Z都是已經見過的識別符號了,程式碼應該不難理解。
由於和Netcode網路資料流的實現細節緊密相關,這一塊兒抄的時候要仔細,看看文件裡是怎麼寫的,看看NetcodeSamples是怎麼寫的,看看我這裡是怎麼寫的,舉一反三。

#if UNITY_EDITOR || NETCODE_DEBUG
        private static void ReportPredictionErrors(ref IComponentData component, in IComponentData backup, ref UnsafeList<float> errors, ref int errorIndex)
        {
        #region __GHOST_REPORT_PREDICTION_ERROR__
            errors[errorIndex] = math.max(errors[errorIndex], UMath.PVector.Distance(component.__GHOST_FIELD_REFERENCE__, backup.__GHOST_FIELD_REFERENCE__));
            ++errorIndex;
        #endregion
        }
        
        private static int GetPredictionErrorNames(ref FixedString512Bytes names, ref int nameCount)
        {
        #region __GHOST_GET_PREDICTION_ERROR_NAME__
            if (nameCount != 0) {
                names.Append(new FixedString32Bytes(","));
            }
            names.Append(new FixedString64Bytes("__GHOST_FIELD_REFERENCE__"));
            ++nameCount;
        #endregion
        }
#endif

最後一段程式碼,看見#if UNITY_EDITOR || NETCODE_DEBUG就明白,只在Editor或者Debug的時候起作用,用來輸出錯誤資訊的。UMath.PVector.Distance的程式碼就不貼了,DistanceSqaured都有了還能不知道Distance怎麼計算嗎?

4、編寫UserDefinedTemplates

開啟UserDefinedTemplates.cs檔案,直接照抄:

using System.Collections.Generic;

namespace Unity.NetCode.Generators
{
    public static partial class UserDefinedTemplates
    {
        static partial void RegisterTemplates(List<TypeRegistryEntry> templates, string defaultRootPath)
        {
            templates.AddRange(new[] {
                new TypeRegistryEntry {
                    Type = "Unity.Mathematics.int3",
                    Quantized = false,
                    Smoothing = SmoothingAction.InterpolateAndExtrapolate,
                    SupportCommand = true,
                    Composite = false,
                    Template = "Custom.IntPositionTemplate",
                    TemplateOverride = "",
                }
            });
        }
    }
}

partial class?partial void方法?另一半去哪裡了?
你能在Library\PackageCache\com.unity.netcode\Runtime\Authoring\UserDefinedTemplates.cs找到這個類的另一半。你會發現Netcode寫了個RegisterTemplates卻沒寫實現。這個實現就是在這裡由我們提供的了。
至於為什麼要用這麼彎彎繞的方法把這個函式“插入”進去?是因為Unity的Source Generator限制,它需要在Netcode庫編譯的時候就能看到這些程式碼,因此才會搞的這麼複雜。
然後我們來分析TypeRegistryEntry每一項都是幹啥的:

  • Type:你需要序列化的型別,這裡我們填上int3的帶上namespace的完整型別名。
  • Quantized:對於int型別沒有意義,所以是false。如果你想加入這方面的支援,可以看看官方文件裡面,__GHOST_QUANTIZE_SCALE____GHOST_DEQUANTIZE_SCALE__這兩個識別符號分別用在了什麼地方,照著做就好。或者去看我後面會提到的一堆“示例檔案”。
  • Smoothing:之所以我們費這麼大勁寫這麼一大堆程式碼就是為了讓int型別支援Smoothing,否則我就不用int3了,直接擺三個int不也一樣麼。所以這裡當然要用SmoothingAction.InterpolateAndExtrapolate
  • SupportCommand:如果你這裡寫成false,那麼Template裡就可以少些一些程式碼。那些識別符號上帶著COMMAND的程式碼塊就都可以不要。我們程式碼都寫完了,當然是true。
  • Composite:建議就用false。用true的話,Source Generator使用Template生成程式碼的方式會有變化,在像int3這種,其內部所有欄位都是相同的型別的場合,能讓你省點事,少打一些Template程式碼。但是生成的規則會變得更復雜一些,我懶得想那麼多,一般就false了。
  • Template:第一行模板程式碼裡指定的#templateid
  • TemplateOverride:作用是讓你寫的這個模板替換掉Netcode自帶的模板,只不過沒有詳細的文件和示例說明這玩意兒該怎麼用。不管它(~ ̄▽ ̄)~

Netcode自帶的模板位於這個資料夾裡:Library\PackageCache\com.unity.netcode\Editor\Templates\DefaultTypes。這些檔案也是非常棒的示例檔案,只不過大部分檔案都不完整(Netcode最後會自己拼成完整的)。比較完整的有:

GhostSnapshotValueInt.cs
GhostSnapshotValueUInt.cs
GhostSnapshotValueFloat.cs
GhostSnapshotValueFloatUnquantized.cs
GhostSnapshotValueQuaternion.cs
GhostSnapshotValueQuaternionUnquantized.cs

另外GhostSnapshotValueEntity.cs也很值得一看,畢竟和數學型別不同,Entity是一個邏輯型別,模板的編寫方式自然也不太一樣。

除了上面說的那些以外,還有一個TypeRegistryEntry.SubType,怎麼用可以去看官方文件和NetcodeSamples。其實用起來很簡單,只需要寫兩行程式碼,然後點一個選項。但是解釋SubType這個概念需要另開一篇文章,而且這文章寫到最後也難免變成官方文件的漢化版。所以我就偷懶不寫了>_<

5、好了,能用了嗎?

我們來建立一個型別:

public struct WorldEntityTransform : IComponentData
{
    [GhostField(Composite = true, Smoothing = SmoothingAction.Interpolate)]
    public int3 Position;
}

然後讓Unity去編譯。如果沒出問題,編譯透過,就能用了。
注意這裡的GhostField.Composite和前面的TypeRegistryEntry.Composite完全不是一碼事。這裡是設定“資料有變化之後,Netcode要怎麼在網路資料流裡進行標記”的。我這裡設定為true,是因為對於三維空間的位置座標來說,經常是XYZ三個值一起變,用Composite在大部分情況下可以節省兩個bit。

如果編譯出現問題了呢?
大機率就是你Template檔案沒寫好,怎麼改?錯誤資訊提示的行數根本找不到啊!
實際上這裡錯誤資訊給出的行數並不是Template檔案裡的行數,而是Source Generator生成的程式碼裡的行數。這個程式碼在Visual Studio裡是找不到的,要去這個地方找:Temp\NetCodeGenerated\Assembly-CSharp
在這裡你會找到一個以WorldEntityTransformSerializer.cs結尾的C#程式碼檔案。開啟後,往下翻一翻,有沒有覺得有點眼熟?這不就是剛才寫的模板檔案,加了一堆有的沒的之後的東西嘛!
找到這個檔案以後,就可以根據錯誤提示的行數,找到出錯的地方,然後回到Template檔案裡找到對應的地方,進行修改即可。

接下來,你可以回到UserDefinedTemplates那邊,把Composite改成true,然後看看生成的程式碼變成了什麼鬼樣子。折騰幾次之後,就應該能明白這個玩意兒要怎麼用了。如果還是搞不懂,那就放著不管,反正也不是什麼不用不行的東西。

也可以給WorldEntityTransform加幾個別的欄位,看看最後會生成什麼。藉此瞭解一下Netcode的底層實現。

6、666

總算是結束了。我能理解Netcode為啥會設計成這個樣子,畢竟要支援的功能確實有點多,又想要同時保證高效能,自然省不了事。還好這套玩意兒也就是第一次上手的時候理解起來比較累,跨過了這個坎之後,就…………就特麼再也不想碰它了😂
程式碼能工作不?能!好了!別動了!就這樣了!

相關文章