UNET SyncVar同步信息

原文:

UNET SyncVar



这篇文章讨论unity网游中的同步状态。我们开始先总结一下unity已有的(传统的)网络系统,再转移到它如何在新的UNET网络系统中运行,还有制作过程中引起新设计思想的思想。


背景和需求

这作为背景的一点点知识。常见的事情是,网络游戏会有一个拥有很多物体对象的服务器,当这些物体的数据有所改变时,需要告知客户端。例如,在对战游戏中,某一个玩家的的血量需要让所有玩家看见。这就要在相关脚本的类中设置一个成员变量,当它变化时将它发送给所有客户端。下面是一个简单的Combat类:

class Combat:MonoBehaviour
{
    public int Health;
    public bool Alive;
 
    public void TakeDamage(intamount)
    {
        if(amount >=Health){
            Alive=false;
            Health=0;
        }else{
            Health-=amount;
        }
    }
}

当服务器中的一个玩家受伤时,游戏中所有玩家都需要被告知手上玩家的新血量值是多少。

这看似简单,但困难的是怎么制作这个网络系统,让这个系统可以免除开发者相关的编码,在CPU、带宽、内存方面是高效的,且灵活支持所有类型开发者想要的功能。所以这个系统应该有这些具体的目标:

1、不能保存变量的卷影复制以最小化内存使用。

2、仅当信息发生变化时才发送(增量更新)以最小化带宽使用。

3、不能一直地检查信息是否发生变化以最小化CPU的使用。

4、不要求开发者手动编写序列化功能代码以最小化协议和序列化错配的问题。

5、不要求开发者明确地设置变量为dirty。

6、对unity所有支持的语言都有效。

7、不能打乱开发者的开发流程。

8、不能采用手动的步骤,会使开发者需要执行才能使用这个系统。

9、允许meta-data(自定义变量)驱动这个系统。

10、操作的类型既有简单的也有复杂的。

11、运行期间避免反射。

这是一个需求的目标列表!


传统的网络系统

unity现有的网络系统有一个关于同步的类型“ReliableDeltaCompressed”,通过提供一个OnSerializeNetworkView()挂钩的方法来达到信息同步。这个方法在绑定了NetworkView组件的物体上调用,而且开发者写的序列化代码写入(或读取)提供的字节流中。这些字节流被引擎贮藏起来,当下次被调用的方法的结果和贮藏的版本不同,这个物体就会被认为是dirty,然后这些信息就会发送给客户端。举个例子,一个序列化的方法可以长这样:

void OnSerializeNetworkView(Bitstream stream,NetworkMessageInfo info)
{
    float horizontalInput=0.0f;
    if(stream.isWriting){
        // Sending
        horizontalInput=Input.GetAxis("Horizontal");
      stream.Serialize(horizontalInput);
    }else{
 
        // Receiving
        stream.Serialize(horizontalInput);
        // ... do something meaningful with the received variable
    }
}

这个方法可以满足上面需求清单上的一些需求,但不完全满足。执行期间它是自动的,因为引擎根据网络发送速率调用OnSerializeNetworkView(),而且开发者也不需要设置变量为dirty。它也没有增加任何额外的搭建步骤或者打乱开发者的工作流程。

但是,他的表现不算很好——特别是有多个网络物体时。CPU的时间浪费在对比上,内存用在字节流的卷影复制上。它也很容易在序列化方法中发生错配的问题,因为有一个新的需要同步的成员变量加进来时,它不得不手动地更新。它也不受metadata驱动,所以编辑器或者其他工具无法察觉到它有什么同步的变量。


SyncVar的代码产生

当UNET团队做出新的信息同步系统时,我们提出的解决方案是由自定义属性驱动的代码生成器。在用户的代码中,它长这样:

using UnityEngine.UNetwork;
class Combat:UNetBehaviour
{
    [SyncVar]
    public int Health;
 
    [SyncVar]
    public bool Alive;
}

因为这个方法覆盖了UNetBehaviour基类的一个虚方法,所以当游戏物体被序列化时,脚本中的变量也自动被序列化。然后,变量们会在另一端拥有由代码生成器产生的配对的反序列化方法中解包。所以没有机会发生错配的问题,而且添加[SyncVar]变量时,代码会自动更新。这个新的属性高速系统Health和Alive变量需要同步的。现在,开发者不需要编写序列化方法,因为代码生成器有自定义属性的数据,它能生成完美的带有正确的顺序和类型的序列化和反序列化方法。这个生成的方法像这样:

public override void UNetSerializeVars(UWriter writer)
{
    writer.WriteInt(Health);
    writer.WriteBool(Alive);
}

这些数据现在可以被编辑器获取了,所以可以再inspector视图中显示更多细节信息,正如:(图片失效)

但是这仍有一些问题。这个方法一直发送所有信息——它不是增量发送的,所以当对象中的一个变量改变后,整个对象的信息都会发送出去。另外,我们怎么知道这些序列化方法应该在什么时候会被调用呢?当什么都没变时,发送信息是浪费效率的。

我们使用属性和dirty flag来克服这一点。很自然,一个属性能封装每个[SyncVar]变量和在某些东西变化时设置dirty flags。这个方法其实还算成功的。有一个dirty flag的bitmask,这让代码生成器产生代码来做增量更新。那样产生的代码想这个样子:

public override void UNetSerializeVars(UWriter writer)
{
    Writer.Write(m_DirtyFlags)
    if(m_DirtyFlags&0x01){writer.WriteInt(Health);}
    if(m_DirtyFlags&0x02){writer.WriteBool(Alive);}
    m_DirtyFlags=0;
}

这种方式中,反序列化方法可以读取dirty flag的掩码并且只能发序列化写进流中的变量。这有利于带宽使用,且让我们知道物体什么时候是dirty。还有,对用户来说他仍旧是全自动的。然而这些属性怎么运行呢?

我们试图封装[SyncVar]变量:

using UnityEngine.UNetwork;
class Combat:UNetBehaviour
{
    [SyncVar]
    public int Health;
 
    // generated property
    public int HealthSync{
        get{returnHealth;}
        set{m_dirtyFlags|=0x01;  Health=value;}
    }
}

这可以实现但是名字错误。上面的TakeDamage()方法使用Health而不是HealthSync,所以绕过了这个属性。用户甚至不能直接使用HealthSync属性,因为知道代码生成器触发前它还不存在。 它可能在代码生成器步骤相应阶段被制作成跨越两个时期的方法,之后用户就更新它们的代码了——然而这是脆弱的。这很可能会经常出现编译错误,说没有解开一大块一大块的代码时不能准备好(can’t be fixed without undoing large chunks of code)。

另一个种方法是要求开发者对每个[SyncVar]变量编写上面那种属性代码。但这取决于开发者的能力,而且容易出现潜在的错误。用户写入和生成的代码中共用的bitmask必须精确匹配才能正常工作,所以增加或删除[SyncVar]变量需要细细掂量。

进入Mono Cecil

所以我们需要能够生成封装属性和令已有的代码可以使用它们,即使代码无法察觉它们的存在。很幸运,有个叫Cecil的Mono工具恰好是做这个的。Cecil能够以ECMA CIL格式加载Mono程序集,修改它们,并且写出来。

这里有一点点让人疯狂的地方。UNET的代码生成器创建了封装属性,然后查找所有存取原始变量的代码地址。之后它用封装属性和Voila的引用替换掉变量的引用!现在用户代码被调用时通过无需用户任何修改而最新创建的属性。

因为Cecil是在CIL层操作的,所以它有个优点,就是对所有编程语言有效的,因为向下编译的都是同一个指令格式。

已注入到脚本装配中的一个最终的序列化方法的生成的CIL现在像这样:

IL_0000:ldarg.2
IL_0001:brfalseIL_000d
 
IL_0006:ldarg.0
IL_0007:ldc.i4.m1
IL_0008:stflduint32[UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
 
IL_000d:nop
IL_000e:ldarg.1
IL_000f:ldarg.0
IL_0010:ldflduint32[UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
IL_0015:callvirtinstancevoid[UnityEngine]UnityEngine.UNetwork.UWriter::UWriteUInt32(uint32)
IL_001a:ldarg.0
IL_001b:ldflduint32[UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
IL_0020:ldc.i41
IL_0025:and
IL_0026:brfalseIL_0037
 
IL_002b:ldarg.1
IL_002c:ldarg.0
IL_002d:ldfldvaluetypeBuf/BufTypePowerup::mbuf
IL_0032:callvirtinstancevoid[mscorlib]System.IO.BinaryWriter::Write(int32)
 
IL_0037:nop
IL_0038:ldarg.0
IL_0039:ldc.i4.0
IL_003a:stflduint32[UnityEngine]UnityEngine.UNetBehaviour::m_DirtyBits
IL_003f:ret

所以我们来看看这是怎么实现我们的上面的需求的:

1、没有变量的卷影复制。

2、增量更新。

3、没有信息变化的对比。

4、没有手动编码的序列化方法。

5、没有明确的dirty调用。

6、对所有unity支持语言有效。

7、不改变开发者工作流程。

8、没有需要开发者手动执行的步骤。

9、受meta-data驱动。

10、处理所有类型(用新的UWriter/UReader序列化器)。

11、执行期间无反射。

看起来已经涵盖全部了。这个系统对开发者来说会更高效和更友好。希望它能帮助每个人更容易地开发unity多人网络游戏。

我们也用Cecil当做RPC调用实现来避免反射中通过名字的查找功能。更多的在一篇更大的文章中。


  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值