26. 你看到的,别人不一定看到
第 13 章那盏灯按一下能切换。第 17 章那扇门按一下会滑开。单人 Build & Test 时一切正常,看着像所有该动的都动了。
把测试条件换成两个玩家同时进同一个世界:第一个玩家按一下开关,自己这边灯灭了,第二个玩家那里灯还亮着。第二个玩家也按一下他面前的同一个开关,他自己看到灯灭、第一个玩家看到的状态又被翻过来。
两个客户端之间,状态从一开始就不一致。
这一章不修这件事,先把它复现一次,再回头把 VRChat 的网络模型讲清楚。修要等下一章。
把单人版的灯放进双人测试
Section titled “把单人版的灯放进双人测试”打开第二部那间小屋的场景,把第 13 章做的 LampSwitch 找回来。脚本 LampToggle.cs 的内容回顾一下:
using UdonSharp;using UnityEngine;
public class LampToggle : UdonSharpBehaviour{ public Light targetLight;
public override void Interact() { targetLight.enabled = !targetLight.enabled; }}五行脚本,没有任何同步相关的字段。这一章接下来会让两个玩家共同进入这个世界,亲眼看见这五行的盲区。
加几行 Debug.Log 帮助观察
Section titled “加几行 Debug.Log 帮助观察”直接看灯的开关有时候反应不过来「到底哪边变了」。在 Interact 里多写一行日志,让 Console 把每次按下的状态打出来:
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 & Publish 到非公开实例
Section titled “Build & Publish 到非公开实例”单人 Build & Test 不能拉别人进来。要测多人,得 Build & Publish 上传到 VRChat 服务器,然后开非公开实例(Friends 或 Friends+)请第二个客户端进来。
VRChat SDK → Show Control Panel → Builder 标签:
- 检查
Configure World那一栏的World Name/Description/Image。如果是新世界,第一次发布会需要这三件齐了才能继续。 - 勾选底部的
The above information is accurate and I have the rights to publish this content之类条款。 - 点
Build & Publish for Windows。
构建要走十几秒到几分钟。中途 Unity 会先打 Build,再上传到 VRChat 后台。完成后 SDK 面板的 Manage 标签会出现这个世界的条目,状态是 Private(仅自己和被邀请的人能进)。
进 VRChat 客户端,从主菜单 Worlds → Mine(自己创建的)找到这个世界,进入,创建实例时把权限设为 Friends 或 Friends+。
第二个客户端的几种安排见第六部入口页。最直接的办法是再开一个 VRChat 进程,登第二个账号,从世界菜单的 Friends 标签里看到第一个账号开的实例并加入。
双客户端复现状态分裂
Section titled “双客户端复现状态分裂”两个客户端都进来后,依次做这几件事,每一步都看两边各自的灯:
- 客户端 A 按一下
LampSwitch。
A:灯灭了。
B:灯仍然亮。 - 客户端 B 按一下同一个
LampSwitch。
B:他自己那边灯灭了。
A:A 这边的灯被翻回亮(因为 A 这边一开始就被自己按灭过,B 按下去本机翻一次也变成亮)。 - 再轮流按几次:两边的灯会按各自的节奏闪。从来没有「两个玩家看到同一种状态」的瞬间。
如果第二人按完之后两个屏幕看起来一致,再点几次。多按几次后两边迟早会对不上。
如果还把第 17 章那扇门也搬进同一个测试,会看到同样的现象:A 按一下门开了,B 那边门没动;B 按一下门又乱跳一次。每一台客户端的脚本都按自己看到的事件独立运行。
VRChat 网络模型的最小心智模型
Section titled “VRChat 网络模型的最小心智模型”发生这件事的原因,可以用三句话概括:
- 世界进实例后,每位玩家的 VRChat 客户端在本地各加载一份场景。
- 玩家自己客户端发生的事件(
Interact、OnPickup、Update),只在本机调用,不会自动飞到别人那里。 - 默认情况下,脚本里的字段只在自己这台机器上变。变了之后没有任何机制把这个变化广播给其他客户端。
把这件事画一遍:
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 不在默认清单里,所以它不同步。
三个等会儿要解决的问题
Section titled “三个等会儿要解决的问题”把上面双人测试看到的现象拆开,背后其实是三个不同的问题。第六部接下来就一个一个回答它们:
| 现象 | 问题 | 哪一章解决 |
|---|---|---|
| 两边脚本各跑一份,谁的值算数? | 同一个对象同一时刻的「权威」是谁 | 第 27 章 Ownership |
| 一边改了值另一边不知道 | 状态变化怎么广播给其他客户端 | 第 28 章 同步变量和网络事件 |
| 第三个玩家半路加入,他要看到当前状态还是从空白开始 | Late Joiner 怎么追上现在 | 第 29 章 |
这三件事不能合并讲,是因为它们各自有自己的取舍。Ownership 决定谁能改,同步变量决定怎么传,Late Joiner 决定新人接住的是什么。任何一个 VRChat 多人系统都得把这三件事各自想清楚。
留几行调试代码不删
Section titled “留几行调试代码不删”把 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 的演示。
自己再多看几个例子
Section titled “自己再多看几个例子”不用动手改,回头扫几个之前章节的脚本,预测一下「在双人下哪些状态会分裂」:
- 第 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.cs里targetLight.intensity的修改不在VRC Object Sync的范围,所以两边亮度不一致。 - 门:
Interact只在按按钮的人客户端触发,isOpen字段没同步,门只在按按钮那位面前动。
这三种行为差异背后是同一条规则:事件在哪些客户端触发 × 哪些字段被声明为同步,组合出最终现象。第 27、28 章会把这两个轴各自拆清楚。
网络模型的两条事实
Section titled “网络模型的两条事实”最重要的就两条:
- VRChat 默认不替作者同步任何脚本字段。状态分裂是默认行为,不是 bug。
- 要让所有玩家看到同一个变化,得显式声明:谁是这件事的主人、变化要怎么广播、后加入的人怎么收到。