首页 星云 工具 资源 星选 资讯 热门工具
:

PDF转图片 完全免费 小红书视频下载 无水印 抖音视频下载 无水印 数字星空

Netcode for Entities如何添加自定义序列化,让GhostField支持任意类型?以int3为例(1.2.3版本)

编程知识
2024年07月17日 17:45

一句话省流:很麻烦也很抽象,能用内置支持的类型就尽量用。

首先看文档。官方文档里一开头就列出了所有内置的支持的类型: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为啥会设计成这个样子,毕竟要支持的功能确实有点多,又想要同时保证高性能,自然省不了事。还好这套玩意儿也就是第一次上手的时候理解起来比较累,跨过了这个坎之后,就…………就特么再也不想碰它了😂
代码能工作不?能!好了!别动了!就这样了!

From:https://www.cnblogs.com/horeaper/p/18303729
本文地址: http://shuzixingkong.net/article/81
0评论
提交 加载更多评论
其他文章 Java21的虚拟线程Virtual Thread初体验
我们之前使用的是操作系统平台的线程,就称之为“系统线程”吧。虚拟线程是JDK维护的,原理跟WebFlux的底层实现差不多,都是工作线程分离。 要使用虚拟线程,需要使用JDK21以上,包括21。 虚拟线程可以创建很多很多 系统线程不能轻易创建太多,我们一直被教导创建线程是很重的活动。 for (int
Java21的虚拟线程Virtual Thread初体验
超级炫酷的终端神器 eDEX-UI
目录eDEX-UI主要亮点:优点:软件简介安装LinuxWindows效果更换皮肤matrixTron-disrupted退出常见问题解答 eDEX-UI,不仅是一款全屏幕、跨平台的终端模拟器和系统监视器,更是一件被封存的艺术品,让你尽情沉浸于科幻般的装逼幻想之中。它的界面设计独特,仿佛来自未来世界
超级炫酷的终端神器 eDEX-UI 超级炫酷的终端神器 eDEX-UI 超级炫酷的终端神器 eDEX-UI
.NET科普:.NET简史、.NET Standard以及C#和.NET Framework之间的关系
最近在不少自媒体上看到有关.NET与C#的资讯与评价,感觉大家对.NET与C#还是不太了解,尤其是对2016年6月发布的跨平台.NET Core 1.0,更是知之甚少。在考虑一番之后,还是决定写点东西总结一下,也回顾一下.NET的发展历史。 首先,你没看错,.NET是跨平台的,可以在Windows、
.NET科普:.NET简史、.NET Standard以及C#和.NET Framework之间的关系 .NET科普:.NET简史、.NET Standard以及C#和.NET Framework之间的关系 .NET科普:.NET简史、.NET Standard以及C#和.NET Framework之间的关系
低开开发笔记(八): 低代码编辑器实现撤销回退(命令模式,防抖处理)
好家伙, 0.代码已开源 https://github.com/Fattiger4399/ph_questionnaire-.git 1.事件触发 我们先从事件的触发开始讲起 大致上我们有两个思路可以选择 1.监控用户行为 2.监控数据变化 两种选择都会有较难处理的部分,这里我们先选第二个选项 关于
低开开发笔记(八): 低代码编辑器实现撤销回退(命令模式,防抖处理) 低开开发笔记(八): 低代码编辑器实现撤销回退(命令模式,防抖处理)
表格集算表高性能原理:揭秘纯前端百万行数据秒级响应的魔法
最新技术资源(建议收藏) https://www.grapecity.com.cn/resources/ 集算表 (Table Sheet)是一个具备高性能渲染、数据绑定功能、公式计算能力的数据表格,通过全新构建的关系型数据管理器结合结构化公式,在高性能表格的基础上提供排序、筛选、样式、行列冻结、自
表格集算表高性能原理:揭秘纯前端百万行数据秒级响应的魔法 表格集算表高性能原理:揭秘纯前端百万行数据秒级响应的魔法 表格集算表高性能原理:揭秘纯前端百万行数据秒级响应的魔法
(开源)都进来!简单易懂、功能强大的权限+可视化流程管理系统
1、预览地址:http://139.155.137.144:9012 2、qq群:801913255 一、前言 随着网络的发展,企业对于信息系统数据的保密工作愈发重视,不同身份、角色对于数据的访问权限都应该大相径庭。 列如 1、不同登录人员对一个数据列表的可见度是不一样的,如数据列、数据行、数据按钮
(开源)都进来!简单易懂、功能强大的权限+可视化流程管理系统 (开源)都进来!简单易懂、功能强大的权限+可视化流程管理系统 (开源)都进来!简单易懂、功能强大的权限+可视化流程管理系统
说说XXLJob分片任务实现原理?
XXL Job 是一个开源的分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展的分布式任务调度框架。 这两天咱们开发的 AI Cloud 项目中,也使用到了 XXL Job 来执行分布式任务的调度,可以看出它的部署和使用虽然步骤很多,但用起来还是很简单的。 因为其本身为 Spri
说说XXLJob分片任务实现原理? 说说XXLJob分片任务实现原理?
开启GitLab的邮件通知功能以及一些外观配置
前言 维护GitLab的同事离职了 刚好又有新实习生需要申请账号 只能我来出手了 其实之前安装了 GitLab 之后一直还是用得比较粗糙的 属于是勉强能用的水平,有些配置都还没改好 这次把邮件功能、域名、外观啥的配置好了,写篇文章记录一下 目录结构 先来回顾一下 GitLab 的目录结构 我们的 G
开启GitLab的邮件通知功能以及一些外观配置