消息

谁都能发的带类型事件、谁来处理、物体自己的通道,以及一条消息在线路上的开销。

更新于 2026-09-10

声明一条消息

消息是一个带字段的类,外加一对说明它们如何传输的方法:

public class OpenDoor : NetworkEvent
{
    public int doorId;

    public override void Serialize(ByteWriter writer)
    {
        writer.WriteInt32(doorId);
    }

    public override void Deserialize(ByteReader reader)
    {
        doorId = reader.ReadInt32();
    }
}

打包要自己写,是因为让它猜代价更大:Udon 没有反射,任何替你序列化的东西最后仍然要拿到每个字段的描述。没有字段的信号两个方法都不用重写。

把愿望和事实分开。 OpenDoorRequestDoorOpened 两个类型,几乎总是好过同一个类型发两次。处理器看类型就知道拿到的是哪一个,也就不会把别人的请求当成已经定下的事来处理。

消息只有两个去处

一条消息要么发给整个房间,要么发到某个物体自己的通道。选择只有这两个,而你怎么写就说明了是哪一个:

Room.Send(new OpenDoor { doorId = 3 });   // 房间:每个客户端
this.Send(new Touched());                 // 这个物体自己的通道
channel.Send(new Touched());              // 引用到的另一个物体的通道

this.Send 不是 Room.Send 它发到 behaviour 所在的那个物体自己的通道上,所以不必再带一个 id 说明指的是哪一个物体。Room.Send 才是发给整个房间的那个,而且它是 static 的,在没有 this 的普通类或抽象类里也能调用。

订阅完全对应:

Room.Subscribe<OpenDoor>(OnOpenDoor);     // 从房间接收该类型
this.Subscribe<Touched>(OnTouched);       // 在这个物体的通道上接收
channel.Subscribe<Touched>(OnTouched);    // 在另一个物体的通道上接收
你写的 去哪里 谁会收到
Room.Send(evt) 房间 每个客户端上订阅了该类型的一切
this.Send(evt) 这个物体 在这个物体上订阅了该类型的一切
channel.Send(evt) 那一个物体 在该通道上订阅了该类型的一切

谁都可能发的时候用房间。物体自己的通道只有一个所有者,所以 this.Send 需要拥有该物体

谁来处理

寻址决定的是谁来处理,从来不是谁可以发

Room.Send(message, NetworkTarget.All);         // 所有人,默认
Room.Send(message, NetworkTarget.Master);      // 只有在仲裁的那个客户端
Room.Send(message, NetworkTarget.Others);      // 除发送者外的所有人
Room.Send(message, player);                    // 某一个玩家

任何客户端都能把任何消息按自己想要的寻址发上线路,字节无论如何都会到每个客户端。NetworkTarget.Master 是过滤,不是许可;真正需要权限的世界要检查是谁发的:

private void OnOpenDoorRequest(VRCPlayerApi sender, OpenDoorRequest message)
{
    if (!Room.IsMaster || !Allowed(from))
        return;

    Room.Send(new DoorOpened { doorId = message.doorId });
}

这就是把 master 当服务器的形状:客户端请求,仲裁的那个客户端决定,而它发给所有人的那条消息才真正改变了什么。

你能得到什么

按顺序、各一次、包括发送者。 发送的客户端会立刻处理自己的消息,因为客户端收不到自己的写入,否则唯一知道发生了什么的那个反而听不到。

同一批到两次也只处理一次。 每条消息带序号,读的一方记着自己处理到哪里,所以 VRChat 拒收后重发也不会改变任何东西。

晚来的人不会收到自己错过的部分。 每一批都带着它是何时生成的,后加入的客户端会把自己到达之前的那批丢掉,而不是重放一场自己没看见的争斗。这和你对同步值的期待正好相反,而正是你对事件的期待。如果晚来的人应该知道当前状态,请用同步值

被拒绝的发送不会丢。 VRChat 限制一个 behaviour 一次能发多少,超过的一批会被拒绝而不是截断。被拒绝的一批会留着重发。如果 Room.Bus.Pending 一直大于零,说明发得比线路能承受的快。

物体自己的通道

关于某个物体的消息可以走那个物体而不是房间,这样就不用带一个 id 说明指的是哪一个。不用加组件,也不用引用,把同样的两个动词直接写在 behaviour 自己身上:

public override void Start()
{
    this.Subscribe<Touched>(OnTouched);
}

public override void Interact()
{
    this.Send(new Touched());
}

处理方法也可以一并接收玩家,从而知道是谁发的:

this.Subscribe<Touched>((sender, message) => Log.Info(sender.displayName + " 碰了它"));

发送需要拥有该物体。 只有所有者的写入会被复制,所以非所有者当场就被告知,而不是事后才发现消息根本没发出去。若想让任意客户端都能发,先用 Ownership.Claim(gameObject) 取得所有权;pickup 或 interact 通常已经取到了。

要从别处读另一个物体的通道,那个物体上需要有 NetworkBus,同样的动词对引用照样有效:

public NetworkBus channel;

channel.Subscribe<Touched>(OnTouched);
channel.Send(new Touched());

无论哪种,物体通道都只有一个所有者,两个玩家往同一个通道发就会争抢。物体的所有者是发送方时用物体通道,谁都可能发时用房间。

开销是多少

每条消息三个字节:一个说谁该处理,一个是长度,一个说它是什么类型。发给特定玩家的消息还会带上那个玩家的 id。

每一批还会一次性付出四个字节,用来记它是何时生成的。是每批一次而不是每条消息一次,所以一批里装的消息越多就摊得越薄。

真正会卡住你的额度,是 VRChat 对一个 behaviour 同步状态的上限,它作用于整批而不是单条消息。this.Pending() 就是让世界看见它逼近的办法:几乎总是 0,若一直大于 0,说明一批的体积超过了一次同步能带走的量。

不用这个包的自定义事件

VRChat 自己的事件调用一直都在,无论你用不用上面这些,因为编译器会直接放行:

this.SendCustomNetworkEvent(NetworkEventTarget.All, nameof(Ring));

public void Ring()
{
    bell.Play();
}

它按名字在每个客户端上调用一个 public 方法,用 NetworkEventTarget.Owner 则只在所有者上调用。它不携带数据,这正是带类型消息存在的理由。SendCustomEventDelayedSecondsSendCustomEventDelayedFrames 只是把本地调用推迟,不发送任何东西。

重写 OnOwnershipRequest 返回 false 可以拒绝转移,OnOwnershipTransferred 会在所有者改变时于每个客户端上运行。ClientSim 不会触发所有权请求,所以这一对要在上传的世界里验证。

什么都没到的时候

它会说出来,每个 behaviour 说一次,而不是留你去猜:

  • 场景里没有 bus 就发送了,于是消息哪儿也没去。
  • 订阅之后,十秒内在任何地方都找不到可读的 bus。

两者在实践中是同一件事:承载房间消息的那个物体缺失或被禁用了。世界第一次发送时它会被自动添加,所以通常的原因是它跟着别的东西一起被关掉了。

出了点问题 重新加载