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

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

代码能工作不?能!好了!别动了!就这样了!

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

  1. json序列化时定制支持datetime类型,和到中文让他保留中文形式

    json序列化时,可以处理的数据类型有哪些?如何定制支持datetime类型 自定义时间序列化转换器 import json from json import JSONEncoder from dat ...

  2. json的序列化和反序列化支持时间格式转换

    .NET自带的json序列有时间格式问题,为了解决自己写了个json格式的序列化和反序列化 1.引入的命名空间 using System; using System.Collections.Gener ...

  3. 让simplejson支持datetime类型的序列化

    simplejson是Python的一个json包,但是觉得有点不爽,就是不能序列化datetime,稍作修改就可以了: 原文:http://blog.csdn.net/hong201/article ...

  4. Json.NET序列化后包含类型,保证序列化和反序列化的对象类型相同(转载)

    This sample uses the TypeNameHandlingsetting to include type information when serializing JSON and r ...

  5. WPF XML序列化保存数据 支持Datagrid 显示/编辑/添加/删除数据

    XML序列化保存数据 using System; using System.Collections.Generic; using System.Linq; using System.Text; usi ...

  6. json序列化字符串后,配置枚举类型显示数值而不是名称

    2019独角兽企业重金招聘Python工程师标准>>> 经常有这么一个需求,实体类里面用到枚举常量,但序列化成json字符串时.默认并不是我想要的值,而是名称,如下 类 @Data ...

  7. 在 LINQ to Entities 查询中无法构造实体或复杂类型

    public List<CustomerType> GetCustomerTypesBySchemaTypeCode(int schemaTypeCode) { var query = ( ...

  8. 在 LINQ to Entities 查询中无法构造实体或复杂类型“Mvc_MusicShop_diy.Models.Order”

      错误代码: var orders = db.Orders.Where(o => o.UserId == userid).Select(c => new Order {   OrderI ...

  9. ExcelUtility 对excel的序列化与反序列化,支持当单元格中数据为空时将属性赋值为指定类型的默认值

    源码https://github.com/leoparddne/EPPlusHelper 安装: Install-Package ExcelUtility -Version 1.1.4 需要为对象添加 ...

  10. Flink资料(4) -- 类型抽取和序列化

    类型抽取和序列化 本文翻译自Type Extraction and Serialization Flink处理类型的方式比较特殊,包括它自己的类型描述,一般类型抽取和类型序列化框架.该文档描述这些概念 ...

随机推荐

  1. django---展示多级评论

    实现原理: 在页面加载完成后,jQuery调用initComments()函数,initComments()函数会向后端发起Ajax请求,后端收到请求后,会把所有评论的数据以JSON格式返回到前端,然 ...

  2. Python爬图片

    1 import requests 2 from lxml import etree 3 4 header = { 5 "user-agent": "Mozilla/5. ...

  3. Jenkins通过脚本进行自动发布

    编写以下脚本: ------------------------------------------------------------------------------------- #!/bin ...

  4. 分享5款.NET开源免费的Redis客户端组件库

    前言 今天大姚给大家分享5款.NET开源.免费的Redis客户端组件库,希望可以帮助到有需要的同学. StackExchange.Redis StackExchange.Redis是一个基于.NET的 ...

  5. C# 配置文件增加自定义节点

    话不多说直接开撸! 首先创建一个Config的文件夹然后新增一个后缀名为.config的文件 配置文件的代码如下: <?xml version="1.0" encoding= ...

  6. 京东web端h5st—4.7逆向分析

    声明 本文章中所有内容仅供学习交流,抓包内容.敏感网址.数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 目标网站 aHR0cHM6 ...

  7. node写接受

    选择数据库类型:mongodb 定义用户集合的字段(域): 用户名  密码  性别  爱好(多选)  简介 npm i -S express mongoose 在项目中连接mongodb服务 inde ...

  8. 「TAOI-2」Ciallo~(∠・ω< )⌒★ 题解

    「TAOI-2」Ciallo-(∠・ω< )⌒★ 题解 不难发现,答案可以分成两种: 整段的 中间删一点,两端凑一起的 考虑分开计算贡献. 如果 \(s\) 中存在子串等于 \(t\),那么自然 ...

  9. kettle从入门到精通 第二十二课 kettle carte web服务中文乱码

    在windows 上面 carte服务的canvas画布展示的中文正常,但是在linux上面中文展示乱码,如下所示: 原因:linux 机器缺少字体所致. kettle源码中使用字体: 解决方法: 安 ...

  10. redis实战技巧

    1.分析key大小 [root@db-51 ~]#redis-cli -h 10.0.0.51 -p 6380 --bigkeys # Scanning the entire keyspace to ...