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

首先看文档。官方文档里一开头就列出了所有内置的支持的类型: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. ICESat-2 从ATL08中获取ATL03分类结果

    ICESat-2 ATL03数据和ATL08数据的分段距离不一致,ATL08在ATL03的基础上重新分段,并对分段内的数据做处理得到一系列的结果,详情见数据字典: ATL08 Product Data ...

  2. 面试题--mysql的数据库优化

    mysql的数据库优化 当有人问你如何对数据库进行优化时,很多人第一反应想到的就是 SQL 优化,如何创建索引,如何改写 SQL,他们把数据库优化与 SQL 优化划上了等号. 当然这不能算是完全错误的 ...

  3. .NET Core 中使用GBK GB2312编码报错的问题

    错误描述 环境 dotnet core 2.1 2.2   dotnet core 3.1 dotnet core 5.0 现象 当代码中使用 System.Text.Encoding.GetEnco ...

  4. Uni-app极速入门(二) - 登录demo

    需求 背景 1.进入小程序,默认页面判断用户是否已经登录,已经登录则进入首页,没有登录则进入登录页面 2.首页为tabbar,包括首页和设置页,设置页可以退出登录,回到登录页面 页面流转 graph ...

  5. AI实战 | 使用元器打造浪漫仪式小管家

    浪漫仪式小管家 以前我们曾经打造过学习助手和待办助手,但这一次,我们决定创造一个与众不同的智能体,而浪漫将成为我们的主题.我们选择浪漫作为主题,是因为我们感到在之前的打造过程中缺乏了一些仪式感,无法给 ...

  6. react mock数据

    为什么要做假数据,因为后端开发接口没有哪么快,此时就需要自己来模拟请求数据. 模拟的数据字段,需要和后端工程师沟通. 创建所需数据的json文件 json-server 此命令可以帮助我们快速创建一个 ...

  7. SELinux策略语法以及示例策略

    首发公号:Rand_cs SELinux策略语法以及示例策略 本文来讲述 SELinux 策略常用的语法,然后解读一下 SELinux 这个项目中给出的示例策略 安全上下文 首先来看一下安全上下文的格 ...

  8. knife4j/swagger救援第一现场

    1.前方来报,测试环境springboot项目无法启动,现场如下: Error starting ApplicationContext. To display the auto-configurati ...

  9. nordic的nrf52系列——ADC在使用时如何校准增益误差(基于SDK)

    简介:ADC在实际使用的时候都要进行误差校准,那Nordic的nrf52系列如何进行校准,如果不校准又有什么影响尼,接下来我将通过实验进行测试,验证不校准和校准的影响(本测试的基础是,默认输入阻抗和采 ...

  10. Postman使用记录,通过表格动态赋值循环调用接口 - Using CSV and JSON data files in the Postman Collection Runner

    1.GET请求,通过导入csv文件来处理 GET http://localhost:8080/web/addstudent?sno={{sno}}&name={{name}}&sex= ...