跳转到内容

26. 你看到的,别人不一定看到

约 35 分钟 难度:进阶 动手章

第 13 章那盏灯按一下能切换。第 17 章那扇门按一下会滑开。单人 Build & Test 时一切正常,看着像所有该动的都动了。

把测试条件换成两个玩家同时进同一个世界:第一个玩家按一下开关,自己这边灯灭了,第二个玩家那里灯还亮着。第二个玩家也按一下他面前的同一个开关,他自己看到灯灭、第一个玩家看到的状态又被翻过来。

两个客户端之间,状态从一开始就不一致。

这一章不修这件事,先把它复现一次,再回头把 VRChat 的网络模型讲清楚。修要等下一章。

打开第二部那间小屋的场景,把第 13 章做的 LampSwitch 找回来。脚本 LampToggle.cs 的内容回顾一下:

LampToggle.cs
using UdonSharp;
using UnityEngine;
public class LampToggle : UdonSharpBehaviour
{
public Light targetLight;
public override void Interact()
{
targetLight.enabled = !targetLight.enabled;
}
}

五行脚本,没有任何同步相关的字段。这一章接下来会让两个玩家共同进入这个世界,亲眼看见这五行的盲区。

直接看灯的开关有时候反应不过来「到底哪边变了」。在 Interact 里多写一行日志,让 Console 把每次按下的状态打出来:

LampToggle.cs
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
public class LampToggle : UdonSharpBehaviour
{
public Light targetLight;
public override void Interact()
{
targetLight.enabled = !targetLight.enabled;
// 在本地客户端的日志里打一行:是谁触发的、当前是哪个状态
VRCPlayerApi local = Networking.LocalPlayer;
string who = local != null ? local.displayName : "unknown";
Debug.Log($"[Lamp] {who} pressed. enabled = {targetLight.enabled}");
}
}

Networking.LocalPlayer 是 VRChat 给的「本机客户端的玩家对象」,第三部见过类似的玩家 API。displayName 是这个玩家在 VRChat 里的显示名。

存盘回 Unity,等编译通过。

单人 Build & Test 不能拉别人进来。要测多人,得 Build & Publish 上传到 VRChat 服务器,然后开非公开实例(Friends 或 Friends+)请第二个客户端进来。

VRChat SDKShow Control PanelBuilder 标签:

  1. 检查 Configure World 那一栏的 World Name / Description / Image。如果是新世界,第一次发布会需要这三件齐了才能继续。
  2. 勾选底部的 The above information is accurate and I have the rights to publish this content 之类条款。
  3. Build & Publish for Windows

构建要走十几秒到几分钟。中途 Unity 会先打 Build,再上传到 VRChat 后台。完成后 SDK 面板的 Manage 标签会出现这个世界的条目,状态是 Private(仅自己和被邀请的人能进)。

进 VRChat 客户端,从主菜单 WorldsMine(自己创建的)找到这个世界,进入,创建实例时把权限设为 FriendsFriends+

第二个客户端的几种安排见第六部入口页。最直接的办法是再开一个 VRChat 进程,登第二个账号,从世界菜单的 Friends 标签里看到第一个账号开的实例并加入。

两个客户端都进来后,依次做这几件事,每一步都看两边各自的灯:

  1. 客户端 A 按一下 LampSwitch
    A:灯灭了。
    B:灯仍然亮。
  2. 客户端 B 按一下同一个 LampSwitch
    B:他自己那边灯灭了。
    A:A 这边的灯被翻回亮(因为 A 这边一开始就被自己按灭过,B 按下去本机翻一次也变成亮)。
  3. 再轮流按几次:两边的灯会按各自的节奏闪。从来没有「两个玩家看到同一种状态」的瞬间。

如果第二人按完之后两个屏幕看起来一致,再点几次。多按几次后两边迟早会对不上。

如果还把第 17 章那扇门也搬进同一个测试,会看到同样的现象:A 按一下门开了,B 那边门没动;B 按一下门又乱跳一次。每一台客户端的脚本都按自己看到的事件独立运行。

发生这件事的原因,可以用三句话概括:

  • 世界进实例后,每位玩家的 VRChat 客户端在本地各加载一份场景。
  • 玩家自己客户端发生的事件(InteractOnPickupUpdate),只在本机调用,不会自动飞到别人那里。
  • 默认情况下,脚本里的字段只在自己这台机器上变。变了之后没有任何机制把这个变化广播给其他客户端。

把这件事画一遍:

Player A 客户端 Player B 客户端
┌────────────────────┐ ┌────────────────────┐
│ LampSwitch │ │ LampSwitch │
│ └─ LampToggle.cs │ │ └─ LampToggle.cs │
│ targetLight │ │ targetLight │
│ .enabled = X │ <-- 各自独立 -->│ .enabled = Y │
└────────────────────┘ └────────────────────┘
↑ ↑
A 按按钮,A 的 Interact 跑 B 按按钮,B 的 Interact 跑
A 的 .enabled 翻转 B 的 .enabled 翻转
B 那边一无所知 A 那边一无所知

两台机器上跑的脚本是同一份代码,但两份独立的实例。一份的字段变了,另一份不会跟着变。

那为什么有些东西不写代码也同步了

Section titled “那为什么有些东西不写代码也同步了”

仔细回想之前的章节:第 15 章那个挂了 VRC Object Sync 的小球,A 抓起来时 B 那边也看到小球被抓走。怎么解释?

VRC Object Sync 是 VRChat 内置的一个组件,官方组件页明确说它会把所在物体 Transform位置和旋转同步给实例里的所有玩家。Network Components 文档还把它归入自动同步,并提供 Gravity / Kinematic / Respawn 等由组件管理的物理相关接口。

但不要把它理解成「所有 Transform 字段和所有 Rigidbody 字段都会同步」。缩放、脚本字段、灯光强度、Animator 参数这些状态,仍然需要作者自己设计同步逻辑。

类似地,VRChat 还内置了:

  • 玩家头像位置、动作、表情:自动同步,不用写一行代码。
  • 玩家加入 / 退出实例:自动通知所有客户端,触发 OnPlayerJoined / OnPlayerLeft
  • VRC Pickup 拿起 / 放下:常和 VRC Object Sync 配合,让拿起的人接管所有权,其他客户端看到物体位置 / 旋转跟着变化。

这些是 VRChat 替作者准备好的「默认同步」。但只要不在这个清单里的状态(一个 bool 字段、一个 int 计数、一个 Animator 参数),都需要作者自己写同步逻辑。

LampToggle.cs 里那个 targetLight.enabled 不在默认清单里,所以它不同步。

把上面双人测试看到的现象拆开,背后其实是三个不同的问题。第六部接下来就一个一个回答它们:

现象问题哪一章解决
两边脚本各跑一份,谁的值算数?同一个对象同一时刻的「权威」是谁第 27 章 Ownership
一边改了值另一边不知道状态变化怎么广播给其他客户端第 28 章 同步变量和网络事件
第三个玩家半路加入,他要看到当前状态还是从空白开始Late Joiner 怎么追上现在第 29 章

这三件事不能合并讲,是因为它们各自有自己的取舍。Ownership 决定谁能改,同步变量决定怎么传,Late Joiner 决定新人接住的是什么。任何一个 VRChat 多人系统都得把这三件事各自想清楚。

LampToggle.cs 里加的那几行 Debug.Log 暂时留着。第 27、28 章会反复读这台客户端那台客户端的日志,对照状态对没对上。等第 28 章那盏灯真的同步起来,再把日志清掉也不迟。

第 17 章那扇门也一样。如果场景里有,把 DoorController.Interact() 末尾加一行:

VRCPlayerApi local = Networking.LocalPlayer;
Debug.Log($"[Door] {local.displayName} pressed. isOpen = {isOpen}");

第 27 章会直接拿这扇门做 Networking.SetOwner 的演示。

不用动手改,回头扫几个之前章节的脚本,预测一下「在双人下哪些状态会分裂」:

  • 第 14 章那个走进区域音乐响:玩家 A 走进去,他听到音乐。玩家 B 在旁边没走进去。B 的客户端会不会播放音乐?
  • 第 15 章那个 PickupGlow.cs,挂了 VRC Object Sync 的小球。A 抓起来,A 看到球变亮。B 那边球被搬走了,但 B 这边的灯亮度变了吗?
  • 第 17 章那扇门,A 按一下门开了。B 那边门会动吗?B 走过去再按,会发生什么?

这三个问题答案都在「事件在哪台客户端触发、状态变量同不同步」两件事的组合里:

  • 区域音乐:OnPlayerTriggerEnter 在所有客户端触发(每台机器都收到「有人进入区域了」),所以即使没加同步,每台客户端都会自己播一遍音乐。但「音乐音量大小」这种状态字段不会同步。
  • 小球抓起来:VRC Pickup / VRC Object Sync 让拿起的人接管所有权,并同步物体位置 / 旋转。PickupGlow.cstargetLight.intensity 的修改不在 VRC Object Sync 的范围,所以两边亮度不一致。
  • 门:Interact 只在按按钮的人客户端触发,isOpen 字段没同步,门只在按按钮那位面前动。

这三种行为差异背后是同一条规则:事件在哪些客户端触发 × 哪些字段被声明为同步,组合出最终现象。第 27、28 章会把这两个轴各自拆清楚。

最重要的就两条:

  • VRChat 默认不替作者同步任何脚本字段。状态分裂是默认行为,不是 bug。
  • 要让所有玩家看到同一个变化,得显式声明:谁是这件事的主人、变化要怎么广播、后加入的人怎么收到。