跳转到内容

第 2 章 · 四种通道:Local、Event、Variable、Object Sync

约 7 分钟 难度:1 动手章

第 1 章把状态分成四象限。这一章把这四象限对到 VRChat 给的四种通道,每一种都用同一个按钮例子写一遍。

按钮的承诺很简单:

  • 在场景里有一颗按钮。
  • 任意玩家按下后,旁边那盏灯亮起。

只要再问一句「灯亮多久」「迟入玩家看不看得见」「Master 离开会不会熄」,这同一个按钮就会逼出四种完全不同的实现。


新项目接到一个新需求时,按这个顺序问下去:

  1. 能不能完全本地? 玩家自己看到反馈就够,那就别上网。
  2. 要让别人看到这件事一次,事后不重要? 用网络事件。
  3. 要让别人随时知道当前是什么状态,包括迟入玩家? 用同步变量。
  4. 要同步的是物体的位置 / 旋转 / 物理?VRCObjectSync

走到第几步停,由「这件事的状态结构」决定,不是由「这个对象看起来像什么」决定。能停在第 1 步就停在第 1 步,是 Vol.2 的工程纪律。


只在按下的那个客户端上把灯亮起来。

public class LocalButton : UdonSharpBehaviour
{
public Light targetLight;
public override void Interact()
{
targetLight.enabled = true;
}
}

效果:按按钮的人看到灯亮了。其他玩家看到的是按按钮的人正在跟一颗按钮交互,但灯没有任何反应。

这不是 bug。Interact 只在按下交互的本地客户端触发,targetLight.enabled 只改本地组件状态。

什么时候要这种?最常见的是私有的 UI 反馈,比如鼠标悬停高亮、本地音量滑块、个人观感切换。调试用的临时开关(「让我自己看一眼这块墙关掉是什么样」)和单人解谜的本地状态机也走这一档。

第 1 章四象限里**「私有 × 任意」**的所有状态默认都走这一档。Vol.2 后面所有「这一项不需要同步」的判断,本质都在说「能不能停在实现 A」。


让所有玩家看到一次「灯亮了」。

public class EventButton : UdonSharpBehaviour
{
public Light targetLight;
public override void Interact()
{
SendCustomNetworkEvent(NetworkEventTarget.All, nameof(LightUp));
}
public void LightUp()
{
targetLight.enabled = true;
}
}

效果:任意玩家按按钮,所有在场玩家都看到灯亮了一下。

但是迟入玩家看不到这件事曾经发生过。如果 30 秒前有人按过按钮、灯被点亮,新进来的玩家看到的灯仍然是默认的灭状态。

什么时候要这种?最典型的是一次性视觉反馈:陷阱触发的爆炸、击杀提示、捡到道具的弹幕公告。开门「叮」一声、广播鸣笛之类不需要回看历史的瞬时音效同理。

四象限里**「共享 × 瞬时」**的所有动作走这一档。


让所有玩家(包括迟入玩家)都看到「现在灯是开的」。

public class StateButton : UdonSharpBehaviour
{
public Light targetLight;
[UdonSynced]
private bool isOn;
public override void Interact()
{
if (!Networking.IsOwner(gameObject))
{
Networking.SetOwner(Networking.LocalPlayer, gameObject);
}
isOn = !isOn;
ApplyLight();
RequestSerialization();
}
public override void OnDeserialization()
{
ApplyLight();
}
private void ApplyLight()
{
targetLight.enabled = isOn;
}
}

这一段是本卷的第一段「认真」UdonSharp。逐行看:

  • [UdonSynced]isOn 标记成同步字段。值变了,VRChat 网络层会把它推给其他客户端。
  • Interact 里先把所有权抢过来。isOn 只有 Owner 改才会被同步,非 Owner 改了也不会发出去。Owner 的概念第 3 章会专门讲,这里先记规则:改之前先 SetOwner
  • 把组件的状态翻转之后,本地直接 ApplyLight(),让按下的人马上看到反馈。不能等同步回环回来再亮,那是几十毫秒之后的事。
  • RequestSerialization() 告诉网络层「我改完了,下一次网络刻请把它发出去」。注意它不是立即发送,原文是「请求」,第 4 章会展开。
  • 远端收到数据后会触发 OnDeserialization,里面再次 ApplyLight() 把灯切到新状态。

迟入玩家进来的时候:VRChat 把当前 isOn 的最新值送给他,触发一次他本地的 OnDeserialization,灯就立刻是对的状态了。

四象限里**「共享 × 持续」**的状态走这一档。Vol.2 大部分时间都在和这一档打交道。


如果按钮本身需要被推、被拽、能掉到地上、能被另一个人捡起来,再加同步变量就不够了,需要把物体当前的位置和旋转同步出去。

这种场景下不写代码,把 VRC Object Sync 组件挂上去即可。它做的事是同步 GameObject 的 Transform 位置和旋转,围绕带 Rigidbody 的可移动物体使用。官方组件页明确写的是位置和旋转,不要把自定义物理状态当成已同步字段依赖。它还提供 Respawn()FlagDiscontinuity()(瞬移时跳过插值)等少量 API。

这是为「有物理表现的物体」设计的专用通道。它不负责同步 Udon 字段;如果同一个对象还要承载手动同步的大状态,通常把状态脚本拆到另一个 GameObject 上,避免同步模式互相影响。

四象限之外的「物理 / Transform 同步」单独走这一档。第六部会专门展开 VRCObjectSync 的限制和它的几个社区替代方案的取舍。


维度A · 本地B · 网络事件C · 同步变量D · VRCObjectSync
写法本地代码SendCustomNetworkEvent[UdonSynced] + RequestSerialization挂组件
别人能看到✅ 触发的瞬间✅ 任何时刻✅ 任何时刻
迟入玩家看到✅ 收到当前值✅ 收到当前 Transform
适合什么私有反馈一次性动作持续状态物理对象
通道开销0高(频繁)

「能停就停」的意思是:能用 A 就别用 B,能用 B 就别用 C,能用 C 就别用 D。每往下一档,状态的复杂度、所有权问题、网络预算压力都上一个台阶。第 6 章讲网络预算时会回到这张表。


挑一个回合制卡牌世界的场景,列出至少五个状态或动作,给每个分配 A / B / C / D 一档。可以从下面这几样里挑:

  • 玩家手牌(只对自己可见)
  • 桌面上摆出来的牌(所有人都看到)
  • 当前是谁的回合
  • 抽牌时的「沙沙」音效
  • 玩家面前那个 3D 杯子(可以被推倒)

写完之后再问一遍:有没有把「瞬时动作」错放进 C?有没有把「持续状态」错放进 B?有没有哪一项其实可以停在 A?

这个练习不需要写代码,目的是让选择动作变成肌肉记忆。


同一个按钮可以写成 A / B / C / D 四种实现,每一种回答的问题不同。默认选择顺序是能停在 A 就停在 A,否则按 B → C → D 往下走。实现 C 的代码骨架([UdonSynced] + SetOwner + RequestSerialization + OnDeserialization)会在后面所有同步变量例子里反复出现。